# MQTT 与嵌入式通信协议

本文档说明后端服务与嵌入式设备之间的 MQTT Topic、通信方向、Payload 类型和 JSON 数据格式。

> **Topic 标识变更（v5.6 起）**：除首次注册回复 `registerDeviceNo` 仍按 **MAC 地址** 下发外，其余 MQTT 下行命令 Topic 第一段统一使用**设备编号 deviceNo**。后端上行处理通过 `DeviceIdentityResolver` 兼容 MAC / 设备编号两种入参；下行命令请按 `deviceNo` 订阅 `subscriber/cmd` 或 `subscriber/schedule`。业务字段（payload 中的 `deviceNo`、数据库主键、API 入参）仍是设备编号，未变更。

当前配置位置：

- 后端订阅配置：`water-admin/src/main/resources/application.yml`
- 后端上行消息分发：`water-modules/water-app/src/main/java/org/dromara/app/mqtt`
- 后端命令下发：`water-common/water-common-mqtt/src/main/java/org/dromara/mqtt/DeviceMqttCommandPublisher.java`

## 基础约定

| 项目 | 说明 |
| --- | --- |
| Broker | `mqtt.broker-url` |
| QoS | `mqtt.qos`，当前默认 `1` |
| 设备标识 | 首次注册 Topic 使用设备 **MAC**；注册完成后的上、下行业务 Topic 统一使用 `deviceNo`；LWT 离线 Topic 兼容 MAC |
| Topic 变量 | 文档中的 `{deviceNo}` 指服务端分配的设备编号，`{mac}` 指设备 MAC 地址 |
| JSON 编码 | UTF-8 |
| 时间格式 | `yyyy-MM-dd HH:mm:ss` |

后端会订阅以下 Topic：

```yaml
- /+/publish/finish/schedule
- /+/publish/register
- /+/publish/status
- /+/publish/power
- /+/publish/ack
- /+/publish/finish/key
- /+/publish/error
- /+/subscriber/cmd
```

## Topic 总览

| 功能名称 | 通信方向 | 订阅名称 / Topic | Payload 类型 | 后端处理 |
| --- | --- | --- | --- | --- |
| 排程任务完成上报 | 设备发布，后端订阅 | `/{deviceNo}/publish/finish/schedule` | JSON | 当前记录日志 |
| 设备注册 | 设备发布，后端订阅 | `/{mac}/publish/register` | JSON | 注册、标记上线并下发 deviceNo |
| 设备离线遗嘱 | 设备发布，后端订阅 | `/{deviceNo}/publish/status` | JSON | 收到 offline 后立即标记离线 |
| 电量及在线心跳 | 设备发布，后端订阅 | `/{deviceNo}/publish/power` | JSON | 更新设备电量并刷新 10 分钟在线心跳 |
| 命令应答 ACK | 设备发布，后端订阅 | `/{deviceNo}/publish/ack` | JSON 或纯文本 | 清理待确认命令 |
| 按键浇水完成上报 | 设备发布，后端订阅 | `/{deviceNo}/publish/finish/key` | JSON | 当前记录日志 |
| 硬件故障上报 | 设备发布，后端订阅 | `/{deviceNo}/publish/error` | JSON | 当前记录日志 |
| 下发命令 | 后端发布，设备订阅 | `/{deviceNo}/subscriber/cmd` | JSON | 设备执行命令 |

## 设备注册

| 项目 | 内容 |
| --- | --- |
| 功能名称 | 设备注册 |
| 通信方向 | 设备发布，后端订阅 |
| Topic | `/{mac}/publish/register` |
| Payload 类型 | JSON |

后端以 Topic 中的设备标识作为解析入口，payload 中的 `deviceNo` 即使传入也会被 Topic 覆盖。

示例：

```json
{
  "deviceName": "一号浇水设备",
  "powerLevel": "86",
  "deviceEm": "WATER-EM-01",
  "deviceSn": "SN202606100001",
  "fwVer": "1.0.0",
  "macAddress": "AA:BB:CC:DD:EE:FF"
 
}
```

字段说明：

| 字段 | 类型 | 必填 | 说明 |
| --- | --- | --- | --- |
| deviceName | string | 否 | 设备名称 |
| powerLevel | string | 否 | 电量 |
| deviceEm | string | 否 | 设备型号 |
| deviceSn | string | 否 | 设备序列号 |
| fwVer | string | 否 | 固件版本 |
| macAddress | string | 否 | MAC 地址 |

## 电量上报

| 项目 | 内容 |
| --- | --- |
| 功能名称 | 电量上报 |
| 通信方向 | 设备发布，后端订阅 |
| Topic | `/{deviceNo}/publish/power` |
| Payload 类型 | JSON |

示例：

```json
{
  "deviceName":	"Waterer_01",
  "powerLevel": "86",
  "charging":	1
}
```

字段说明：

| 字段 | 类型 | 必填 | 说明 |
| --- | --- | --- | --- |
| powerLevel | string | 是 | 电量值，后端当前按字符串保存 |

