---
AIGC:
    Label: "1"
    ContentProducer: 001191440300708461136T1XGW3
    ProduceID: 75f1f6d52a2df80429a8a02a6b2346a7_50515d32addb11f1b128525400f8a581
    ReservedCode1: 9L/owjhnungFe2sImJi4lRV12tZWjy60j0kFkk5K0sap/NBLz/n5qUOl1UwegkHOnQGmeozLgtoqG0Nl71U9wMWtEi4kGaakzgSoFv/lhi5m55GmXSDtiximuyw3nHFEe/wqs/2LroT8I7Jxvs8g6MeX7m3ug28438mzWRQ4Ln3Mr62WU/g+8+HkO4s=
    ContentPropagator: 001191440300708461136T1XGW3
    PropagateID: 75f1f6d52a2df80429a8a02a6b2346a7_50515d32addb11f1b128525400f8a581
    ReservedCode2: 9L/owjhnungFe2sImJi4lRV12tZWjy60j0kFkk5K0sap/NBLz/n5qUOl1UwegkHOnQGmeozLgtoqG0Nl71U9wMWtEi4kGaakzgSoFv/lhi5m55GmXSDtiximuyw3nHFEe/wqs/2LroT8I7Jxvs8g6MeX7m3ug28438mzWRQ4Ln3Mr62WU/g+8+HkO4s=
---



# 设备接入与公开应用协议（贺简云物联 IoT 开发者参考）

> 适用于：贺简云物联 IoT 平台的设备接入与公开应用开发（设备接入协议 v2）。
>
> 面向对象：接入设备的厂商/嵌入式工程师、需要调用公开应用 API 的前端开发者。
>
> 所有接口统一返回 JSON：`{ "code": 0, "msg": "ok", "data": {...} }`，`code=0` 表示成功。
> 注意响应字段名为 `msg`（不是 `message`）；错误码与 HTTP 状态码含义见「7. 错误码速查」。
>
> 本文档中的设备 ID、令牌、分享密钥等示例一律使用占位符。真实值请以
> **贺简云物联 IoT 平台管理后台 → 设备管理 → 设备详情** 中生成的设备 ID/令牌为准，
> 并在生产环境中妥善保管，切勿写入公开仓库或对外文档。

---

## 1. HTTP 设备接入（api.php）

设备端 HTTP 统一入口：`/api.php?action=<动作名>`。

| 动作 | 方法 | 用途 |
|---|---|---|
| `heartbeat` | POST / GET | 心跳/上线；设备由离线变在线时触发一次 `device_status` 规则 |
| `report` | POST | 上报属性（合并快照 + 触发 `device_prop` 规则）；可携带 `fs` 维护云盘内容 |
| `command` | GET | 拉取待执行指令（拉取成功即标记 sent） |
| `disk_stats` | GET / POST | 设备云盘空间概览（只读） |
| `disk_list` | GET / POST | 浏览设备云盘目录（只读） |
| `disk_read` | GET / POST | 读取云盘 txt / jpg 文件内容（只读） |
| `ota_download` | GET / POST | 下载本设备待升级的 OTA 固件（返回二进制流，见「3. 设备 OTA 固件下载」） |

基础 URL 示例：`https://www.hjyiot.cn/api.php`（生产以平台实际公网地址为准）。

### 1.1 鉴权（二选一，与后台登录态无关）

设备端**没有 session/cookie**，鉴权独立于后台账号体系，每次请求携带凭据：

| 方式 | 说明 | 示例 |
|---|---|---|
| HTTP Basic | 用户名 = `device_id`，密码 = `device_token` | `Authorization: Basic base64("device_id:device_token")` |
| 自定义请求头 | `X-Device-ID` + `X-Device-Token` | `X-Device-ID: <device_id>`、`X-Device-Token: <device_token>` |

鉴权要点：

- 令牌在后台「设备管理 → 设备详情」中生成/重置，重置后旧令牌立即失效，设备固件需同步更新；
- 设备定位基于**全局唯一 device_id**：鉴权时在全部启用租户内按 `device_id` 全局查找，凭据匹配即放行，**设备请求无需携带租户 ID**；
- `X-Tenant-ID` 请求头为协议预留位，设备通道不读取，可携带但不影响鉴权结果；
- 凭据错误、设备已停用（后台删除后档案停用）、设备所属租户被停用，统一返回 `code=1001`（HTTP 401），不区分提示，避免暴露设备/租户存在性；
- `ts` 为可选字段（Unix 秒级时间戳）：当前版本随上报记录到设备日志（`client_ts`），**平台未强制校验**；平台已预留时间容差参数（默认 300 秒），建议设备始终携带 `ts` 并做 NTP 校时（见「8.5」）。

### 1.2 心跳 heartbeat

- 方法 POST 或 GET 均可，无需请求体；
- 服务端将设备置为 `online=1`、更新最近在线时间与来源 IP，写心跳日志；
- **仅当设备由离线变在线（首次心跳/断线重连恢复）时触发一次 `device_status` 规则**（在线事件）；后续心跳只续期在线状态，不重复触发；
- 响应示例：

```json
{
  "code": 0,
  "msg": "ok",
  "data": {
    "online": 1,
    "server_ts": 1788600000,
    "last_seen": 1788600000
  }
}
```

### 1.3 属性上报 report

请求体（JSON，POST，`Content-Type: application/json`）：

```json
{
  "props": { "temperature": 27.5, "humidity": 52 },
  "ts": 1788600000
}
```

| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `props` | object | 二选一 | 属性字典，键=属性名，值=标量或 null；**嵌套对象/数组会被丢弃** |
| （平铺） | object | 二选一 | 不传 `props` 时可直接平铺属性：`{ "temperature": 27.5 }`（见下方 ts 注意事项） |
| `fs` | array | 可选 | 云盘内容维护指令（op=write / delete / delete_content），最多 20 条/包，见「2.3」 |
| `ts` | int | 可选 | Unix 秒级时间戳，仅记录到日志（见 1.1） |

行为要点：

1. **合并快照**：属性合并进设备属性快照（同名覆盖、其他键保留），画布/规则读取的是合并后的完整快照；
2. **比对触发**：服务端将合并后的新快照与上报前旧快照逐键比对，键**新增或值变化**才进入 `changed` 列表，随后派发 `device_prop` 规则事件；属性值未变化时该键不触发；
3. 属性值仅接受标量或 null：嵌套对象/数组一律丢弃；`props` 键存在但**不是 JSON 对象**时返回 `1004`；
4. `props` 传空对象 `{}`：刷新在线状态并写 report 日志，但因没有属性新增/变化键，**不会产生属性变化事件**；
5. 请求体缺失/非 JSON：返回 `1004`（HTTP 400）；单包上限 256KB，超限返回 `1004`（HTTP 413）；
6. **`ts` 位置建议**：推荐使用 `{ "props": {...}, "ts": ... }` 结构。若采用平铺属性且把 `ts` 平铺在同一层，`ts` 会被视为普通标量属性写入快照，因此平铺时不要携带 `ts`，或使用 `props` 包裹；
7. 响应（`data`）：

```json
{
  "accepted": 1,
  "props": { "temperature": 27.5 },
  "server_ts": 1788600000,
  "online": 1
}
```

若请求体携带 `fs`，响应 `data` 中额外返回 `fs` 数组，逐条给出每条指令的执行结果（详见 2.3）。

### 1.4 指令拉取 command（GET）

- 请求：`GET /api.php?action=command`（每次拉取同时视为一次心跳，保持在线）；
- 响应（`data`）：

```json
{
  "code": 0,
  "msg": "ok",
  "data": {
    "online": 1,
    "server_ts": 1788600000,
    "commands": [
      {
        "id": "65f1a2b3c4d5e6f7",
        "type": "command",
        "payload": { "fan_on": 1 },
        "source": "rule:2",
        "status": "sent",
        "ts": 1788600000,
        "sent_at": 1788600005
      }
    ]
  }
}
```

指令项字段：

| 字段 | 类型 | 说明 |
|---|---|---|
| `id` | string | 指令 ID（注意字段名是 `id`，不是 `command_id`） |
| `type` | string | 指令类型（手动/规则/公开页默认 `command`，由创建方定义） |
| `payload` | object | 指令参数，按业务自定义 |
| `source` | string | 指令来源，如 `rule:<规则ID>` / `public_app:<应用ID>` / `manual:<操作者>` |
| `status` | string | 返回给设备时恒为 `sent` |
| `ts` | int | 指令入队时间 |
| `sent_at` | int | 本次被设备拉取（标记 sent）的时间 |

**拉取即 sent 语义（重要）**：

- GET 拉取成功即把全部待处理指令一次性返回并标记 `status=sent` + 写入 `sent_at`——**不是"执行成功才算 sent"**；
- 标记后**不再进入后续拉取结果**：设备流程必须是**先拉取 → 执行 → 通过 report（或 MQTT telemetry）回写实际执行结果**，否则平台侧看不到执行结果；
- 指令队列保留最近 200 条已 sent 记录供追溯；队列总量有上限（默认 5000 条，超出丢最旧）。

### 1.5 限流与租户/设备状态

- 设备 HTTP 通道限流两档叠加：

| 层级 | 阈值 | 触发时机 |
|---|---|---|
| IP 级 | 600 次/分钟/IP | **先于鉴权**执行（防止爆破，凭据错误也计数） |
| 设备级 | 120 次/分钟/设备 | 鉴权通过后按 device_id 计数 |

超限返回 `code=1005`。窗口为固定 60 秒。

- 租户被平台管理员停用后：该租户设备**无法通过鉴权**，表现与凭据错误一致（`code=1001`，HTTP 401）；
- 设备被后台删除（档案停用）后：同样无法鉴权（`code=1001`），其待处理指令队列一并清除。

### 1.6 联调示例（占位符）

```bash
DEVICE_ID="<your_device_id>"
DEVICE_TOKEN="<your_device_token>"
BASE="https://www.hjyiot.cn/api.php"

# 1) 心跳上线
curl -u "$DEVICE_ID:$DEVICE_TOKEN" -X POST -H 'Content-Type: application/json' \
  -d '{"ts":'$(date +%s)'}' "$BASE?action=heartbeat"

# 2) 属性上报
curl -u "$DEVICE_ID:$DEVICE_TOKEN" -X POST -H 'Content-Type: application/json' \
  -d '{"props":{"temperature":27.5},"ts":'$(date +%s)'}' "$BASE?action=report"

# 3) 拉取指令（取走即 sent，执行结果需另行 report 回写）
curl -u "$DEVICE_ID:$DEVICE_TOKEN" "$BASE?action=command"

# 4) 云盘空间概览（只读）
curl -u "$DEVICE_ID:$DEVICE_TOKEN" "$BASE?action=disk_stats"
```

---

## 2. 设备云盘（协议 v2）

