# ESP32 设备接入协议 v1

> 任何 ESP32（或其它能跑 WebSocket 的 MCU）只要按本文实现，就能接入平台：
> 开机连上、自报身份与能力、出现在控制台。平台不需要为每种板子改代码。

- 平台入口：`https://esp32hub.luo-jin-ai.com`
- 设备端点：`wss://esp32hub.luo-jin-ai.com/ws/device`（加密）
  或 `ws://esp32hub.luo-jin-ai.com/ws/device`（**80 端口明文，ESP32 推荐**：免证书、免对时）
- 操作端点：`wss://esp32hub.luo-jin-ai.com/ws/op?did=<设备编号>`
- 传输：WebSocket，**只收文本帧**（收到二进制帧立即 `close(1008)`），每条消息都是一个 JSON 对象。

---

## 一、设备身份

设备第一次上电时自行生成三个值，写进 NVS（掉电不丢）：

| 字段 | 规则 | 说明 |
|---|---|---|
| `did` | 8~32 位小写十六进制，建议用 MAC 后 6 字节 | 设备编号，平台内唯一 |
| `key` | 32~64 位小写十六进制，硬件随机源生成 | 设备密钥，**首次注册后平台只存 SHA256**，明文不出设备 |
| `pairCode` | 6 位数字，可定期轮换 | 配对码，用户在主控台输入它把设备加到自己名下 |

`did` 与 `key` 用两个请求头带上（不使用 URL 参数，避免进访问日志）：

```
x-lab-device: a1b2c3d4e5f6
x-lab-key: 3f8c1d...（64 位 hex）
```

> 首次出现即注册（TOFU，Trust On First Use）：平台记录 `did → SHA256(key)`。
> 之后同一个 `did` 用不同 `key` 连接会被 401 拒绝，防止被顶替。
> 设备刷机丢了 NVS 会被当成一台新设备——这是刻意的：无法证明身份的旧记录不该被继承。

---

## 二、设备→平台：消息

### 2.1 握手（连上后 5 秒内必须发，否则 `close(1008)`）

```json
{
  "t": "device_hello",
  "boot": "9f2c...32位hex",
  "link": "77ab...32位hex",
  "permit": "1c3e...32位hex",
  "permitTtlMs": 600,
  "model": "ESP32-S3 实验板",
  "fw": "1.5.0",
  "pair": "482913",
  "cmds": ["led","ledall","relay","mode","stop"],
  "caps": [ ... 见第三节 ... ]
}
```

| 字段 | 必填 | 说明 |
|---|---|---|
| `boot` | ✅ | 本次上电的代次，重启必须换新值 |
| `link` | ✅ | 本次连接的代次，重连必须换新值 |
| `permit` | ✅ | 短期许可，见 2.3 |
| `permitTtlMs` | ✅ | 许可有效期，100~1000 ms |
| `model` | 建议 | 设备型号名，平台用来挑展示模板 |
| `fw` | 建议 | 固件版本，显示在设备卡片上 |
| `pair` | 建议 | 当前配对码（6 位数字），用于把设备加到账户下 |
| `cmds` | 建议 | 允许的动作名白名单；不填则只接受 `hb`/`stop`（也可由 `caps[].cmd` 推导） |
| `caps` | 建议 | 能力清单，平台据此自动生成控制面板；不填则只能看在线状态 |

平台回：

```json
{"t":"ready","did":"a1b2c3d4e5f6","boot":"9f2c...","link":"77ab...","telemetryHz":2}
```

### 2.2 每 250 ms 续一次许可

```json
{"t":"permit","boot":"<同当前>","link":"<同当前>","permit":"<新的32位hex>","permitTtlMs":600}
```

平台最多保留 4 个未过期许可，重复 token 不续期。**连续 2 秒没收到新许可 → 判定设备失联**，
撤销租约、清空遥测。所以这个心跳是设备端的硬性义务。

### 2.3 遥测

```json
{"t":"tm","mode":2,"led":[255,0,0,0],"dht":{"ok":1,"t":24.5,"h":56},"sys":{"heap":180000,"up":93}}
```

- 频率由平台在 `ready` 里给（默认 2 Hz，1~50 可调），**发送方自己控速**。
- 内容自由：平台原样缓存并转发给所有观看者；控制台按 `caps` 决定怎么显示。
- 单帧上限 16 KB。

### 2.4 指令回执

```json
{"t":"ack","lease":"<租约>","seq":17,"ok":true,"message":"已执行"}
```

必须带回平台下发的 `lease` 与 `seq`，否则回执丢弃。

---

## 三、能力清单 `caps`（平台自动生成面板的依据）

数组，每项描述一个可展示/可控制的单元：

```json
{"id":"led","type":"pwm","label":"LED","n":4,"pin":[4,5,6,7],
 "cmd":"led","range":[0,255],"state":"led"}

{"id":"dht","type":"sensor","label":"温湿度",
 "fields":[{"k":"t","label":"温度","unit":"℃","digits":1},
           {"k":"h","label":"湿度","unit":"%","digits":0}],
 "path":"dht","okKey":"ok"}

{"id":"relay","type":"switch","label":"继电器","cmd":"relay","param":"on","state":"relay"}

{"id":"key","type":"input","label":"按键","n":6,"events":["down","up","short","long"]}

{"id":"mode","type":"enum","label":"实验","cmd":"mode","param":"id",
 "options":[{"v":0,"n":"手动"},{"v":1,"n":"LED 点亮"}]}
```

