一张图看懂整个系统的连接形状,以及「为什么这么设计」。
关键点:两端都只向外发起连接,没有任何一端需要公网 IP 或端口转发。
| 路径 | 走什么 | 什么时候用 | 实现位置 |
|---|---|---|---|
| 公网锚点 | Wi-Fi → WebSocket → Cloudflare | 设备不在身边、要跨网络 | 固件 wb_anchor.c + 锚点 |
| BLE 直连 | 蓝牙低功耗 | 设备就在电脑旁边、免 Wi-Fi | 固件 wb_ble.c + 宿主 transport/ble.py |
| 局域网中继 | Wi-Fi → 本机 relay | 同一局域网、开发调试 | 固件 wb_anchor.c(LAN 模式)+ 宿主 transport/relay.py |
设备侧用一个统一的 wb_link.c 抽象层把「走蓝牙还是走锚点」藏起来,上层不关心具体走哪条路(见 wb_link.c:99 wb_link_start、:279 wb_link_use_ble、:300 wb_link_use_anchor)。宿主侧同样有一个 transport/base.py 的 Link 抽象基类,BleLink / RelayLink / FakeLink 都实现它。
协议是「两端怎么说话」的约定。这里分两条通道:控制通道(发命令)和音频通道(搬声音)。
控制消息是 NDJSON(每行一个 JSON 对象),用 t 字段区分类型。
类型 t | 谁发 | 含义(说人话) |
|---|---|---|
hello / answer | 双方 | 握手:上来先报「我是谁、支持什么」 |
status | 网关→卡片 | 报告当前状态(idle / running / blocked / done / error) |
ask | 网关→卡片 | 向卡片提问,给一组选项让用户按按钮选 |
say | 网关→卡片 | 让卡片说一句话(触发放 TTS 音频) |
audio_begin / audio_end | 网关→卡片 | 「我要开始放一段语音了」/「放完了」,带总帧数 |
audio_ack | 卡片→网关 | 收到音频的确认(用于流量控制,说「再来点」) |
anchor | 锚点→卡片 | 锚点自己发的状态(大脑在不在线、有没有排队) |
ping / pong | 双方 | 心跳,判断对方还活着 |
set | 网关→卡片 | 改卡片配置(音量等) |
bye | 双方 | 礼貌告别、断开 |
完整常量定义见 anchor/src/protocol.js:33-55(E_* 事件类)与 :47-55(C_* 命令类);宿主侧镜像在 passport_companion/protocol.py。
┌────────────┬──────────┬────────────────────────────────────┐
│ seq_lo (1) │ seq_hi(1)│ n (1) payload (240 字节) │
└────────────┴──────────┴────────────────────────────────────┘
▲ ▲
帧序号(16 位) μ-law 8kHz 音频数据
每帧固定 243 字节 = 3 字节头(序号 + 有效长度)+ 240 字节音频。 音频用 μ-law 8kHz 编码——这是一种把 16 位 PCM 压到 8 位的古老但极省带宽的语音编码, 240 字节 ≈ 30 毫秒声音。帧头带序号是为了让接收方知道「有没有丢、有没有乱序」。
帧常量定义见 anchor/src/protocol.js:23-24(AUDIO_FRAME_PAYLOAD=240、AUDIO_FRAME_SIZE=243);
固件打包/解包在 wb_proto.c:136 wb_aframe_pack 和 :153 wb_aframe_unpack;
宿主侧打包/解析在 protocol.py:206 pack_audio_frame 和 :224 AudioFrameParser。
锚点只转发、不解析音频、不理解业务。好处:固件、锚点、宿主三套软件能各自独立升级,谁改都不影响别人。
放音频时,卡片每收到一段就回一个 audio_ack 说「再来点」,网关再发下一段。避免一口气灌太多把卡片的小内存冲爆。
录音时声音先存进环形缓冲;如果网络断了、缓冲写满,就丢最旧的帧、保住最新的声音——因为刚说的那句最重要。
session.py:487 _on_audio_frame(接受空洞)和 :559 _finish_rx_audio(CRC 降级)。