平台为同一租户下的所有设备提供一块共享云盘空间（默认 10MB 配额，以租户维度统计），
设备可通过 HTTP 接口进行**只读浏览**与**受控内容维护**。

> **配额口径**：该 10MB 为**账户级共享配额**，由「云盘文件 + OTA 固件」**合计占用**（见「3. 设备 OTA 固件下载」）。
> `disk_stats` 返回的 `used` / `free` / `percent` 均为**含 OTA 固件在内**的合计口径。

> 云盘为协议 v2 能力，与旧版"设备上传整文件"方式**不兼容**：平台已下架整文件上传，
> 所有内容保存均须通过 `report.fs` 指令显式指定文件路径完成。

### 2.1 开通前提：签署《云盘使用协议》

- 租户需在 **Web 端（租户控制台 → 云盘）** 阅读并签署当前版本《云盘使用协议》后，云盘才处于**已开通**状态；
- **未签署（或协议版本已更新、未重新签署）时默认未开通**：设备端任何写类指令（write / delete / delete_content）都会被拒绝，逐条返回
  `ok=false`、错误信息为「云盘尚未开通：请先在 Web 端签署《云盘使用协议》」；
- 只读三接口（`disk_stats` / `disk_list` / `disk_read`）不要求协议签署即可使用；若云盘处于"未开通"状态，只读接口仍按空/零值口径返回（以接口实际响应为准）。

### 2.2 只读三接口

#### 2.2.1 空间概览 disk_stats

`GET/POST /api.php?action=disk_stats`

响应 `data` 字段：

| 字段 | 类型 | 说明 |
|---|---|---|
| `limit` / `limit_text` | int / string | 配额上限（字节）/ 可读文本（如 `10.0 MB`） |
| `used` / `used_text` | int / string | 已用（字节）/ 可读文本（**含云盘文件与 OTA 固件合计占用**） |
| `free` / `free_text` | int / string | 剩余（字节）/ 可读文本 |
| `percent` | int | 使用百分比（0-100） |
| `read_only` | int | 1=只读（平台封禁或配额已满），0=可写 |
| `disabled` | int | 1=已被平台封禁 |
| `disabled_reason` | string | 封禁原因（未封禁为空） |
| `quota_hit` | int | 1=配额已满 |
| `file_types` | array | 云盘支持的文件类型（`["txt","jpg"]`） |

云盘被平台封禁时返回 `code=1002`（权限不足），附带封禁原因。

#### 2.2.2 目录浏览 disk_list

`GET/POST /api.php?action=disk_list`，可选参数 `path`（相对路径，空/缺省=云盘根目录）。

响应 `data` 字段：

| 字段 | 类型 | 说明 |
|---|---|---|
| `rel` | string | 规范化后的相对路径（根为 `""`） |
| `items` | array | 当前目录条目，按「目录优先，再按名称」排序 |
| `limit` / `used` / `free` / `percent` | int | 配额/已用/剩余/百分比 |
| `read_only` / `disabled` / `quota_hit` | int | 语义同 2.2.1 |

`items` 中每条条目：

| 字段 | 类型 | 说明 |
|---|---|---|
| `name` | string | 名称 |
| `type` | string | `dir` 或 `file` |
| `ext` | string | 文件扩展名（目录为空） |
| `size` | int | 文件字节数（目录为 0） |
| `modified` | int | 最后修改时间（Unix 秒） |
| `frozen` | int | 1=已被平台冻结（不可删除/读取内容，目录下浏览仍可见） |

云盘被平台封禁时返回 `code=1002`。

#### 2.2.3 文件读取 disk_read

`GET/POST /api.php?action=disk_read`，参数 `path`（必填，指向文件，如 `logs/2026-09-08.txt`）。

- 仅支持读取 `txt` 与 `jpg` 两种类型；其他类型返回 `code=1004`；
- 文件不存在返回 `code=1003`；文件被冻结返回 `code=1002`；
- `txt` 响应（`data`）：`{ "name": "...", "ext": "txt", "size": 1234, "encoding": "utf-8", "content": "<文本内容>" }`
- `jpg` 响应（`data`）：`{ "name": "...", "ext": "jpg", "size": 5678, "content_type": "image/jpeg", "data": "<base64 编码图片>" }`

### 2.3 report.fs 写类指令（协议 v2 内容维护方式）

写类云盘操作**不独立成接口**，而是放在 `report` 请求体的 `fs` 数组中一并上报：

```json
{
  "props": { "log_ok": 1 },
  "fs": [
    { "op": "write", "path": "logs/2026-09-08.txt", "content": "温度 27.5°C\n", "mode": "append", "create": true },
    { "op": "delete_content", "path": "logs/2026-09-08.txt", "needle": "告警#101", "all": false },
    { "op": "delete", "path": "tmp/old.txt" }
  ]
}
```

通用规则：

- `fs` 必须是数组，每条指令为一个对象；单包最多 **20 条**，超限返回 `1004`（HTTP 400）；
- `fs` 与属性上报解耦：`fs` 不会写入属性快照、不触发属性规则；
- 每条指令独立执行、独立回报（部分失败不影响属性上报与其它指令）；
- 响应 `data.fs` 为数组，逐条对应指令结果：

```json
{
  "accepted": 1,
  "props": { "log_ok": 1 },
  "fs": [
    { "op": "write", "path": "/logs/2026-09-08.txt", "ok": true, "bytes": 18, "created": 1, "mode": "append" },
    { "op": "delete_content", "path": "/logs/2026-09-08.txt", "ok": true, "removed": 1, "bytes": 42 },
    { "op": "delete", "path": "/tmp/old.txt", "ok": false, "error": "目标不存在：/tmp/old.txt" }
  ]
}
```

