AI Passport

架构与协议

一张图看懂整个系统的连接形状,以及「为什么这么设计」。

#连接形状

关键点:两端都只向外发起连接,没有任何一端需要公网 IP 或端口转发。

📟 卡片(设备)
主动连出去
WebSocket over Wi-Fi
──▶wss://card.jxccwx.com
☁️ 锚点(公网)
Cloudflare Worker
一个设备 = 一个房间
◀──wss://card.jxccwx.com
🧠 网关(宿主)
主动连出去
WebSocket
为什么中间要放一个锚点? 因为卡片(巴掌大的芯片)和大脑(你的电脑)可能各自躲在不同的网络后面,谁也找不到谁。锚点放在公网上,两个人都主动去找它,它负责「把这两根线接起来」。这就是典型的 「星形中转」模型。

#三种传输路径

路径走什么什么时候用实现位置
公网锚点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 都实现它。

#协议规格(v1)

协议是「两端怎么说话」的约定。这里分两条通道:控制通道(发命令)和音频通道(搬声音)。

控制通道 —— 一行一个 JSON

控制消息是 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。

音频通道 —— 243 字节一帧

┌────────────┬──────────┬────────────────────────────────────┐
│ 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。

#三个关键设计决策

决策 1

锚点「装傻」

锚点只转发、不解析音频、不理解业务。好处:固件、锚点、宿主三套软件能各自独立升级,谁改都不影响别人。

决策 2

停等流控(音频)

放音频时,卡片每收到一段就回一个 audio_ack 说「再来点」,网关再发下一段。避免一口气灌太多把卡片的小内存冲爆。

决策 3

环缓冲 + 丢最旧帧

录音时声音先存进环形缓冲;如果网络断了、缓冲写满,就丢最旧的帧、保住最新的声音——因为刚说的那句最重要。

#一个容易混淆的点

「序号」不等于「一定连续」。 早期版本里,宿主发现帧序号跳号(比如期望 21、收到 31)会认为「中间丢了、要求重传」。但设备在断线重连 + 环缓冲写满时,是主动丢帧的,这些帧根本没发出去,也不可能重传。于是改成:宿主接受「序号有空洞」,直接按最新序号继续;CRC 对不上时也从「放弃转写」降级为「警告 + 照样转写」。这个改动记录在 session.py:487 _on_audio_frame(接受空洞)和 :559 _finish_rx_audio(CRC 降级)。