| `type` | 平台渲染 | 关键字段 |
|---|---|---|
| `pwm` | 一组滑块 | `n`、`range`、`cmd`、`state` |
| `switch` | 一个开关 | `cmd`、`param`、`state` |
| `sensor` | 数值卡片 | `fields[]`、`path`、`okKey` |
| `input` | 状态灯阵 | `n`、`events` |
| `enum` | 下拉或按钮组 | `options[]`、`cmd`、`param` |
| `chart` | 折线图 | `series[]`（引用 `sensor` 的 `k`） |

- `state` / `path` 是**遥测里的取值路径**（如 `led`、`dht.t`），平台据此把实时值填进控件。
- 不认识的 `type` 会被忽略，不影响其它控件。
- `cmd` 是对应指令的 `t`，参数名由 `param` 指明。
- `type` 写成 `select` 会被自动当作 `enum`；其它未知类型静默忽略。

---

## 四、平台→设备：消息

### 4.1 授予租约（有人取得控制权）

```json
{"t":"lease","boot":"...","link":"...","lease":"32位hex","state":"claimed"}
```

### 4.2 指令

```json
{"t":"command","did":"a1b2c3d4e5f6","boot":"...","link":"...",
 "lease":"32位hex","seq":17,"permit":"32位hex","command":{"t":"led","i":0,"v":255}}
```

设备端必须校验：`boot`/`link` 与当前一致、`lease` 与当前一致、`seq` 严格大于上一条、`permit` 未过期。
任一不过 → 丢弃并回 `ack ok=false`。

### 4.3 撤销租约

```json
{"t":"lease_end","boot":"...","link":"...","lease":"...","reason":"released"}
```

---

## 五、操作端（浏览器）协议

与设备侧共用同一套租约语义，只是方向相反。连接：

```js
new WebSocket("wss://esp32hub.luo-jin-ai.com/ws/op?did=" + did,
              ["lab.v1", "tk." + ticket]);   // 票据走子协议，避免进 URL 日志
```

| 方向 | 消息 |
|---|---|
| 平台→浏览器 | `{"t":"welcome","device":{...}}`、`{"t":"tm",...}`、`{"t":"device",...}`、`{"t":"permit",...}`、`{"t":"lease",...}`、`{"t":"lease_end",...}`、`{"t":"ack",...}` |
| 浏览器→平台 | `{"t":"claim"}`、`{"t":"hb"}`、`{"t":"release","lease":"..."}`、`{"t":"command","lease":"...","seq":N,"permit":"...","command":{...}}` |

一个设备同一时刻只有一个租约（一人控制、多人围观）。心跳 1.2 秒不发自动收回。

---

## 六、HTTP 接口

| 方法 | 路径 | 用途 |
|---|---|---|
| `GET` | `/api/info` | `{ok,mode:"hub",version}` |
| `POST` | `/api/pair` | 体 `{code}` → `{ok,did,ticket,device}`：用配对码换票据 |
| `GET` | `/api/device/:did` | 头 `x-lab-ticket` → 设备状态；公开设备免票据 |
| `POST` | `/api/device/:did/access` | 体 `{access}`，需票据：`private`/`public-read`/`public-control` |
| `POST` | `/api/device/:did/rename` | 体 `{name}`，需票据 |
| `DELETE` | `/api/device/:did/ticket` | 需票据：解除本机与设备的绑定 |
| `GET` | `/api/public` | 公开设备列表（不含票据、不含密钥） |
| `GET` | `/api/protocol` | 返回本文的机器可读摘要 |

票据：32 位十六进制，**只在配对时返回一次**，服务端只存 SHA256；默认 90 天，可随时解除。

---

## 七、错误码

| code | 含义 | 设备/前端动作 |
|---|---|---|
| `forbidden_device` | did 与连接不匹配 | 断开重连 |
| `invalid_lease` | 租约无效或不属于本连接 | 重新 claim |
| `stale_sequence` | seq 非递增（重放） | 刷新 seq 后重发 |
| `permit_expired` | 许可过期 | 等下一个 permit |
| `forbidden_command` | 指令不在白名单 | 不要重试 |
| `bad_command` | 参数类型/名称非法 | 修正后重发 |
| `device_offline` | 设备不在线 | **不缓存、不排队**，提示用户 |
| `busy` | 已有他人控制 | 提示「XX 正在控制」 |
| `rate_limited` | 超过 120/40 条每秒 | 退避 |

---

## 八、断线与退避

设备端建议：1s → 2s → 4s → 8s → 15s 封顶，每次重连换新的 `link`。
收到 `close(1008)`（协议错误）不要立即重连，先修逻辑；收到 `1006/1013` 可立即退避重连。

---

## 九、最小实现清单（新设备接入必做）

1. NVS 里保存 `did` / `key`，首次随机生成。
2. 每 250 ms 发一次 `permit`。
3. 按 `telemetryHz` 发 `tm`，内容自定。
4. 收到 `command` 校验 4 个字段后执行，回 `ack`。
5. 填 `caps`，平台就能自动生成面板。
6. 在串口或屏幕上显示 6 位 `pairCode`，用户输入它完成配对。

---

## 十、字段速查（复制到代码注释里）

```
Header  : x-lab-device(8-32 hex)  x-lab-key(32-64 hex)
Device→ : device_hello{boot,link,permit,permitTtlMs,model,fw,pair,caps} | permit | tm | ack
Hub→Dev : ready{did,boot,link,telemetryHz} | lease{boot,link,lease,state} | command{...} | lease_end
Op→Hub  : claim | hb | release{lease} | command{lease,seq,permit,command}
Hub→Op  : welcome{device} | tm | device | permit | lease | lease_end | ack | error
```