**op=write（写入/追加 txt 文本）**

| 字段 | 类型 | 说明 |
|---|---|---|
| `op` | string | 固定 `write` |
| `path` | string | 目标文件相对路径（必填，不能为根目录） |
| `content` | string | UTF-8 文本内容（必填；单条上限 128KB；单文件上限 8MB） |
| `mode` | string | `append`=追加（默认）\| `overwrite`=整文件覆盖 |
| `create` | bool | 目标文件/目录不存在时是否自动创建（默认 false，缺省时不存在直接报错） |

仅支持 `txt` 文件；写入内容必须为合法 UTF-8。

**op=delete（删除文件）**

| 字段 | 类型 | 说明 |
|---|---|---|
| `op` | string | 固定 `delete` |
| `path` | string | 目标文件相对路径（必填，不能为根目录） |

仅允许删除文件，**目录删除请由平台人工处理，设备仅可删除文件**；目标不存在返回错误；云盘被封禁或目标被冻结时拒绝。

**op=delete_content（删除 txt 文件中的内容片段）**

| 字段 | 类型 | 说明 |
|---|---|---|
| `op` | string | 固定 `delete_content` |
| `path` | string | 目标 txt 文件相对路径（必填） |
| `needle` | string | 要删除的内容片段（子串匹配，必填） |
| `all` | bool | true=删除全部匹配；false（默认）=仅删除首个匹配 |

仅支持 `txt` 文件。

**开通/封禁/冻结/配额语义汇总**

| 状态 | 写类指令 | 只读接口 |
|---|---|---|
| 未签署协议 | 拒绝（提示先签署《云盘使用协议》） | 可用（返回空/零值口径） |
| 平台封禁 | 拒绝 | `disk_stats`/`disk_list` 返回 1002；`disk_read` 返回 1002 |
| 文件/目录冻结 | 对应路径拒绝 | `disk_list` 仍列出并标 `frozen=1`；`disk_read` 读取被冻结文件返回 1002 |
| 配额已满 | `quota_hit=1`、`read_only=1`（写可能被拒） | 读取不受影响 |

---

## 3. 设备 OTA 固件下载（ota_download）

平台租户侧 OTA 更新流水线为：**上传源码/固件 → （源码）平台审查 → 编译 → 推送设备**。
推送时平台向设备指令队列写入一条 `type=ota` 指令；设备拉取到该指令后调用 `ota_download`
下载固件二进制，校验通过后写入 OTA 分区并重启（见 3.5）。

> **配额口径（重要）**：OTA 固件（上传源码与编译产物）与云盘（见 2）**共享同一账户 10MB 配额**，
> `disk_stats.used` 返回的是「云盘文件 + OTA 固件」的**合计**占用；批量上传按设备份数计入占用。

### 3.1 前置条件（平台逐项校验）

设备调用 `ota_download` 时，平台按以下顺序校验，任一不满足即返回 JSON 错误包（不会返回二进制）：

| # | 条件 | 不满足时返回 |
|---|---|---|
| 1 | 租户 OTA 功能已开启（平台管理员侧开关） | `1002`「租户 OTA 功能已停用」 |
| 2 | 租户云盘未被平台封禁（存储同源，一并停用） | `1002`「云盘已被平台封禁，OTA 固件下载已停用：<原因>」 |
| 3 | 任务存在，且任务归属设备 = 当前鉴权设备 | `1003`「OTA 任务不存在」（两种情况同一提示，防跨设备探测） |
| 4 | 任务未被平台冻结（`frozen=1` 或 `status=frozen`） | `1002`「该固件已被平台冻结，禁止下载」 |
| 5 | 任务状态为 `ready` 或 `pushed` | `1004`「固件未就绪」 |
| 6 | 处于下载窗口内：自最近推送时间（未推送则退化为任务创建时间）起 **24 小时**（`download_ttl=86400`，窗口基准时间缺失同样视为过期） | `1004`「固件下载窗口已过期，请重新推送」 |
| 7 | 固件文件存在于租户 `ota_files/` 目录 | `1006`「固件文件缺失」 |

### 3.2 请求

```bash
DEVICE_ID="<your_device_id>"
DEVICE_TOKEN="<your_device_token>"
BASE="https://www.hjyiot.cn/api.php"

# GET（推荐：curl -OJ 直接以响应头文件名落盘）
curl -u "$DEVICE_ID:$DEVICE_TOKEN" -OJ "$BASE?action=ota_download&job_id=<job_id>"

# POST（body 传 job_id）
curl -u "$DEVICE_ID:$DEVICE_TOKEN" -X POST -H 'Content-Type: application/json' \
  -d '{"job_id":"<job_id>"}' "$BASE?action=ota_download"
```

- `job_id` **必填**（GET query 或 POST body 均可），缺失返回 `1004`「缺少 job_id」；
- 鉴权方式同 1.1，限流口径同 1.5（IP 级 600 次/分、设备级 120 次/分，**下载同样计入设备级次数**）。

### 3.3 响应：成功为二进制流（不是 JSON）

成功时返回 `HTTP 200` + `Content-Type: application/octet-stream`，**响应体即固件二进制原文**，
**不再有 `{"code":0,"msg":"ok","data":...}` 包裹**。设备的 HTTP 客户端必须按「字节流」处理，
不能按 JSON 解析；只有失败时才会返回 3.4 的 JSON 错误包，因此需按 `Content-Type` 区分两种响应。

