一个「云端中转站」。卡片和大脑都主动连它,它负责鉴权、配对、原样转发。
Cloudflare Worker + Durable Objects · 部署在 card.jxccwx.com
| 做 ✅ | 不做 ❌ |
|---|---|
| 鉴权(设备令牌 / 网关令牌分开) | 不解析音频,二进制帧原样转发 |
| 一设备一房间,同时挂两个 WebSocket 互转 | 不理解 ask/answer/status 的业务含义 |
| 控制消息原样转发 | 不做 STT / TTS / Agent 推理(那些在网关上) |
| 卡片离线时把网关的 say 排队 | 不为卡片操作排队(过期动作比丢失更糟) |
| 自己回答 ping(判活) | 不重连、不退避(那是卡片侧的责任) |
这个「做窄边界」是有意为之——锚点、固件、宿主三套软件有各自的发布节奏,锚点理解得越少,三者越容易独立升级。
| 方法 | 路径 | 鉴权 | 用途 |
|---|---|---|---|
| GET | /card/v1/health | 无 | 连通性探测,返回 {ok,ver,ts,caps} |
| GET | /card/v1/ws?role=device|gw&id=设备号 | 有 | WebSocket 升级 |
| GET | /card/v1/room?id=设备号 | 网关令牌 | 房间状态快照(排障用) |
鉴权方式:Authorization: Bearer <token> 或 ?token=(为 ESP32 / Node 内置 WebSocket 这类不方便设 header 的实现留的口子)。
卡片用 DEVICE_TOKEN、网关用 GATEWAY_TOKEN,两个不同的 secret。
核心。每个设备对应一个 DO 实例(房间)。房间里同时挂两张 WebSocket(卡片 + 网关),把收到的消息互转给对方。用 Hibernation API 省空闲成本。
记录「部署的到底是哪一份代码」。SOURCE_DIGEST 是对 src 下所有 .js + wrangler.toml 逐文件 sha256 再摘要得到,用于核对部署版本是否一致。
原始需求里最硬的一条是「不允许转圈等待」——用户必须一眼分清「网络坏了」「大脑没上线」「任务在跑」。这三者需要一个卡片能读的明确信号。
// 锚点自有的状态消息(不是网关的语义)
{ "t": "anchor", "ver": 1, "gw": "online" | "offline", "pending": 0, "id": "WB-3A7C" }
因为 status 是「网关」的语义,锚点若借用它,卡片就无法区分「大脑说任务被阻塞」和「大脑根本没上线」。
所以协议 V1 加了一个锚点自有消息类型 anchor,卡片用它渲染连接指示器。
| 情况 | 卡片收到什么 |
|---|---|
| 锚点不可达 | 什么都收不到 —— WS 建不起来,按传输层错误处理 |
| 锚点在、网关不在 | anchor{gw:"offline"} + status{state:"blocked",detail:"大脑未上线"} |
# anchor/wrangler.toml name = "card-anchor" main = "src/index.js" compatibility_date = "2026-09-01" account_id = "67a19f55851c5090d43fdbc8119317d2" workers_dev = false # workers.dev 国内被 DNS 污染,关掉 routes = [ { pattern = "card.jxccwx.com", custom_domain = true } ] [[durable_objects.bindings]] name = "SESSION" class_name = "SessionRoom" [[migrations]] tag = "v1" new_sqlite_classes = ["SessionRoom"]
令牌 DEVICE_TOKEN / GATEWAY_TOKEN 走 wrangler secret put,不写进配置文件。
workers_dev,只走自有域名 card.jxccwx.com(custom_domain 模式,deploy 时 Cloudflare 自动建 DNS 记录和证书)。