注册成功后后端会立即将设备标记为在线。之后收到包含 `powerLevel` 的有效消息，会把在线心跳有效期刷新为 600 秒。设备必须周期上报，即使电量没有变化也要发送，建议每 5 分钟一次；连续 10 分钟未收到电量或 ACK 刷新时将被判定为离线。ACK 仍保留现有的在线缓存刷新逻辑。

## 设备离线遗嘱

| 项目 | 内容 |
| --- | --- |
| 功能名称 | 设备离线遗嘱 |
| 通信方向 | 设备发布，后端订阅 |
| Topic | `/{deviceNo}/publish/status`，注册前可使用 `/{mac}/publish/status` |
| Payload 类型 | JSON 或纯文本 |

推荐将以下消息配置为 MQTT LWT，并设置 `retain=false`：

```json
{
  "status": "offline"
}
```

后端收到 `offline` 或 `0` 后立即标记设备离线。`online` 或 `1` 不会标记在线，设备上线通过注册成功或电量心跳确认。

兼容设备可发送 `{"deviceMac":"AA:BB:CC:DD:EE:FF","offline":"true"}`，服务端会使用 `deviceMac` 解析设备并标记离线。

## 命令应答 ACK

| 项目 | 内容 |
| --- | --- |
| 功能名称 | 命令应答 |
| 通信方向 | 设备发布，后端订阅 |
| Topic | `/{deviceNo}/publish/ack` |
| Payload 类型 | JSON，兼容纯文本 |

推荐 JSON：

```json
{
  "commandId": "9f4f1f2e8c6a4e1f8b7f3a1d2c0b9e11",
  "status": "1",
  "message": "receive"
}
```

字段说明：

| 字段 | 类型 | 必填 | 说明 |
| --- | --- | --- | --- |
| commandId | string | 是 | 后端下发命令时携带的命令 ID，用于清理待 ACK 命令 |
| status | string | 否 | 执行状态，建议 `1` 成功，`0` 失败 |
| message | string | 否 | 设备返回消息 |

兼容格式：

```text
receive
```

说明：如果设备只返回纯文本，例如 `receive`，后端会查找该设备当前待确认命令。若只有一条 pending 命令，则按该命令完成 ACK；若没有或有多条 pending 命令，只记录日志，不清理命令。嵌入式侧建议优先使用 JSON ACK，并回传 `commandId`。

## 下发命令

| 项目 | 内容 |
| --- | --- |
| 功能名称 | 下发命令 |
| 通信方向 | 后端发布，设备订阅 |
| Topic | `/{deviceNo}/subscriber/cmd` |
| Payload 类型 | JSON |

后端下发手动开关设备命令示例：

```json
{
  "deviceNo": "01",
  "workStatus": "1",
  "startTime": "2026-06-10 14:35:31",
  "durationMin": 20,
  "commandId": "9f4f1f2e8c6a4e1f8b7f3a1d2c0b9e11",
  "commandType": "switchDevice"
}
```

字段说明：

| 字段 | 类型 | 必填 | 说明 |
| --- | --- | --- | --- |
| deviceNo | string | 是 | 设备编号 |
| workStatus | string | 是 | `0` 关闭浇水，`1` 开始浇水 |
| startTime | string | 否 | 开始时间，格式 `yyyy-MM-dd HH:mm:ss` |
| durationMin | number | 是 | 持续时间，单位分钟，必须大于 0 |
| commandId | string | 是 | 后端自动生成，设备 ACK 必须原样返回 |
| commandType | string | 是 | 命令类型，手动开关设备为 `switchDevice` |

设备收到命令后，应发布 ACK 到：

```text
/{deviceNo}/publish/ack
```

推荐 ACK：

```json
{
  "commandId": "9f4f1f2e8c6a4e1f8b7f3a1d2c0b9e11",
  "status": "1",
  "message": "receive"
}
```

## 设备编号下发

| 项目 | 内容 |
| --- | --- |
| 功能名称 | 设备编号下发 |
| 通信方向 | 后端发布，设备订阅 |
| Topic | `/{deviceMac}/subscriber/cmd` |
| Payload 类型 | JSON |

说明：这是首次注册后的唯一例外，后端按设备 MAC 回传设备编号；其余下行命令都使用 `deviceNo` 作为 Topic 第一段。

## 排程任务完成上报

| 项目 | 内容 |
| --- | --- |
| 功能名称 | 排程任务完成上报 |
| 通信方向 | 设备发布，后端订阅 |
| Topic | `/{deviceNo}/publish/finish/schedule` |
| Payload 类型 | JSON |
| 当前后端行为 | 写入浇水记录 |

JSON 示例：

```json
{
  "deviceNo": "dcda0cfa289c",
  "startWeek": 5,
  "startTime": "2026-06-05 14:37",
  "durationMin": 3,
  "triggerType": "mqtt on"
}
```

字段说明：