成功响应头：

| 响应头 | 说明 |
|---|---|
| `X-OTA-Version` | 固件版本号（URL 编码，需 decode 后使用） |
| `X-OTA-SHA256` | 固件 SHA-256（**下载后必须校验**） |
| `X-OTA-Size` | 固件字节数 |
| `X-OTA-Job` | OTA 任务 ID（回显请求的 job_id） |
| `Content-Length` | 固件字节数（与 `X-OTA-Size` 一致） |
| `Cache-Control` | `no-store`（固件不做缓存） |

下载后校验示例：

```bash
curl -u "$DEVICE_ID:$DEVICE_TOKEN" -D headers.txt -o fw.bin \
  "$BASE?action=ota_download&job_id=<job_id>"
grep -i '^X-OTA-' headers.txt          # 取 X-OTA-Size / X-OTA-SHA256 / X-OTA-Version
sha256sum fw.bin                       # 与 X-OTA-SHA256 逐字比对
```

### 3.4 错误码

| code | HTTP | 场景（按校验顺序） |
|---|---|---|
| `1002` | 200/403 | 租户 OTA 功能已停用；云盘已被平台封禁；固件已被平台冻结 |
| `1003` | 200/404 | OTA 任务不存在（任务不存在或不属于本设备） |
| `1004` | 200/400 | 缺少 job_id；固件未就绪（状态非 ready/pushed）；固件下载窗口已过期 |
| `1006` | 200 | 固件文件缺失；固件文件读取失败 |

### 3.5 与 command 指令联动（type=ota）

推送时平台向目标设备指令队列写入一条 `type=ota` 的指令（设备离线则入队等待，上线后拉取即可），其 `payload` 固定为：

```json
{
  "version": "1.2.0",
  "job_id": "65f1a2b3c4d5e6f7",
  "size": 512000,
  "sha256": "9f2c...（64 位十六进制）",
  "ts": 1788600000
}
```

- 指令 `source` 形如 `ota:<操作者用户名>`，`id` 为该指令的 16 位十六进制 ID（与 1.4 的指令结构一致）；
- 设备经 `command` 拉取到该指令后：① 取 `payload.job_id` / `payload.version`；② 与本地版本比对
  （平台侧已保证版本递增，设备侧建议再判一次防回退）；③ 调 `ota_download` 下载，按
  `X-OTA-Size` / `X-OTA-SHA256` 校验；④ 写入 OTA 分区 → 重启 → 上线后 `report` 回写新版本，形成闭环；
- 重复推送同一任务会再次入队一条 `ota` 指令（任务状态转 `pushed` 并刷新推送时间，从而重置 24 小时下载窗口）。

### 3.6 平台侧任务状态机（11 态）

| 状态 | 平台页面显示 | 含义 |
|---|---|---|
| `pending_review` | 待审查 | 已上传等待审查（`.ino` 源码） |
| `reviewing` | 审查中 | 正在隔离审查（页面提示约 10 分钟内完成） |
| `review_fail` | 审查未通过 | 审查未通过（含问题代码定位，平台不代修） |
| `queued` | 排队中 | 审查通过，等待编译（页面展示排队位次与预计完成时间） |
| `need_payment` | 待充值 | 编译额度不足，等待充值（3 元/次，联系客服） |
| `compiling` | 编译中 | 正在编译 |
| `compile_fail` | 编译失败 | 编译失败（含错误与问题代码片段） |
| `ready` | 可推送 | 编译成功 / 直传 `.bin`，可推送、可下载 |
| `pushed` | 已推送 | 已推送设备（仍可重复推送；下载窗口内可取固件） |
| `canceled` | 已撤销 | 用户撤销 |
| `frozen` | 已冻结 | 平台冻结（阻断编译、推送与下载） |

> 平台管理端的状态显示为「正常 / 已停用」两类 OTA 开关状态 + 任务冻结标记，与上表一一对应；
> 租户侧「OTA 升级」页面按上表第二列展示。


流转要点：

- 上传 `.ino`（≤512KB）→ `pending_review` → 审查通过自动 `queued` → 编译成功 `ready`；
- 上传 `.bin`（≤4MB）→ **直接 `ready`**（跳过审查与编译，不消耗编译额度）；
- 编译额度：每租户免费 `compile_free=3` 次。一次可勾选**多台设备**批量提交，但**每台设备生成一条
  独立任务、各占 1 次编译额度**（同一批共用一次编译产物）；任务**实际进入编译即扣 1 次**（编译失败
  同样计费；审查未通过、源文件拉取失败不计费）；额度用尽进入 `need_payment`，充值（3 元/次，联系
  客服）后继续；
- `compiling` 超过 **20 分钟**未推进 → 后台 worker 判定为编译中断，回退 `queued` 下轮自动重试；
- 单次批量推送上限 **50** 个任务；单租户任务上限 **200** 个；每任务保留 20 条状态更新（对租户展示最近 10 条）；
- 版本号须**严格大于**该设备已发布最高版本（防回退），长度 ≤60 字符，`v`/`V` 前缀会被规范化剥离。

### 3.7 任务删除规则

- **仅 `queued`（排队中）与 `compiling`（编译中）两个「流水线在途」状态不可删除**，平台返回
  `1004`「任务当前状态为「排队中/编译中」，流水线处理中不可删除，请等待编译结束后再删除」；
