Compare commits

...

15 Commits

Author SHA1 Message Date
beea537802 ota升级方案修改 2025-12-02 13:27:20 +08:00
2dfa90bc33 ota升级方案修改 2025-12-02 13:02:27 +08:00
fb017da381 支持ota升级方案初稿 2025-12-01 22:26:31 +08:00
49555c0842 增加ping指令并获取带版本号的响应 2025-12-01 20:42:21 +08:00
d5999cee7e 支持ota升级结果相应处理 2025-12-01 17:49:30 +08:00
450d2ea15a 增加AreaControllerProperties 2025-12-01 17:29:28 +08:00
75c79260a7 index.md 2025-12-01 14:43:39 +08:00
4a91cd8c11 提供lora公共逻辑 2025-12-01 14:32:50 +08:00
6ac753327e lora处理逻辑统一方案 2025-11-29 17:57:45 +08:00
80100658a2 重构webhook包 2025-11-29 17:17:36 +08:00
0eb7c6f371 proto 2025-11-29 17:06:09 +08:00
c2c6577064 格式化 2025-11-29 16:04:12 +08:00
260c7d054c 更新makefile 2025-11-29 15:54:00 +08:00
d25933cf26 Merge pull request 'issue_66' (#70) from issue_66 into main
Reviewed-on: #70
2025-11-29 15:39:49 +08:00
4aa56441ce 归档任务 2025-11-29 15:38:52 +08:00
37 changed files with 2366 additions and 764 deletions

View File

@@ -10,10 +10,15 @@ help:
@echo " build Build the application"
@echo " clean Clean generated files"
@echo " test Run all tests"
@echo " swag Generate swagger docs"
@echo " help Show this help message"
@echo " swag Generate Swagger docs"
@echo " proto Generate protobuf files"
@echo " lint Lint the code"
@echo " lint Lint the code"
@echo " dev Run in development mode with hot-reload"
@echo " mcp-chrome Start the Google Chrome MCP server"
@echo " mcp-pgsql Start the PostgreSQL MCP server"
@echo " tree Generate the project file structure list"
@echo " gemini Start the gemini-cli"
@echo " help Show this help message"
# 运行应用
.PHONY: run

View File

@@ -46,6 +46,20 @@ http://git.huangwc.com/pig/pig-farm-controller/issues/66
7. 简单查看功能
- 两个配方对比页面(营养+成本对比)
# 实现总结
## 实现内容
实现库存和原料和营养和猪营养需求的管理, 支持根据库存和已录入原料和猪营养需求生成配方
## TODO
1. 发酵料管理考虑到发酵目前没有自动化流程, 不好追踪, 遂暂时不做
2. 目前的价格是根据原料的参考价设置的, 后续应当实现一个在服务供平台采集参考价, 以及使用原料采购价计算
3. 原料应该加上膨润土等, 比如膨润土的黄曲霉素含量应该是负数以表示减少饲料里的含量
4. 饲料保质期考虑到批次间管理暂时不方便, 等可以实现同一原料先进先出后再实现
5. 暂时不支持指定原料列表然后自动生成, 也不支持告诉用户当前生成不出是为什么, 等以后再做
# 完成事项
1. 定义原料表, 营养表, 原料营养表, 原料库存变更表

View File

@@ -0,0 +1,20 @@
# 需求
支持主控设备ota升级和远程查看日志
## issue
http://git.huangwc.com/pig/pig-farm-controller/issues/71
# 开发计划
## OTA 升级
- [x] 增加一个proto对象, 用于封装ota升级包
- [x] 区域主控增加版本号
- [x] 增加ping指令并获取带版本号的响应
- [ ] 实现ota升级逻辑
## Lora 监听逻辑重构
- [x] [Lora逻辑重构](design/ota-upgrade-and-log-monitoring/lora_refactoring_plan.md)

View File

@@ -0,0 +1,199 @@
# LoRa 通信层统一重构方案
## 1. 目标
统一项目当前并存的两种 LoRa 通信模式(基于 ChirpStack API 和基于串口透传),使其在架构层面遵循相同的接口和设计模式。最终实现:
- **业务逻辑统一**:所有上行业务处理逻辑集中在一个地方,与具体的通信方式无关。
- **发送接口统一**:上层服务使用同一个接口发送下行指令,无需关心底层实现。
- **架构清晰**:明确划分基础设施层(负责传输)和应用层(负责业务)的职责,并确保正确的依赖方向 (`app` -> `infra`)。
- **高扩展性**:未来支持新的通信方式时,只需添加新的“适配器”,而无需改动核心业务代码。
## 2. 背景与问题分析
### 2.1. 当前存在两种 LoRa 通信模式
1. **ChirpStack 模式**: 通过 `internal/infra/transport/lora/chirp_stack.go` 实现发送,通过 `internal/app/listener/chirp_stack/chirp_stack.go` 监听并处理 ChirpStack Webhook 推送的数据。
2. **串口透传模式**: 通过 `internal/infra/transport/lora/lora_mesh_uart_passthrough_transport.go` 实现发送和接收处理。
### 2.2. 核心差异
| 特性 | ChirpStack 模式 | 串口透传模式 |
| :--- | :--- | :--- |
| **通信模型** | 双向、有状态、异步API调用 | 单向、无状态、直接串口读写 |
| **接收机制** | Webhook (HTTP POST) 推送 | 主动从串口读取字节流 |
| **数据格式** | JSON 包装 + Base64 编码 | 自定义二进制物理帧 |
| **寻址方式**| `DevEui` | 自定义 16 位网络地址 |
| **核心职责** | LNS管理会话、ACK、队列 | 纯粹的“无线串口” |
### 2.3. 问题
- **业务逻辑分散**:处理 `CollectResult` 的业务逻辑在 `chirp_stack.go``lora_mesh_uart_passthrough_transport.go` 中都存在,造成代码重复和维护困难。
- **职责不清**`lora_mesh_uart_passthrough_transport.go` 同时承担了基础设施(串口读写)和应用(处理业务)两种职责。
- **依赖关系混乱**:为了让 `infra` 层的串口模块能调用业务逻辑,可能会导致 `infra` 层反向依赖 `app` 层,破坏了项目的核心架构原则。
## 3. 统一架构设计方案
### 3.1. 核心思想
采用 **端口与适配器模式 (Ports and Adapters Pattern)**,严格遵守 **依赖倒置原则**
- **端口 (Port)**:在 `infra` 层定义一个 `UpstreamHandler` 接口。这个接口是 `infra` 层向上层暴露的“端口”,它规定了上行业务处理器必须满足的协约。
- **适配器 (Adapter)**:在 `app` 层创建一个 `LoRaListener` 作为“适配器”,它实现 `infra` 层定义的 `UpstreamHandler` 接口,并封装所有核心业务处理逻辑。
- **依赖注入**:在系统启动时,将 `app` 层的 `LoRaListener` 实例注入到需要它的 `infra` 层组件中。
### 3.2. 统一接口定义
#### 3.2.1. 发送接口 (已存在,无需修改)
```go
// file: internal/infra/transport/transport.go
package transport
type Communicator interface {
Send(ctx context.Context, address string, payload []byte) (*SendResult, error)
}
```
#### 3.2.2. 接收处理接口 (端口定义)
此接口定义了 `infra` 层对上行业务处理器的期望,是 `infra` 层向上层暴露的“端口”。
```go
// file: internal/infra/transport/transport.go
package transport
import (
"context"
"git.huangwc.com/pig/pig-farm-controller/internal/infra/transport/proto"
)
// UpstreamHandler 定义了处理所有来源的上行数据的统一协约。
// 任何实现了上行消息监听的基础设施如串口、MQTT客户端都应该在收到消息后调用此接口的实现者。
// 这样,基础设施层只负责“接收和解析”,而将“业务处理”的控制权交给了上层。
type UpstreamHandler interface {
// HandleInstruction 处理来自设备的、已解析为Instruction的业务指令。
HandleInstruction(ctx context.Context, sourceAddr string, instruction *proto.Instruction) error
// HandleStatus 处理非业务指令的设备状态更新,例如信号强度、电量等。
HandleStatus(ctx context.Context, sourceAddr string, status map[string]interface{}) error
}
```
### 3.3. 组件职责划分 (重构后)
#### 3.3.1. 统一业务处理器 (应用层适配器)
- **文件**: `internal/app/listener/lora_listener.go` (新)
- **职责**:
- 实现 `transport.UpstreamHandler` 接口。
- 包含所有处理业务所需的依赖(如领域服务、仓储等)。
- 实现 `HandleInstruction` 方法,通过 `switch-case` 编排所有核心业务。
- 实现 `HandleStatus` 方法,处理设备状态更新。
- **这是项目中唯一处理 LoRa 上行业务的地方。**
#### 3.3.2. 基础设施层 (Infra Layer)
- **文件 1**: `internal/app/listener/chirp_stack/chirp_stack.go` (重构)
- **职责**: 纯粹的 Webhook 适配器。
- 移除所有业务逻辑和数据库依赖。
- 依赖 `transport.UpstreamHandler` 接口。
- 功能:接收 Webhook -> 解析 JSON -> 调用 `handler.HandleInstruction``handler.HandleStatus`
- **文件 2**: `internal/infra/transport/lora/lora_mesh_uart_passthrough_transport.go` (重构)
- **职责**: 纯粹的串口传输工具。
- 移除所有业务逻辑和数据库依赖。
- 依赖 `transport.UpstreamHandler` 接口。
- 功能:管理串口 -> 读字节流 -> 重组分片 -> 解析 `proto.Instruction` -> 调用 `handler.HandleInstruction`
### 3.4. 架构图 (重构后)
```
+--------------------------------+
| Upper-Level Services |
| (e.g., DeviceService) |
+--------------------------------+
|
v (uses)
+--------------------------------+
| transport.Communicator (I) | <-- Infra Layer (Send Port)
+--------------------------------+
^ ^
| | (implements)
+------------------+------------------+
| ChirpStackSender | UartSender | <-- Infra Layer (Senders)
+------------------+------------------+
+--------------------------------+
| listener.LoRaListener | <-- App Layer (Adapter)
| (Implements UpstreamHandler) |
+--------------------------------+
^
| (dependency, via interface)
+--------------------------------+
| transport.UpstreamHandler (I) | <-- Infra Layer (Receive Port)
+--------------------------------+
^ ^
| | (calls)
+------------------+------------------+
| ChirpStackWebhook| UartPassthrough | <-- Infra Layer (Receivers)
+------------------+------------------+
^ ^
| | (receives from)
+------------------+------------------+
| HTTP Webhook | Serial Port |
+------------------+------------------+
```
### 3.5. 依赖注入与组装示例
```go
// file: internal/core/component_initializers.go
// 1. 创建统一的业务处理器 (App层适配器)
// 它实现了 infra 层的 transport.UpstreamHandler 接口
loraListener := listener.NewLoRaListener(logger, dbRepo1, dbRepo2)
// 2. 初始化 ChirpStack 模式
// 2a. 创建 ChirpStack 的发送器 (infra)
chirpStackCommunicator := chirp_stack.NewChirpStackTransport(...)
// 2b. 创建 ChirpStack 的监听器 (infra),并注入 App 层的业务处理器
chirpStackListener := chirp_stack.NewChirpStackListener(loraListener)
// 2c. 注册 Webhook 路由
api.RegisterWebhook("/chirpstack", chirpStackListener.Handler())
// 3. 初始化串口透传模式
// 3a. 创建串口的传输工具 (infra),并注入 App 层的业务处理器
uartTransport := lora.NewLoRaMeshUartPassthroughTransport(port, loraListener)
// 3b. 启动串口监听
uartTransport.Listen()
// 4. 向上层业务提供统一的发送器
var finalCommunicator transport.Communicator
if config.UseChirpStack {
finalCommunicator = chirpStackCommunicator
} else {
finalCommunicator = uartTransport
}
// 将 finalCommunicator 注入到需要发送指令的服务中...
```
## 4. 实施步骤
1. **定义端口**: 在 `internal/infra/transport/transport.go` 中定义 `UpstreamHandler` 接口。
2. **创建适配器**: 创建 `internal/app/listener/lora_listener.go`,定义 `LoRaListener` 结构体,并实现 `transport.UpstreamHandler` 接口。
3. **迁移业务逻辑**: 将 `chirp_stack.go``lora_mesh_uart_passthrough_transport.go` 中的业务逻辑(查库、存数据等)逐步迁移到 `lora_listener.go` 的对应方法中。
4. **重构基础设施**:
- 清理 `chirp_stack.go`,移除 Repo 依赖,改为依赖 `transport.UpstreamHandler` 接口,并调用其方法。
- 清理 `lora_mesh_uart_passthrough_transport.go`,做同样的操作。
5. **更新依赖注入**: 修改 `component_initializers.go`,按照 `3.5` 中的示例完成组件的创建和注入。
6. **测试与验证**: 对两种模式分别进行完整的上下行通信测试。
## 5. 收益
- **消除代码重复**:业务逻辑仅存在于一处。
- **职责清晰**:基础设施层只管传输,应用层只管业务。
- **正确的依赖关系**:确保了 `app` -> `infra` 的单向依赖,核心架构更加稳固。
- **可维护性**:修改业务逻辑只需改一个文件,修改传输细节不影响业务。
- **可测试性**:可以轻松地对 `LoRaListener` 进行单元测试,无需真实的硬件或网络。

View File

@@ -0,0 +1,372 @@
# 区域主控 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 升级流程描述
整个固件 OTAOver-The-Air升级流程涉及三个主要参与者**开发者 (User)**、**平台 (Platform)** 和 **区域主控设备 (Device)**。
### 阶段一:任务准备(开发者与平台)
1. **上传固件包 (User -> Platform)**:
* 开发者上传固件包(.zip 文件)。
* 平台接收固件包,解压,分析文件,排除配置文件。
* 平台计算所有文件MD5生成清单文件 (manifest.json)。
* 平台存储固件文件和清单文件,并记录 OTA 升级任务 (状态: 待开始)。
### 阶段二:设备接收并请求清单文件
1. **下发更新通知 (Platform -> Device)**:
* 平台向设备发送 `PrepareUpdateReq` (包含 version, task_id, manifest_md5)。
2. **设备准备 (Device)**:
* 设备接收请求,并尝试清空**非活动 OTA 分区**(如 /ota_b
* **失败分支:** 如果清空分区失败,设备报告 `UpdateStatusReport` (FAILED_PREPARE),平台更新任务状态为 FAILED_PREPARE。
* **成功分支:** 设备向平台发送 `RequestFile` (filename: "manifest.json")。
3. **清单文件传输 (Platform <-> Device)**:
* 平台收到请求,更新任务状态为进行中,并发送 `FileResponse` (manifest.json) 给设备。
* 设备写入清单文件。
4. **校验清单文件 (Device)**:
* **失败分支 1 (写入失败)** 报告 `UpdateStatusReport` (FAILED_FILE_RECEIVE)。
* **失败分支 2 (校验失败)** 计算 MD5 与 `PrepareUpdateReq` 的 MD5 不匹配,或 JSON 解析失败,报告
`UpdateStatusReport` (FAILED_MANIFEST_VERIFY)。
* **成功分支:** 设备解析清单文件,获取文件列表。
### 阶段三:文件循环下载和校验(核心 OTA 过程)
设备循环请求清单中的每一个固件文件:
1. **文件请求与响应 (Device <-> Platform)**:
* **循环开始:** 设备发送 `RequestFile` (filename: "file_X.py")。
* 平台响应 `FileResponse` (file_X.py)。
2. **写入与校验 (Device)**:
* 设备接收文件,确保目录存在,写入非活动分区,并计算写入文件的 MD5。
* **失败分支 1 (写入失败)** 报告 `FAILED_FILE_RECEIVE`,中断下载循环。
* **失败分支 2 (校验失败/超时)**
* 设备增加 `retry_count`。
* **达到最大重试次数:** 报告失败 (`FAILED_FILE_VERIFY`/`FAILED_FILE_RECEIVE`),中断下载循环。
* **未达最大重试次数:** 重置定时器,重试发送 `RequestFile`。
### 阶段四:激活与最终状态(重启与回滚)
1. **激活准备 (Device)**:
* 所有文件下载并校验成功后,设备配置 OTA 分区为新固件分区,并自触发重启。
2. **新固件自检 (Device)**:
* 设备重启,加载新固件,执行自检。
* **成功分支:**
* 设备标记自身为有效 (`esp.ota_mark_app_valid_cancel_rollback()`)。
* 设备报告 `UpdateStatusReport` (SUCCESS, current_version)。
* 平台更新任务状态为 SUCCESS更新设备固件版本。
* **失败分支:**
* 设备不标记自身为有效,报告 `UpdateStatusReport` (FAILED_ACTIVATE)。
* 设备等待看门狗超时或系统自动回滚到旧固件。
* 设备报告 `UpdateStatusReport` (ROLLED_BACK, current_version: 旧版本)。
* 平台更新任务状态为 ROLLED_BACK更新设备固件版本为旧版本。
3. **总超时检查 (Platform)**:
* 如果平台长时间未收到最终状态,则标记任务状态为 FAILED_TIMEOUT。

View File

@@ -216,12 +216,14 @@ const docTemplate = `{
"电池电量",
"温度",
"湿度",
"重量"
"重量",
"在线状态"
],
"type": "string",
"x-enum-comments": {
"SensorTypeBatteryLevel": "电池电量",
"SensorTypeHumidity": "湿度",
"SensorTypeOnlineStatus": "在线状态",
"SensorTypeSignalMetrics": "信号强度",
"SensorTypeTemperature": "温度",
"SensorTypeWeight": "重量"
@@ -231,14 +233,16 @@ const docTemplate = `{
"电池电量",
"温度",
"湿度",
"重量"
"重量",
"在线状态"
],
"x-enum-varnames": [
"SensorTypeSignalMetrics",
"SensorTypeBatteryLevel",
"SensorTypeTemperature",
"SensorTypeHumidity",
"SensorTypeWeight"
"SensorTypeWeight",
"SensorTypeOnlineStatus"
],
"description": "按传感器类型过滤",
"name": "sensor_type",
@@ -497,12 +501,14 @@ const docTemplate = `{
"电池电量",
"温度",
"湿度",
"重量"
"重量",
"在线状态"
],
"type": "string",
"x-enum-comments": {
"SensorTypeBatteryLevel": "电池电量",
"SensorTypeHumidity": "湿度",
"SensorTypeOnlineStatus": "在线状态",
"SensorTypeSignalMetrics": "信号强度",
"SensorTypeTemperature": "温度",
"SensorTypeWeight": "重量"
@@ -512,14 +518,16 @@ const docTemplate = `{
"电池电量",
"温度",
"湿度",
"重量"
"重量",
"在线状态"
],
"x-enum-varnames": [
"SensorTypeSignalMetrics",
"SensorTypeBatteryLevel",
"SensorTypeTemperature",
"SensorTypeHumidity",
"SensorTypeWeight"
"SensorTypeWeight",
"SensorTypeOnlineStatus"
],
"description": "按传感器类型过滤",
"name": "sensor_type",
@@ -6836,6 +6844,9 @@ const docTemplate = `{
"created_at": {
"type": "string"
},
"firmware_version": {
"type": "string"
},
"id": {
"type": "integer"
},
@@ -10513,11 +10524,13 @@ const docTemplate = `{
"电池电量",
"温度",
"湿度",
"重量"
"重量",
"在线状态"
],
"x-enum-comments": {
"SensorTypeBatteryLevel": "电池电量",
"SensorTypeHumidity": "湿度",
"SensorTypeOnlineStatus": "在线状态",
"SensorTypeSignalMetrics": "信号强度",
"SensorTypeTemperature": "温度",
"SensorTypeWeight": "重量"
@@ -10527,14 +10540,16 @@ const docTemplate = `{
"电池电量",
"温度",
"湿度",
"重量"
"重量",
"在线状态"
],
"x-enum-varnames": [
"SensorTypeSignalMetrics",
"SensorTypeBatteryLevel",
"SensorTypeTemperature",
"SensorTypeHumidity",
"SensorTypeWeight"
"SensorTypeWeight",
"SensorTypeOnlineStatus"
]
},
"models.SeverityLevel": {
@@ -10602,6 +10617,7 @@ const docTemplate = `{
"等待",
"下料",
"全量采集",
"心跳检测",
"告警通知",
"通知刷新",
"设备阈值检查",
@@ -10613,6 +10629,7 @@ const docTemplate = `{
"TaskTypeAreaCollectorThresholdCheck": "区域阈值检查任务",
"TaskTypeDeviceThresholdCheck": "设备阈值检查任务",
"TaskTypeFullCollection": "新增的全量采集任务",
"TaskTypeHeartbeat": "区域主控心跳检测任务",
"TaskTypeNotificationRefresh": "通知刷新任务",
"TaskTypeReleaseFeedWeight": "下料口释放指定重量任务",
"TaskTypeWaiting": "等待任务"
@@ -10622,6 +10639,7 @@ const docTemplate = `{
"等待任务",
"下料口释放指定重量任务",
"新增的全量采集任务",
"区域主控心跳检测任务",
"告警通知任务",
"通知刷新任务",
"设备阈值检查任务",
@@ -10632,6 +10650,7 @@ const docTemplate = `{
"TaskTypeWaiting",
"TaskTypeReleaseFeedWeight",
"TaskTypeFullCollection",
"TaskTypeHeartbeat",
"TaskTypeAlarmNotification",
"TaskTypeNotificationRefresh",
"TaskTypeDeviceThresholdCheck",

View File

@@ -208,12 +208,14 @@
"电池电量",
"温度",
"湿度",
"重量"
"重量",
"在线状态"
],
"type": "string",
"x-enum-comments": {
"SensorTypeBatteryLevel": "电池电量",
"SensorTypeHumidity": "湿度",
"SensorTypeOnlineStatus": "在线状态",
"SensorTypeSignalMetrics": "信号强度",
"SensorTypeTemperature": "温度",
"SensorTypeWeight": "重量"
@@ -223,14 +225,16 @@
"电池电量",
"温度",
"湿度",
"重量"
"重量",
"在线状态"
],
"x-enum-varnames": [
"SensorTypeSignalMetrics",
"SensorTypeBatteryLevel",
"SensorTypeTemperature",
"SensorTypeHumidity",
"SensorTypeWeight"
"SensorTypeWeight",
"SensorTypeOnlineStatus"
],
"description": "按传感器类型过滤",
"name": "sensor_type",
@@ -489,12 +493,14 @@
"电池电量",
"温度",
"湿度",
"重量"
"重量",
"在线状态"
],
"type": "string",
"x-enum-comments": {
"SensorTypeBatteryLevel": "电池电量",
"SensorTypeHumidity": "湿度",
"SensorTypeOnlineStatus": "在线状态",
"SensorTypeSignalMetrics": "信号强度",
"SensorTypeTemperature": "温度",
"SensorTypeWeight": "重量"
@@ -504,14 +510,16 @@
"电池电量",
"温度",
"湿度",
"重量"
"重量",
"在线状态"
],
"x-enum-varnames": [
"SensorTypeSignalMetrics",
"SensorTypeBatteryLevel",
"SensorTypeTemperature",
"SensorTypeHumidity",
"SensorTypeWeight"
"SensorTypeWeight",
"SensorTypeOnlineStatus"
],
"description": "按传感器类型过滤",
"name": "sensor_type",
@@ -6828,6 +6836,9 @@
"created_at": {
"type": "string"
},
"firmware_version": {
"type": "string"
},
"id": {
"type": "integer"
},
@@ -10505,11 +10516,13 @@
"电池电量",
"温度",
"湿度",
"重量"
"重量",
"在线状态"
],
"x-enum-comments": {
"SensorTypeBatteryLevel": "电池电量",
"SensorTypeHumidity": "湿度",
"SensorTypeOnlineStatus": "在线状态",
"SensorTypeSignalMetrics": "信号强度",
"SensorTypeTemperature": "温度",
"SensorTypeWeight": "重量"
@@ -10519,14 +10532,16 @@
"电池电量",
"温度",
"湿度",
"重量"
"重量",
"在线状态"
],
"x-enum-varnames": [
"SensorTypeSignalMetrics",
"SensorTypeBatteryLevel",
"SensorTypeTemperature",
"SensorTypeHumidity",
"SensorTypeWeight"
"SensorTypeWeight",
"SensorTypeOnlineStatus"
]
},
"models.SeverityLevel": {
@@ -10594,6 +10609,7 @@
"等待",
"下料",
"全量采集",
"心跳检测",
"告警通知",
"通知刷新",
"设备阈值检查",
@@ -10605,6 +10621,7 @@
"TaskTypeAreaCollectorThresholdCheck": "区域阈值检查任务",
"TaskTypeDeviceThresholdCheck": "设备阈值检查任务",
"TaskTypeFullCollection": "新增的全量采集任务",
"TaskTypeHeartbeat": "区域主控心跳检测任务",
"TaskTypeNotificationRefresh": "通知刷新任务",
"TaskTypeReleaseFeedWeight": "下料口释放指定重量任务",
"TaskTypeWaiting": "等待任务"
@@ -10614,6 +10631,7 @@
"等待任务",
"下料口释放指定重量任务",
"新增的全量采集任务",
"区域主控心跳检测任务",
"告警通知任务",
"通知刷新任务",
"设备阈值检查任务",
@@ -10624,6 +10642,7 @@
"TaskTypeWaiting",
"TaskTypeReleaseFeedWeight",
"TaskTypeFullCollection",
"TaskTypeHeartbeat",
"TaskTypeAlarmNotification",
"TaskTypeNotificationRefresh",
"TaskTypeDeviceThresholdCheck",

View File

@@ -86,6 +86,8 @@ definitions:
properties:
created_at:
type: string
firmware_version:
type: string
id:
type: integer
location:
@@ -2622,10 +2624,12 @@ definitions:
- 温度
- 湿度
- 重量
- 在线状态
type: string
x-enum-comments:
SensorTypeBatteryLevel: 电池电量
SensorTypeHumidity: 湿度
SensorTypeOnlineStatus: 在线状态
SensorTypeSignalMetrics: 信号强度
SensorTypeTemperature: 温度
SensorTypeWeight: 重量
@@ -2635,12 +2639,14 @@ definitions:
- 温度
- 湿度
- 重量
- 在线状态
x-enum-varnames:
- SensorTypeSignalMetrics
- SensorTypeBatteryLevel
- SensorTypeTemperature
- SensorTypeHumidity
- SensorTypeWeight
- SensorTypeOnlineStatus
models.SeverityLevel:
enum:
- debug
@@ -2697,6 +2703,7 @@ definitions:
- 等待
- 下料
- 全量采集
- 心跳检测
- 告警通知
- 通知刷新
- 设备阈值检查
@@ -2708,6 +2715,7 @@ definitions:
TaskTypeAreaCollectorThresholdCheck: 区域阈值检查任务
TaskTypeDeviceThresholdCheck: 设备阈值检查任务
TaskTypeFullCollection: 新增的全量采集任务
TaskTypeHeartbeat: 区域主控心跳检测任务
TaskTypeNotificationRefresh: 通知刷新任务
TaskTypeReleaseFeedWeight: 下料口释放指定重量任务
TaskTypeWaiting: 等待任务
@@ -2716,6 +2724,7 @@ definitions:
- 等待任务
- 下料口释放指定重量任务
- 新增的全量采集任务
- 区域主控心跳检测任务
- 告警通知任务
- 通知刷新任务
- 设备阈值检查任务
@@ -2725,6 +2734,7 @@ definitions:
- TaskTypeWaiting
- TaskTypeReleaseFeedWeight
- TaskTypeFullCollection
- TaskTypeHeartbeat
- TaskTypeAlarmNotification
- TaskTypeNotificationRefresh
- TaskTypeDeviceThresholdCheck
@@ -2969,12 +2979,14 @@ paths:
- 温度
- 湿度
- 重量
- 在线状态
in: query
name: sensor_type
type: string
x-enum-comments:
SensorTypeBatteryLevel: 电池电量
SensorTypeHumidity: 湿度
SensorTypeOnlineStatus: 在线状态
SensorTypeSignalMetrics: 信号强度
SensorTypeTemperature: 温度
SensorTypeWeight: 重量
@@ -2984,12 +2996,14 @@ paths:
- 温度
- 湿度
- 重量
- 在线状态
x-enum-varnames:
- SensorTypeSignalMetrics
- SensorTypeBatteryLevel
- SensorTypeTemperature
- SensorTypeHumidity
- SensorTypeWeight
- SensorTypeOnlineStatus
produces:
- application/json
responses:
@@ -3151,12 +3165,14 @@ paths:
- 温度
- 湿度
- 重量
- 在线状态
in: query
name: sensor_type
type: string
x-enum-comments:
SensorTypeBatteryLevel: 电池电量
SensorTypeHumidity: 湿度
SensorTypeOnlineStatus: 在线状态
SensorTypeSignalMetrics: 信号强度
SensorTypeTemperature: 温度
SensorTypeWeight: 重量
@@ -3166,12 +3182,14 @@ paths:
- 温度
- 湿度
- 重量
- 在线状态
x-enum-varnames:
- SensorTypeSignalMetrics
- SensorTypeBatteryLevel
- SensorTypeTemperature
- SensorTypeHumidity
- SensorTypeWeight
- SensorTypeOnlineStatus
produces:
- application/json
responses:

View File

@@ -28,8 +28,8 @@ import (
"git.huangwc.com/pig/pig-farm-controller/internal/app/controller/monitor"
"git.huangwc.com/pig/pig-farm-controller/internal/app/controller/plan"
"git.huangwc.com/pig/pig-farm-controller/internal/app/controller/user"
"git.huangwc.com/pig/pig-farm-controller/internal/app/listener"
"git.huangwc.com/pig/pig-farm-controller/internal/app/service"
"git.huangwc.com/pig/pig-farm-controller/internal/app/webhook"
domain_plan "git.huangwc.com/pig/pig-farm-controller/internal/domain/plan"
"git.huangwc.com/pig/pig-farm-controller/internal/infra/config"
"git.huangwc.com/pig/pig-farm-controller/internal/infra/logs"
@@ -64,7 +64,7 @@ type API struct {
rawMaterialController *feed.RawMaterialController // 原料控制器实例
recipeController *feed.RecipeController // 配方控制器实例
inventoryController *inventory.InventoryController // 库存控制器实例
listenHandler webhook.ListenHandler // 设备上行事件监听器
listenHandler listener.ListenHandler // 设备上行事件监听器
analysisTaskManager *domain_plan.AnalysisPlanTaskManager // 计划触发器管理器实例
}
@@ -89,7 +89,7 @@ func NewAPI(cfg config.ServerConfig,
recipeService service.RecipeService,
inventoryService service.InventoryService,
tokenGenerator token.Generator,
listenHandler webhook.ListenHandler,
listenHandler listener.ListenHandler,
) *API {
// 使用 echo.New() 创建一个 Echo 引擎实例
e := echo.New()

View File

@@ -1,7 +1,6 @@
package dto
import (
"encoding/json"
"fmt"
"time"
@@ -65,22 +64,34 @@ func NewAreaControllerResponse(ac *models.AreaController) (*AreaControllerRespon
return nil, nil
}
var props map[string]interface{}
// 解析 firmware_version
var firmwareVersion string
// 使用模型上的辅助方法来解析强类型属性
acProps := &models.AreaControllerProperties{}
if err := ac.ParseProperties(acProps); err == nil {
firmwareVersion = acProps.FirmwareVersion
}
// 如果解析出错firmwareVersion 将保持为空字符串,这通常是可接受的降级行为
// 解析完整的 properties 以便向后兼容或用于其他未知属性
var allProps map[string]interface{}
if len(ac.Properties) > 0 && string(ac.Properties) != "null" {
if err := json.Unmarshal(ac.Properties, &props); err != nil {
return nil, fmt.Errorf("解析区域主控属性失败 (ID: %d): %w", ac.ID, err)
// 这里我们使用通用的 ParseProperties 方法
if err := ac.ParseProperties(&allProps); err != nil {
return nil, fmt.Errorf("解析区域主控完整属性失败 (ID: %d): %w", ac.ID, err)
}
}
return &AreaControllerResponse{
ID: ac.ID,
Name: ac.Name,
NetworkID: ac.NetworkID,
Location: ac.Location,
Status: ac.Status,
Properties: props,
CreatedAt: ac.CreatedAt.Format(time.RFC3339),
UpdatedAt: ac.UpdatedAt.Format(time.RFC3339),
ID: ac.ID,
Name: ac.Name,
NetworkID: ac.NetworkID,
FirmwareVersion: firmwareVersion,
Location: ac.Location,
Status: ac.Status,
Properties: allProps, // 填充完整的 properties
CreatedAt: ac.CreatedAt.Format(time.RFC3339),
UpdatedAt: ac.UpdatedAt.Format(time.RFC3339),
}, nil
}

View File

@@ -78,14 +78,15 @@ type DeviceResponse struct {
// AreaControllerResponse 定义了返回给客户端的单个区域主控信息的结构
type AreaControllerResponse struct {
ID uint32 `json:"id"`
Name string `json:"name"`
NetworkID string `json:"network_id"`
Location string `json:"location"`
Status string `json:"status"`
Properties map[string]interface{} `json:"properties"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
ID uint32 `json:"id"`
Name string `json:"name"`
NetworkID string `json:"network_id"`
FirmwareVersion string `json:"firmware_version"`
Location string `json:"location"`
Status string `json:"status"`
Properties map[string]interface{} `json:"properties"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
}
// DeviceTemplateResponse 定义了返回给客户端的单个设备模板信息的结构

View File

@@ -0,0 +1,175 @@
package chirp_stack
import (
"context"
"encoding/base64"
"encoding/json"
"fmt"
"io"
"net/http"
"git.huangwc.com/pig/pig-farm-controller/internal/app/listener"
"git.huangwc.com/pig/pig-farm-controller/internal/infra/logs"
"git.huangwc.com/pig/pig-farm-controller/internal/infra/transport"
"git.huangwc.com/pig/pig-farm-controller/internal/infra/transport/proto"
gproto "google.golang.org/protobuf/proto"
)
// ChirpStackListener 主动发送的请求的event字段, 这个字段代表事件类型
const (
eventTypeUp = "up" // 上行数据事件:当接收到设备发送的数据时触发,这是最核心的事件。
eventTypeStatus = "status" // 设备状态事件:当设备报告其状态时触发(例如电池电量、信号强度)。
eventTypeJoin = "join" // 入网事件:当设备成功加入网络时触发。
eventTypeAck = "ack" // 下行确认事件:当设备确认收到下行消息时触发。
eventTypeTxAck = "txack" // 网关发送确认事件:当网关确认已发送下行消息时触发(不代表设备已收到)。
eventTypeLog = "log" // 日志事件:当设备或 ChirpStack 产生日志信息时触发。
eventTypeLocation = "location" // 位置事件:当设备的位置被解析或更新时触发。
eventTypeIntegration = "integration" // 集成事件:当其他集成(如第三方服务)处理数据后触发。
)
// ChirpStackListener 是一个监听器, 用于将ChirpStack的Webhook事件适配到统一的UpstreamHandler。
type ChirpStackListener struct {
selfCtx context.Context
handler transport.UpstreamHandler // 依赖注入的统一业务处理器
}
// NewChirpStackListener 创建一个新的 ChirpStackListener 实例。
func NewChirpStackListener(
ctx context.Context,
handler transport.UpstreamHandler,
) listener.ListenHandler {
return &ChirpStackListener{
selfCtx: logs.AddCompName(ctx, "ChirpStackListener"),
handler: handler,
}
}
// Handler 监听ChirpStack反馈的事件, 因为这是个Webhook, 所以直接回复掉再慢慢处理信息
func (c *ChirpStackListener) Handler() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
// 注意:这里的 selfCtx 是 r.Context()因为它包含了HTTP请求的追踪信息
ctx, logger := logs.Trace(r.Context(), c.selfCtx, "Handler")
defer r.Body.Close()
b, err := io.ReadAll(r.Body)
if err != nil {
logger.Errorw("读取请求体失败", "error", err)
http.Error(w, "failed to read body", http.StatusBadRequest)
return
}
event := r.URL.Query().Get("event")
w.WriteHeader(http.StatusOK)
// 使用分离的上下文进行异步处理,防止原始请求取消导致处理中断
detachedCtx := logs.DetachContext(ctx)
go c.dispatch(detachedCtx, b, event)
}
}
// dispatch 用于解析并分发 ChirpStack 发送的事件
func (c *ChirpStackListener) dispatch(ctx context.Context, data []byte, eventType string) {
reqCtx, logger := logs.Trace(ctx, c.selfCtx, "dispatch")
var err error
switch eventType {
case eventTypeUp:
var msg UpEvent
if err = json.Unmarshal(data, &msg); err == nil {
c.adaptUpEvent(reqCtx, &msg)
}
case eventTypeStatus:
var msg StatusEvent
if err = json.Unmarshal(data, &msg); err == nil {
c.adaptStatusEvent(reqCtx, &msg)
}
case eventTypeAck:
var msg AckEvent
if err = json.Unmarshal(data, &msg); err == nil {
c.adaptAckEvent(reqCtx, &msg)
}
// --- 其他事件只记录日志,不进行业务处理 ---
case eventTypeJoin, eventTypeTxAck, eventTypeLog, eventTypeLocation, eventTypeIntegration:
logger.Infow("收到一个非业务处理的ChirpStack事件", "type", eventType)
default:
logger.Warnw("未知的ChirpStack事件类型", "type", eventType)
}
if err != nil {
logger.Errorw("解析ChirpStack事件失败", "type", eventType, "error", err, "data", string(data))
}
}
// --- 适配器函数 ---
// adaptUpEvent 将 'up' 事件适配并委托给 UpstreamHandler
func (c *ChirpStackListener) adaptUpEvent(ctx context.Context, event *UpEvent) {
reqCtx, logger := logs.Trace(ctx, c.selfCtx, "adaptUpEvent")
// 1. 优先处理并委托旁路状态信息(如信号强度)
if len(event.RxInfo) > 0 {
rx := event.RxInfo[0]
status := map[string]interface{}{
"rssi": float64(rx.Rssi),
"snr": float64(rx.Snr),
}
if err := c.handler.HandleStatus(reqCtx, event.DeviceInfo.DevEui, status); err != nil {
logger.Errorw("委托 'up' 事件中的状态信息失败", "error", err)
}
}
// 2. 如果没有业务数据,则直接返回
if event.Data == "" {
return
}
// 3. 解码并解析业务指令
decodedData, err := base64.StdEncoding.DecodeString(event.Data)
if err != nil {
logger.Errorw("Base64解码 'up' 事件的Data失败", "error", err, "data", event.Data)
return
}
var instruction proto.Instruction
if err := gproto.Unmarshal(decodedData, &instruction); err != nil {
logger.Errorw("解析上行Instruction Protobuf失败", "error", err, "decodedData", fmt.Sprintf("%x", decodedData))
return
}
// 4. 委托给统一处理器
if err := c.handler.HandleInstruction(reqCtx, event.DeviceInfo.DevEui, &instruction); err != nil {
logger.Errorw("委托 'up' 事件中的业务指令失败", "error", err)
}
}
// adaptStatusEvent 将 'status' 事件适配并委托给 UpstreamHandler
func (c *ChirpStackListener) adaptStatusEvent(ctx context.Context, event *StatusEvent) {
reqCtx, logger := logs.Trace(ctx, c.selfCtx, "adaptStatusEvent")
status := map[string]interface{}{
"margin": float64(event.Margin),
"batteryLevel": float64(event.BatteryLevel),
"batteryLevelUnavailable": event.BatteryLevelUnavailable,
"externalPower": event.ExternalPower,
}
if err := c.handler.HandleStatus(reqCtx, event.DeviceInfo.DevEui, status); err != nil {
logger.Errorw("委托 'status' 事件失败", "error", err)
}
}
// adaptAckEvent 将 'ack' 事件适配并委托给 UpstreamHandler
func (c *ChirpStackListener) adaptAckEvent(ctx context.Context, event *AckEvent) {
reqCtx, logger := logs.Trace(ctx, c.selfCtx, "adaptAckEvent")
if err := c.handler.HandleAck(reqCtx, event.DeviceInfo.DevEui, event.DeduplicationID, event.Acknowledged, event.Time); err != nil {
logger.Errorw("委托 'ack' 事件失败", "error", err)
}
}

View File

@@ -1,4 +1,4 @@
package webhook
package chirp_stack
import (
"encoding/json"

View File

@@ -1,9 +1,10 @@
package webhook
package chirp_stack
import (
"context"
"net/http"
"git.huangwc.com/pig/pig-farm-controller/internal/app/listener"
"git.huangwc.com/pig/pig-farm-controller/internal/infra/logs"
)
@@ -13,8 +14,8 @@ type PlaceholderListener struct {
}
// NewPlaceholderListener 创建一个新的 PlaceholderListener 实例
// 它只打印一条日志, 表明 ChirpStack webhook 未被激活
func NewPlaceholderListener(ctx context.Context) ListenHandler {
// 它只打印一条日志, 表明 ChirpStack listener 未被激活
func NewPlaceholderListener(ctx context.Context) listener.ListenHandler {
return &PlaceholderListener{
ctx: ctx,
}

View File

@@ -0,0 +1,339 @@
package listener
import (
"context"
"encoding/json"
"fmt"
"math"
"time"
"git.huangwc.com/pig/pig-farm-controller/internal/infra/logs"
"git.huangwc.com/pig/pig-farm-controller/internal/infra/models"
"git.huangwc.com/pig/pig-farm-controller/internal/infra/repository"
"git.huangwc.com/pig/pig-farm-controller/internal/infra/transport"
"git.huangwc.com/pig/pig-farm-controller/internal/infra/transport/proto"
"gorm.io/datatypes"
)
// loraListener 是一个统一的LoRa上行业务处理器实现了 transport.UpstreamHandler 接口。
// 它包含了处理业务所需的所有依赖是项目中唯一处理LoRa上行业务的地方。
type loraListener struct {
selfCtx context.Context
areaControllerRepo repository.AreaControllerRepository
pendingCollectionRepo repository.PendingCollectionRepository
deviceRepo repository.DeviceRepository
sensorDataRepo repository.SensorDataRepository
deviceCommandLogRepo repository.DeviceCommandLogRepository
}
// NewLoRaListener 创建一个新的 loraListener 实例。
// 注意:返回的是 transport.UpstreamHandler 接口,向上层隐藏具体实现。
func NewLoRaListener(
ctx context.Context,
areaControllerRepo repository.AreaControllerRepository,
pendingCollectionRepo repository.PendingCollectionRepository,
deviceRepo repository.DeviceRepository,
sensorDataRepo repository.SensorDataRepository,
deviceCommandLogRepo repository.DeviceCommandLogRepository,
) transport.UpstreamHandler {
return &loraListener{
selfCtx: logs.AddCompName(ctx, "LoRaListener"),
areaControllerRepo: areaControllerRepo,
pendingCollectionRepo: pendingCollectionRepo,
deviceRepo: deviceRepo,
sensorDataRepo: sensorDataRepo,
deviceCommandLogRepo: deviceCommandLogRepo,
}
}
// HandleInstruction 处理来自设备的、已解析为Instruction的业务指令。
func (l *loraListener) HandleInstruction(upstreamCtx context.Context, sourceAddr string, instruction *proto.Instruction) error {
ctx, logger := logs.Trace(upstreamCtx, l.selfCtx, "HandleInstruction")
logger.Infow("接收到设备指令", "来源地址", sourceAddr)
switch p := instruction.Payload.(type) {
case *proto.Instruction_CollectResult:
return l.handleCollectResult(ctx, sourceAddr, p.CollectResult)
case *proto.Instruction_OtaUpgradeStatus:
return l.handleOtaStatus(ctx, sourceAddr, p.OtaUpgradeStatus)
case *proto.Instruction_Pong:
return l.handlePong(ctx, sourceAddr, p.Pong)
case *proto.Instruction_LogUploadRequest:
logger.Infow("收到设备日志上传请求,暂未实现处理逻辑", "来源地址", sourceAddr, "日志条数", len(p.LogUploadRequest.Entries))
// TODO: 在这里实现设备日志的处理逻辑
return nil
default:
logger.Warnw("收到一个当前未处理的上行指令类型", "来源地址", sourceAddr, "类型", fmt.Sprintf("%T", p))
return nil
}
}
// HandleStatus 处理非业务指令的设备状态更新,例如信号强度、电量等。
func (l *loraListener) HandleStatus(upstreamCtx context.Context, sourceAddr string, status map[string]interface{}) error {
ctx, logger := logs.Trace(upstreamCtx, l.selfCtx, "HandleStatus")
logger.Infow("接收到设备状态更新", "来源地址", sourceAddr, "状态", status)
areaController, err := l.areaControllerRepo.FindByNetworkID(ctx, sourceAddr)
if err != nil {
return fmt.Errorf("处理 'status' 事件失败:无法通过源地址 '%s' 找到区域主控设备: %w", sourceAddr, err)
}
eventTime := time.Now() // 状态事件通常是实时的,使用当前时间
// 尝试记录信号强度
if rssi, ok := status["rssi"].(float64); ok {
if snr, ok := status["snr"].(float64); ok {
signalMetrics := models.SignalMetrics{
RssiDbm: int(rssi),
SnrDb: float32(snr),
}
if margin, ok := status["margin"].(float64); ok {
signalMetrics.MarginDb = int(margin)
}
l.recordSensorData(ctx, areaController.ID, areaController.ID, eventTime, models.SensorTypeSignalMetrics, signalMetrics)
logger.Infow("已记录区域主控的信号强度", "主控ID", areaController.ID, "指标", signalMetrics)
}
}
// 尝试记录电池电量
if batteryLevel, ok := status["batteryLevel"].(float64); ok {
batteryData := models.BatteryLevel{
BatteryLevelRatio: float32(batteryLevel),
}
if unavailable, ok := status["batteryLevelUnavailable"].(bool); ok {
batteryData.BatteryLevelUnavailable = unavailable
}
if externalPower, ok := status["externalPower"].(bool); ok {
batteryData.ExternalPower = externalPower
}
l.recordSensorData(ctx, areaController.ID, areaController.ID, eventTime, models.SensorTypeBatteryLevel, batteryData)
logger.Infow("已记录区域主控的电池状态", "主控ID", areaController.ID, "状态", batteryData)
}
return nil
}
// HandleAck 处理对下行指令的确认ACK事件。
func (l *loraListener) HandleAck(upstreamCtx context.Context, sourceAddr string, deduplicationID string, acknowledged bool, eventTime time.Time) error {
ctx, logger := logs.Trace(upstreamCtx, l.selfCtx, "HandleAck")
err := l.deviceCommandLogRepo.UpdateAcknowledgedAt(ctx, deduplicationID, eventTime, acknowledged)
if err != nil {
logger.Errorw("更新下行任务记录的确认状态失败",
"MessageID", deduplicationID,
"DevEui", sourceAddr,
"Acknowledged", acknowledged,
"error", err,
)
return fmt.Errorf("更新下行任务记录失败: %w", err)
}
logger.Infow("成功更新下行任务记录确认状态",
"MessageID", deduplicationID,
"DevEui", sourceAddr,
"Acknowledged", acknowledged,
"AcknowledgedAt", eventTime.Format(time.RFC3339),
)
return nil
}
// handleCollectResult 是处理采集结果的核心业务逻辑
func (l *loraListener) handleCollectResult(ctx context.Context, sourceAddr string, collectResp *proto.CollectResult) error {
if collectResp == nil {
return fmt.Errorf("传入的CollectResult为nil")
}
correlationID := collectResp.CorrelationId
logger := logs.GetLogger(ctx).With("correlationID", correlationID, "来源地址", sourceAddr)
logger.Infow("开始处理采集响应", "数据点数量", len(collectResp.Values))
// 1. 查找区域主控
areaController, err := l.areaControllerRepo.FindByNetworkID(ctx, sourceAddr)
if err != nil {
return fmt.Errorf("处理采集响应失败:无法通过源地址 '%s' 找到区域主控设备: %w", sourceAddr, err)
}
if err := areaController.SelfCheck(); err != nil {
return fmt.Errorf("处理采集响应失败:区域主控 %v(ID: %d) 未通过自检: %w", areaController.Name, areaController.ID, err)
}
// 2. 根据 CorrelationID 查找待处理请求
pendingReq, err := l.pendingCollectionRepo.FindByCorrelationID(ctx, correlationID)
if err != nil {
return fmt.Errorf("处理采集响应失败:无法找到待处理请求: %w", err)
}
// 3. 检查状态,防止重复处理
if pendingReq.Status != models.PendingStatusPending && pendingReq.Status != models.PendingStatusTimedOut {
logger.Warnw("收到一个已处理过的采集响应,将忽略。", "状态", string(pendingReq.Status))
return nil // 返回 nil因为这不是一个错误只是一个重复的请求
}
// 4. 匹配数据并存入数据库
deviceIDs := pendingReq.CommandMetadata
values := collectResp.Values
if len(deviceIDs) != len(values) {
err := fmt.Errorf("数据不匹配:下行指令要求采集 %d 个设备,但上行响应包含 %d 个值", len(deviceIDs), len(values))
// 即使数量不匹配,也尝试更新状态为完成,以防止请求永远 pending
if updateErr := l.pendingCollectionRepo.UpdateStatusToFulfilled(ctx, correlationID, time.Now()); updateErr != nil {
logger.Errorw("更新待采集请求状态为 'fulfilled' 失败", "error", updateErr)
}
return err
}
eventTime := time.Now() // 对整个采集批次使用统一的时间戳
for i, deviceID := range deviceIDs {
rawSensorValue := values[i]
devLogger := logger.With("设备ID", deviceID)
if math.IsNaN(float64(rawSensorValue)) {
devLogger.Warnw("设备上报了一个无效的 NaN 值,已跳过当前值的记录。")
continue
}
dev, err := l.deviceRepo.FindByID(ctx, deviceID)
if err != nil {
devLogger.Errorw("处理采集数据失败:无法找到设备", "error", err)
continue
}
if err := dev.SelfCheck(); err != nil {
devLogger.Warnw("跳过设备,因其未通过自检或设备模板无效", "error", err)
continue
}
var valueDescriptors []*models.ValueDescriptor
if err := dev.DeviceTemplate.ParseValues(&valueDescriptors); err != nil {
devLogger.Warnw("跳过设备,因其设备模板的 Values 属性解析失败", "error", err)
continue
}
if len(valueDescriptors) == 0 {
devLogger.Warnw("跳过设备,因其设备模板缺少 ValueDescriptor 定义")
continue
}
valueDescriptor := valueDescriptors[0]
parsedValue := rawSensorValue*valueDescriptor.Multiplier + valueDescriptor.Offset
var dataToRecord interface{}
switch valueDescriptor.Type {
case models.SensorTypeTemperature:
dataToRecord = models.TemperatureData{TemperatureCelsius: parsedValue}
case models.SensorTypeHumidity:
dataToRecord = models.HumidityData{HumidityPercent: parsedValue}
case models.SensorTypeWeight:
dataToRecord = models.WeightData{WeightKilograms: parsedValue}
default:
devLogger.Warnw("未知的传感器类型,将使用通用格式记录", "传感器类型", string(valueDescriptor.Type))
dataToRecord = map[string]float32{"value": parsedValue}
}
l.recordSensorData(ctx, areaController.ID, dev.ID, eventTime, valueDescriptor.Type, dataToRecord)
devLogger.Infow("成功记录传感器数据",
"类型", string(valueDescriptor.Type),
"原始值", rawSensorValue,
"解析值", parsedValue,
)
}
// 5. 更新请求状态为“已完成”
if err := l.pendingCollectionRepo.UpdateStatusToFulfilled(ctx, correlationID, eventTime); err != nil {
logger.Errorw("更新待采集请求状态为 'fulfilled' 失败", "error", err)
return fmt.Errorf("更新待采集请求状态失败: %w", err)
}
logger.Infow("成功完成并关闭采集请求")
return nil
}
// recordSensorData 是一个通用方法,用于将传感器数据存入数据库。
func (l *loraListener) recordSensorData(ctx context.Context, areaControllerID uint32, sensorDeviceID uint32, eventTime time.Time, sensorType models.SensorType, data interface{}) {
logger := logs.GetLogger(ctx).With("方法", "recordSensorData")
jsonData, err := json.Marshal(data)
if err != nil {
logger.Errorw("记录传感器数据失败:序列化数据为 JSON 时出错", "error", err)
return
}
sensorData := &models.SensorData{
Time: eventTime,
DeviceID: sensorDeviceID,
AreaControllerID: areaControllerID,
SensorType: sensorType,
Data: datatypes.JSON(jsonData),
}
if err := l.sensorDataRepo.Create(ctx, sensorData); err != nil {
logger.Errorw("记录传感器数据失败:存入数据库时出错",
"设备ID", sensorDeviceID,
"传感器类型", string(sensorType),
"error", err,
)
}
}
// handleOtaStatus 处理设备上报的OTA升级状态。
func (l *loraListener) handleOtaStatus(ctx context.Context, sourceAddr string, status *proto.OtaUpgradeStatus) error {
reqCtx, logger := logs.Trace(ctx, l.selfCtx, "handleOtaStatus")
logger.Infow("开始处理OTA升级状态",
"来源地址", sourceAddr,
"状态码", status.StatusCode,
"当前版本", status.CurrentFirmwareVersion,
)
// 1. 查找区域主控
areaController, err := l.areaControllerRepo.FindByNetworkID(reqCtx, sourceAddr)
if err != nil {
return fmt.Errorf("处理OTA状态失败无法找到区域主控: %w", err)
}
// 2. 更新固件版本号
// 我们信任设备上报的版本号,无论成功失败都进行更新
if status.CurrentFirmwareVersion != "" {
err = l.areaControllerRepo.UpdateFirmwareVersion(reqCtx, areaController.ID, status.CurrentFirmwareVersion)
if err != nil {
logger.Errorw("更新区域主控固件版本号失败", "主控ID", areaController.ID, "error", err)
return fmt.Errorf("更新固件版本号失败: %w", err)
}
logger.Infow("成功更新区域主控固件版本号", "主控ID", areaController.ID, "新版本", status.CurrentFirmwareVersion)
}
// TODO: 后续可以在这里增加逻辑,比如记录一条操作日志,或者发送一个通知
return nil
}
// handlePong 处理设备上报的Pong响应或主动心跳。
func (l *loraListener) handlePong(ctx context.Context, sourceAddr string, pong *proto.Pong) error {
reqCtx, logger := logs.Trace(ctx, l.selfCtx, "handlePong")
logger.Infow("开始处理Pong", "来源地址", sourceAddr, "携带版本", pong.FirmwareVersion)
// 1. 查找区域主控
areaController, err := l.areaControllerRepo.FindByNetworkID(reqCtx, sourceAddr)
if err != nil {
return fmt.Errorf("处理Pong失败无法找到区域主控: %w", err)
}
// 2. 如果 Pong 中包含版本号,则更新
if pong.FirmwareVersion != "" {
err := l.areaControllerRepo.UpdateFirmwareVersion(reqCtx, areaController.ID, pong.FirmwareVersion)
if err != nil {
// 只记录错误,不中断流程,因为还要记录在线状态
logger.Errorw("处理Pong时更新固件版本失败", "主控ID", areaController.ID, "error", err)
} else {
logger.Infow("处理Pong时成功更新固件版本", "主控ID", areaController.ID, "新版本", pong.FirmwareVersion)
}
}
// 3. 记录在线状态
onlineStatus := models.OnlineStatusData{State: models.StateOnline}
l.recordSensorData(reqCtx, areaController.ID, areaController.ID, time.Now(), models.SensorTypeOnlineStatus, onlineStatus)
logger.Infow("已记录区域主控为在线状态", "主控ID", areaController.ID)
return nil
}

View File

@@ -1,8 +1,8 @@
package webhook
package listener
import "net/http"
// ListenHandler 是一个监听器, 用于监听设备上行事件
// ListenHandler 是一个监听器, 用于监听设备上行事件, 通常用于适配http webhook。
type ListenHandler interface {
Handler() http.HandlerFunc
}

View File

@@ -1,454 +0,0 @@
package webhook
import (
"context"
"encoding/base64"
"encoding/json"
"io"
"math"
"net/http"
"time"
"git.huangwc.com/pig/pig-farm-controller/internal/infra/logs"
"git.huangwc.com/pig/pig-farm-controller/internal/infra/models"
"git.huangwc.com/pig/pig-farm-controller/internal/infra/repository"
"git.huangwc.com/pig/pig-farm-controller/internal/infra/transport/proto"
gproto "google.golang.org/protobuf/proto"
"gorm.io/datatypes"
)
// ChirpStackListener 主动发送的请求的event字段, 这个字段代表事件类型
const (
eventTypeUp = "up" // 上行数据事件:当接收到设备发送的数据时触发,这是最核心的事件。
eventTypeStatus = "status" // 设备状态事件:当设备报告其状态时触发(例如电池电量、信号强度)。
eventTypeJoin = "join" // 入网事件:当设备成功加入网络时触发。
eventTypeAck = "ack" // 下行确认事件:当设备确认收到下行消息时触发。
eventTypeTxAck = "txack" // 网关发送确认事件:当网关确认已发送下行消息时触发(不代表设备已收到)。
eventTypeLog = "log" // 日志事件:当设备或 ChirpStack 产生日志信息时触发。
eventTypeLocation = "location" // 位置事件:当设备的位置被解析或更新时触发。
eventTypeIntegration = "integration" // 集成事件:当其他集成(如第三方服务)处理数据后触发。
)
// ChirpStackListener 是一个监听器, 用于监听ChirpStack反馈的设备上行事件
type ChirpStackListener struct {
ctx context.Context
sensorDataRepo repository.SensorDataRepository
deviceRepo repository.DeviceRepository
areaControllerRepo repository.AreaControllerRepository
deviceCommandLogRepo repository.DeviceCommandLogRepository
pendingCollectionRepo repository.PendingCollectionRepository
}
// NewChirpStackListener 创建一个新的 ChirpStackListener 实例
func NewChirpStackListener(
ctx context.Context,
sensorDataRepo repository.SensorDataRepository,
deviceRepo repository.DeviceRepository,
areaControllerRepo repository.AreaControllerRepository,
deviceCommandLogRepo repository.DeviceCommandLogRepository,
pendingCollectionRepo repository.PendingCollectionRepository,
) ListenHandler {
return &ChirpStackListener{
ctx: ctx,
sensorDataRepo: sensorDataRepo,
deviceRepo: deviceRepo,
areaControllerRepo: areaControllerRepo,
deviceCommandLogRepo: deviceCommandLogRepo,
pendingCollectionRepo: pendingCollectionRepo,
}
}
// Handler 监听ChirpStack反馈的事件, 因为这是个Webhook, 所以直接回复掉再慢慢处理信息
func (c *ChirpStackListener) Handler() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
ctx, logger := logs.Trace(r.Context(), c.ctx, "ChirpStackListener")
defer r.Body.Close()
b, err := io.ReadAll(r.Body)
if err != nil {
logger.Errorf("读取请求体失败: %v", err)
http.Error(w, "failed to read body", http.StatusBadRequest)
return
}
event := r.URL.Query().Get("event")
w.WriteHeader(http.StatusOK)
// 将异步处理逻辑委托给 handler 方法
go c.handler(ctx, b, event)
}
}
// handler 用于处理 ChirpStack 发送的事件
func (c *ChirpStackListener) handler(ctx context.Context, data []byte, eventType string) {
reqCtx, logger := logs.Trace(ctx, c.ctx, "ChirpStackListener.handler")
switch eventType {
case eventTypeUp:
var msg UpEvent
if err := json.Unmarshal(data, &msg); err != nil {
logger.Errorf("解析 'up' 事件失败: %v, data: %s", err, string(data))
return
}
c.handleUpEvent(reqCtx, &msg)
case eventTypeJoin:
var msg JoinEvent
if err := json.Unmarshal(data, &msg); err != nil {
logger.Errorf("解析 'join' 事件失败: %v, data: %s", err, string(data))
return
}
c.handleJoinEvent(reqCtx, &msg)
case eventTypeAck:
var msg AckEvent
if err := json.Unmarshal(data, &msg); err != nil {
logger.Errorf("解析 'ack' 事件失败: %v, data: %s", err, string(data))
return
}
c.handleAckEvent(reqCtx, &msg)
case eventTypeTxAck:
var msg TxAckEvent
if err := json.Unmarshal(data, &msg); err != nil {
logger.Errorf("解析 'txack' 事件失败: %v, data: %s", err, string(data))
return
}
c.handleTxAckEvent(reqCtx, &msg)
case eventTypeStatus:
var msg StatusEvent
if err := json.Unmarshal(data, &msg); err != nil {
logger.Errorf("解析 'status' 事件失败: %v, data: %s", err, string(data))
return
}
c.handleStatusEvent(reqCtx, &msg)
case eventTypeLog:
var msg LogEvent
if err := json.Unmarshal(data, &msg); err != nil {
logger.Errorf("解析 'log' 事件失败: %v, data: %s", err, string(data))
return
}
c.handleLogEvent(reqCtx, &msg)
case eventTypeLocation:
var msg LocationEvent
if err := json.Unmarshal(data, &msg); err != nil {
logger.Errorf("解析 'location' 事件失败: %v, data: %s", err, string(data))
return
}
c.handleLocationEvent(reqCtx, &msg)
case eventTypeIntegration:
var msg IntegrationEvent
if err := json.Unmarshal(data, &msg); err != nil {
logger.Errorf("解析 'integration' 事件失败: %v, data: %s", err, string(data))
return
}
c.handleIntegrationEvent(reqCtx, &msg)
default:
logger.Errorf("未知的ChirpStack事件: %s, data: %s", eventType, string(data))
}
}
// --- 业务处理函数 ---
// handleUpEvent 处理上行数据事件
func (c *ChirpStackListener) handleUpEvent(ctx context.Context, event *UpEvent) {
reqCtx, logger := logs.Trace(ctx, c.ctx, "ChirpStackListener.handleUpEvent")
logger.Infof("开始处理 'up' 事件, DevEui: %s", event.DeviceInfo.DevEui)
// 1. 查找区域主控设备
areaController, err := c.areaControllerRepo.FindByNetworkID(reqCtx, event.DeviceInfo.DevEui)
if err != nil {
logger.Errorf("处理 'up' 事件失败:无法通过 DevEui '%s' 找到区域主控设备: %v", event.DeviceInfo.DevEui, err)
return
}
// 依赖 SelfCheck 确保区域主控有效
if err := areaController.SelfCheck(); err != nil {
logger.Errorf("处理 'up' 事件失败:区域主控 %v(ID: %d) 未通过自检: %v", areaController.Name, areaController.ID, err)
return
}
logger.Infof("找到区域主控: %s (ID: %d)", areaController.Name, areaController.ID)
// 2. 记录区域主控的信号强度 (如果存在)
if len(event.RxInfo) > 0 {
// 根据业务逻辑,一个猪场只有一个网关,所以 RxInfo 中通常只有一个元素,或者 gateway_id 都是相同的。
// 因此,我们只取第一个 RxInfo 中的信号数据即可。
rx := event.RxInfo[0] // 取第一个接收到的网关信息
// 构建 SignalMetrics 结构体
signalMetrics := models.SignalMetrics{
RssiDbm: rx.Rssi,
SnrDb: rx.Snr,
}
// 记录信号强度
c.recordSensorData(reqCtx, areaController.ID, areaController.ID, event.Time, models.SensorTypeSignalMetrics, signalMetrics)
logger.Infof("已记录区域主控 (ID: %d) 的信号强度: RSSI=%d, SNR=%.2f", areaController.ID, rx.Rssi, rx.Snr)
} else {
logger.Warnf("处理 'up' 事件时未找到 RxInfo无法记录信号数据。DevEui: %s", event.DeviceInfo.DevEui)
}
// 3. 处理上报的传感器数据
if event.Data == "" {
logger.Warnf("处理 'up' 事件时 Data 字段为空无需记录上行数据。DevEui: %s", event.DeviceInfo.DevEui)
return
}
// 3.1 Base64 解码
decodedData, err := base64.StdEncoding.DecodeString(event.Data)
if err != nil {
logger.Errorf("Base64 解码 'up' 事件的 Data 失败: %v, Data: %s", err, event.Data)
return
}
// 3.2 解析外层 "信封"
var instruction proto.Instruction
if err := gproto.Unmarshal(decodedData, &instruction); err != nil {
logger.Errorf("解析上行 Instruction Protobuf 失败: %v, Decoded Data: %x", err, decodedData)
return
}
// 3.3 使用 type switch 从 oneof payload 中提取 CollectResult
var collectResp *proto.CollectResult
switch p := instruction.GetPayload().(type) {
case *proto.Instruction_CollectResult:
collectResp = p.CollectResult
default:
// 如果上行的数据不是采集结果,记录日志并忽略
logger.Infof("收到一个非采集响应的上行指令 (Type: %T),无需处理。", p)
return
}
// 检查 collectResp 是否为 nil虽然在 type switch 成功的情况下不太可能
if collectResp == nil {
logger.Errorf("从 Instruction 中提取的 CollectResult 为 nil")
return
}
correlationID := collectResp.CorrelationId
logger.Infof("成功解析采集响应 (CorrelationID: %s),包含 %d 个值。", correlationID, len(collectResp.Values))
// 4. 根据 CorrelationID 查找待处理请求
pendingReq, err := c.pendingCollectionRepo.FindByCorrelationID(reqCtx, correlationID)
if err != nil {
logger.Errorf("处理采集响应失败:无法找到待处理请求 (CorrelationID: %s): %v", correlationID, err)
return
}
// 检查状态,防止重复处理
if pendingReq.Status != models.PendingStatusPending && pendingReq.Status != models.PendingStatusTimedOut {
logger.Warnf("收到一个已处理过的采集响应 (CorrelationID: %s, Status: %s),将忽略。", correlationID, pendingReq.Status)
return
}
// 5. 匹配数据并存入数据库
deviceIDs := pendingReq.CommandMetadata
values := collectResp.Values
if len(deviceIDs) != len(values) {
logger.Errorf("数据不匹配:下行指令要求采集 %d 个设备,但上行响应包含 %d 个值 (CorrelationID: %s)", len(deviceIDs), len(values), correlationID)
// 即使数量不匹配,也更新状态为完成,以防止请求永远 pending
err = c.pendingCollectionRepo.UpdateStatusToFulfilled(reqCtx, correlationID, event.Time)
if err != nil {
logger.Errorf("处理采集响应失败:无法更新待处理请求 (CorrelationID: %s) 的状态为完成: %v", correlationID, err)
}
return
}
for i, deviceID := range deviceIDs {
rawSensorValue := values[i] // 这是设备上报的原始值
// 检查设备上报的值是否为 NaN (Not a Number),如果是则跳过
if math.IsNaN(float64(rawSensorValue)) {
logger.Warnf("设备 (ID: %d) 上报了一个无效的 NaN 值,已跳过当前值的记录。", deviceID)
continue
}
// 5.1 获取设备及其模板
dev, err := c.deviceRepo.FindByID(reqCtx, deviceID)
if err != nil {
logger.Errorf("处理采集数据失败:无法找到设备 (ID: %d): %v", deviceID, err)
continue
}
// 依赖 SelfCheck 确保设备和模板有效
if err := dev.SelfCheck(); err != nil {
logger.Warnf("跳过设备 %d因其未通过自检: %v", dev.ID, err)
continue
}
if err := dev.DeviceTemplate.SelfCheck(); err != nil {
logger.Warnf("跳过设备 %d因其设备模板未通过自检: %v", dev.ID, err)
continue
}
// 5.2 从设备模板中解析 ValueDescriptor
var valueDescriptors []*models.ValueDescriptor
if err := dev.DeviceTemplate.ParseValues(&valueDescriptors); err != nil {
logger.Warnf("跳过设备 %d因其设备模板的 Values 属性解析失败: %v", dev.ID, err)
continue
}
// 根据 DeviceTemplate.SelfCheck这里应该只有一个 ValueDescriptor
if len(valueDescriptors) == 0 {
logger.Warnf("跳过设备 %d因其设备模板缺少 ValueDescriptor 定义", dev.ID)
continue
}
valueDescriptor := valueDescriptors[0]
// 5.3 应用乘数和偏移量计算最终值
parsedValue := rawSensorValue*valueDescriptor.Multiplier + valueDescriptor.Offset
// 5.4 根据传感器类型构建具体的数据结构
var dataToRecord interface{}
switch valueDescriptor.Type {
case models.SensorTypeTemperature:
dataToRecord = models.TemperatureData{TemperatureCelsius: parsedValue}
case models.SensorTypeHumidity:
dataToRecord = models.HumidityData{HumidityPercent: parsedValue}
case models.SensorTypeWeight:
dataToRecord = models.WeightData{WeightKilograms: parsedValue}
default:
// TODO 未知传感器的数据需要记录吗
logger.Warnf("未知的传感器类型 '%s',将使用通用格式记录", valueDescriptor.Type)
dataToRecord = map[string]float32{"value": parsedValue}
}
// 5.5 记录传感器数据
c.recordSensorData(reqCtx, areaController.ID, dev.ID, event.Time, valueDescriptor.Type, dataToRecord)
logger.Infof("成功记录传感器数据: 设备ID=%d, 类型=%s, 原始值=%f, 解析值=%.2f", dev.ID, valueDescriptor.Type, rawSensorValue, parsedValue)
}
// 6. 更新请求状态为“已完成”
if err := c.pendingCollectionRepo.UpdateStatusToFulfilled(reqCtx, correlationID, event.Time); err != nil {
logger.Errorf("更新待采集请求状态为 'fulfilled' 失败 (CorrelationID: %s): %v", correlationID, err)
} else {
logger.Infof("成功完成并关闭采集请求 (CorrelationID: %s)", correlationID)
}
}
// handleStatusEvent 处理设备状态事件
func (c *ChirpStackListener) handleStatusEvent(ctx context.Context, event *StatusEvent) {
reqCtx, logger := logs.Trace(ctx, c.ctx, "handleStatusEvent")
logger.Infof("处接收到理 'status' 事件: %+v", event)
// 查找区域主控设备
areaController, err := c.areaControllerRepo.FindByNetworkID(reqCtx, event.DeviceInfo.DevEui)
if err != nil {
logger.Errorf("处理 'status' 事件失败:无法通过 DevEui '%s' 找到区域主控设备: %v", event.DeviceInfo.DevEui, err)
return
}
// 记录信号强度
signalMetrics := models.SignalMetrics{
MarginDb: event.Margin,
}
c.recordSensorData(reqCtx, areaController.ID, areaController.ID, event.Time, models.SensorTypeSignalMetrics, signalMetrics)
logger.Infof("已记录区域主控 (ID: %d) 的信号状态: %+v", areaController.ID, signalMetrics)
// 记录电量
batteryLevel := models.BatteryLevel{
BatteryLevelRatio: event.BatteryLevel,
BatteryLevelUnavailable: event.BatteryLevelUnavailable,
ExternalPower: event.ExternalPower,
}
c.recordSensorData(reqCtx, areaController.ID, areaController.ID, event.Time, models.SensorTypeBatteryLevel, batteryLevel)
logger.Infof("已记录区域主控 (ID: %d) 的电池状态: %+v", areaController.ID, batteryLevel)
}
// handleAckEvent 处理下行确认事件
func (c *ChirpStackListener) handleAckEvent(ctx context.Context, event *AckEvent) {
reqCtx, logger := logs.Trace(ctx, c.ctx, "handleAckEvent")
logger.Infof("接收到 'ack' 事件: %+v", event)
// 更新下行任务记录的确认时间及接收成功状态
err := c.deviceCommandLogRepo.UpdateAcknowledgedAt(reqCtx, event.DeduplicationID, event.Time, event.Acknowledged)
if err != nil {
logger.Errorf("更新下行任务记录的确认时间及接收成功状态失败 (MessageID: %s, DevEui: %s, Acknowledged: %t): %v",
event.DeduplicationID, event.DeviceInfo.DevEui, event.Acknowledged, err)
return
}
logger.Infof("成功更新下行任务记录确认时间及接收成功状态 (MessageID: %s, DevEui: %s, Acknowledged: %t, AcknowledgedAt: %s)",
event.DeduplicationID, event.DeviceInfo.DevEui, event.Acknowledged, event.Time.Format(time.RFC3339))
}
// handleLogEvent 处理日志事件
func (c *ChirpStackListener) handleLogEvent(ctx context.Context, event *LogEvent) {
logger := logs.TraceLogger(ctx, c.ctx, "handleLogEvent")
// 首先,打印完整的事件结构体,用于详细排查
logger.Infof("接收到 'log' 事件的完整内容: %+v", event)
// 接着,根据 ChirpStack 日志的级别,使用我们自己的 logger 对应级别来打印核心信息
logMessage := "ChirpStack 日志: [%s] %s (DevEui: %s)"
switch event.Level {
case "INFO":
logger.Infof(logMessage, event.Code, event.Description, event.DeviceInfo.DevEui)
case "WARNING":
logger.Warnf(logMessage, event.Code, event.Description, event.DeviceInfo.DevEui)
case "ERROR":
logger.Errorf(logMessage, event.Code, event.Description, event.DeviceInfo.DevEui)
default:
// 对于未知级别,使用 Warn 级别打印,并明确指出级别未知
logger.Warnf("ChirpStack 日志: [未知级别: %s] %s %s (DevEui: %s)",
event.Level, event.Code, event.Description, event.DeviceInfo.DevEui)
}
}
// handleJoinEvent 处理入网事件
func (c *ChirpStackListener) handleJoinEvent(ctx context.Context, event *JoinEvent) {
logger := logs.TraceLogger(ctx, c.ctx, "handleJoinEvent")
logger.Infof("接收到 'join' 事件: %+v", event)
// 在这里添加您的业务逻辑
}
// handleTxAckEvent 处理网关发送确认事件
func (c *ChirpStackListener) handleTxAckEvent(ctx context.Context, event *TxAckEvent) {
logger := logs.TraceLogger(ctx, c.ctx, "handleTxAckEvent")
logger.Infof("接收到 'txack' 事件: %+v", event)
// 在这里添加您的业务逻辑
}
// handleLocationEvent 处理位置事件
func (c *ChirpStackListener) handleLocationEvent(ctx context.Context, event *LocationEvent) {
logger := logs.TraceLogger(ctx, c.ctx, "handleLocationEvent")
logger.Infof("接收到 'location' 事件: %+v", event)
// 在这里添加您的业务逻辑
}
// handleIntegrationEvent 处理集成事件
func (c *ChirpStackListener) handleIntegrationEvent(ctx context.Context, event *IntegrationEvent) {
logger := logs.TraceLogger(ctx, c.ctx, "handleIntegrationEvent")
logger.Infof("接收到 'integration' 事件: %+v", event)
// 在这里添加您的业务逻辑
}
// recordSensorData 是一个通用方法,用于将传感器数据存入数据库。
// areaControllerID: 区域主控设备的ID
// sensorDeviceID: 实际产生传感器数据的普通设备的ID
// sensorType: 传感器值的类型 (例如 models.SensorTypeTemperature)
// data: 具体的传感器数据结构体实例 (例如 models.TemperatureData)
func (c *ChirpStackListener) recordSensorData(ctx context.Context, areaControllerID uint32, sensorDeviceID uint32, eventTime time.Time, sensorType models.SensorType, data interface{}) {
reqCtx, logger := logs.Trace(ctx, c.ctx, "recordSensorData")
// 1. 将传入的结构体序列化为 JSON
jsonData, err := json.Marshal(data)
if err != nil {
logger.Errorf("记录传感器数据失败:序列化数据为 JSON 时出错: %v", err)
return
}
// 2. 构建 SensorData 模型
sensorData := &models.SensorData{
Time: eventTime,
DeviceID: sensorDeviceID,
AreaControllerID: areaControllerID,
SensorType: sensorType,
Data: datatypes.JSON(jsonData),
}
// 3. 调用仓库创建记录
if err := c.sensorDataRepo.Create(reqCtx, sensorData); err != nil {
logger.Errorf("记录传感器数据失败:存入数据库时出错: %v", err)
}
}

View File

@@ -5,8 +5,9 @@ import (
"fmt"
"time"
"git.huangwc.com/pig/pig-farm-controller/internal/app/listener"
"git.huangwc.com/pig/pig-farm-controller/internal/app/listener/chirp_stack"
"git.huangwc.com/pig/pig-farm-controller/internal/app/service"
"git.huangwc.com/pig/pig-farm-controller/internal/app/webhook"
"git.huangwc.com/pig/pig-farm-controller/internal/domain/alarm"
"git.huangwc.com/pig/pig-farm-controller/internal/domain/device"
"git.huangwc.com/pig/pig-farm-controller/internal/domain/inventory"
@@ -170,6 +171,7 @@ func initDomainServices(ctx context.Context, cfg *config.Config, infra *Infrastr
generalDeviceService := device.NewGeneralDeviceService(
logs.AddCompName(baseCtx, "GeneralDeviceService"),
infra.repos.deviceRepo,
infra.repos.areaControllerRepo,
infra.repos.deviceCommandLogRepo,
infra.repos.pendingCollectionRepo,
infra.lora.comm,
@@ -187,6 +189,7 @@ func initDomainServices(ctx context.Context, cfg *config.Config, infra *Infrastr
infra.repos.sensorDataRepo,
infra.repos.deviceRepo,
infra.repos.alarmRepo,
infra.repos.areaControllerRepo,
generalDeviceService,
notifyService,
alarmService,
@@ -350,7 +353,7 @@ func initAppServices(ctx context.Context, infra *Infrastructure, domainServices
// LoraComponents 聚合了所有 LoRa 相关组件。
type LoraComponents struct {
listenHandler webhook.ListenHandler
listenHandler listener.ListenHandler
comm transport.Communicator
loraListener transport.Listener
}
@@ -361,21 +364,44 @@ func initLora(
cfg *config.Config,
repos *Repositories,
) (*LoraComponents, error) {
var listenHandler webhook.ListenHandler
var listenHandler listener.ListenHandler
var comm transport.Communicator
var loraListener transport.Listener
baseCtx := context.Background()
logger := logs.GetLogger(ctx)
// 1. 创建统一的业务处理器 (App层适配器)
// 它实现了 infra 层的 transport.UpstreamHandler 接口
upstreamHandler := listener.NewLoRaListener(
baseCtx,
repos.areaControllerRepo,
repos.pendingCollectionRepo,
repos.deviceRepo,
repos.sensorDataRepo,
repos.deviceCommandLogRepo,
)
// 2. 根据配置初始化具体的传输层和监听器
if cfg.Lora.Mode == config.LoraMode_LoRaWAN {
logger.Info("当前运行模式: lora_wan。初始化 ChirpStack 监听器和传输层。")
listenHandler = webhook.NewChirpStackListener(logs.AddCompName(baseCtx, "ChirpStackListener"), repos.sensorDataRepo, repos.deviceRepo, repos.areaControllerRepo, repos.deviceCommandLogRepo, repos.pendingCollectionRepo)
// 2a. 创建 ChirpStack 的 Webhook 监听器 (infra),并注入 App 层的业务处理器
listenHandler = chirp_stack.NewChirpStackListener(baseCtx, upstreamHandler)
// 2b. 创建 ChirpStack 的发送器 (infra)
comm = lora.NewChirpStackTransport(logs.AddCompName(baseCtx, "ChirpStackTransport"), cfg.ChirpStack)
// 2c. LoRaWAN 模式下没有主动监听的 Listener使用占位符
loraListener = lora.NewPlaceholderTransport(logs.AddCompName(baseCtx, "PlaceholderTransport"))
} else {
logger.Info("当前运行模式: lora_mesh。初始化 LoRa Mesh 传输层和占位符监听器。")
listenHandler = webhook.NewPlaceholderListener(logs.AddCompName(baseCtx, "PlaceholderListener"))
tp, err := lora.NewLoRaMeshUartPassthroughTransport(logs.AddCompName(baseCtx, "LoRaMeshTransport"), cfg.LoraMesh, repos.areaControllerRepo, repos.pendingCollectionRepo, repos.deviceRepo, repos.sensorDataRepo)
// 2a. LoRa Mesh 模式下没有 Webhook 监听器,使用占位符
listenHandler = chirp_stack.NewPlaceholderListener(logs.AddCompName(baseCtx, "PlaceholderListener"))
// 2b. 创建串口的传输工具 (infra),它同时实现了发送和监听,并注入 App 层的业务处理器
tp, err := lora.NewLoRaMeshUartPassthroughTransport(baseCtx, cfg.LoraMesh, upstreamHandler)
if err != nil {
return nil, fmt.Errorf("无法初始化 LoRa Mesh 模块: %w", err)
}

View File

@@ -83,6 +83,10 @@ func (app *Application) initializeSystemPlans(ctx context.Context) error {
return err
}
if err := app.initializeHeartbeatCheckPlan(appCtx, existingPlanMap); err != nil {
return err
}
logger.Info("预定义系统计划检查完成。")
return nil
}
@@ -244,6 +248,56 @@ func (app *Application) initializeAlarmNotificationPlan(ctx context.Context, exi
return nil
}
// initializeHeartbeatCheckPlan 负责初始化 "周期性心跳检测" 计划。
func (app *Application) initializeHeartbeatCheckPlan(ctx context.Context, existingPlanMap map[models.PlanName]*models.Plan) error {
appCtx, logger := logs.Trace(ctx, app.Ctx, "initializeHeartbeatCheckPlan")
predefinedPlan := &models.Plan{
Name: models.PlanNamePeriodicHeartbeatCheck,
Description: "这是一个系统预定义的计划, 每5分钟自动触发一次区域主控心跳检测。",
PlanType: models.PlanTypeSystem,
ExecutionType: models.PlanExecutionTypeAutomatic,
CronExpression: "*/5 * * * *", // 每5分钟执行一次
Status: models.PlanStatusEnabled,
ContentType: models.PlanContentTypeTasks,
Tasks: []models.Task{
{
Name: "心跳检测",
Description: "向所有区域主控发送Ping指令",
ExecutionOrder: 1,
Type: models.TaskTypeHeartbeat,
},
},
}
if foundExistingPlan, ok := existingPlanMap[predefinedPlan.Name]; ok {
// 如果计划存在,则进行无差别更新
logger.Infof("预定义计划 '%s' 已存在,正在进行无差别更新...", predefinedPlan.Name)
predefinedPlan.ID = foundExistingPlan.ID
predefinedPlan.ExecuteCount = foundExistingPlan.ExecuteCount
if err := app.Infra.repos.planRepo.UpdatePlanMetadataAndStructure(appCtx, predefinedPlan); err != nil {
return fmt.Errorf("更新预定义计划 '%s' 的元数据和结构失败: %w", predefinedPlan.Name, err)
}
if err := app.Infra.repos.planRepo.UpdatePlan(appCtx, predefinedPlan); err != nil {
return fmt.Errorf("更新预定义计划 '%s' 的所有顶层字段失败: %w", predefinedPlan.Name, err)
}
logger.Infof("成功更新预定义计划 '%s'。", predefinedPlan.Name)
} else {
// 如果计划不存在, 则创建
logger.Infof("预定义计划 '%s' 不存在,正在创建...", predefinedPlan.Name)
if err := app.Infra.repos.planRepo.CreatePlan(appCtx, predefinedPlan); err != nil {
return fmt.Errorf("创建预定义计划 '%s' 失败: %w", predefinedPlan.Name, err)
} else {
logger.Infof("成功创建预定义计划 '%s'。", predefinedPlan.Name)
}
}
return nil
}
// initializePendingCollections 在应用启动时处理所有未完成的采集请求。
// 我们的策略是:任何在程序重启前仍处于“待处理”状态的请求,都应被视为已失败。
// 这保证了系统在每次启动时都处于一个干净、确定的状态。

View File

@@ -4,6 +4,7 @@ import (
"context"
"git.huangwc.com/pig/pig-farm-controller/internal/infra/models"
"git.huangwc.com/pig/pig-farm-controller/internal/infra/transport/proto"
)
// 设备行为
@@ -21,6 +22,26 @@ var (
MethodSwitch Method = "switch" // 启停指令
)
// SendOptions 包含了发送通用指令时的可选参数。
type SendOptions struct {
// NotTrackable 如果为 true则指示本次发送无需被追踪。
// 这将阻止系统为本次发送创建 device_command_logs 记录。
// 默认为 false即需要追踪。
NotTrackable bool
}
// SendOption 是一个函数类型,用于修改 SendOptions。
// 这是实现 "Functional Options Pattern" 的核心。
type SendOption func(*SendOptions)
// WithoutTracking 是一个公开的选项函数,用于明确指示本次发送无需追踪。
// 调用方在发送 Ping 等无需响应确认的指令时,应使用此选项。
func WithoutTracking() SendOption {
return func(opts *SendOptions) {
opts.NotTrackable = true
}
}
// Service 抽象了一组方法用于控制设备行为
type Service interface {
@@ -29,6 +50,10 @@ type Service interface {
// Collect 用于发起对指定区域主控下的多个设备的批量采集请求。
Collect(ctx context.Context, areaControllerID uint32, devicesToCollect []*models.Device) error
// Send 是一个通用的发送方法,用于将一个标准的指令载荷发送到指定的区域主控。
// 它负责将载荷包装成顶层指令、序列化、调用底层发送器,并默认记录下行命令日志。
Send(ctx context.Context, areaControllerID uint32, payload proto.InstructionPayload, opts ...SendOption) error
}
// 设备操作指令通用结构(最外层)

View File

@@ -20,6 +20,7 @@ import (
type GeneralDeviceService struct {
ctx context.Context
deviceRepo repository.DeviceRepository
areaControllerRepo repository.AreaControllerRepository
deviceCommandLogRepo repository.DeviceCommandLogRepository
pendingCollectionRepo repository.PendingCollectionRepository
comm transport.Communicator
@@ -29,6 +30,7 @@ type GeneralDeviceService struct {
func NewGeneralDeviceService(
ctx context.Context,
deviceRepo repository.DeviceRepository,
areaControllerRepo repository.AreaControllerRepository,
deviceCommandLogRepo repository.DeviceCommandLogRepository,
pendingCollectionRepo repository.PendingCollectionRepository,
comm transport.Communicator,
@@ -36,6 +38,7 @@ func NewGeneralDeviceService(
return &GeneralDeviceService{
ctx: ctx,
deviceRepo: deviceRepo,
areaControllerRepo: areaControllerRepo,
deviceCommandLogRepo: deviceCommandLogRepo,
pendingCollectionRepo: pendingCollectionRepo,
comm: comm,
@@ -249,3 +252,70 @@ func (g *GeneralDeviceService) Collect(ctx context.Context, areaControllerID uin
logger.Debugf("成功将采集请求 (CorrelationID: %s) 发送到设备 %s", correlationID, networkID)
return nil
}
// Send 实现了 Service 接口,用于发送一个通用的指令载荷。
// 它将载荷包装成顶层指令,然后执行查找网络地址、序列化、发送和记录日志的完整流程。
func (g *GeneralDeviceService) Send(ctx context.Context, areaControllerID uint32, payload proto.InstructionPayload, opts ...SendOption) error {
serviceCtx, logger := logs.Trace(ctx, g.ctx, "Send")
// 1. 应用选项
options := &SendOptions{}
for _, opt := range opts {
opt(options)
}
// 2. 查找区域主控以获取 NetworkID
areaController, err := g.areaControllerRepo.FindByID(serviceCtx, areaControllerID)
if err != nil {
return fmt.Errorf("发送通用指令失败无法找到ID为 %d 的区域主控: %w", areaControllerID, err)
}
// 3. 将载荷包装进顶层 Instruction 结构体
instruction := &proto.Instruction{
Payload: payload,
}
// 4. 序列化指令
message, err := gproto.Marshal(instruction)
if err != nil {
return fmt.Errorf("序列化通用指令失败: %w", err)
}
// 5. 发送指令
networkID := areaController.NetworkID
sendResult, err := g.comm.Send(serviceCtx, networkID, message)
if err != nil {
return fmt.Errorf("发送通用指令到 %s 失败: %w", networkID, err)
}
// 6. 始终创建 DeviceCommandLog 记录,但根据选项设置其初始状态
logRecord := &models.DeviceCommandLog{
MessageID: sendResult.MessageID,
DeviceID: areaController.ID, // 将日志与区域主控关联
SentAt: time.Now(),
}
if options.NotTrackable {
// 对于无需追踪的指令,直接标记为已完成
now := time.Now()
logRecord.AcknowledgedAt = &now
logRecord.ReceivedSuccess = true
logger.Infow("成功发送一个无需追踪的通用指令,并记录为已完成日志", "networkID", networkID, "MessageID", sendResult.MessageID)
} else {
// 对于需要追踪的指令,记录其发送结果,等待异步确认
if sendResult.AcknowledgedAt != nil {
logRecord.AcknowledgedAt = sendResult.AcknowledgedAt
}
if sendResult.ReceivedSuccess != nil {
logRecord.ReceivedSuccess = *sendResult.ReceivedSuccess
}
logger.Infow("成功发送通用指令,并创建追踪日志", "networkID", networkID, "MessageID", sendResult.MessageID)
}
if err := g.deviceCommandLogRepo.Create(serviceCtx, logRecord); err != nil {
// 记录日志失败是一个需要关注的问题,但可能不应该中断主流程。
logger.Errorw("创建通用指令的日志失败", "MessageID", sendResult.MessageID, "error", err)
}
return nil
}

View File

@@ -0,0 +1,93 @@
package task
import (
"context"
"fmt"
"git.huangwc.com/pig/pig-farm-controller/internal/domain/device"
"git.huangwc.com/pig/pig-farm-controller/internal/domain/plan"
"git.huangwc.com/pig/pig-farm-controller/internal/infra/logs"
"git.huangwc.com/pig/pig-farm-controller/internal/infra/models"
"git.huangwc.com/pig/pig-farm-controller/internal/infra/repository"
"git.huangwc.com/pig/pig-farm-controller/internal/infra/transport/proto"
)
// HeartbeatTask 实现了 plan.Task 接口用于执行一次区域主控心跳检测发送Ping
type HeartbeatTask struct {
ctx context.Context
log *models.TaskExecutionLog
areaControllerRepo repository.AreaControllerRepository
deviceService device.Service
}
// NewHeartbeatTask 创建一个心跳检测任务实例
func NewHeartbeatTask(
ctx context.Context,
log *models.TaskExecutionLog,
areaControllerRepo repository.AreaControllerRepository,
deviceService device.Service,
) plan.Task {
return &HeartbeatTask{
ctx: ctx,
log: log,
areaControllerRepo: areaControllerRepo,
deviceService: deviceService,
}
}
// Execute 是任务的核心执行逻辑
func (t *HeartbeatTask) Execute(ctx context.Context) error {
taskCtx, logger := logs.Trace(ctx, t.ctx, "Execute")
logger.Infow("开始执行区域主控心跳检测任务", "task_id", t.log.TaskID, "task_type", t.log.Task.Type, "log_id", t.log.ID)
controllers, err := t.areaControllerRepo.ListAll(taskCtx)
if err != nil {
return fmt.Errorf("心跳检测任务:获取所有区域主控失败: %w", err)
}
if len(controllers) == 0 {
logger.Infow("心跳检测任务:未发现任何区域主控,跳过本次检测")
return nil
}
// 构建 Ping 指令
pingInstruction := &proto.Instruction_Ping{
Ping: &proto.Ping{},
}
var firstError error
for _, controller := range controllers {
logger.Infow("向区域主控发送Ping指令", "controller_id", controller.ID)
err := t.deviceService.Send(taskCtx, controller.ID, pingInstruction, device.WithoutTracking())
if err != nil {
logger.Errorw("向区域主控发送Ping指令失败", "controller_id", controller.ID, "error", err)
if firstError == nil {
firstError = err // 保存第一个发生的错误
}
}
}
if firstError != nil {
return fmt.Errorf("心跳检测任务执行期间发生错误: %w", firstError)
}
logger.Infow("区域主控心跳检测任务执行完成", "task_id", t.log.TaskID, "task_type", t.log.Task.Type, "log_id", t.log.ID)
return nil
}
// OnFailure 定义了当 Execute 方法返回错误时,需要执行的回滚或清理逻辑
func (t *HeartbeatTask) OnFailure(ctx context.Context, executeErr error) {
logger := logs.TraceLogger(ctx, t.ctx, "OnFailure")
logger.Errorw("区域主控心跳检测任务执行失败",
"task_id", t.log.TaskID,
"task_type", t.log.Task.Type,
"log_id", t.log.ID,
"error", executeErr,
)
}
// ResolveDeviceIDs 获取当前任务需要使用的设备ID列表
func (t *HeartbeatTask) ResolveDeviceIDs(ctx context.Context) ([]uint32, error) {
// 心跳检测任务不和任何特定设备绑定
return []uint32{}, nil
}

View File

@@ -18,14 +18,16 @@ const (
CompNameReleaseFeedWeight = "ReleaseFeedWeightTask"
CompNameFullCollectionTask = "FullCollectionTask"
CompNameAlarmNotification = "AlarmNotificationTask"
CompNameHeartbeatTask = "HeartbeatTask"
)
type taskFactory struct {
ctx context.Context
sensorDataRepo repository.SensorDataRepository
deviceRepo repository.DeviceRepository
alarmRepo repository.AlarmRepository
sensorDataRepo repository.SensorDataRepository
deviceRepo repository.DeviceRepository
alarmRepo repository.AlarmRepository
areaControllerRepo repository.AreaControllerRepository
deviceService device.Service
notificationService notify.Service
@@ -37,6 +39,7 @@ func NewTaskFactory(
sensorDataRepo repository.SensorDataRepository,
deviceRepo repository.DeviceRepository,
alarmRepo repository.AlarmRepository,
areaControllerRepo repository.AreaControllerRepository,
deviceService device.Service,
notifyService notify.Service,
alarmService alarm.AlarmService,
@@ -46,6 +49,7 @@ func NewTaskFactory(
sensorDataRepo: sensorDataRepo,
deviceRepo: deviceRepo,
alarmRepo: alarmRepo,
areaControllerRepo: areaControllerRepo,
deviceService: deviceService,
notificationService: notifyService,
alarmService: alarmService,
@@ -62,6 +66,8 @@ func (t *taskFactory) Production(ctx context.Context, claimedLog *models.TaskExe
return NewReleaseFeedWeightTask(logs.AddCompName(baseCtx, CompNameReleaseFeedWeight), claimedLog, t.sensorDataRepo, t.deviceRepo, t.deviceService)
case models.TaskTypeFullCollection:
return NewFullCollectionTask(logs.AddCompName(baseCtx, CompNameFullCollectionTask), claimedLog, t.deviceRepo, t.deviceService)
case models.TaskTypeHeartbeat:
return NewHeartbeatTask(logs.AddCompName(baseCtx, CompNameHeartbeatTask), claimedLog, t.areaControllerRepo, t.deviceService)
case models.TaskTypeAlarmNotification:
return NewAlarmNotificationTask(logs.AddCompName(baseCtx, CompNameAlarmNotification), claimedLog, t.notificationService, t.alarmRepo)
case models.TaskTypeDeviceThresholdCheck:
@@ -71,7 +77,6 @@ func (t *taskFactory) Production(ctx context.Context, claimedLog *models.TaskExe
case models.TaskTypeNotificationRefresh:
return NewRefreshNotificationTask(logs.AddCompName(baseCtx, "NotificationRefreshTask"), claimedLog, t.alarmService)
default:
// TODO 这里直接panic合适吗? 不过这个场景确实不该出现任何异常的任务类型
logger.Panicf("不支持的任务类型: %s", claimedLog.Task.Type)
panic("不支持的任务类型") // 显式panic防编译器报错
}
@@ -79,8 +84,6 @@ func (t *taskFactory) Production(ctx context.Context, claimedLog *models.TaskExe
// CreateTaskFromModel 实现了 TaskFactory 接口,用于从模型创建任务实例。
func (t *taskFactory) CreateTaskFromModel(ctx context.Context, taskModel *models.Task) (plan.TaskDeviceIDResolver, error) {
// 这个方法不关心 claimedLog 的其他字段,所以可以构造一个临时的
// 它只用于访问那些不依赖于执行日志的方法,比如 ResolveDeviceIDs
tempLog := &models.TaskExecutionLog{Task: *taskModel}
baseCtx := context.Background()
@@ -97,6 +100,8 @@ func (t *taskFactory) CreateTaskFromModel(ctx context.Context, taskModel *models
), nil
case models.TaskTypeFullCollection:
return NewFullCollectionTask(logs.AddCompName(baseCtx, CompNameFullCollectionTask), tempLog, t.deviceRepo, t.deviceService), nil
case models.TaskTypeHeartbeat:
return NewHeartbeatTask(logs.AddCompName(baseCtx, CompNameHeartbeatTask), tempLog, t.areaControllerRepo, t.deviceService), nil
case models.TaskTypeAlarmNotification:
return NewAlarmNotificationTask(logs.AddCompName(baseCtx, CompNameAlarmNotification), tempLog, t.notificationService, t.alarmRepo), nil
case models.TaskTypeDeviceThresholdCheck:

View File

@@ -13,6 +13,7 @@ import (
"time"
"git.huangwc.com/pig/pig-farm-controller/internal/infra/config"
"go.uber.org/zap"
"go.uber.org/zap/zapcore"
"gopkg.in/natefinch/lumberjack.v2"

View File

@@ -3,6 +3,7 @@ package models
import (
"encoding/json"
"errors"
"fmt"
"strings"
"gorm.io/datatypes"
@@ -16,6 +17,11 @@ type Bus485Properties struct {
BusAddress uint8 `json:"bus_address"` // 485 总线地址
}
// AreaControllerProperties 定义了区域主控的特有属性
type AreaControllerProperties struct {
FirmwareVersion string `json:"firmware_version,omitempty"` // 主控程序版本
}
// AreaController 是一个LoRa转总线(如485)的通信网关
type AreaController struct {
Model
@@ -45,6 +51,29 @@ func (ac *AreaController) SelfCheck() error {
return nil
}
// ParseProperties 解析 JSON 属性到一个具体的结构体中。
// 调用方需要传入一个指向目标结构体实例的指针。
func (ac *AreaController) ParseProperties(v interface{}) error {
if ac.Properties == nil {
return errors.New("区域主控属性为空,无法解析")
}
return json.Unmarshal(ac.Properties, v)
}
// SetProperties 将一个结构体编码为 JSON 并设置到 Properties 字段。
func (ac *AreaController) SetProperties(v interface{}) error {
if v == nil {
ac.Properties = nil
return nil
}
jsonBytes, err := json.Marshal(v)
if err != nil {
return fmt.Errorf("无法编码区域主控的属性 (Properties): %w", err)
}
ac.Properties = jsonBytes
return nil
}
// TableName 自定义 GORM 使用的数据库表名
func (AreaController) TableName() string {
return "area_controllers"

View File

@@ -16,6 +16,8 @@ type PlanName string
const (
// PlanNamePeriodicSystemHealthCheck 是周期性系统健康检查计划的名称
PlanNamePeriodicSystemHealthCheck PlanName = "周期性系统健康检查"
// PlanNamePeriodicHeartbeatCheck 是周期性心跳检测计划的名称
PlanNamePeriodicHeartbeatCheck PlanName = "周期性心跳检测"
// PlanNameAlarmNotification 是告警通知发送计划的名称
PlanNameAlarmNotification PlanName = "告警通知发送"
)
@@ -44,6 +46,7 @@ const (
TaskTypeWaiting TaskType = "等待" // 等待任务
TaskTypeReleaseFeedWeight TaskType = "下料" // 下料口释放指定重量任务
TaskTypeFullCollection TaskType = "全量采集" // 新增的全量采集任务
TaskTypeHeartbeat TaskType = "心跳检测" // 区域主控心跳检测任务
TaskTypeAlarmNotification TaskType = "告警通知" // 告警通知任务
TaskTypeNotificationRefresh TaskType = "通知刷新" // 通知刷新任务
TaskTypeDeviceThresholdCheck TaskType = "设备阈值检查" // 设备阈值检查任务

View File

@@ -17,6 +17,16 @@ const (
SensorTypeTemperature SensorType = "温度" // 温度
SensorTypeHumidity SensorType = "湿度" // 湿度
SensorTypeWeight SensorType = "重量" // 重量
SensorTypeOnlineStatus SensorType = "在线状态" // 在线状态
)
// OnlineState 定义了设备的在线状态枚举
type OnlineState string
const (
StateOnline OnlineState = "在线" // 设备在线
StateOffline OnlineState = "离线" // 设备离线
StateAbnormal OnlineState = "异常" // 设备状态异常
)
// SignalMetrics 存储信号强度数据
@@ -49,6 +59,11 @@ type WeightData struct {
WeightKilograms float32 `json:"weight_kilograms"` // 重量值 (公斤)
}
// OnlineStatusData 记录了设备的在线状态
type OnlineStatusData struct {
State OnlineState `json:"state"` // 在线状态
}
// SensorData 存储所有类型的传感器数据,对应数据库中的 'sensor_data' 表。
type SensorData struct {
// Time 是数据记录的时间戳,作为复合主键的一部分。

View File

@@ -18,6 +18,8 @@ type AreaControllerRepository interface {
Create(ctx context.Context, ac *models.AreaController) error
ListAll(ctx context.Context) ([]*models.AreaController, error)
Update(ctx context.Context, ac *models.AreaController) error
// UpdateFirmwareVersion 更新指定ID的区域主控的固件版本号。
UpdateFirmwareVersion(ctx context.Context, id uint32, version string) error
Delete(ctx context.Context, id uint32) error
// IsAreaControllerUsedByTasks 检查区域主控是否被特定任务类型使用,可以忽略指定任务类型
IsAreaControllerUsedByTasks(ctx context.Context, areaControllerID uint32, ignoredTaskTypes []models.TaskType) (bool, error)
@@ -59,6 +61,25 @@ func (r *gormAreaControllerRepository) Update(ctx context.Context, ac *models.Ar
return r.db.WithContext(repoCtx).Save(ac).Error
}
// UpdateFirmwareVersion 使用 jsonb_set 函数原子性地更新 properties 字段中的固件版本号。
func (r *gormAreaControllerRepository) UpdateFirmwareVersion(ctx context.Context, id uint32, version string) error {
repoCtx := logs.AddFuncName(ctx, r.ctx, "UpdateFirmwareVersion")
// 使用 gorm.Expr 包装 PostgreSQL 的 jsonb_set 函数
// jsonb_set(properties, '{firmware_version}', '"new_version"', true)
// 注意jsonb_set 的第三个参数需要是有效的 JSON 值,所以字符串需要被双引号包围。
jsonbExpr := gorm.Expr(`jsonb_set(COALESCE(properties, '{}'::jsonb), '{firmware_version}', ?::jsonb)`, fmt.Sprintf(`"%s"`, version))
result := r.db.WithContext(repoCtx).Model(&models.AreaController{}).Where("id = ?", id).Update("properties", jsonbExpr)
if result.Error != nil {
return result.Error
}
if result.RowsAffected == 0 {
return fmt.Errorf("更新固件版本失败未找到ID为 %d 的区域主控", id)
}
return nil
}
// Delete 删除一个 AreaController 记录。
func (r *gormAreaControllerRepository) Delete(ctx context.Context, id uint32) error {
repoCtx := logs.AddFuncName(ctx, r.ctx, "Delete")

View File

@@ -297,7 +297,7 @@ type ApplicationServiceCreateIftttIntegrationParamsBodyIntegration struct {
// Event prefix.
// If set, the event name will be PREFIX_EVENT. For example if event_prefix
// is set to weatherstation, and uplink event will be sent as
// weatherstation_up to the IFTTT webhook.
// weatherstation_up to the IFTTT listener.
// Note: Only characters in the A-Z, a-z and 0-9 range are allowed.
EventPrefix string `json:"eventPrefix,omitempty"`

View File

@@ -297,7 +297,7 @@ type ApplicationServiceUpdateIftttIntegrationParamsBodyIntegration struct {
// Event prefix.
// If set, the event name will be PREFIX_EVENT. For example if event_prefix
// is set to weatherstation, and uplink event will be sent as
// weatherstation_up to the IFTTT webhook.
// weatherstation_up to the IFTTT listener.
// Note: Only characters in the A-Z, a-z and 0-9 range are allowed.
EventPrefix string `json:"eventPrefix,omitempty"`

View File

@@ -28,7 +28,7 @@ type APIIftttIntegration struct {
// Event prefix.
// If set, the event name will be PREFIX_EVENT. For example if event_prefix
// is set to weatherstation, and uplink event will be sent as
// weatherstation_up to the IFTTT webhook.
// weatherstation_up to the IFTTT listener.
// Note: Only characters in the A-Z, a-z and 0-9 range are allowed.
EventPrefix string `json:"eventPrefix,omitempty"`

View File

@@ -4,25 +4,20 @@ import (
"bytes"
"context"
"encoding/binary"
"encoding/json"
"fmt"
"io"
"math"
"strconv"
"sync"
"time"
"git.huangwc.com/pig/pig-farm-controller/internal/infra/config"
"git.huangwc.com/pig/pig-farm-controller/internal/infra/logs"
"git.huangwc.com/pig/pig-farm-controller/internal/infra/models"
"git.huangwc.com/pig/pig-farm-controller/internal/infra/repository"
"git.huangwc.com/pig/pig-farm-controller/internal/infra/transport"
"git.huangwc.com/pig/pig-farm-controller/internal/infra/transport/proto"
"github.com/google/uuid"
"github.com/tarm/serial"
gproto "google.golang.org/protobuf/proto"
"gorm.io/datatypes"
)
// transportState 定义了传输层的内部状态
@@ -43,9 +38,10 @@ type message struct {
// LoRaMeshUartPassthroughTransport 实现了 transport.Communicator 和 transport.Listener 接口
type LoRaMeshUartPassthroughTransport struct {
ctx context.Context
config config.LoraMeshConfig
port *serial.Port
selfCtx context.Context
config config.LoraMeshConfig
port *serial.Port
handler transport.UpstreamHandler // 依赖注入的统一业务处理器
mu sync.Mutex // 用于保护对外的公共方法如Send的并发调用
state transportState
@@ -59,12 +55,6 @@ type LoRaMeshUartPassthroughTransport struct {
currentRecvSource uint16 // 当前正在接收的源地址
reassemblyTimeout *time.Timer // 分片重组的超时定时器
reassemblyTimeoutCh chan uint16 // 当超时触发时,用于传递源地址
// --- 依赖注入的仓库 ---
areaControllerRepo repository.AreaControllerRepository
pendingCollectionRepo repository.PendingCollectionRepository
deviceRepo repository.DeviceRepository
sensorDataRepo repository.SensorDataRepository
}
// sendRequest 封装了一次发送请求
@@ -91,10 +81,7 @@ type reassemblyBuffer struct {
func NewLoRaMeshUartPassthroughTransport(
ctx context.Context,
config config.LoraMeshConfig,
areaControllerRepo repository.AreaControllerRepository,
pendingCollectionRepo repository.PendingCollectionRepository,
deviceRepo repository.DeviceRepository,
sensorDataRepo repository.SensorDataRepository,
handler transport.UpstreamHandler,
) (*LoRaMeshUartPassthroughTransport, error) {
c := &serial.Config{
Name: config.UARTPort,
@@ -108,20 +95,15 @@ func NewLoRaMeshUartPassthroughTransport(
}
t := &LoRaMeshUartPassthroughTransport{
ctx: ctx,
selfCtx: logs.AddCompName(ctx, "LoRaMeshUartPassthroughTransport"),
config: config,
port: port,
handler: handler,
state: stateIdle,
stopChan: make(chan struct{}),
sendChan: make(chan *sendRequest),
reassemblyBuffers: make(map[uint16]*reassemblyBuffer),
reassemblyTimeoutCh: make(chan uint16, 1),
// 注入依赖
areaControllerRepo: areaControllerRepo,
pendingCollectionRepo: pendingCollectionRepo,
deviceRepo: deviceRepo,
sensorDataRepo: sensorDataRepo,
}
return t, nil
@@ -129,10 +111,11 @@ func NewLoRaMeshUartPassthroughTransport(
// Listen 启动后台监听协程(非阻塞)
func (t *LoRaMeshUartPassthroughTransport) Listen(ctx context.Context) error {
loraCtx, logger := logs.Trace(ctx, t.ctx, "Listen")
// 注意:这里的 loraCtx 是从 selfCtx 派生的,因为它代表了这个组件自身的生命周期
loraCtx, logger := logs.Trace(ctx, t.selfCtx, "Listen")
t.wg.Add(1)
go t.workerLoop(loraCtx)
logger.Info("LoRa传输层工作协程已启动")
logger.Info("LoRa Mesh 传输层工作协程已启动")
return nil
}
@@ -167,7 +150,7 @@ func (t *LoRaMeshUartPassthroughTransport) Stop(ctx context.Context) error {
// workerLoop 是核心的状态机和调度器
func (t *LoRaMeshUartPassthroughTransport) workerLoop(ctx context.Context) {
loraCtx, logger := logs.Trace(ctx, t.ctx, "workerLoop")
loraCtx, logger := logs.Trace(ctx, t.selfCtx, "workerLoop")
defer t.wg.Done()
@@ -218,7 +201,7 @@ func (t *LoRaMeshUartPassthroughTransport) workerLoop(ctx context.Context) {
// runIdleState 处理空闲状态下的逻辑,主要是检查并启动发送任务
func (t *LoRaMeshUartPassthroughTransport) runIdleState(ctx context.Context) {
loraCtx := logs.AddFuncName(ctx, t.ctx, "Listen")
loraCtx, _ := logs.Trace(ctx, t.selfCtx, "runIdleState")
select {
case req := <-t.sendChan:
@@ -234,10 +217,10 @@ func (t *LoRaMeshUartPassthroughTransport) runIdleState(ctx context.Context) {
// runReceivingState 处理接收状态下的逻辑,主要是检查超时
func (t *LoRaMeshUartPassthroughTransport) runReceivingState(ctx context.Context) {
logger := logs.TraceLogger(ctx, t.ctx, "runReceivingState")
_, logger := logs.Trace(ctx, t.selfCtx, "runReceivingState")
select {
case sourceAddr := <-t.reassemblyTimeoutCh:
logger.Warnf("接收来自 0x%04X 的消息超时", sourceAddr)
logger.Warnw("接收消息超时", "sourceAddr", fmt.Sprintf("0x%04X", sourceAddr))
delete(t.reassemblyBuffers, sourceAddr)
t.state = stateIdle
default:
@@ -247,7 +230,7 @@ func (t *LoRaMeshUartPassthroughTransport) runReceivingState(ctx context.Context
// executeSend 执行完整的发送流程(分片、构建、写入)
func (t *LoRaMeshUartPassthroughTransport) executeSend(ctx context.Context, req *sendRequest) (*transport.SendResult, error) {
logger := logs.TraceLogger(ctx, t.ctx, "executeSend")
_, logger := logs.Trace(ctx, t.selfCtx, "executeSend")
chunks := splitPayload(req.payload, t.config.MaxChunkSize)
totalChunks := uint8(len(chunks))
@@ -266,7 +249,7 @@ func (t *LoRaMeshUartPassthroughTransport) executeSend(ctx context.Context, req
frame.WriteByte(currentChunk) // 当前包序号
frame.Write(chunk) // 数据块
logger.Debugf("构建LoRa数据包: %v", frame.Bytes())
logger.Debugw("构建LoRa数据包", "bytes", frame.Bytes())
_, err := t.port.Write(frame.Bytes())
if err != nil {
return nil, fmt.Errorf("写入串口失败: %w", err)
@@ -282,9 +265,9 @@ func (t *LoRaMeshUartPassthroughTransport) executeSend(ctx context.Context, req
// handleFrame 处理一个从串口解析出的完整物理帧
func (t *LoRaMeshUartPassthroughTransport) handleFrame(ctx context.Context, frame []byte) {
loraCtx, logger := logs.Trace(ctx, t.ctx, "handleFrame")
reqCtx, logger := logs.Trace(ctx, t.selfCtx, "handleFrame")
if len(frame) < 8 {
logger.Warnf("收到了一个无效长度的帧: %d", len(frame))
logger.Warnw("收到了一个无效长度的帧", "length", len(frame))
return
}
@@ -301,7 +284,9 @@ func (t *LoRaMeshUartPassthroughTransport) handleFrame(ctx context.Context, fram
DestAddr: fmt.Sprintf("%04X", destAddr),
Payload: chunkData,
}
go t.handleUpstreamMessage(loraCtx, msg)
// 使用分离的上下文进行异步处理
detachedCtx := logs.DetachContext(reqCtx)
go t.handleUpstreamMessage(detachedCtx, msg)
return
}
@@ -326,18 +311,21 @@ func (t *LoRaMeshUartPassthroughTransport) handleFrame(ctx context.Context, fram
t.reassemblyTimeoutCh <- sourceAddr
})
} else {
logger.Warnf("在空闲状态下收到了一个来自 0x%04X 的非首包分片,已忽略。", sourceAddr)
logger.Warnw("在空闲状态下收到了一个非首包分片,已忽略", "sourceAddr", fmt.Sprintf("0x%04X", sourceAddr))
}
case stateReceiving:
if sourceAddr != t.currentRecvSource {
logger.Warnf("正在接收来自 0x%04X 的数据时,收到了另一个源 0x%04X 的分片,已忽略", t.currentRecvSource, sourceAddr)
logger.Warnw("正在接收数据时,收到了另一个源的分片,已忽略",
"currentSource", fmt.Sprintf("0x%04X", t.currentRecvSource),
"newSource", fmt.Sprintf("0x%04X", sourceAddr),
)
return
}
buffer, ok := t.reassemblyBuffers[sourceAddr]
if !ok {
logger.Errorf("内部错误: 处于接收状态,但没有为 0x%04X 找到缓冲区", sourceAddr)
logger.Errorw("内部错误: 处于接收状态,但没有找到缓冲区", "sourceAddr", fmt.Sprintf("0x%04X", sourceAddr))
t.state = stateIdle // 重置状态
return
}
@@ -362,165 +350,43 @@ func (t *LoRaMeshUartPassthroughTransport) handleFrame(ctx context.Context, fram
DestAddr: fmt.Sprintf("%04X", destAddr),
Payload: fullPayload.Bytes(),
}
go t.handleUpstreamMessage(loraCtx, msg)
// 使用分离的上下文进行异步处理
detachedCtx := logs.DetachContext(reqCtx)
go t.handleUpstreamMessage(detachedCtx, msg)
// 清理并返回空闲状态
delete(t.reassemblyBuffers, sourceAddr)
t.state = stateIdle
}
default:
logger.Errorf("内部错误: 状态机处于未知状态 %d", t.state)
logger.Errorw("内部错误: 状态机处于未知状态", "state", t.state)
}
}
// handleUpstreamMessage 在独立的协程中处理单个上行的、完整的消息。
// 【已重构】此方法现在只负责解析和委托,不包含任何业务逻辑。
func (t *LoRaMeshUartPassthroughTransport) handleUpstreamMessage(ctx context.Context, msg *message) {
loraCtx, logger := logs.Trace(ctx, t.ctx, "handleUpstreamMessage")
logger.Infof("开始处理来自 %s 的上行消息", msg.SourceAddr)
reqCtx, logger := logs.Trace(ctx, t.selfCtx, "handleUpstreamMessage")
logger.Infow("开始适配上行消息并委托", "sourceAddr", msg.SourceAddr)
// 1. 解析外层 "信封"
var instruction proto.Instruction
if err := gproto.Unmarshal(msg.Payload, &instruction); err != nil {
logger.Errorf("解析上行 Instruction Protobuf 失败: %v, 源地址: %s, 原始数据: %x", err, msg.SourceAddr, msg.Payload)
logger.Errorw("解析上行 Instruction Protobuf 失败",
"sourceAddr", msg.SourceAddr,
"error", err,
"rawData", fmt.Sprintf("%x", msg.Payload),
)
return
}
// 2. 使用 type switch 从 oneof payload 中提取 CollectResult
var collectResp *proto.CollectResult
switch p := instruction.GetPayload().(type) {
case *proto.Instruction_CollectResult:
collectResp = p.CollectResult
default:
// 如果上行的数据不是采集结果,记录日志并忽略
logger.Infof("收到一个非采集响应的上行指令 (类型: %T),无需处理。源地址: %s", p, msg.SourceAddr)
return
}
if collectResp == nil {
logger.Errorf("从 Instruction 中提取的 CollectResult 为 nil。源地址: %s", msg.SourceAddr)
return
}
correlationID := collectResp.CorrelationId
logger.Infof("成功解析采集响应 (CorrelationID: %s),包含 %d 个值。", correlationID, len(collectResp.Values))
// 3. 查找区域主控 (注意LoRa Mesh 的 SourceAddr 对应于区域主控的 NetworkID)
areaController, err := t.areaControllerRepo.FindByNetworkID(loraCtx, msg.SourceAddr)
if err != nil {
logger.Errorf("处理上行消息失败:无法通过源地址 '%s' 找到区域主控设备: %v", msg.SourceAddr, err)
return
}
if err := areaController.SelfCheck(); err != nil {
logger.Errorf("处理上行消息失败:区域主控 %v(ID: %d) 未通过自检: %v", areaController.Name, areaController.ID, err)
return
}
// 4. 根据 CorrelationID 查找待处理请求
pendingReq, err := t.pendingCollectionRepo.FindByCorrelationID(loraCtx, correlationID)
if err != nil {
logger.Errorf("处理采集响应失败:无法找到待处理请求 (CorrelationID: %s): %v", correlationID, err)
return
}
// 检查状态,防止重复处理
if pendingReq.Status != models.PendingStatusPending && pendingReq.Status != models.PendingStatusTimedOut {
logger.Warnf("收到一个已处理过的采集响应 (CorrelationID: %s, Status: %s),将忽略。", correlationID, pendingReq.Status)
return
}
// 5. 匹配数据并存入数据库
deviceIDs := pendingReq.CommandMetadata
values := collectResp.Values
if len(deviceIDs) != len(values) {
logger.Errorf("数据不匹配:下行指令要求采集 %d 个设备,但上行响应包含 %d 个值 (CorrelationID: %s)", len(deviceIDs), len(values), correlationID)
err = t.pendingCollectionRepo.UpdateStatusToFulfilled(loraCtx, correlationID, time.Now())
if err != nil {
logger.Errorf("处理采集响应失败:无法更新待处理请求 (CorrelationID: %s) 的状态为完成: %v", correlationID, err)
}
return
}
for i, deviceID := range deviceIDs {
rawSensorValue := values[i]
if math.IsNaN(float64(rawSensorValue)) {
logger.Warnf("设备 (ID: %d) 上报了一个无效的 NaN 值,已跳过当前值的记录。", deviceID)
continue
}
dev, err := t.deviceRepo.FindByID(loraCtx, deviceID)
if err != nil {
logger.Errorf("处理采集数据失败:无法找到设备 (ID: %d): %v", deviceID, err)
continue
}
if err := dev.SelfCheck(); err != nil {
logger.Warnf("跳过设备 %d因其未通过自检: %v", dev.ID, err)
continue
}
if err := dev.DeviceTemplate.SelfCheck(); err != nil {
logger.Warnf("跳过设备 %d因其设备模板未通过自检: %v", dev.ID, err)
continue
}
var valueDescriptors []*models.ValueDescriptor
if err := dev.DeviceTemplate.ParseValues(&valueDescriptors); err != nil {
logger.Warnf("跳过设备 %d因其设备模板的 Values 属性解析失败: %v", dev.ID, err)
continue
}
if len(valueDescriptors) == 0 {
logger.Warnf("跳过设备 %d因其设备模板缺少 ValueDescriptor 定义", dev.ID)
continue
}
valueDescriptor := valueDescriptors[0]
parsedValue := rawSensorValue*valueDescriptor.Multiplier + valueDescriptor.Offset
var dataToRecord interface{}
switch valueDescriptor.Type {
case models.SensorTypeTemperature:
dataToRecord = models.TemperatureData{TemperatureCelsius: parsedValue}
case models.SensorTypeHumidity:
dataToRecord = models.HumidityData{HumidityPercent: parsedValue}
case models.SensorTypeWeight:
dataToRecord = models.WeightData{WeightKilograms: parsedValue}
default:
logger.Warnf("未知的传感器类型 '%s',将使用通用格式记录", valueDescriptor.Type)
dataToRecord = map[string]float32{"value": parsedValue}
}
t.recordSensorData(loraCtx, areaController.ID, dev.ID, time.Now(), valueDescriptor.Type, dataToRecord)
logger.Infof("成功记录传感器数据: 设备ID=%d, 类型=%s, 原始值=%f, 解析值=%.2f", dev.ID, valueDescriptor.Type, rawSensorValue, parsedValue)
}
// 6. 更新请求状态为“已完成”
if err := t.pendingCollectionRepo.UpdateStatusToFulfilled(loraCtx, correlationID, time.Now()); err != nil {
logger.Errorf("更新待采集请求状态为 'fulfilled' 失败 (CorrelationID: %s): %v", correlationID, err)
} else {
logger.Infof("成功完成并关闭采集请求 (CorrelationID: %s)", correlationID)
}
}
// recordSensorData 是一个通用方法,用于将传感器数据存入数据库。
func (t *LoRaMeshUartPassthroughTransport) recordSensorData(ctx context.Context, areaControllerID uint32, sensorDeviceID uint32, eventTime time.Time, sensorType models.SensorType, data interface{}) {
loraCtx, logger := logs.Trace(ctx, t.ctx, "recordSensorData")
jsonData, err := json.Marshal(data)
if err != nil {
logger.Errorf("记录传感器数据失败:序列化数据为 JSON 时出错: %v", err)
return
}
sensorData := &models.SensorData{
Time: eventTime,
DeviceID: sensorDeviceID,
AreaControllerID: areaControllerID,
SensorType: sensorType,
Data: datatypes.JSON(jsonData),
}
if err := t.sensorDataRepo.Create(loraCtx, sensorData); err != nil {
logger.Errorf("记录传感器数据失败:存入数据库时出错: %v", err)
// 2. 委托给统一处理器
// 注意:对于 LoRa Mesh目前只处理业务指令没有单独的状态或ACK事件。
if err := t.handler.HandleInstruction(reqCtx, msg.SourceAddr, &instruction); err != nil {
logger.Errorw("委托上行指令给统一处理器失败",
"sourceAddr", msg.SourceAddr,
"error", err,
)
}
}

View File

@@ -21,6 +21,123 @@ const (
_ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20)
)
// LogLevel 定义了日志的严重级别。
type LogLevel int32
const (
LogLevel_LOG_LEVEL_UNSPECIFIED LogLevel = 0 // 未指定
LogLevel_DEBUG LogLevel = 1 // 调试信息
LogLevel_INFO LogLevel = 2 // 普通信息
LogLevel_WARN LogLevel = 3 // 警告
LogLevel_ERROR LogLevel = 4 // 错误
)
// Enum value maps for LogLevel.
var (
LogLevel_name = map[int32]string{
0: "LOG_LEVEL_UNSPECIFIED",
1: "DEBUG",
2: "INFO",
3: "WARN",
4: "ERROR",
}
LogLevel_value = map[string]int32{
"LOG_LEVEL_UNSPECIFIED": 0,
"DEBUG": 1,
"INFO": 2,
"WARN": 3,
"ERROR": 4,
}
)
func (x LogLevel) Enum() *LogLevel {
p := new(LogLevel)
*p = x
return p
}
func (x LogLevel) String() string {
return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x))
}
func (LogLevel) Descriptor() protoreflect.EnumDescriptor {
return file_device_proto_enumTypes[0].Descriptor()
}
func (LogLevel) Type() protoreflect.EnumType {
return &file_device_proto_enumTypes[0]
}
func (x LogLevel) Number() protoreflect.EnumNumber {
return protoreflect.EnumNumber(x)
}
// Deprecated: Use LogLevel.Descriptor instead.
func (LogLevel) EnumDescriptor() ([]byte, []int) {
return file_device_proto_rawDescGZIP(), []int{0}
}
// LogEntry 代表一条由设备生成的日志记录。
type LogEntry struct {
state protoimpl.MessageState `protogen:"open.v1"`
TimestampUnix int64 `protobuf:"varint,1,opt,name=timestamp_unix,json=timestampUnix,proto3" json:"timestamp_unix,omitempty"` // 日志生成的Unix时间戳 (秒)
Level LogLevel `protobuf:"varint,2,opt,name=level,proto3,enum=device.LogLevel" json:"level,omitempty"` // 日志级别
Message string `protobuf:"bytes,3,opt,name=message,proto3" json:"message,omitempty"` // 日志内容
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *LogEntry) Reset() {
*x = LogEntry{}
mi := &file_device_proto_msgTypes[0]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *LogEntry) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*LogEntry) ProtoMessage() {}
func (x *LogEntry) ProtoReflect() protoreflect.Message {
mi := &file_device_proto_msgTypes[0]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use LogEntry.ProtoReflect.Descriptor instead.
func (*LogEntry) Descriptor() ([]byte, []int) {
return file_device_proto_rawDescGZIP(), []int{0}
}
func (x *LogEntry) GetTimestampUnix() int64 {
if x != nil {
return x.TimestampUnix
}
return 0
}
func (x *LogEntry) GetLevel() LogLevel {
if x != nil {
return x.Level
}
return LogLevel_LOG_LEVEL_UNSPECIFIED
}
func (x *LogEntry) GetMessage() string {
if x != nil {
return x.Message
}
return ""
}
// 平台生成的原始485指令单片机直接发送到总线
type Raw485Command struct {
state protoimpl.MessageState `protogen:"open.v1"`
@@ -32,7 +149,7 @@ type Raw485Command struct {
func (x *Raw485Command) Reset() {
*x = Raw485Command{}
mi := &file_device_proto_msgTypes[0]
mi := &file_device_proto_msgTypes[1]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -44,7 +161,7 @@ func (x *Raw485Command) String() string {
func (*Raw485Command) ProtoMessage() {}
func (x *Raw485Command) ProtoReflect() protoreflect.Message {
mi := &file_device_proto_msgTypes[0]
mi := &file_device_proto_msgTypes[1]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -57,7 +174,7 @@ func (x *Raw485Command) ProtoReflect() protoreflect.Message {
// Deprecated: Use Raw485Command.ProtoReflect.Descriptor instead.
func (*Raw485Command) Descriptor() ([]byte, []int) {
return file_device_proto_rawDescGZIP(), []int{0}
return file_device_proto_rawDescGZIP(), []int{1}
}
func (x *Raw485Command) GetBusNumber() int32 {
@@ -74,7 +191,6 @@ func (x *Raw485Command) GetCommandBytes() []byte {
return nil
}
// BatchCollectCommand
// 一个完整的、包含所有元数据的批量采集任务。
type BatchCollectCommand struct {
state protoimpl.MessageState `protogen:"open.v1"`
@@ -86,7 +202,7 @@ type BatchCollectCommand struct {
func (x *BatchCollectCommand) Reset() {
*x = BatchCollectCommand{}
mi := &file_device_proto_msgTypes[1]
mi := &file_device_proto_msgTypes[2]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -98,7 +214,7 @@ func (x *BatchCollectCommand) String() string {
func (*BatchCollectCommand) ProtoMessage() {}
func (x *BatchCollectCommand) ProtoReflect() protoreflect.Message {
mi := &file_device_proto_msgTypes[1]
mi := &file_device_proto_msgTypes[2]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -111,7 +227,7 @@ func (x *BatchCollectCommand) ProtoReflect() protoreflect.Message {
// Deprecated: Use BatchCollectCommand.ProtoReflect.Descriptor instead.
func (*BatchCollectCommand) Descriptor() ([]byte, []int) {
return file_device_proto_rawDescGZIP(), []int{1}
return file_device_proto_rawDescGZIP(), []int{2}
}
func (x *BatchCollectCommand) GetCorrelationId() string {
@@ -128,7 +244,6 @@ func (x *BatchCollectCommand) GetTasks() []*CollectTask {
return nil
}
// CollectTask
// 定义了单个采集任务的“意图”。
type CollectTask struct {
state protoimpl.MessageState `protogen:"open.v1"`
@@ -139,7 +254,7 @@ type CollectTask struct {
func (x *CollectTask) Reset() {
*x = CollectTask{}
mi := &file_device_proto_msgTypes[2]
mi := &file_device_proto_msgTypes[3]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -151,7 +266,7 @@ func (x *CollectTask) String() string {
func (*CollectTask) ProtoMessage() {}
func (x *CollectTask) ProtoReflect() protoreflect.Message {
mi := &file_device_proto_msgTypes[2]
mi := &file_device_proto_msgTypes[3]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -164,7 +279,7 @@ func (x *CollectTask) ProtoReflect() protoreflect.Message {
// Deprecated: Use CollectTask.ProtoReflect.Descriptor instead.
func (*CollectTask) Descriptor() ([]byte, []int) {
return file_device_proto_rawDescGZIP(), []int{2}
return file_device_proto_rawDescGZIP(), []int{3}
}
func (x *CollectTask) GetCommand() *Raw485Command {
@@ -174,7 +289,6 @@ func (x *CollectTask) GetCommand() *Raw485Command {
return nil
}
// CollectResult
// 这是设备响应的、极致精简的数据包。
type CollectResult struct {
state protoimpl.MessageState `protogen:"open.v1"`
@@ -186,7 +300,7 @@ type CollectResult struct {
func (x *CollectResult) Reset() {
*x = CollectResult{}
mi := &file_device_proto_msgTypes[3]
mi := &file_device_proto_msgTypes[4]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -198,7 +312,7 @@ func (x *CollectResult) String() string {
func (*CollectResult) ProtoMessage() {}
func (x *CollectResult) ProtoReflect() protoreflect.Message {
mi := &file_device_proto_msgTypes[3]
mi := &file_device_proto_msgTypes[4]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -211,7 +325,7 @@ func (x *CollectResult) ProtoReflect() protoreflect.Message {
// Deprecated: Use CollectResult.ProtoReflect.Descriptor instead.
func (*CollectResult) Descriptor() ([]byte, []int) {
return file_device_proto_rawDescGZIP(), []int{3}
return file_device_proto_rawDescGZIP(), []int{4}
}
func (x *CollectResult) GetCorrelationId() string {
@@ -228,16 +342,318 @@ func (x *CollectResult) GetValues() []float32 {
return nil
}
// 指令 (所有从平台下发到设备的数据都应该被包装在这里面)
// 使用 oneof 来替代 google.protobuf.Any这是嵌入式环境下的标准做法。
// 它高效、类型安全,且只解码一次。
// OTA空中下载升级指令包含完整的固件包。
type OtaUpgradeCommand struct {
state protoimpl.MessageState `protogen:"open.v1"`
FirmwareVersion string `protobuf:"bytes,1,opt,name=firmware_version,json=firmwareVersion,proto3" json:"firmware_version,omitempty"` // 目标固件版本, e.g., "v1.2.3"
FirmwareHash string `protobuf:"bytes,2,opt,name=firmware_hash,json=firmwareHash,proto3" json:"firmware_hash,omitempty"` // 固件包的SHA-256哈希值用于完整性校验
FirmwarePackage []byte `protobuf:"bytes,3,opt,name=firmware_package,json=firmwarePackage,proto3" json:"firmware_package,omitempty"` // 完整的固件二进制文件
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *OtaUpgradeCommand) Reset() {
*x = OtaUpgradeCommand{}
mi := &file_device_proto_msgTypes[5]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *OtaUpgradeCommand) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*OtaUpgradeCommand) ProtoMessage() {}
func (x *OtaUpgradeCommand) ProtoReflect() protoreflect.Message {
mi := &file_device_proto_msgTypes[5]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use OtaUpgradeCommand.ProtoReflect.Descriptor instead.
func (*OtaUpgradeCommand) Descriptor() ([]byte, []int) {
return file_device_proto_rawDescGZIP(), []int{5}
}
func (x *OtaUpgradeCommand) GetFirmwareVersion() string {
if x != nil {
return x.FirmwareVersion
}
return ""
}
func (x *OtaUpgradeCommand) GetFirmwareHash() string {
if x != nil {
return x.FirmwareHash
}
return ""
}
func (x *OtaUpgradeCommand) GetFirmwarePackage() []byte {
if x != nil {
return x.FirmwarePackage
}
return nil
}
// 设备端执行OTA升级后的状态报告 (上行)。
type OtaUpgradeStatus struct {
state protoimpl.MessageState `protogen:"open.v1"`
// 状态码: 0=成功, 1=哈希校验失败, 2=烧录失败, 3=空间不足, 99=其他未知错误
StatusCode int32 `protobuf:"varint,1,opt,name=status_code,json=statusCode,proto3" json:"status_code,omitempty"`
// 设备当前运行的固件版本 (升级后或升级失败时)
CurrentFirmwareVersion string `protobuf:"bytes,2,opt,name=current_firmware_version,json=currentFirmwareVersion,proto3" json:"current_firmware_version,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *OtaUpgradeStatus) Reset() {
*x = OtaUpgradeStatus{}
mi := &file_device_proto_msgTypes[6]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *OtaUpgradeStatus) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*OtaUpgradeStatus) ProtoMessage() {}
func (x *OtaUpgradeStatus) ProtoReflect() protoreflect.Message {
mi := &file_device_proto_msgTypes[6]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use OtaUpgradeStatus.ProtoReflect.Descriptor instead.
func (*OtaUpgradeStatus) Descriptor() ([]byte, []int) {
return file_device_proto_rawDescGZIP(), []int{6}
}
func (x *OtaUpgradeStatus) GetStatusCode() int32 {
if x != nil {
return x.StatusCode
}
return 0
}
func (x *OtaUpgradeStatus) GetCurrentFirmwareVersion() string {
if x != nil {
return x.CurrentFirmwareVersion
}
return ""
}
// 控制设备日志上传的指令 (下行)。
type ControlLogUploadCommand struct {
state protoimpl.MessageState `protogen:"open.v1"`
Enable bool `protobuf:"varint,1,opt,name=enable,proto3" json:"enable,omitempty"` // true = 开始上传, false = 停止上传
DurationSeconds uint32 `protobuf:"varint,2,opt,name=duration_seconds,json=durationSeconds,proto3" json:"duration_seconds,omitempty"` // 指定上传持续时间(秒)。
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *ControlLogUploadCommand) Reset() {
*x = ControlLogUploadCommand{}
mi := &file_device_proto_msgTypes[7]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *ControlLogUploadCommand) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*ControlLogUploadCommand) ProtoMessage() {}
func (x *ControlLogUploadCommand) ProtoReflect() protoreflect.Message {
mi := &file_device_proto_msgTypes[7]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use ControlLogUploadCommand.ProtoReflect.Descriptor instead.
func (*ControlLogUploadCommand) Descriptor() ([]byte, []int) {
return file_device_proto_rawDescGZIP(), []int{7}
}
func (x *ControlLogUploadCommand) GetEnable() bool {
if x != nil {
return x.Enable
}
return false
}
func (x *ControlLogUploadCommand) GetDurationSeconds() uint32 {
if x != nil {
return x.DurationSeconds
}
return 0
}
// 设备用于向平台批量上传日志的请求 (上行)。
type LogUploadRequest struct {
state protoimpl.MessageState `protogen:"open.v1"`
Entries []*LogEntry `protobuf:"bytes,1,rep,name=entries,proto3" json:"entries,omitempty"` // 一批日志条目
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *LogUploadRequest) Reset() {
*x = LogUploadRequest{}
mi := &file_device_proto_msgTypes[8]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *LogUploadRequest) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*LogUploadRequest) ProtoMessage() {}
func (x *LogUploadRequest) ProtoReflect() protoreflect.Message {
mi := &file_device_proto_msgTypes[8]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use LogUploadRequest.ProtoReflect.Descriptor instead.
func (*LogUploadRequest) Descriptor() ([]byte, []int) {
return file_device_proto_rawDescGZIP(), []int{8}
}
func (x *LogUploadRequest) GetEntries() []*LogEntry {
if x != nil {
return x.Entries
}
return nil
}
// 平台向设备发送的Ping指令用于检查存活性。
type Ping struct {
state protoimpl.MessageState `protogen:"open.v1"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *Ping) Reset() {
*x = Ping{}
mi := &file_device_proto_msgTypes[9]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *Ping) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*Ping) ProtoMessage() {}
func (x *Ping) ProtoReflect() protoreflect.Message {
mi := &file_device_proto_msgTypes[9]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use Ping.ProtoReflect.Descriptor instead.
func (*Ping) Descriptor() ([]byte, []int) {
return file_device_proto_rawDescGZIP(), []int{9}
}
// 设备对Ping的响应或设备主动上报的心跳。
// 它包含了设备的关键状态信息。
type Pong struct {
state protoimpl.MessageState `protogen:"open.v1"`
FirmwareVersion string `protobuf:"bytes,1,opt,name=firmware_version,json=firmwareVersion,proto3" json:"firmware_version,omitempty"` // 当前固件版本
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *Pong) Reset() {
*x = Pong{}
mi := &file_device_proto_msgTypes[10]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *Pong) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*Pong) ProtoMessage() {}
func (x *Pong) ProtoReflect() protoreflect.Message {
mi := &file_device_proto_msgTypes[10]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use Pong.ProtoReflect.Descriptor instead.
func (*Pong) Descriptor() ([]byte, []int) {
return file_device_proto_rawDescGZIP(), []int{10}
}
func (x *Pong) GetFirmwareVersion() string {
if x != nil {
return x.FirmwareVersion
}
return ""
}
// Instruction 封装了所有与设备间的通信。
// 使用 oneof 来确保每个消息只有一个负载类型,这在嵌入式系统中是高效且类型安全的。
type Instruction struct {
state protoimpl.MessageState `protogen:"open.v1"`
// Types that are valid to be assigned to Payload:
//
// *Instruction_Raw_485Command
// *Instruction_BatchCollectCommand
// *Instruction_OtaUpgradeCommand
// *Instruction_ControlLogUploadCommand
// *Instruction_Ping
// *Instruction_CollectResult
// *Instruction_OtaUpgradeStatus
// *Instruction_LogUploadRequest
// *Instruction_Pong
Payload isInstruction_Payload `protobuf_oneof:"payload"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
@@ -245,7 +661,7 @@ type Instruction struct {
func (x *Instruction) Reset() {
*x = Instruction{}
mi := &file_device_proto_msgTypes[4]
mi := &file_device_proto_msgTypes[11]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -257,7 +673,7 @@ func (x *Instruction) String() string {
func (*Instruction) ProtoMessage() {}
func (x *Instruction) ProtoReflect() protoreflect.Message {
mi := &file_device_proto_msgTypes[4]
mi := &file_device_proto_msgTypes[11]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -270,7 +686,7 @@ func (x *Instruction) ProtoReflect() protoreflect.Message {
// Deprecated: Use Instruction.ProtoReflect.Descriptor instead.
func (*Instruction) Descriptor() ([]byte, []int) {
return file_device_proto_rawDescGZIP(), []int{4}
return file_device_proto_rawDescGZIP(), []int{11}
}
func (x *Instruction) GetPayload() isInstruction_Payload {
@@ -298,6 +714,33 @@ func (x *Instruction) GetBatchCollectCommand() *BatchCollectCommand {
return nil
}
func (x *Instruction) GetOtaUpgradeCommand() *OtaUpgradeCommand {
if x != nil {
if x, ok := x.Payload.(*Instruction_OtaUpgradeCommand); ok {
return x.OtaUpgradeCommand
}
}
return nil
}
func (x *Instruction) GetControlLogUploadCommand() *ControlLogUploadCommand {
if x != nil {
if x, ok := x.Payload.(*Instruction_ControlLogUploadCommand); ok {
return x.ControlLogUploadCommand
}
}
return nil
}
func (x *Instruction) GetPing() *Ping {
if x != nil {
if x, ok := x.Payload.(*Instruction_Ping); ok {
return x.Ping
}
}
return nil
}
func (x *Instruction) GetCollectResult() *CollectResult {
if x != nil {
if x, ok := x.Payload.(*Instruction_CollectResult); ok {
@@ -307,11 +750,39 @@ func (x *Instruction) GetCollectResult() *CollectResult {
return nil
}
func (x *Instruction) GetOtaUpgradeStatus() *OtaUpgradeStatus {
if x != nil {
if x, ok := x.Payload.(*Instruction_OtaUpgradeStatus); ok {
return x.OtaUpgradeStatus
}
}
return nil
}
func (x *Instruction) GetLogUploadRequest() *LogUploadRequest {
if x != nil {
if x, ok := x.Payload.(*Instruction_LogUploadRequest); ok {
return x.LogUploadRequest
}
}
return nil
}
func (x *Instruction) GetPong() *Pong {
if x != nil {
if x, ok := x.Payload.(*Instruction_Pong); ok {
return x.Pong
}
}
return nil
}
type isInstruction_Payload interface {
isInstruction_Payload()
}
type Instruction_Raw_485Command struct {
// --- 下行指令 (平台 -> 设备) ---
Raw_485Command *Raw485Command `protobuf:"bytes,1,opt,name=raw_485_command,json=raw485Command,proto3,oneof"`
}
@@ -319,21 +790,62 @@ type Instruction_BatchCollectCommand struct {
BatchCollectCommand *BatchCollectCommand `protobuf:"bytes,2,opt,name=batch_collect_command,json=batchCollectCommand,proto3,oneof"`
}
type Instruction_OtaUpgradeCommand struct {
OtaUpgradeCommand *OtaUpgradeCommand `protobuf:"bytes,3,opt,name=ota_upgrade_command,json=otaUpgradeCommand,proto3,oneof"`
}
type Instruction_ControlLogUploadCommand struct {
ControlLogUploadCommand *ControlLogUploadCommand `protobuf:"bytes,4,opt,name=control_log_upload_command,json=controlLogUploadCommand,proto3,oneof"`
}
type Instruction_Ping struct {
Ping *Ping `protobuf:"bytes,6,opt,name=ping,proto3,oneof"`
}
type Instruction_CollectResult struct {
CollectResult *CollectResult `protobuf:"bytes,3,opt,name=collect_result,json=collectResult,proto3,oneof"` // ADDED用于上行数据
// --- 上行数据 (设备 -> 平台) ---
CollectResult *CollectResult `protobuf:"bytes,101,opt,name=collect_result,json=collectResult,proto3,oneof"`
}
type Instruction_OtaUpgradeStatus struct {
OtaUpgradeStatus *OtaUpgradeStatus `protobuf:"bytes,102,opt,name=ota_upgrade_status,json=otaUpgradeStatus,proto3,oneof"`
}
type Instruction_LogUploadRequest struct {
LogUploadRequest *LogUploadRequest `protobuf:"bytes,103,opt,name=log_upload_request,json=logUploadRequest,proto3,oneof"`
}
type Instruction_Pong struct {
Pong *Pong `protobuf:"bytes,104,opt,name=pong,proto3,oneof"`
}
func (*Instruction_Raw_485Command) isInstruction_Payload() {}
func (*Instruction_BatchCollectCommand) isInstruction_Payload() {}
func (*Instruction_OtaUpgradeCommand) isInstruction_Payload() {}
func (*Instruction_ControlLogUploadCommand) isInstruction_Payload() {}
func (*Instruction_Ping) isInstruction_Payload() {}
func (*Instruction_CollectResult) isInstruction_Payload() {}
func (*Instruction_OtaUpgradeStatus) isInstruction_Payload() {}
func (*Instruction_LogUploadRequest) isInstruction_Payload() {}
func (*Instruction_Pong) isInstruction_Payload() {}
var File_device_proto protoreflect.FileDescriptor
const file_device_proto_rawDesc = "" +
"\n" +
"\fdevice.proto\x12\x06device\"S\n" +
"\fdevice.proto\x12\x06device\"s\n" +
"\bLogEntry\x12%\n" +
"\x0etimestamp_unix\x18\x01 \x01(\x03R\rtimestampUnix\x12&\n" +
"\x05level\x18\x02 \x01(\x0e2\x10.device.LogLevelR\x05level\x12\x18\n" +
"\amessage\x18\x03 \x01(\tR\amessage\"S\n" +
"\rRaw485Command\x12\x1d\n" +
"\n" +
"bus_number\x18\x01 \x01(\x05R\tbusNumber\x12#\n" +
@@ -345,12 +857,40 @@ const file_device_proto_rawDesc = "" +
"\acommand\x18\x01 \x01(\v2\x15.device.Raw485CommandR\acommand\"N\n" +
"\rCollectResult\x12%\n" +
"\x0ecorrelation_id\x18\x01 \x01(\tR\rcorrelationId\x12\x16\n" +
"\x06values\x18\x02 \x03(\x02R\x06values\"\xec\x01\n" +
"\x06values\x18\x02 \x03(\x02R\x06values\"\x8e\x01\n" +
"\x11OtaUpgradeCommand\x12)\n" +
"\x10firmware_version\x18\x01 \x01(\tR\x0ffirmwareVersion\x12#\n" +
"\rfirmware_hash\x18\x02 \x01(\tR\ffirmwareHash\x12)\n" +
"\x10firmware_package\x18\x03 \x01(\fR\x0ffirmwarePackage\"m\n" +
"\x10OtaUpgradeStatus\x12\x1f\n" +
"\vstatus_code\x18\x01 \x01(\x05R\n" +
"statusCode\x128\n" +
"\x18current_firmware_version\x18\x02 \x01(\tR\x16currentFirmwareVersion\"\\\n" +
"\x17ControlLogUploadCommand\x12\x16\n" +
"\x06enable\x18\x01 \x01(\bR\x06enable\x12)\n" +
"\x10duration_seconds\x18\x02 \x01(\rR\x0fdurationSeconds\">\n" +
"\x10LogUploadRequest\x12*\n" +
"\aentries\x18\x01 \x03(\v2\x10.device.LogEntryR\aentries\"\x06\n" +
"\x04Ping\"1\n" +
"\x04Pong\x12)\n" +
"\x10firmware_version\x18\x01 \x01(\tR\x0ffirmwareVersion\"\xf5\x04\n" +
"\vInstruction\x12?\n" +
"\x0fraw_485_command\x18\x01 \x01(\v2\x15.device.Raw485CommandH\x00R\rraw485Command\x12Q\n" +
"\x15batch_collect_command\x18\x02 \x01(\v2\x1b.device.BatchCollectCommandH\x00R\x13batchCollectCommand\x12>\n" +
"\x0ecollect_result\x18\x03 \x01(\v2\x15.device.CollectResultH\x00R\rcollectResultB\t\n" +
"\apayloadB\x1eZ\x1cinternal/domain/device/protob\x06proto3"
"\x15batch_collect_command\x18\x02 \x01(\v2\x1b.device.BatchCollectCommandH\x00R\x13batchCollectCommand\x12K\n" +
"\x13ota_upgrade_command\x18\x03 \x01(\v2\x19.device.OtaUpgradeCommandH\x00R\x11otaUpgradeCommand\x12^\n" +
"\x1acontrol_log_upload_command\x18\x04 \x01(\v2\x1f.device.ControlLogUploadCommandH\x00R\x17controlLogUploadCommand\x12\"\n" +
"\x04ping\x18\x06 \x01(\v2\f.device.PingH\x00R\x04ping\x12>\n" +
"\x0ecollect_result\x18e \x01(\v2\x15.device.CollectResultH\x00R\rcollectResult\x12H\n" +
"\x12ota_upgrade_status\x18f \x01(\v2\x18.device.OtaUpgradeStatusH\x00R\x10otaUpgradeStatus\x12H\n" +
"\x12log_upload_request\x18g \x01(\v2\x18.device.LogUploadRequestH\x00R\x10logUploadRequest\x12\"\n" +
"\x04pong\x18h \x01(\v2\f.device.PongH\x00R\x04pongB\t\n" +
"\apayload*O\n" +
"\bLogLevel\x12\x19\n" +
"\x15LOG_LEVEL_UNSPECIFIED\x10\x00\x12\t\n" +
"\x05DEBUG\x10\x01\x12\b\n" +
"\x04INFO\x10\x02\x12\b\n" +
"\x04WARN\x10\x03\x12\t\n" +
"\x05ERROR\x10\x04B\x1eZ\x1cinternal/domain/device/protob\x06proto3"
var (
file_device_proto_rawDescOnce sync.Once
@@ -364,25 +904,42 @@ func file_device_proto_rawDescGZIP() []byte {
return file_device_proto_rawDescData
}
var file_device_proto_msgTypes = make([]protoimpl.MessageInfo, 5)
var file_device_proto_enumTypes = make([]protoimpl.EnumInfo, 1)
var file_device_proto_msgTypes = make([]protoimpl.MessageInfo, 12)
var file_device_proto_goTypes = []any{
(*Raw485Command)(nil), // 0: device.Raw485Command
(*BatchCollectCommand)(nil), // 1: device.BatchCollectCommand
(*CollectTask)(nil), // 2: device.CollectTask
(*CollectResult)(nil), // 3: device.CollectResult
(*Instruction)(nil), // 4: device.Instruction
(LogLevel)(0), // 0: device.LogLevel
(*LogEntry)(nil), // 1: device.LogEntry
(*Raw485Command)(nil), // 2: device.Raw485Command
(*BatchCollectCommand)(nil), // 3: device.BatchCollectCommand
(*CollectTask)(nil), // 4: device.CollectTask
(*CollectResult)(nil), // 5: device.CollectResult
(*OtaUpgradeCommand)(nil), // 6: device.OtaUpgradeCommand
(*OtaUpgradeStatus)(nil), // 7: device.OtaUpgradeStatus
(*ControlLogUploadCommand)(nil), // 8: device.ControlLogUploadCommand
(*LogUploadRequest)(nil), // 9: device.LogUploadRequest
(*Ping)(nil), // 10: device.Ping
(*Pong)(nil), // 11: device.Pong
(*Instruction)(nil), // 12: device.Instruction
}
var file_device_proto_depIdxs = []int32{
2, // 0: device.BatchCollectCommand.tasks:type_name -> device.CollectTask
0, // 1: device.CollectTask.command:type_name -> device.Raw485Command
0, // 2: device.Instruction.raw_485_command:type_name -> device.Raw485Command
1, // 3: device.Instruction.batch_collect_command:type_name -> device.BatchCollectCommand
3, // 4: device.Instruction.collect_result:type_name -> device.CollectResult
5, // [5:5] is the sub-list for method output_type
5, // [5:5] is the sub-list for method input_type
5, // [5:5] is the sub-list for extension type_name
5, // [5:5] is the sub-list for extension extendee
0, // [0:5] is the sub-list for field type_name
0, // 0: device.LogEntry.level:type_name -> device.LogLevel
4, // 1: device.BatchCollectCommand.tasks:type_name -> device.CollectTask
2, // 2: device.CollectTask.command:type_name -> device.Raw485Command
1, // 3: device.LogUploadRequest.entries:type_name -> device.LogEntry
2, // 4: device.Instruction.raw_485_command:type_name -> device.Raw485Command
3, // 5: device.Instruction.batch_collect_command:type_name -> device.BatchCollectCommand
6, // 6: device.Instruction.ota_upgrade_command:type_name -> device.OtaUpgradeCommand
8, // 7: device.Instruction.control_log_upload_command:type_name -> device.ControlLogUploadCommand
10, // 8: device.Instruction.ping:type_name -> device.Ping
5, // 9: device.Instruction.collect_result:type_name -> device.CollectResult
7, // 10: device.Instruction.ota_upgrade_status:type_name -> device.OtaUpgradeStatus
9, // 11: device.Instruction.log_upload_request:type_name -> device.LogUploadRequest
11, // 12: device.Instruction.pong:type_name -> device.Pong
13, // [13:13] is the sub-list for method output_type
13, // [13:13] is the sub-list for method input_type
13, // [13:13] is the sub-list for extension type_name
13, // [13:13] is the sub-list for extension extendee
0, // [0:13] is the sub-list for field type_name
}
func init() { file_device_proto_init() }
@@ -390,23 +947,30 @@ func file_device_proto_init() {
if File_device_proto != nil {
return
}
file_device_proto_msgTypes[4].OneofWrappers = []any{
file_device_proto_msgTypes[11].OneofWrappers = []any{
(*Instruction_Raw_485Command)(nil),
(*Instruction_BatchCollectCommand)(nil),
(*Instruction_OtaUpgradeCommand)(nil),
(*Instruction_ControlLogUploadCommand)(nil),
(*Instruction_Ping)(nil),
(*Instruction_CollectResult)(nil),
(*Instruction_OtaUpgradeStatus)(nil),
(*Instruction_LogUploadRequest)(nil),
(*Instruction_Pong)(nil),
}
type x struct{}
out := protoimpl.TypeBuilder{
File: protoimpl.DescBuilder{
GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
RawDescriptor: unsafe.Slice(unsafe.StringData(file_device_proto_rawDesc), len(file_device_proto_rawDesc)),
NumEnums: 0,
NumMessages: 5,
NumEnums: 1,
NumMessages: 12,
NumExtensions: 0,
NumServices: 0,
},
GoTypes: file_device_proto_goTypes,
DependencyIndexes: file_device_proto_depIdxs,
EnumInfos: file_device_proto_enumTypes,
MessageInfos: file_device_proto_msgTypes,
}.Build()
File_device_proto = out.File

View File

@@ -2,11 +2,27 @@ syntax = "proto3";
package device;
// import "google/protobuf/any.proto"; // REMOVED: Not suitable for embedded systems.
option go_package = "internal/domain/device/proto";
// --- Concrete Command & Data Structures ---
// --- 日志相关 ---
// LogLevel 定义了日志的严重级别。
enum LogLevel {
LOG_LEVEL_UNSPECIFIED = 0; // 未指定
DEBUG = 1; // 调试信息
INFO = 2; // 普通信息
WARN = 3; // 警告
ERROR = 4; // 错误
}
// LogEntry 代表一条由设备生成的日志记录。
message LogEntry {
int64 timestamp_unix = 1; // 日志生成的Unix时间戳 (秒)
LogLevel level = 2; // 日志级别
string message = 3; // 日志内容
}
// --- 核心指令与数据结构 ---
// 平台生成的原始485指令单片机直接发送到总线
message Raw485Command {
@@ -14,38 +30,79 @@ message Raw485Command {
bytes command_bytes = 2; // 原始485指令的字节数组
}
// BatchCollectCommand
// 一个完整的、包含所有元数据的批量采集任务。
message BatchCollectCommand {
string correlation_id = 1; // 用于关联请求和响应的唯一ID
repeated CollectTask tasks = 2; // 采集任务列表
}
// CollectTask
// 定义了单个采集任务的“意图”。
message CollectTask {
Raw485Command command = 1; // 平台生成的原始485指令
}
// CollectResult
// 这是设备响应的、极致精简的数据包。
message CollectResult {
string correlation_id = 1; // 从下行指令中原样返回的关联ID
repeated float values = 2; // 按预定顺序排列的采集值
}
// OTA空中下载升级指令包含完整的固件包。
message OtaUpgradeCommand {
string firmware_version = 1; // 目标固件版本, e.g., "v1.2.3"
string firmware_hash = 2; // 固件包的SHA-256哈希值用于完整性校验
bytes firmware_package = 3; // 完整的固件二进制文件
}
// --- Main Downlink Instruction Wrapper ---
// 设备端执行OTA升级后的状态报告 (上行)。
message OtaUpgradeStatus {
// 状态码: 0=成功, 1=哈希校验失败, 2=烧录失败, 3=空间不足, 99=其他未知错误
int32 status_code = 1;
// 设备当前运行的固件版本 (升级后或升级失败时)
string current_firmware_version = 2;
}
// 指令 (所有从平台下发到设备的数据都应该被包装在这里面)
// 使用 oneof 来替代 google.protobuf.Any这是嵌入式环境下的标准做法。
// 它高效、类型安全,且只解码一次。
// 控制设备日志上传的指令 (下行)。
message ControlLogUploadCommand {
bool enable = 1; // true = 开始上传, false = 停止上传
uint32 duration_seconds = 2; // 指定上传持续时间(秒)。
}
// 设备用于向平台批量上传日志的请求 (上行)。
message LogUploadRequest {
repeated LogEntry entries = 1; // 一批日志条目
}
// 平台向设备发送的Ping指令用于检查存活性。
message Ping {
// 可以留空,指令本身即代表意图
}
// 设备对Ping的响应或设备主动上报的心跳。
// 它包含了设备的关键状态信息。
message Pong {
string firmware_version = 1; // 当前固件版本
// 可以扩展更多状态, e.g., int32 uptime_seconds = 2;
}
// --- 顶层指令包装器 ---
// Instruction 封装了所有与设备间的通信。
// 使用 oneof 来确保每个消息只有一个负载类型,这在嵌入式系统中是高效且类型安全的。
message Instruction {
oneof payload {
// --- 下行指令 (平台 -> 设备) ---
Raw485Command raw_485_command = 1;
BatchCollectCommand batch_collect_command = 2;
CollectResult collect_result = 3; // ADDED用于上行数据
// 如果未来有其他指令类型,比如开关控制,可以直接在这里添加
// SwitchCommand switch_command = 3;
OtaUpgradeCommand ota_upgrade_command = 3;
ControlLogUploadCommand control_log_upload_command = 4;
Ping ping = 6;
// --- 上行数据 (设备 -> 平台) ---
CollectResult collect_result = 101;
OtaUpgradeStatus ota_upgrade_status = 102;
LogUploadRequest log_upload_request = 103;
Pong pong = 104;
}
}

View File

@@ -0,0 +1,13 @@
package proto
// InstructionPayload 是 protoc 为 oneof 生成的未导出接口 isInstruction_Payload 的一个公开别名。
// 通过接口嵌入,我们创建了一个新的、可导出的接口,它拥有与 isInstruction_Payload 完全相同的方法集。
//
// 根据 Go 的接口规则,任何实现了 isInstruction_Payload 接口的类型 (例如 *Instruction_Ping)
// 都会自动、隐式地满足此接口。
//
// 这使得我们可以在项目的其他包(如 domain 层)的公开 API 中使用这个接口,
// 从而在保持类型安全的同时,避免了对 protoc 生成的未导出类型的直接依赖。
type InstructionPayload interface {
isInstruction_Payload
}

View File

@@ -3,6 +3,8 @@ package transport
import (
"context"
"time"
"git.huangwc.com/pig/pig-farm-controller/internal/infra/transport/proto"
)
// Communicator 用于其他设备通信
@@ -35,3 +37,17 @@ type Listener interface {
// Stop 用于停止监听
Stop(ctx context.Context) error
}
// UpstreamHandler 定义了处理所有来源的上行数据的统一协约。
// 任何实现了上行消息监听的基础设施如串口、MQTT客户端都应该在收到消息后调用此接口的实现者。
// 这样,基础设施层只负责“接收和解析”,而将“业务处理”的控制权交给了上层。
type UpstreamHandler interface {
// HandleInstruction 处理来自设备的、已解析为Instruction的业务指令。
HandleInstruction(ctx context.Context, sourceAddr string, instruction *proto.Instruction) error
// HandleStatus 处理非业务指令的设备状态更新,例如信号强度、电量等。
HandleStatus(ctx context.Context, sourceAddr string, status map[string]interface{}) error
// HandleAck 处理对下行指令的确认ACK事件。
HandleAck(ctx context.Context, sourceAddr string, deduplicationID string, acknowledged bool, eventTime time.Time) error
}

View File

@@ -37,7 +37,9 @@ design/archive/2025-11-05-provide-logger-with-mothed/task-webhook.md
design/archive/2025-11-06-health-check-routing/index.md
design/archive/2025-11-06-system-plan-continuously-triggered/index.md
design/archive/2025-11-10-exceeding-threshold-alarm/index.md
design/recipe-management/index.md
design/archive/2025-11-29-recipe-management/index.md
design/ota-upgrade-and-log-monitoring/index.md
design/ota-upgrade-and-log-monitoring/lora_refactoring_plan.md
docs/docs.go
docs/swagger.json
docs/swagger.yaml
@@ -84,6 +86,11 @@ internal/app/dto/pig_farm_dto.go
internal/app/dto/plan_converter.go
internal/app/dto/plan_dto.go
internal/app/dto/user_dto.go
internal/app/listener/chirp_stack/chirp_stack.go
internal/app/listener/chirp_stack/chirp_stack_types.go
internal/app/listener/chirp_stack/placeholder_listener.go
internal/app/listener/lora_listener.go
internal/app/listener/transport.go
internal/app/middleware/audit.go
internal/app/middleware/auth.go
internal/app/service/audit_service.go
@@ -102,10 +109,6 @@ internal/app/service/raw_material_service.go
internal/app/service/recipe_service.go
internal/app/service/threshold_alarm_service.go
internal/app/service/user_service.go
internal/app/webhook/chirp_stack.go
internal/app/webhook/chirp_stack_types.go
internal/app/webhook/placeholder_listener.go
internal/app/webhook/transport.go
internal/core/application.go
internal/core/component_initializers.go
internal/core/data_initializer.go
@@ -139,6 +142,7 @@ internal/domain/task/area_threshold_check_task.go
internal/domain/task/delay_task.go
internal/domain/task/device_threshold_check_task.go
internal/domain/task/full_collection_task.go
internal/domain/task/heartbeat_task.go
internal/domain/task/refresh_notification_task.go
internal/domain/task/release_feed_weight_task.go
internal/domain/task/task.go
@@ -210,6 +214,7 @@ internal/infra/transport/lora/lora_mesh_uart_passthrough_transport.go
internal/infra/transport/lora/placeholder_transport.go
internal/infra/transport/proto/device.pb.go
internal/infra/transport/proto/device.proto
internal/infra/transport/proto/exported.go
internal/infra/transport/transport.go
internal/infra/utils/command_generater/modbus_rtu.go
internal/infra/utils/time.go