| 字段 | 类型 | 必填 | 说明 |
| --- | --- | --- | --- |
| deviceNo | string | 否 | 设备编号；后端以 Topic 中的 `{deviceNo}` 为准，不一致时记录 warn |
| startWeek | number | 否 | 星期几，`1` 到 `7`；当前后端不入库 |
| startTime | string | 是 | 开始时间，支持 `yyyy-MM-dd HH:mm:ss` 或 `yyyy-MM-dd HH:mm` |
| endTime | string | 否 | 结束时间，支持 `yyyy-MM-dd HH:mm:ss` 或 `yyyy-MM-dd HH:mm` |
| durationMin | number | 否 | 预计持续时间，单位分钟；大于 `0` 时后端按 `startTime + durationMin` 计算预计结束时间 |
| triggerType | string | 否 | 设备触发来源；当前后端忽略该字段，排程完成记录固定入库为 `0` |
| scheduleId | number | 否 | 后端忽略 payload 中的该字段，按 `AppSchedulingDevice.deviceNo` 查询排程 ID；查不到时写入 `0` |

后端先计算预计结束时间：`durationMin > 0` 时使用 `startTime + durationMin`，否则使用 payload 中的 `endTime`。实际入库的 `endTime` 取“消息接收时间”和“预计结束时间”中较早的时间，`durationMin` 按 `startTime` 到实际入库 `endTime` 重新计算。

## 按键浇水完成上报

| 项目 | 内容 |
| --- | --- |
| 功能名称 | 按键浇水完成上报 |
| 通信方向 | 设备发布，后端订阅 |
| Topic | `/{deviceNo}/publish/finish/key` |
| Payload 类型 | JSON |
| 当前后端行为 | 写入浇水记录 |

JSON 示例：

```json
{
  "deviceNo": "dcda0cfa289c",
  "startWeek": 5,
  "startTime": "2026-06-05 17:43",
  "durationMin": 0,
  "triggerType": "key"
}
```

字段说明：

| 字段 | 类型 | 必填 | 说明 |
| --- | --- | --- | --- |
| deviceNo | string | 否 | 设备编号；后端以 Topic 中的 `{deviceNo}` 为准，不一致时记录 warn |
| startWeek | number | 否 | 星期几，`1` 到 `7`；当前后端不入库 |
| startTime | string | 是 | 开始时间，支持 `yyyy-MM-dd HH:mm:ss` 或 `yyyy-MM-dd HH:mm` |
| endTime | string | 否 | 结束时间，支持 `yyyy-MM-dd HH:mm:ss` 或 `yyyy-MM-dd HH:mm` |
| durationMin | number | 否 | 预计持续时间，单位分钟；大于 `0` 时后端按 `startTime + durationMin` 计算预计结束时间 |
| triggerType | string | 否 | 设备触发来源；当前后端忽略该字段，按键完成记录固定入库为 `1` |

后端先计算预计结束时间：`durationMin > 0` 时使用 `startTime + durationMin`，否则使用 payload 中的 `endTime`。实际入库的 `endTime` 取“消息接收时间”和“预计结束时间”中较早的时间，`durationMin` 按 `startTime` 到实际入库 `endTime` 重新计算。按键完成记录的 `scheduleId` 固定为 `0`。

## 硬件故障上报

| 项目 | 内容 |
| --- | --- |
| 功能名称 | 硬件故障上报 |
| 通信方向 | 设备发布，后端订阅 |
| Topic | `/{deviceNo}/publish/error` |
| Payload 类型 | JSON |
| 当前后端行为 | 记录日志，暂未入库 |

建议 JSON：

```json
{
  "errorCode": "E001",
  "errorType": "pump",
  "message": "pump blocked",
  "level": "error",
  "time": "2026-06-10 10:00:00"
}
```

字段说明：

| 字段 | 类型 | 必填 | 说明 |
| --- | --- | --- | --- |
| errorCode | string | 建议 | 故障编码 |
| errorType | string | 否 | 故障类型 |
| message | string | 建议 | 故障描述 |
| level | string | 否 | 故障级别，例如 `warn`、`error` |
| time | string | 否 | 故障发生时间 |

## 嵌入式侧实现要点

1. 设备启动后使用 MAC 发布注册消息到 `/{mac}/publish/register`，取得服务端分配的 deviceNo。
2. MQTT 连接时配置离线遗嘱 `/{deviceNo}/publish/status`，payload 为 `{"status":"offline"}`，`retain=false`。
3. 设备至少每 5 分钟发布一次电量到 `/{deviceNo}/publish/power`，电量未变化也要上报。
4. 设备订阅自己的命令 Topic：`/{deviceNo}/subscriber/cmd` 和 `/{deviceNo}/subscriber/schedule`。
5. 设备收到命令后立即返回 ACK 到 `/{deviceNo}/publish/ack`，推荐返回 JSON 并携带 `commandId`。
6. 排程或按键浇水完成后，分别发布到 `/{deviceNo}/publish/finish/schedule` 或 `/{deviceNo}/publish/finish/key`。
7. 硬件异常时发布到 `/{deviceNo}/publish/error`。