- 其余状态（`pending_review` / `reviewing` / `review_fail` / `need_payment` / `compile_fail` /
  `ready` / `pushed` / `canceled` / `frozen`）**均可删除**；
- 删除为**物理删除**：清理租户 `ota_files/` 下的 `<jobId>.ino` 与 `<jobId>.bin`，
  并**释放其占用的账户配额**（平台记录释放量并在日志中体现）；
- 租户侧「OTA 升级」页面删除时会二次确认：「该任务的 .ino 源文件与 .bin 固件产物将一并删除，
  并释放约 <体积> 云盘空间」；**固件只能在 OTA 页删除任务释放，云盘侧不可删除**；
- 每次删除写 OTA 审计 `ota_delete`，设备侧不存在删除入口（设备只能下载，不能删除任务）。

### 3.8 审计口径（仅平台管理员可见）

- OTA 全部动作（上传 / 审查 / 编译 / 推送 / 删除 / 平台冻结 / 额度）写入**独立审计文件**
  `ota_audit.json`，并与通用审计 `audit_log.json`、云盘审计 `disk_audit.json` **双写**；
- **审计仅平台管理员侧可读**（平台管理端「OTA 审计」入口），**租户侧与设备侧均不开放**审计查询接口；
- 常见 action：`ota_upload` / `ota_compile_ok` / `ota_compile_fail` / `ota_push` / `ota_delete` /
  `ota_quota_reject` 等；每条含 `operator`（Web 用户名 / platform_admin / 设备 ID / system）与
  `source`（web / admin / device / system）。

### 3.9 SDK 支持

| SDK | 方法 | 说明 |
|---|---|---|
| ESP32 Arduino C++ | `dev.otaDownload(jobId, version, size, sha256Hex, err)` | 流式写入 OTA 分区，边写边算 SHA-256，比对通过才 `Update.end()`；成功经 `version`/`size`/`sha256Hex` 回传固件信息，失败经 `err` 回传错误包或本地原因 |
| Python | `dev.ota_download(job_id, dest_path)` | 下载到本地文件并按 `X-OTA-SHA256` 校验，返回 `{version,size,sha256,sha256_match,path}` |

两类 SDK 均只做「下载 + 校验 + 落盘/落分区」，**版本比对与重启策略由业务侧决定**。

## 4. MQTT 通道（可选）

MQTT 为**可选实时通道**，用于加速指令下行；设备属性上报/心跳亦可经 MQTT 上行，
与 HTTP 行为等价。平台需要预先配置可用的 MQTT Broker（平台自身不内置 Broker 服务，
由部署方提供 Mosquitto/EMQX 等标准 MQTT 3.1.1 Broker）。

### 4.1 平台侧配置与运行

- MQTT 总开关与 Broker 参数在「平台管理 → 系统参数」中维护：`enabled`（总开关）、`host`、`port`、
  `username`、`password`、`client_id`（平台连接前缀）、`keepalive`、`use_ssl`、`ca_file` 等；
- 平台开启 MQTT 后需运行平台侧订阅进程（常驻消费者），订阅设备上行主题；
  断线自动重连；未启用 MQTT 时进程提示退出；
- 设备接入 broker 使用 **broker 账号体系**（由部署方/平台管理分配），设备 HTTP 通道的
  device_token 不用于 MQTT 建连。

### 4.2 Topic 约定

`{tenant_id}` 为**数字租户 ID**（与平台后台地址中的租户 ID 一致）；`{device_id}` 为设备 ID。

| 方向 | Topic | 负载 | 等价行为 |
|---|---|---|---|
| 上行·属性上报 | `devices/{tenant_id}/{device_id}/telemetry` | `{"props":{...},"ts":123}` 或平铺 `{"temperature":27.5}` | 等价 HTTP report：合并属性快照 + 触发 `device_prop` 规则 |
| 上行·状态 | `devices/{tenant_id}/{device_id}/status` | `{"online":1}` / `{"online":0}` | 更新在线状态 + 触发 `device_status` 规则 |
| 下行·指令 | `devices/{tenant_id}/{device_id}/cmd` | 见 3.3 | 平台各来源下发指令的实时通道 |

Topic 中 `{tenant_id}` 必须是纯数字，且该租户在平台中真实存在，否则消息被忽略。

### 4.3 下行指令 cmd 消息

平台任何来源下发指令时，**都会先把指令写入 HTTP 拉取队列**（保证设备即使不走 MQTT 也能拉取到），
然后**尽量**向 `devices/{tenant_id}/{device_id}/cmd` 主题发布实时消息（QoS 1）；
MQTT 未启用/发布失败**不阻断**下发——HTTP 拉取始终兜底。

设备侧订阅 cmd 主题后收到的消息形如：

- 规则引擎 `device_command` 动作下发：

```json
{ "type": "command", "payload": { "fan_on": 1 }, "rule_id": 2, "ts": 1788600000 }
```

- 后台手动下发 / 公开应用页指令下发：

```json
{ "id": "65f1a2b3c4d5e6f7", "type": "command", "payload": { "fan_on": 1 }, "ts": 1788600000 }
```

> MQTT cmd 消息没有统一 `command_id` 字段：规则场景携带 `rule_id`，手动/公开页场景携带 `id`。
> 设备应优先按 `id` 判重（有则用；无则可用 `type + ts` 近似去重），执行结果一律经 telemetry/report 回写。

### 4.4 上行限制

平台订阅进程按**租户**限流（120 条/分钟/租户），超限消息被丢弃；
平台不校验 MQTT 消息中的设备 token（接入由 Broker 账号体系与租户目录控制）。

