Files
pig-farm-controller/design/ota-upgrade-and-log-monitoring/ota_upgrade_solution.md
2025-12-02 13:02:27 +08:00

411 lines
24 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 区域主控 MicroPython OTA 升级方案
## 1. 概述
### 1.1. 目标
实现区域主控 (ESP32-S3-N16R8, MicroPython 固件) 的安全、可靠的远程固件升级 (OTA)。
### 1.2. 核心思想
* **AB 分区模式**: 区域主控采用 AB 分区模式,允许在设备运行时更新非活动分区,升级失败时可回滚到上一个已知的工作版本。
* **平台主导**: 升级过程由平台完全控制,包括固件准备、文件分发和升级指令下发。
* **LoRa 传输层自动分片**: 充分利用 LoRa 传输层自动分片和重组的能力,简化应用层协议设计。
* **逐文件校验**: 设备在接收每个文件后立即进行 MD5 校验,确保文件完整性,并处理重试。
* **清单文件**: 使用清单文件管理所有待更新文件的元数据和校验信息。
* **设备自驱动**: 设备主动请求清单文件和固件文件,并在所有文件校验成功后自行激活新固件并重启。
* **平台记录升级任务**: 平台将记录 OTA 升级任务的创建、进度和最终状态。
* **配置文件独立管理**: OTA 升级过程将不涉及配置文件的更新,配置文件由平台提供独立的远程修改功能。
### 1.3. 涉及组件
* **平台**: 负责固件包管理、清单文件生成、数字签名(未来)、文件分发、指令下发、状态接收和**升级任务记录**。
* **LoRa 传输层**: 负责应用层数据的分片、传输和重组。
* **区域主控 (ESP32-S3-N16R8)**: 负责接收文件、存储到非活动分区、文件校验、分区切换、新固件启动验证和状态上报。
## 2. 固件包结构与准备
### 2.1. 原始固件包 (由开发者提供给平台)
* 一个标准的压缩包(例如 `.zip`),其中包含所有 MicroPython `.py` 文件、资源文件等。
* 压缩包内的文件结构应与期望在设备上部署的路径结构一致。
### 2.2. 平台处理流程
1. **接收**: 平台接收开发者上传的 MicroPython 项目压缩包。
2. **解压**: 平台将该压缩包解压到内部的一个临时目录。
3. **分析与生成清单**: 平台遍历解压后的所有文件,为每个文件计算:
* 文件名 (`name`)
* 在设备上的目标路径 (`path`)
* MD5 校验和 (`md5`)
* 文件大小 (`size`)
* **排除配置文件**: 平台会识别配置文件(例如通过文件名约定,如 `/config/` 目录下的所有文件),并**排除**这些文件,不将其包含在清单文件中,也不通过 OTA 传输。
4. **生成清单文件**: 平台根据上述信息,生成一个 JSON 格式的清单文件。
5. **数字签名 (未来扩展)**: 平台使用其私钥对**清单文件**的内容进行数字签名,并将签名添加到清单文件中。此步骤目前可跳过,但为未来安全性预留。
### 2.3. 清单文件 (Manifest File) 结构
清单文件是一个 JSON 对象,包含新固件的元数据和所有文件的详细信息。
```json
{
"version": "1.0.1", // 新固件版本号
"signature": "...", // 清单文件内容的数字签名 (未来扩展)
"files": [
{
"name": "manifest.json", // 清单文件本身
"path": "/manifest.json",
"md5": "a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6",
"size": 1024
},
{
"name": "main.py",
"path": "/main.py",
"md5": "b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a1",
"size": 10240
},
{
"name": "lib/sensor.py",
"path": "/lib/sensor.py",
"md5": "c3d4e5f6a7b8c9d0e1f2a3b4c5d6a1b2",
"size": 5120
}
// ... 更多文件 (不包含配置文件)
]
}
```
## 3. 通信协议定义 (Protobuf Messages)
以下是 OTA 过程中平台与区域主控之间通信所需的 Protobuf 消息定义。
```protobuf
// OTA 升级指令和状态消息
// PrepareUpdateReq: 平台发送给设备,通知设备准备开始 OTA 升级
message PrepareUpdateReq {
string version = 1; // 新固件版本号
string task_id = 2; // 升级任务唯一ID
string manifest_md5 = 3; // 清单文件的 MD5 校验和,用于设备初步校验清单文件完整性
}
// RequestFile: 设备向平台请求特定文件 (包括清单文件和固件文件)
message RequestFile {
string task_id = 1; // 升级任务ID
string filename = 2; // 请求的文件名 (例如 "manifest.json" 或 "main.py")
string filepath = 3; // 请求的文件路径 (例如 "/manifest.json" 或 "/main.py")
uint32 retry_count = 4; // 设备请求该文件的重试次数
}
// FileResponse: 平台响应设备请求,发送单个文件的完整内容
// LoRa 传输层会自动处理分片和重组,因此应用层可以直接发送完整的单个文件内容
message FileResponse {
string task_id = 1; // 升级任务ID
string filename = 2; // 文件名 (例如 "manifest.json" 或 "main.py")
string filepath = 3; // 设备上的目标路径 (例如 "/manifest.json" 或 "/main.py")
bytes content = 4; // 文件的完整内容
}
// UpdateStatusReport: 设备向平台报告升级状态
message UpdateStatusReport {
string device_id = 1; // 设备ID
string task_id = 2; // 升级任务ID
string current_version = 3; // 设备当前运行的固件版本
enum Status {
UNKNOWN = 0;
SUCCESS = 1; // 升级成功,新固件已运行
FAILED_PREPARE = 2; // 准备阶段失败 (如清空分区失败,或文件系统错误)
FAILED_FILE_REQUEST = 3; // 文件请求失败 (如平台未找到文件)
FAILED_FILE_RECEIVE = 4; // 文件接收失败 (如LoRa传输层错误或文件写入失败)
FAILED_FILE_VERIFY = 5; // 文件MD5校验失败 (单个文件校验失败)
FAILED_MANIFEST_VERIFY = 6; // 清单文件验证失败 (如MD5不匹配或格式错误)
FAILED_ACTIVATE = 7; // 激活失败 (如设置启动分区失败,或新固件自检失败)
ROLLED_BACK = 8; // 新固件启动失败,已回滚到旧版本
IN_PROGRESS = 9; // 升级进行中 (可用于报告阶段性进度)
}
Status status = 4; // 升级状态
string error_message = 5; // 错误信息 (可选,用于详细说明失败原因)
string failed_file = 6; // 如果是文件相关失败,可包含文件名
}
```
## 4. 平台侧操作流程
### 4.1. 准备升级任务
1. 接收开发者提供的 MicroPython 项目压缩包。
2. 解压压缩包。
3. 遍历解压后的文件,计算每个文件的 MD5、大小并确定目标路径。
4. **排除配置文件**: 平台会识别配置文件(例如通过文件名约定,如 `/config/` 目录下的所有文件),并**排除**这些文件,不将其包含在清单文件中,也不通过 OTA 传输。
5. 生成清单文件 (Manifest File)。**注意:清单文件本身也应作为 OTA 的一部分其元数据文件名、路径、MD5、大小应包含在清单文件自身的 `files` 列表中。Manifest文件生成后将被放在解压后的文件夹的根目录下, 方便后续主控设备获取**
6. (未来扩展)对清单文件进行数字签名。
7. 将清单文件和所有固件文件存储在平台内部,等待分发。
8. **记录 OTA 升级任务**: 在数据库中创建一条新的 OTA 升级任务记录(模型名为 `OTATask`,位于 `internal/infra/models/ota.go`),包含任务 ID、目标设备、新固件版本、状态例如“待开始”
### 4.2. 发送“准备更新”指令
1. 平台向目标区域主控发送 `PrepareUpdateReq` 消息,其中包含清单文件的 MD5 校验和。
2. 此消息通知设备即将进行 OTA 升级,并要求设备清空其非活动 OTA 分区。
3. **确认指令送达**: 平台发送 `PrepareUpdateReq` 后,启动一个定时器。如果在预设的超时时间内(例如 30 秒)未收到设备请求清单文件的 `RequestFile` 消息,平台可以重试发送 `PrepareUpdateReq`,重试次数可配置。
4. **更新任务记录**: 平台根据设备开始索要清单文件的动作,更新 OTA 任务记录的状态为“进行中”。
### 4.3. 响应设备文件请求 (统一处理清单文件和固件文件)
1. 平台接收区域主控发送的 `RequestFile` 消息。
2. 平台根据 `task_id``filename``filepath` 在内部存储中找到对应的文件内容。
3. 平台构建 `FileResponse` 消息,将文件的完整内容、文件名和路径放入其中。
4. 平台通过 LoRa 传输层发送 `FileResponse` 消息。设备自己发现接收失败或超时会自行重发请求,多次失败设备会直接上报 `UpdateStatusReport` 结束更新。
5. 更新任务记录: 平台根据设备请求文件的动作,更新 OTA 任务记录中该文件的传输状态。
### 4.4. 处理设备状态上报
1. 平台接收区域主控发送的 `UpdateStatusReport` 消息。
2. **总超时管理**: 平台为每个 OTA 任务设置一个总的超时时间(例如 2 小时)。如果在总超时时间内未能收到设备的最终状态报告(`SUCCESS``FAILED_XXX``ROLLED_BACK`),平台应自动将该任务标记为 `FAILED_TIMEOUT`
3. 根据报告的状态,更新设备在平台上的固件版本和 OTA 任务记录的最终状态。
4. 如果报告失败或回滚,平台应记录错误信息,并可能触发告警或人工干预。
5. **处理重复报告**: 平台在收到设备的最终状态报告后,即使后续再次收到相同的最终状态报告,也只需更新一次任务记录,无需重复处理。
## 5. 区域主控侧操作流程 (MicroPython)
### 5.1. 接收“准备更新”指令
1. 区域主控接收 `PrepareUpdateReq` 消息。
2. 清空非活动分区: 使用 MicroPython 的文件系统操作(例如 `os.remove()``os.rmdir()`),递归删除非活动 OTA 分区(例如 `/ota_b`)下的所有文件和目录,为新固件腾出空间。
* **错误处理**: 在清空分区过程中,如果遇到文件系统错误(例如文件被占用、目录无法删除),设备应立即中止准备,并向平台发送 `UpdateStatusReport`,状态为 `FAILED_PREPARE`,并在 `error_message` 中包含详细的错误信息。
3. 设备准备就绪后,将直接开始请求清单文件,平台将通过设备请求清单文件的动作来判断设备已准备就绪。
### 5.2. 请求并验证清单文件
1. 设备完成准备后,向平台发送 `RequestFile` 消息,请求清单文件(例如 `filename: "manifest.json", filepath: "/manifest.json"`)。
* **请求超时与重试**: 设备发送 `RequestFile` 后,启动一个定时器。如果在预设的超时时间内(例如 30 秒)没有收到 `FileResponse`,则认为传输失败,并进行重试。设备应为清单文件请求设置最大重试次数(例如 5 次)。如果达到最大重试次数仍未成功,则上报 `FAILED_FILE_RECEIVE` 并中止 OTA 任务。
2. 区域主控接收平台响应的 `FileResponse` 消息。
3. **写入非活动分区**: 将清单文件内容写入非活动分区(例如 `/ota_b/manifest.json`)。
* **错误处理**: 如果文件写入失败,设备应立即中止升级,并向平台发送 `UpdateStatusReport`,状态为 `FAILED_FILE_RECEIVE`,并在 `error_message` 中包含详细的错误信息。
4. **MD5 校验**: 计算写入非活动分区的清单文件的 MD5并与 `PrepareUpdateReq` 消息中提供的 `manifest_md5` 进行比对。
5. **解析 JSON**: 解析清单文件内容,将其转换为 MicroPython 字典对象。
6. **数字签名验证 (未来扩展)**: 使用预置在设备中的平台公钥,验证清单文件的数字签名。如果签名验证失败,立即中止升级并报告错误。
7. 如果上述任何校验或解析失败,设备应向平台发送 `UpdateStatusReport` 报告 `FAILED_MANIFEST_VERIFY`,并在 `error_message` 中说明原因,然后中止升级。
### 5.3. 请求与存储固件文件 (逐文件校验)
1. 设备成功接收并验证清单文件后,根据清单文件中的文件列表,**逐个文件**地向平台发送 `RequestFile` 消息。
2. 对于每个请求的文件:
* **请求超时与重试**: 设备发送 `RequestFile` 后,启动一个定时器。如果在预设的超时时间内(例如 30 秒)没有收到 `FileResponse`,则认为传输失败,并进行重试。设备应为每个文件的请求设置最大重试次数(例如 5 次)。如果达到最大重试次数仍未成功,则上报 `FAILED_FILE_RECEIVE` 并中止当前文件下载,进而中止整个 OTA 任务。
* 设备接收平台响应的 `FileResponse` 消息。
* **写入非活动分区**: 根据 `filepath` 字段,将 `content` 写入到 ESP32 的非活动 OTA 分区。需要确保目标目录存在,如果不存在则创建。
* 示例 (伪代码):
```python
import os
# 假设非活动分区挂载在 /ota_b
target_path = "/ota_b" + file_response.filepath
target_dir = os.path.dirname(target_path)
if not os.path.exists(target_dir):
os.makedirs(target_dir) # 错误处理:如果创建目录失败,应上报 FAILED_FILE_RECEIVE
with open(target_path, "wb") as f:
f.write(file_response.content) # 错误处理:如果写入失败,应上报 FAILED_FILE_RECEIVE
```
* **错误处理**: 如果文件写入失败,设备应立即中止升级,并向平台发送 `UpdateStatusReport`,状态为 `FAILED_FILE_RECEIVE`,并在 `error_message` 中包含详细的错误信息。
* **MD5 校验**: 在文件写入完成后,计算该文件的 MD5 校验和。将计算出的 MD5 与清单文件中记录的 MD5 进行比对。
* MicroPython 的 `hashlib` 模块通常提供 MD5 算法。
* 如果 MD5 校验失败,设备应再次发送 `RequestFile` 消息请求该文件(并设置重试次数,例如连续三次失败则报告 `FAILED_FILE_VERIFY` 并中止升级)。平台不需等待每个文件的接收和校验状态报告。
### 5.4. 自激活与重启
1. **所有文件接收并校验成功后**,设备将自行执行以下操作:
* **配置 OTA 分区**: 使用 MicroPython 提供的 ESP-IDF OTA API通常通过 `esp` 模块或特定 OTA 模块),设置下一个启动分区为刚刚写入新固件的非活动分区。
* **自触发重启**: 在成功配置 OTA 分区后,区域主控自行触发重启。
### 5.5. 新版本启动与验证
1. 设备重启后,启动加载器会从新的 OTA 分区加载 MicroPython 固件。
2. **自检**: 新固件启动后应执行必要的自检和健康检查确保核心功能正常。这包括但不限于LoRa 模块初始化、关键传感器读取、网络连接测试、核心业务逻辑初始化等。
3. **标记有效**: 只有当所有自检项都成功通过后,新固件才必须调用相应的 MicroPython API例如 `esp.ota_mark_app_valid_cancel_rollback()`)来标记自身为有效,以防止自动回滚。
4. **版本上报**: 向平台发送 `UpdateStatusReport` 报告当前运行的版本号和升级成功状态。
5. **看门狗与回滚**:
* ESP-IDF 的 OTA 机制通常包含一个“启动计数器”或“验证机制”。如果新固件在一定次数的尝试后仍未标记自身为有效,启动加载器会自动回滚到上一个有效固件。
* 在 MicroPython 应用层,如果自检失败,**绝不能**标记自身为有效。设备应等待看门狗超时或系统自动重启,让 ESP-IDF 的底层 OTA 机制自动触发回滚到上一个有效固件。
### 5.6. 报告最终状态
1. 无论是成功升级到新版本还是回滚到旧版本,区域主控都应向平台发送 `UpdateStatusReport` 报告最终的升级状态。
2. **重复发送最终状态**: 为了提高在单向 LoRa 通信中平台接收到最终状态报告的可靠性,设备在发送最终的 `UpdateStatusReport` (无论是 `SUCCESS`、`FAILED_XXX` 还是 `ROLLED_BACK`) 时,应在短时间内(例如,间隔几秒)**重复发送该报告多次**(例如 3-5 次)。
## 6. 关键技术点与注意事项
### 6.1. LoRa 传输层
* 确保 `internal/infra/transport/lora/lora_mesh_uart_passthrough_transport.go` 实现的 LoRa 传输层能够稳定、可靠地处理大尺寸 Protobuf 消息的分片和重组。
* 注意 LoRa 传输的速率和可靠性,合理设置超时和重试机制。
### 6.2. 平台侧的请求处理
* `internal/app/listener/lora_listener.go` 模块在接收到设备发来的 `RequestFile` 消息时,需要高效地处理。这可能涉及到快速查询数据库以获取文件内容,或者通过回调机制将请求转发给 OTA 任务管理器进行处理,以避免阻塞 LoRa 监听器并确保及时响应设备请求。
### 6.3. 文件系统操作 (MicroPython)
* MicroPython 在 ESP32 上通常使用 LittleFS 或 FATFS。确保文件系统操作创建目录、写入文件、删除文件的正确性和鲁棒性。
* 清空非活动分区时,需要递归删除文件和目录,并对可能出现的错误进行捕获和报告。
* 在创建目录和写入文件时,也应进行错误捕获,并在失败时上报详细错误信息。
### 6.4. MD5 校验 (MicroPython)
* MicroPython 的 `hashlib` 模块通常提供 MD5 算法。确保在设备上计算 MD5 的效率和准确性。
* 设备将依赖 `PrepareUpdateReq` 中的 `manifest_md5` 对清单文件进行校验,并依赖清单文件中记录的 MD5 对所有固件文件进行校验。
### 6.5. OTA 分区管理 (MicroPython)
* 熟悉 ESP-IDF 的 OTA 机制在 MicroPython 中的绑定和使用方法。
* 正确调用 API 来设置下一个启动分区和标记当前应用为有效。
* 确保在自检失败时,**不**调用标记有效的 API以触发回滚机制。
### 6.6. 回滚机制
* 依赖 ESP-IDF 提供的 OTA 回滚机制。新固件必须在启动后标记自身为有效,否则在多次重启后会自动回滚。
* 在 MicroPython 应用层,如果自检失败,不标记有效,以触发回滚。
### 6.7. 错误处理与重试
* 在平台和设备两侧,都需要实现完善的错误处理逻辑。
* 设备在请求文件时应包含重试次数,平台可以根据重试次数决定是否继续响应。
* 设备应能向平台准确报告错误类型和原因包括文件系统操作失败、MD5 校验失败等。
* 平台应具备对 OTA 任务的总超时管理能力。
### 6.8. 安全性 (未来扩展)
* **数字签名**: 尽管目前暂时忽略密钥管理,但强烈建议在未来实现清单文件的数字签名。这将有效防止恶意固件注入和篡改。平台使用私钥签名,设备使用硬编码的公钥验证。
* **LoRaWAN 安全**: 确保 LoRaWAN 的网络层和应用层密钥管理得当, 防止未经授权的设备加入网络或窃听数据。
---
## 7. OTA 升级流程时序图
```mermaid
sequenceDiagram
participant User as 开发者
participant Platform as 平台
participant Device as 区域主控
User->>Platform: 上传固件包 (.zip)
activate Platform
Platform->>Platform: 解压固件包,分析文件,排除配置文件
Platform->>Platform: 计算文件MD5生成清单文件 (manifest.json)
Platform->>Platform: 存储固件文件和清单文件
Platform->>Platform: 记录OTA升级任务 (状态: 待开始)
deactivate Platform
Platform->>Device: PrepareUpdateReq (version, task_id, manifest_md5)
activate Platform
Platform->>Platform: 启动定时器 (等待设备请求清单文件)
activate Device
Device->>Device: 接收 PrepareUpdateReq
Device->>Device: 清空非活动OTA分区 (如 /ota_b)
alt 清空分区失败
Device->>Platform: UpdateStatusReport (FAILED_PREPARE, error_message) (重复发送多次)
deactivate Device
Platform->>Platform: 更新任务状态为 FAILED_PREPARE
deactivate Platform
break
end
Device->>Platform: RequestFile (task_id, filename: "manifest.json", filepath: "/manifest.json", retry_count: 0)
deactivate Device
activate Platform
Platform->>Platform: 收到 RequestFile更新任务状态 (状态: 进行中)
Platform->>Platform: 查找 manifest.json 内容
Platform->>Device: FileResponse (task_id, filename: "manifest.json", filepath: "/manifest.json", content)
deactivate Platform
activate Device
Device->>Device: 接收 FileResponse (manifest.json)
Device->>Device: 将内容写入非活动分区 (/ota_b/manifest.json)
alt 写入失败
Device->>Platform: UpdateStatusReport (FAILED_FILE_RECEIVE, error_message) (重复发送多次)
deactivate Device
Platform->>Platform: 更新任务状态为 FAILED_FILE_RECEIVE
deactivate Platform
break
end
Device->>Device: 计算写入文件的MD5
Device->>Device: 比对 MD5 (与 PrepareUpdateReq.manifest_md5)
alt MD5不匹配 或 JSON解析失败
Device->>Platform: UpdateStatusReport (FAILED_MANIFEST_VERIFY, error_message) (重复发送多次)
deactivate Device
Platform->>Platform: 更新任务状态为 FAILED_MANIFEST_VERIFY
deactivate Platform
break
end
Device->>Device: 解析 manifest.json获取文件列表
deactivate Device
loop For each file in manifest
activate Device
Device->>Platform: RequestFile (task_id, filename: "file_X.py", filepath: "/path/to/file_X.py", retry_count)
Device->>Device: 启动定时器 (等待 FileResponse)
deactivate Device
activate Platform
Platform->>Platform: 收到 RequestFile更新文件传输状态
Platform->>Platform: 查找 file_X.py 内容
Platform->>Device: FileResponse (task_id, filename: "file_X.py", filepath: "/path/to/file_X.py", content)
deactivate Platform
activate Device
Device->>Device: 接收 FileResponse (file_X.py)
Device->>Device: 确保目标目录存在,将内容写入非活动分区 (/ota_b/path/to/file_X.py)
alt 写入失败
Device->>Platform: UpdateStatusReport (FAILED_FILE_RECEIVE, error_message) (重复发送多次)
deactivate Device
Platform->>Platform: 更新任务状态为 FAILED_FILE_RECEIVE
deactivate Platform
break loop
end
Device->>Device: 计算写入文件的MD5
Device->>Device: 比对 MD5 (与 manifest.files[X].md5)
alt MD5不匹配 或 超时未收到 FileResponse (达到最大重试次数)
Device->>Device: 增加 retry_count
alt 达到最大重试次数
Device->>Platform: UpdateStatusReport (FAILED_FILE_VERIFY/FAILED_FILE_RECEIVE, error_message) (重复发送多次)
deactivate Device
Platform->>Platform: 更新任务状态为 FAILED_FILE_VERIFY/FAILED_FILE_RECEIVE
deactivate Platform
break loop
else
Device->>Platform: RequestFile (重试请求该文件)
end
end
deactivate Device
end
activate Device
Device->>Device: 所有文件下载并校验成功
Device->>Device: 配置OTA分区为新固件分区
Device->>Device: 自触发重启
deactivate Device
Device-->>Device: (设备重启,加载新固件)
activate Device
Device->>Device: 新固件启动,执行自检
alt 自检成功
Device->>Device: 标记自身为有效 (esp.ota_mark_app_valid_cancel_rollback())
Device->>Platform: UpdateStatusReport (SUCCESS, current_version) (重复发送多次)
deactivate Device
activate Platform
Platform->>Platform: 更新任务状态为 SUCCESS
Platform->>Platform: 更新设备固件版本
deactivate Platform
else 自检失败
Device->>Device: 不标记自身为有效
Device->>Platform: UpdateStatusReport (FAILED_ACTIVATE, error_message) (重复发送多次)
Device-->>Device: (等待看门狗超时或系统自动回滚)
Device-->>Device: (回滚到旧固件)
Device->>Platform: UpdateStatusReport (ROLLED_BACK, current_version: 旧版本) (重复发送多次)
deactivate Device
activate Platform
Platform->>Platform: 更新任务状态为 ROLLED_BACK
Platform->>Platform: 更新设备固件版本为旧版本
deactivate Platform
end
Platform->>Platform: 总超时检查 (如果长时间未收到最终状态,则标记为 FAILED_TIMEOUT)
```