---

## 5. Webhook 触发器入口（webhook.php）

规则引擎支持由外部系统通过 Webhook 触发。URL：

```
/webhook.php?tenant_id=<租户ID>&key=<webhook密钥>&path=<触发路径>
```

参数与行为：

- `tenant_id`：数字租户 ID（规则所在租户，须为启用状态）；
- `key`：规则 Webhook 触发器中配置的密钥（规则编辑页生成/复制）；
- `path`：触发路径，须与规则中配置的 `webhook_path` 一致（格式 `/[A-Za-z0-9_\-/]`，长度 1-80）；
  同一规则可有多个 path，多钩子靠 path 区分；
- 负载支持三种形式：JSON body / `application/x-www-form-urlencoded` 表单 / GET query
  （GET query 作为负载时会自动剔除 `tenant_id`、`key`、`path` 三个路由参数）；
- 参数非法：`code=1004`（HTTP 400）；租户不存在/未启用、key/path 未命中任何启用规则：
  `code=1003`（HTTP 404）；
- IP 限流 120 次/分钟（按 key+IP 计数），超限 `code=1005`；
- 匹配后请求负载整体投递给规则引擎执行（可触发 device_command / 属性设置 / 通知 / HTTP 回调等动作），
  外部只看到受理结果，规则执行细节见平台规则日志：

```json
{ "code": 0, "msg": "ok", "data": { "accepted": 1, "server_ts": 1788600000 } }
```

---

## 6. 公开应用页（免登录）

发布后的应用可通过免登录链接访问，并对外提供只读数据与受控指令两类 JSON 子接口。

### 6.1 访问形式

```
# 查询串方式（通用）
/app.php?app=<应用ID>&k=<分享密钥>

# 路径式（Web 服务器支持 rewrite 时可用）
/app/<应用ID>?k=<分享密钥>
```

- `app`：应用 ID（`app_id`）；
- `k`：分享密钥（发布时生成；取消发布/刷新密钥后旧链接失效）；
- `&preview=1`：草稿预览模式——要求已登录且属于本租户（未登录跳转登录页），
  按当前租户定位应用画布，无需传 `k`；预览模式同样可调 data/cmd 子接口（免 `k`）；
- JSON 子接口触发：URL 带 `data=1` / `cmd=1` / `json` 任意一个即按 JSON 返回。

### 6.2 只读数据子接口（data=1）

```
GET /app.php?app=<应用ID>&k=<分享密钥>&data=1
```

返回**画布中绑定了设备的组件**所涉及设备的实时快照 + 折线序列
（未绑定画布的设备不会出现；折线由设备最近 300 条上报日志还原、最多 60 点、按时间升序）：

```json
{
  "code": 0,
  "msg": "ok",
  "data": {
    "devices": {
      "sensor-temp-001": {
        "online": 1,
        "last_seen": 1788600000,
        "props": { "temperature": 27.5, "humidity": 52 }
      }
    },
    "series": {
      "sensor-temp-001:temperature": [
        { "t": 1788599700, "v": 27.0 },
        { "t": 1788600000, "v": 27.5 }
      ]
    },
    "server_ts": 1788600000
  }
}
```

- 公开应用页 HTML 已内置每 4 秒轮询该接口刷新；自研前端/嵌入式页面建议轮询间隔 ≥ 3~5 秒；
- 限流：300 次/分钟/IP，超限 `code=1005`；
- 应用未发布/密钥错误/租户停用：返回 `code=1003`（HTTP 404）。

### 6.3 受控指令子接口（cmd=1）

POST JSON，服务端从**画布内对应组件的指令模板**读取允许下发的设备与类型，再与客户端负载合并执行：

```
POST /app.php?app=<应用ID>&k=<分享密钥>&cmd=1
Content-Type: application/json

{ "component_id": "sw_fan", "payload": { "fan_on": 1 } }
```

- 组件必须存在于画布且已配置下发设备，否则 `code=1004`；
- 最终下发的 `type` = 组件指令模板中的 `type`（默认 `command`）；
- 最终 `payload` = 组件模板 payload 与客户端 payload 合并（客户端可补充/覆盖模板键，
  但**无法**指向模板外设备或模板外指令类型——这是公开页防滥用控设备的核心白名单）；
- 目标设备不存在：`code=1003`；
- 限流：60 次/分钟/IP，超限 `code=1005`；
- 成功后指令先入队、再尝试 MQTT cmd 实时下发，响应：

```json
{
  "code": 0,
  "msg": "ok",
  "data": {
    "command_id": "65f1a2b3c4d5e6f7",
    "queued": true,
    "mqtt_sent": true,
    "mqtt_error": ""
  }
}
```

---

## 7. 错误码速查

平台后台 API、设备 API、公开应用页使用同一套错误码。

| code | HTTP | 含义 | 典型场景 |
|---|---|---|---|
| 0 | 200 | 成功 | — |
| 1001 | 200/401 | 鉴权失败 | 未登录/会话过期；设备凭据错误、设备不存在或已停用、所属租户被停用 |
| 1002 | 200/403 | 权限不足 | 角色不匹配、跨租户访问、云盘被封禁/冻结 |
| 1003 | 200/404 | 资源不存在 | 应用/设备/规则/Webhook 未找到、应用未发布或密钥错误、云盘文件不存在 |
| 1004 | 200/400/413 | 参数校验失败 | 参数缺失或非法、props 非对象、上报包超 256KB（413）、fs 超 20 条 |
| 1005 | 200 | 请求频率超限 | 设备 IP 600/分、设备 120/分；公开页 data 300/分、cmd 60/分；Webhook 120/分 |
| 1006 | 200 | 文件 IO 错误 | 存储层异常 |
| 1007 | 200 | MQTT 错误 | Broker 相关错误 |
| 1008 | 200 | 设备离线 | 需在线上执行的操作遇离线设备 |

---

## 8. 真机联调注意事项

### 8.1 ESP32 固件上传：波特率过高会失败

`arduino-cli`/Arduino IDE 向 ESP32 上传固件默认波特率过高可能导致串口握手失败，
报 `Failed to connect to ESP32` 之类错误：

- 上传时将 Upload Speed（`--upload-speed`）显式降为 **460800**；
- 上传期间关闭占用串口的程序（串口监视器/串口调试助手），否则握手失败；
- 失败后先拔插 USB / 按住 BOOT 重新进入下载模式再重试。

### 8.2 属性名必须与画布/规则完全一致（大小写敏感）

设备上报的属性名是**大小写敏感**的字典键，平台不做别名归一化：

- 上报 `Temperature` 而画布绑定 `temperature` 时，数据会照常写入设备快照，但画布组件、
  规则条件、报表都读不到；
- 联调前请确认：画布组件绑定属性、规则触发条件属性、设备 `props` 上报键三者逐字一致；
- 排障时优先查看后台设备详情中的属性快照与日志。

### 8.3 command 是"先拉后执行"，拉取即 sent

- 设备流程必须是：周期 GET `action=command` → 拿到指令 → 执行 → 把执行结果通过
  `report`（或 MQTT telemetry）回写实际属性；
- 不要"拉取后不执行"（平台侧已视为下发成功）；一次拉取会返回全部待处理指令，需逐条处理并回写。

### 8.4 ts 时间戳与校时

- 平台已预留时间容差参数（默认 300 秒）用于 ts 校验，**当前版本设备通道尚未强制执行**
  （超差不拒收，ts 仅记录到设备日志）；
- 建议真机一律做 NTP 校时并携带 `ts`，避免未来启用强校验后被拒收；无法校时的老设备
  可不带 `ts`，仅日志缺失提示，不影响接入；
- 判断"数据是否真的被处理"以接口返回 `code=0` + 后台设备日志为准，不要只看设备侧发送成功。

### 8.5 网络地址与生产环境

- 本地联调时 `127.0.0.1` 仅本机可用：局域网真机应改用电脑/服务器的局域网 IP；
  公网设备需把平台地址配置为公网可访问的域名或映射地址；
- PHP 内置服务器仅适合内网联调，生产环境请使用 Web 服务器正式部署；
- 切勿在固件源码或对外示例中提交真实设备令牌/分享密钥；令牌泄露后请在后台重置。

### 8.6 MQTT 与 HTTP 双通道

- 平台下发指令**始终先写入 HTTP 拉取队列**，MQTT 只是实时通知加速；设备只走 HTTP
  也能正常收到全部指令，只是存在轮询延迟；
- 若同时使用 HTTP 与 MQTT，请注意按指令 `id` 判重，避免同一指令重复执行。

---

## 9. 多语言 SDK

平台提供 ESP32 Arduino C++ 与 Python 两套设备接入 SDK（含云盘只读、report.fs 与
OTA 固件下载示例），使用方式见 [SDK 说明](sdk/SDK使用说明.md)。

- ESP32 Arduino C++ SDK：`sdk/esp32/`（库目录 `HjyIot` + example 工程，含 `ota_update` 示例）
- Python SDK：`sdk/python/`（模块 `hjyiot.py` + 示例脚本，含 `ota_download.py`）

OTA 下载能力（对应「3. 设备 OTA 固件下载」）：

| SDK | 方法 | 行为 |
|---|---|---|
| ESP32 | `dev.otaDownload(jobId, version, size, sha256Hex, err)` | 流式写入 OTA 分区并逐块校验 SHA-256，成功后可立即重启生效（见 `examples/ota_update/`） |
| Python | `dev.ota_download(job_id, dest_path)` | 下载到本地文件，按 `X-OTA-SHA256` 校验并返回版本/大小/校验结果 |

两者均只负责「下载 + 校验 + 落盘（落分区）」，版本比对与重启时机由业务侧决定。

---

## 附：接口与实现对应关系（便于版本升级核对）

| 文档章节 | 对应实现 |
|---|---|
| §1 设备 HTTP API | `api.php` → `DeviceApiController` → `DeviceModel` |
| §1.5 限流 | `DeviceApiController`（IP 600/分、设备 120/分） |
| §2 云盘 | `DiskController` / `DiskFs` / `DeviceApiController`（disk_* 与 report.fs） |
| §3 设备 OTA 下载 | `DeviceApiController::doOtaDownload` + `OtaModel`（状态机/配额）+ `TenantController`（ota_upload/ota_push/ota_delete）+ `OtaAuditModel`（审计） |
| §4 MQTT | 平台 MQTT 订阅进程 + `MqttClient` + `RuleEngine`（device_command 下行） |
| §5 Webhook | `webhook.php` → `RuleEngine`（dispatchWebhook） |
| §6 公开应用页 | 公开应用入口 → `PublicAppController` |
| §7 错误码 | `Response`（0/1001-1008） |
*（内容由AI生成，仅供参考）*
*（内容由AI生成，仅供参考）*
