Compare commits

...

27 Commits

Author SHA1 Message Date
3cfe532517 创建检查系统计划 2025-12-03 17:51:29 +08:00
b26b7ee0f3 更新任务工厂 2025-12-03 17:39:46 +08:00
9d9b5f801f ota升级超时检查任务 2025-12-03 17:34:38 +08:00
a1deb0011b 定义otatask模型 2025-12-03 16:23:33 +08:00
4a3c82fc25 拆分device.Service接口 2025-12-03 15:12:43 +08:00
7974955335 更新proto 2025-12-03 14:06:56 +08:00
72d70e90f1 更新ota方案 2025-12-02 17:16:48 +08:00
3bede99cc6 ota升级方案修改 2025-12-02 16:40:11 +08:00
95aaf80f3a ota升级方案修改 2025-12-02 16:40:10 +08:00
77ed812901 支持ota升级方案初稿 2025-12-02 16:40:10 +08:00
2e6a0abac3 增加ping指令并获取带版本号的响应 2025-12-02 16:40:10 +08:00
d5056af676 支持ota升级结果相应处理 2025-12-02 16:40:10 +08:00
1e685340f8 增加AreaControllerProperties 2025-12-02 16:40:10 +08:00
7ec9fb3f0b index.md 2025-12-02 16:40:10 +08:00
d430307b48 提供lora公共逻辑 2025-12-02 16:40:10 +08:00
5113e5953a lora处理逻辑统一方案 2025-12-02 16:40:10 +08:00
766fda7292 重构webhook包 2025-12-02 16:40:10 +08:00
1bc49ea249 proto 2025-12-02 16:40:10 +08:00
70fad51f40 Merge pull request 'issue_72' (#73) from issue_72 into main
Reviewed-on: #73
2025-12-02 16:34:47 +08:00
6764684fe7 优化ai初始化逻辑 2025-12-02 16:34:14 +08:00
da2c296c05 去掉无效日志 2025-12-02 16:19:56 +08:00
bdf74652b3 实现ai 2025-12-02 15:51:37 +08:00
70e8627a96 增加ai配置 2025-12-02 13:38:49 +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
58 changed files with 3347 additions and 819 deletions

View File

@@ -10,10 +10,15 @@ help:
@echo " build Build the application" @echo " build Build the application"
@echo " clean Clean generated files" @echo " clean Clean generated files"
@echo " test Run all tests" @echo " test Run all tests"
@echo " swag Generate swagger docs" @echo " swag Generate Swagger docs"
@echo " help Show this help message"
@echo " proto Generate protobuf files" @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 .PHONY: run

View File

@@ -125,3 +125,21 @@ alarm_notification:
dpanic: 1 dpanic: 1
panic: 1 panic: 1
fatal: 1 fatal: 1
# AI 服务配置
ai:
model: "gemini" # 不指定就是不用AI
gemini:
api_key: "YOUR_GEMINI_API_KEY" # 替换为你的 Gemini API Key
model_name: "gemini-2.5-flash" # Gemini 模型名称,例如 "gemini-pro"
timeout: 30 # AI 请求超时时间 (秒)
# OTA 升级配置
ota:
# 升级任务的全局超时时间(秒)。如果一个升级任务在此时间内没有完成,将被标记为超时。
default_timeout_seconds: 300
# 等待设备响应的单次请求超时时间(秒)。例如,下发 PrepareUpdateReq 后等待设备请求文件的超时。
default_request_timeout_seconds: 60
# 默认的固件块请求重试次数。
default_retry_count: 3

View File

@@ -103,3 +103,20 @@ alarm_notification:
dpanic: 1 dpanic: 1
panic: 1 panic: 1
fatal: 1 fatal: 1
# AI 服务配置
ai:
model: Gemini
gemini:
api_key: "AIzaSyAJdXUmoN07LIswDac6YxPeRnvXlR73OO8" # 替换为你的 Gemini API Key
model_name: "gemini-2.0-flash" # Gemini 模型名称,例如 "gemini-pro"
timeout: 30 # AI 请求超时时间 (秒)
# OTA 升级配置
ota:
# 升级任务的全局超时时间(秒)。如果一个升级任务在此时间内没有完成,将被标记为超时。
default_timeout_seconds: 300
# 等待设备响应的单次请求超时时间(秒)。例如,下发 PrepareUpdateReq 后等待设备请求文件的超时。
default_request_timeout_seconds: 60
# 默认的固件块请求重试次数。
default_retry_count: 3

View File

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

View File

@@ -0,0 +1,20 @@
# 需求
支持主控设备ota升级和远程查看日志
## issue
http://git.huangwc.com/pig/pig-farm-controller/issues/71
# 开发计划
## Lora 监听逻辑重构
- [x] [Lora逻辑重构](design/ota-upgrade-and-log-monitoring/lora_refactoring_plan.md)
## OTA 升级
- [x] 增加一个proto对象, 用于封装ota升级包
- [x] 区域主控增加版本号
- [x] 增加ping指令并获取带版本号的响应
- [ ] [实现ota升级逻辑](design/ota-upgrade-and-log-monitoring/ota_upgrade_solution.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,304 @@
# 区域主控 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. **分析与生成清单**: 平台遍历解压后的所有文件,为每个文件计算:
* 在设备上的目标路径 (`path`)
* MD5 校验和 (`md5`)
* 文件大小 (`size`)
* **排除配置文件**: 平台会识别配置文件(例如通过文件名约定,如 `/config/` 目录下的所有文件),并**排除**
这些文件,不将其包含在清单文件中,也不通过 OTA 传输。
4. **生成清单文件**: 平台根据上述信息,生成一个 JSON 格式的清单文件。
5. **数字签名 (未来扩展)**: 平台使用其私钥对**清单文件**的内容进行数字签名,并将签名添加到清单文件中。
### 2.3. 清单文件 (Manifest File) 结构
清单文件是一个 JSON 对象,包含新固件的元数据和所有文件的详细信息。
```json
{
"version": "1.0.1",
// 新固件版本号
"signature": "...",
// 清单文件内容的数字签名 (未来扩展)
"files": [
{
"path": "/manifest.json",
// 清单文件本身也作为文件列表的一部分
"md5": "a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6",
"size": 1024
},
{
"path": "/main.py",
"md5": "b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a1",
"size": 10240
},
{
"path": "/lib/sensor.py",
"md5": "c3d4e5f6a7b8c9d0e1f2a3b4c5d6a1b2",
"size": 5120
}
// ... 更多文件 (不包含配置文件)
]
}
```
## 3. 通信协议定义 (Protobuf Messages)
以下是 OTA 过程中平台与区域主控之间通信所需的 Protobuf 消息定义。
```protobuf
// OTA 升级指令和状态消息
// PrepareUpdateReq: 平台发送给设备,通知设备准备开始 OTA 升级
message PrepareUpdateReq {
string version = 1; // 新固件版本号
uint32 task_id = 2; // 升级任务唯一ID
string manifest_md5 = 3; // 清单文件的 MD5 校验和,用于设备初步校验清单文件完整性
}
// RequestFile: 设备向平台请求特定文件 (包括清单文件和固件文件)
message RequestFile {
uint32 task_id = 1; // 升级任务ID
string filepath = 2; // 请求的文件路径 (例如 "/manifest.json" 或 "/main.py")
}
// FileResponse: 平台响应设备请求,发送单个文件的完整内容
// LoRa 传输层会自动处理分片和重组,因此应用层可以直接发送完整的单个文件内容
message FileResponse {
uint32 task_id = 1; // 升级任务ID
string filepath = 2; // 设备上的目标路径 (例如 "/manifest.json" 或 "/main.py")
bytes content = 3; // 文件的完整内容
}
// UpdateStatusReport: 设备向平台报告升级状态
message UpdateStatusReport {
uint32 task_id = 1; // 升级任务ID
string current_version = 2; // 操作完成后的当前版本
enum Status {
STATUS_UNKNOWN = 0;
// --- 设备主动上报的状态 ---
SUCCESS = 1; // 升级成功,新固件已运行 (由设备在自检成功后主动上报)
SUCCESS_ALREADY_UP_TO_DATE = 2; // 版本已是最新,未执行升级 (由设备在版本检查后主动上报)
FAILED_PRE_CHECK = 3; // 升级前检查失败 (例如拒绝降级、准备分区失败等,由设备主动上报)
FAILED_DOWNLOAD = 4; // 文件下载或校验失败 (由设备在下载过程中主动上报)
// --- 平台推断的状态 (数据库记录用) ---
FAILED_TIMEOUT = 5; // 平台在超时后仍未收到SUCCESS报告将任务标记为此状态
}
Status status = 3; // 升级的最终状态
string error_message = 4; // 人类可读的详细错误信息
string failed_file = 5; // 失败时关联的文件路径 (可选)
}
```
## 4. 平台侧操作流程
### 4.1. 准备升级任务
1. 接收开发者提供的 MicroPython 项目压缩包。
2. 解压压缩包。
3. 遍历解压后的文件,计算每个文件的 MD5、大小并确定目标路径。
4. **排除配置文件**: 平台会识别配置文件(例如通过文件名约定,如 `/config/` 目录下的所有文件),并**排除**这些文件。
5. 生成清单文件 (Manifest File)。**注意:清单文件本身也应作为 OTA 的一部分,其元数据应包含在清单文件自身的 `files`
列表中。Manifest文件生成后将被放在解压后的文件夹的根目录下, 方便后续主控设备获取**
6. (未来扩展)对清单文件进行数字签名。
7. 将清单文件和所有固件文件存储在平台内部,等待分发。
8. **记录 OTA 升级任务**: 在数据库中创建一条新的 OTA 升级任务记录(模型名为 `OTATask`,位于 `internal/infra/models/ota.go`
),包含任务 ID、目标设备、新固件版本、状态例如“待开始”
### 4.2. 发送“准备更新”指令
1. 平台向目标区域主控发送 `PrepareUpdateReq` 消息。
2. **更新任务记录**: 平台发送指令后,更新 OTA 任务记录的状态为“进行中”。
### 4.3. 响应设备文件请求
1. 平台接收区域主控发送的 `RequestFile` 消息。
2. 平台根据 `task_id``filepath` 在内部存储中找到对应的文件内容。
3. 平台构建 `FileResponse` 消息,将文件的完整内容和路径放入其中。
4. 平台通过 LoRa 传输层发送 `FileResponse` 消息。
### 4.4. 处理设备状态上报
1. 平台接收区域主控发送的 `UpdateStatusReport` 消息。
2. 根据报告的 `status` (`SUCCESS``FAILED`),更新 OTA 任务记录的最终状态,并记录 `error_code``error_message`
3. 如果状态为 `SUCCESS`,平台应更新该设备在系统中的固件版本记录。
4. **总超时管理**: 平台为每个 OTA 任务设置一个总的超时时间(例如 2 小时)。如果在总超时时间内未能收到设备的最终状态报告,平台应自动将该任务标记为
`FAILED``error_code` 设为 `ERR_TIMEOUT`
5. **处理重复报告**: 平台在收到最终状态报告后,即使后续再次收到相同的报告,也只需更新一次任务记录,无需重复处理。
## 5. 区域主控侧操作流程 (MicroPython)
### 5.1. 接收“准备更新”指令与版本检查
1. 区域主控接收 `PrepareUpdateReq` 消息。
2. **版本检查**: 设备将 `PrepareUpdateReq` 中的 `version` 与自身当前运行的固件版本进行比较。
* **降级场景**: 如果 `新版本 < 当前版本`,设备立即中止升级,并向平台发送 `UpdateStatusReport` (status: `FAILED`,
error_code: `ERR_VERSION_ROLLBACK`, error_message: "拒绝版本回滚,目标版本低于当前版本")。
* **同版本场景**: 如果 `新版本 == 当前版本`,设备立即中止升级,并向平台发送 `UpdateStatusReport` (status: `SUCCESS`,
error_code: `SUCCESS_ALREADY_UP_TO_DATE`, error_message: "版本已是最新,无需升级")。
* **正常升级场景**: 如果 `新版本 > 当前版本`,继续执行下一步。
3. **清空非活动分区**: 使用 MicroPython 的文件系统操作(例如 `os.remove()``os.rmdir()`),递归删除非活动 OTA 分区(例如
`/ota_b`)下的所有文件和目录。
* **错误处理**: 如果清空分区失败,设备应立即中止,并向平台发送 `UpdateStatusReport` (status: `FAILED`, error_code:
`ERR_PREPARE`, error_message: "清空非活动分区失败: [具体错误]").
4. 设备准备就绪后,将直接开始请求清单文件。
### 5.2. 请求并验证清单文件
1. 设备完成准备后,向平台发送 `RequestFile` 消息,请求清单文件 (`filepath: "/manifest.json"`)。
2. 设备接收平台响应的 `FileResponse` 消息,并将其写入非活动分区(例如 `/ota_b/manifest.json`)。
3. **MD5 校验**: 计算写入的清单文件的 MD5并与 `PrepareUpdateReq` 消息中提供的 `manifest_md5` 进行比对。
4. **解析 JSON**: 解析清单文件内容。
5. **数字签名验证 (未来扩展)**: 使用预置的平台公钥,验证清单文件的数字签名。
6. 如果上述任何步骤失败,设备应向平台发送 `UpdateStatusReport` (status: `FAILED`, error_code: `ERR_MANIFEST_VERIFY`,
error_message: "[具体失败原因]"), 然后中止升级。
### 5.3. 请求与存储固件文件 (逐文件校验)
1. 设备成功接收并验证清单文件后,根据清单文件中的文件列表,**逐个文件**地向平台发送 `RequestFile` 消息。
2. 对于每个请求的文件:
* **请求、接收与写入**: 设备请求文件,接收响应,并根据 `filepath` 将内容写入到非活动 OTA 分区。需要确保目标目录存在,如果不存在则创建。
* **MD5 校验**: 在文件写入完成后,计算该文件的 MD5 校验和,并与清单文件中记录的 MD5 进行比对。
* **错误处理与重试**:
* 如果文件下载超时、写入失败或 MD5 校验失败,设备将进行重试(例如最多 3 次)。
* 如果达到最大重试次数仍失败,设备应立即中止整个 OTA 任务,并向平台发送 `UpdateStatusReport` (status: `FAILED`,
error_code: `ERR_DOWNLOAD``ERR_VERIFY`, error_message: "[具体失败原因]", failed_file: "[失败的文件路径]")。
### 5.4. 自激活与重启
1. **所有文件接收并校验成功后**,设备将自行执行以下操作:
* **配置 OTA 分区**: 使用 MicroPython 提供的 ESP-IDF OTA API设置下一个启动分区为刚刚写入新固件的非活动分区。
* **自触发重启**: 在成功配置 OTA 分区后,区域主控自行触发重启。
### 5.5. 新版本启动与验证
1. 设备重启后,启动加载器会从新的 OTA 分区加载 MicroPython 固件。
2. **自检**: 新固件启动后,应执行必要的自检(如 LoRa 初始化、网络连接等)。
3. **标记有效**: 只有当所有自检项都成功通过后,新固件才必须调用相应的 API例如 `esp.ota_mark_app_valid_cancel_rollback()`
)来标记自身为有效。
4. **看门狗与回滚**:
* 如果新固件在一定次数的尝试后仍未标记自身为有效,启动加载器会自动回滚到上一个有效固件。
* 在 MicroPython 应用层,如果自检失败,**绝不能**标记自身为有效,并应等待底层机制自动触发回滚。
### 5.6. 报告最终状态
1. **成功场景**: 新固件自检成功并标记有效后,向平台发送 `UpdateStatusReport` (status: `SUCCESS`, current_version:
新版本号)。
2. **回滚场景**: 设备回滚到旧版本后,向平台发送 `UpdateStatusReport` (status: `FAILED`, error_code: `ERR_ROLLED_BACK`,
error_message: "新固件启动失败,已自动回滚", current_version: 旧版本号)。
3. **重复发送**: 为了提高在单向 LoRa 通信中的可靠性,设备在发送最终状态报告时,应在短时间内重复发送多次(例如 3-5 次)。
## 6. 关键技术点与注意事项
### 6.1. LoRa 传输层
* 确保 `internal/infra/transport/lora/lora_mesh_uart_passthrough_transport.go` 能稳定处理大尺寸 Protobuf 消息的分片和重组。
### 6.2. 平台侧的请求处理
* `internal/app/listener/lora_listener.go` 在接收 `RequestFile` 消息时,需要高效处理,避免阻塞监听器。
### 6.3. 文件系统操作 (MicroPython)
* 确保文件系统操作(创建目录、写入文件、删除文件)的正确性和鲁棒性,并对错误进行捕获和报告。
### 6.4. MD5 校验 (MicroPython)
* MicroPython 的 `hashlib` 模块提供 MD5 算法。确保计算的效率和准确性。
### 6.5. OTA 分区管理 (MicroPython)
* 熟悉 ESP-IDF 的 OTA 机制在 MicroPython 中的绑定和使用方法。正确调用 API 来设置启动分区和标记应用有效。
### 6.6. 回滚机制
* 完全依赖 ESP-IDF 提供的 OTA 回滚机制。新固件必须在启动后标记自身为有效,否则会自动回滚。
### 6.7. 错误处理与重试
* **设备侧**: 实现文件级别的下载和校验重试。对于无法恢复的错误,立即上报 `FAILED` 状态并中止任务。
* **平台侧**: 实现任务级别的总超时管理。这是处理设备意外断电、失联等情况的关键机制。设备重启后无需保留升级状态,简化了设备端逻辑。
### 6.8. 安全性
* **数字签名**: 强烈建议尽快实现清单文件的数字签名。**没有数字签名OTA 过程将面临严重的安全风险(如中间人攻击)**
,攻击者可能下发恶意固件。平台的公钥需要被硬编码到设备固件中,作为信任的根基。
* **LoRaWAN 安全**: 确保 LoRaWAN 的网络层和应用层密钥管理得当, 防止未经授权的设备加入网络或窃听数据。
---
## 7. 固件 OTA 升级流程描述
### 阶段一:任务准备与下发
1. **上传与准备 (Developer -> Platform)**: 开发者上传固件包平台解压、计算MD5、生成清单文件、创建升级任务。
2. **下发更新通知 (Platform -> Device)**: 平台向设备发送 `PrepareUpdateReq`
### 阶段二:设备版本检查与准备
1. **版本检查 (Device)**:
* **失败分支 (降级/同版本)**: 设备拒绝升级,上报 `FAILED` (ERR_VERSION_ROLLBACK) 或 `SUCCESS` (
SUCCESS_ALREADY_UP_TO_DATE),流程结束。
* **成功分支**: 版本检查通过,设备继续。
2. **设备准备 (Device)**:
* 设备清空非活动分区。
* **失败分支**: 上报 `FAILED` (ERR_PREPARE),流程结束。
* **成功分支**: 设备发送 `RequestFile` 请求清单文件。
### 阶段三:文件循环下载和校验
1. **清单文件传输与校验 (Platform <-> Device)**:
* 平台发送清单文件,设备接收并校验。
* **失败分支**: 上报 `FAILED` (ERR_MANIFEST_VERIFY),流程结束。
2. **固件文件循环 (Device <-> Platform)**:
* 设备逐个请求、下载、校验清单中的所有文件。
* **失败分支 (重试耗尽)**: 上报 `FAILED` (ERR_DOWNLOAD / ERR_VERIFY),流程结束。
### 阶段四:激活与最终状态
1. **激活重启 (Device)**: 所有文件成功下载后,设备配置启动分区并重启。
2. **新固件自检 (Device)**:
* **成功分支**:
* 设备标记自身为有效。
* 设备上报 `SUCCESS`
* 平台更新任务状态为 `SUCCESS`
* **失败分支 (自检失败/未标记)**:
* 设备等待底层机制自动回滚。
* 设备回滚后,上报 `FAILED` (ERR_ROLLED_BACK)。
* 平台更新任务状态为 `FAILED`
3. **总超时检查 (Platform)**: 如果在规定时间内未收到任何最终报告,平台将任务标记为 `FAILED` (ERR_TIMEOUT)。

View File

@@ -216,12 +216,14 @@ const docTemplate = `{
"电池电量", "电池电量",
"温度", "温度",
"湿度", "湿度",
"重量" "重量",
"在线状态"
], ],
"type": "string", "type": "string",
"x-enum-comments": { "x-enum-comments": {
"SensorTypeBatteryLevel": "电池电量", "SensorTypeBatteryLevel": "电池电量",
"SensorTypeHumidity": "湿度", "SensorTypeHumidity": "湿度",
"SensorTypeOnlineStatus": "在线状态",
"SensorTypeSignalMetrics": "信号强度", "SensorTypeSignalMetrics": "信号强度",
"SensorTypeTemperature": "温度", "SensorTypeTemperature": "温度",
"SensorTypeWeight": "重量" "SensorTypeWeight": "重量"
@@ -231,14 +233,16 @@ const docTemplate = `{
"电池电量", "电池电量",
"温度", "温度",
"湿度", "湿度",
"重量" "重量",
"在线状态"
], ],
"x-enum-varnames": [ "x-enum-varnames": [
"SensorTypeSignalMetrics", "SensorTypeSignalMetrics",
"SensorTypeBatteryLevel", "SensorTypeBatteryLevel",
"SensorTypeTemperature", "SensorTypeTemperature",
"SensorTypeHumidity", "SensorTypeHumidity",
"SensorTypeWeight" "SensorTypeWeight",
"SensorTypeOnlineStatus"
], ],
"description": "按传感器类型过滤", "description": "按传感器类型过滤",
"name": "sensor_type", "name": "sensor_type",
@@ -497,12 +501,14 @@ const docTemplate = `{
"电池电量", "电池电量",
"温度", "温度",
"湿度", "湿度",
"重量" "重量",
"在线状态"
], ],
"type": "string", "type": "string",
"x-enum-comments": { "x-enum-comments": {
"SensorTypeBatteryLevel": "电池电量", "SensorTypeBatteryLevel": "电池电量",
"SensorTypeHumidity": "湿度", "SensorTypeHumidity": "湿度",
"SensorTypeOnlineStatus": "在线状态",
"SensorTypeSignalMetrics": "信号强度", "SensorTypeSignalMetrics": "信号强度",
"SensorTypeTemperature": "温度", "SensorTypeTemperature": "温度",
"SensorTypeWeight": "重量" "SensorTypeWeight": "重量"
@@ -512,14 +518,16 @@ const docTemplate = `{
"电池电量", "电池电量",
"温度", "温度",
"湿度", "湿度",
"重量" "重量",
"在线状态"
], ],
"x-enum-varnames": [ "x-enum-varnames": [
"SensorTypeSignalMetrics", "SensorTypeSignalMetrics",
"SensorTypeBatteryLevel", "SensorTypeBatteryLevel",
"SensorTypeTemperature", "SensorTypeTemperature",
"SensorTypeHumidity", "SensorTypeHumidity",
"SensorTypeWeight" "SensorTypeWeight",
"SensorTypeOnlineStatus"
], ],
"description": "按传感器类型过滤", "description": "按传感器类型过滤",
"name": "sensor_type", "name": "sensor_type",
@@ -3371,6 +3379,59 @@ const docTemplate = `{
} }
} }
}, },
"/api/v1/feed/recipes/{id}/ai-diagnose": {
"get": {
"security": [
{
"BearerAuth": []
}
],
"description": "使用AI对指定配方进行点评并针对目标猪类型给出建议。",
"produces": [
"application/json"
],
"tags": [
"饲料管理-配方"
],
"summary": "AI点评配方",
"parameters": [
{
"type": "integer",
"description": "配方ID",
"name": "id",
"in": "path",
"required": true
},
{
"type": "integer",
"description": "猪类型ID",
"name": "pig_type_id",
"in": "query",
"required": true
}
],
"responses": {
"200": {
"description": "业务码为200代表AI点评成功",
"schema": {
"allOf": [
{
"$ref": "#/definitions/controller.Response"
},
{
"type": "object",
"properties": {
"data": {
"$ref": "#/definitions/dto.ReviewRecipeResponse"
}
}
}
]
}
}
}
}
},
"/api/v1/inventory/stock/adjust": { "/api/v1/inventory/stock/adjust": {
"post": { "post": {
"security": [ "security": [
@@ -6836,6 +6897,9 @@ const docTemplate = `{
"created_at": { "created_at": {
"type": "string" "type": "string"
}, },
"firmware_version": {
"type": "string"
},
"id": { "id": {
"type": "integer" "type": "integer"
}, },
@@ -9165,6 +9229,23 @@ const docTemplate = `{
} }
} }
}, },
"dto.ReviewRecipeResponse": {
"type": "object",
"properties": {
"ai_model": {
"description": "使用的 AI 模型",
"allOf": [
{
"$ref": "#/definitions/models.AIModel"
}
]
},
"review_message": {
"description": "点评内容",
"type": "string"
}
}
},
"dto.SellPigsRequest": { "dto.SellPigsRequest": {
"type": "object", "type": "object",
"required": [ "required": [
@@ -10084,6 +10165,15 @@ const docTemplate = `{
} }
} }
}, },
"models.AIModel": {
"type": "string",
"enum": [
"Gemini"
],
"x-enum-varnames": [
"AI_MODEL_GEMINI"
]
},
"models.AlarmCode": { "models.AlarmCode": {
"type": "string", "type": "string",
"enum": [ "enum": [
@@ -10513,11 +10603,13 @@ const docTemplate = `{
"电池电量", "电池电量",
"温度", "温度",
"湿度", "湿度",
"重量" "重量",
"在线状态"
], ],
"x-enum-comments": { "x-enum-comments": {
"SensorTypeBatteryLevel": "电池电量", "SensorTypeBatteryLevel": "电池电量",
"SensorTypeHumidity": "湿度", "SensorTypeHumidity": "湿度",
"SensorTypeOnlineStatus": "在线状态",
"SensorTypeSignalMetrics": "信号强度", "SensorTypeSignalMetrics": "信号强度",
"SensorTypeTemperature": "温度", "SensorTypeTemperature": "温度",
"SensorTypeWeight": "重量" "SensorTypeWeight": "重量"
@@ -10527,14 +10619,16 @@ const docTemplate = `{
"电池电量", "电池电量",
"温度", "温度",
"湿度", "湿度",
"重量" "重量",
"在线状态"
], ],
"x-enum-varnames": [ "x-enum-varnames": [
"SensorTypeSignalMetrics", "SensorTypeSignalMetrics",
"SensorTypeBatteryLevel", "SensorTypeBatteryLevel",
"SensorTypeTemperature", "SensorTypeTemperature",
"SensorTypeHumidity", "SensorTypeHumidity",
"SensorTypeWeight" "SensorTypeWeight",
"SensorTypeOnlineStatus"
] ]
}, },
"models.SeverityLevel": { "models.SeverityLevel": {
@@ -10602,6 +10696,7 @@ const docTemplate = `{
"等待", "等待",
"下料", "下料",
"全量采集", "全量采集",
"心跳检测",
"告警通知", "告警通知",
"通知刷新", "通知刷新",
"设备阈值检查", "设备阈值检查",
@@ -10613,6 +10708,7 @@ const docTemplate = `{
"TaskTypeAreaCollectorThresholdCheck": "区域阈值检查任务", "TaskTypeAreaCollectorThresholdCheck": "区域阈值检查任务",
"TaskTypeDeviceThresholdCheck": "设备阈值检查任务", "TaskTypeDeviceThresholdCheck": "设备阈值检查任务",
"TaskTypeFullCollection": "新增的全量采集任务", "TaskTypeFullCollection": "新增的全量采集任务",
"TaskTypeHeartbeat": "区域主控心跳检测任务",
"TaskTypeNotificationRefresh": "通知刷新任务", "TaskTypeNotificationRefresh": "通知刷新任务",
"TaskTypeReleaseFeedWeight": "下料口释放指定重量任务", "TaskTypeReleaseFeedWeight": "下料口释放指定重量任务",
"TaskTypeWaiting": "等待任务" "TaskTypeWaiting": "等待任务"
@@ -10622,6 +10718,7 @@ const docTemplate = `{
"等待任务", "等待任务",
"下料口释放指定重量任务", "下料口释放指定重量任务",
"新增的全量采集任务", "新增的全量采集任务",
"区域主控心跳检测任务",
"告警通知任务", "告警通知任务",
"通知刷新任务", "通知刷新任务",
"设备阈值检查任务", "设备阈值检查任务",
@@ -10632,6 +10729,7 @@ const docTemplate = `{
"TaskTypeWaiting", "TaskTypeWaiting",
"TaskTypeReleaseFeedWeight", "TaskTypeReleaseFeedWeight",
"TaskTypeFullCollection", "TaskTypeFullCollection",
"TaskTypeHeartbeat",
"TaskTypeAlarmNotification", "TaskTypeAlarmNotification",
"TaskTypeNotificationRefresh", "TaskTypeNotificationRefresh",
"TaskTypeDeviceThresholdCheck", "TaskTypeDeviceThresholdCheck",

View File

@@ -208,12 +208,14 @@
"电池电量", "电池电量",
"温度", "温度",
"湿度", "湿度",
"重量" "重量",
"在线状态"
], ],
"type": "string", "type": "string",
"x-enum-comments": { "x-enum-comments": {
"SensorTypeBatteryLevel": "电池电量", "SensorTypeBatteryLevel": "电池电量",
"SensorTypeHumidity": "湿度", "SensorTypeHumidity": "湿度",
"SensorTypeOnlineStatus": "在线状态",
"SensorTypeSignalMetrics": "信号强度", "SensorTypeSignalMetrics": "信号强度",
"SensorTypeTemperature": "温度", "SensorTypeTemperature": "温度",
"SensorTypeWeight": "重量" "SensorTypeWeight": "重量"
@@ -223,14 +225,16 @@
"电池电量", "电池电量",
"温度", "温度",
"湿度", "湿度",
"重量" "重量",
"在线状态"
], ],
"x-enum-varnames": [ "x-enum-varnames": [
"SensorTypeSignalMetrics", "SensorTypeSignalMetrics",
"SensorTypeBatteryLevel", "SensorTypeBatteryLevel",
"SensorTypeTemperature", "SensorTypeTemperature",
"SensorTypeHumidity", "SensorTypeHumidity",
"SensorTypeWeight" "SensorTypeWeight",
"SensorTypeOnlineStatus"
], ],
"description": "按传感器类型过滤", "description": "按传感器类型过滤",
"name": "sensor_type", "name": "sensor_type",
@@ -489,12 +493,14 @@
"电池电量", "电池电量",
"温度", "温度",
"湿度", "湿度",
"重量" "重量",
"在线状态"
], ],
"type": "string", "type": "string",
"x-enum-comments": { "x-enum-comments": {
"SensorTypeBatteryLevel": "电池电量", "SensorTypeBatteryLevel": "电池电量",
"SensorTypeHumidity": "湿度", "SensorTypeHumidity": "湿度",
"SensorTypeOnlineStatus": "在线状态",
"SensorTypeSignalMetrics": "信号强度", "SensorTypeSignalMetrics": "信号强度",
"SensorTypeTemperature": "温度", "SensorTypeTemperature": "温度",
"SensorTypeWeight": "重量" "SensorTypeWeight": "重量"
@@ -504,14 +510,16 @@
"电池电量", "电池电量",
"温度", "温度",
"湿度", "湿度",
"重量" "重量",
"在线状态"
], ],
"x-enum-varnames": [ "x-enum-varnames": [
"SensorTypeSignalMetrics", "SensorTypeSignalMetrics",
"SensorTypeBatteryLevel", "SensorTypeBatteryLevel",
"SensorTypeTemperature", "SensorTypeTemperature",
"SensorTypeHumidity", "SensorTypeHumidity",
"SensorTypeWeight" "SensorTypeWeight",
"SensorTypeOnlineStatus"
], ],
"description": "按传感器类型过滤", "description": "按传感器类型过滤",
"name": "sensor_type", "name": "sensor_type",
@@ -3363,6 +3371,59 @@
} }
} }
}, },
"/api/v1/feed/recipes/{id}/ai-diagnose": {
"get": {
"security": [
{
"BearerAuth": []
}
],
"description": "使用AI对指定配方进行点评并针对目标猪类型给出建议。",
"produces": [
"application/json"
],
"tags": [
"饲料管理-配方"
],
"summary": "AI点评配方",
"parameters": [
{
"type": "integer",
"description": "配方ID",
"name": "id",
"in": "path",
"required": true
},
{
"type": "integer",
"description": "猪类型ID",
"name": "pig_type_id",
"in": "query",
"required": true
}
],
"responses": {
"200": {
"description": "业务码为200代表AI点评成功",
"schema": {
"allOf": [
{
"$ref": "#/definitions/controller.Response"
},
{
"type": "object",
"properties": {
"data": {
"$ref": "#/definitions/dto.ReviewRecipeResponse"
}
}
}
]
}
}
}
}
},
"/api/v1/inventory/stock/adjust": { "/api/v1/inventory/stock/adjust": {
"post": { "post": {
"security": [ "security": [
@@ -6828,6 +6889,9 @@
"created_at": { "created_at": {
"type": "string" "type": "string"
}, },
"firmware_version": {
"type": "string"
},
"id": { "id": {
"type": "integer" "type": "integer"
}, },
@@ -9157,6 +9221,23 @@
} }
} }
}, },
"dto.ReviewRecipeResponse": {
"type": "object",
"properties": {
"ai_model": {
"description": "使用的 AI 模型",
"allOf": [
{
"$ref": "#/definitions/models.AIModel"
}
]
},
"review_message": {
"description": "点评内容",
"type": "string"
}
}
},
"dto.SellPigsRequest": { "dto.SellPigsRequest": {
"type": "object", "type": "object",
"required": [ "required": [
@@ -10076,6 +10157,15 @@
} }
} }
}, },
"models.AIModel": {
"type": "string",
"enum": [
"Gemini"
],
"x-enum-varnames": [
"AI_MODEL_GEMINI"
]
},
"models.AlarmCode": { "models.AlarmCode": {
"type": "string", "type": "string",
"enum": [ "enum": [
@@ -10505,11 +10595,13 @@
"电池电量", "电池电量",
"温度", "温度",
"湿度", "湿度",
"重量" "重量",
"在线状态"
], ],
"x-enum-comments": { "x-enum-comments": {
"SensorTypeBatteryLevel": "电池电量", "SensorTypeBatteryLevel": "电池电量",
"SensorTypeHumidity": "湿度", "SensorTypeHumidity": "湿度",
"SensorTypeOnlineStatus": "在线状态",
"SensorTypeSignalMetrics": "信号强度", "SensorTypeSignalMetrics": "信号强度",
"SensorTypeTemperature": "温度", "SensorTypeTemperature": "温度",
"SensorTypeWeight": "重量" "SensorTypeWeight": "重量"
@@ -10519,14 +10611,16 @@
"电池电量", "电池电量",
"温度", "温度",
"湿度", "湿度",
"重量" "重量",
"在线状态"
], ],
"x-enum-varnames": [ "x-enum-varnames": [
"SensorTypeSignalMetrics", "SensorTypeSignalMetrics",
"SensorTypeBatteryLevel", "SensorTypeBatteryLevel",
"SensorTypeTemperature", "SensorTypeTemperature",
"SensorTypeHumidity", "SensorTypeHumidity",
"SensorTypeWeight" "SensorTypeWeight",
"SensorTypeOnlineStatus"
] ]
}, },
"models.SeverityLevel": { "models.SeverityLevel": {
@@ -10594,6 +10688,7 @@
"等待", "等待",
"下料", "下料",
"全量采集", "全量采集",
"心跳检测",
"告警通知", "告警通知",
"通知刷新", "通知刷新",
"设备阈值检查", "设备阈值检查",
@@ -10605,6 +10700,7 @@
"TaskTypeAreaCollectorThresholdCheck": "区域阈值检查任务", "TaskTypeAreaCollectorThresholdCheck": "区域阈值检查任务",
"TaskTypeDeviceThresholdCheck": "设备阈值检查任务", "TaskTypeDeviceThresholdCheck": "设备阈值检查任务",
"TaskTypeFullCollection": "新增的全量采集任务", "TaskTypeFullCollection": "新增的全量采集任务",
"TaskTypeHeartbeat": "区域主控心跳检测任务",
"TaskTypeNotificationRefresh": "通知刷新任务", "TaskTypeNotificationRefresh": "通知刷新任务",
"TaskTypeReleaseFeedWeight": "下料口释放指定重量任务", "TaskTypeReleaseFeedWeight": "下料口释放指定重量任务",
"TaskTypeWaiting": "等待任务" "TaskTypeWaiting": "等待任务"
@@ -10614,6 +10710,7 @@
"等待任务", "等待任务",
"下料口释放指定重量任务", "下料口释放指定重量任务",
"新增的全量采集任务", "新增的全量采集任务",
"区域主控心跳检测任务",
"告警通知任务", "告警通知任务",
"通知刷新任务", "通知刷新任务",
"设备阈值检查任务", "设备阈值检查任务",
@@ -10624,6 +10721,7 @@
"TaskTypeWaiting", "TaskTypeWaiting",
"TaskTypeReleaseFeedWeight", "TaskTypeReleaseFeedWeight",
"TaskTypeFullCollection", "TaskTypeFullCollection",
"TaskTypeHeartbeat",
"TaskTypeAlarmNotification", "TaskTypeAlarmNotification",
"TaskTypeNotificationRefresh", "TaskTypeNotificationRefresh",
"TaskTypeDeviceThresholdCheck", "TaskTypeDeviceThresholdCheck",

View File

@@ -86,6 +86,8 @@ definitions:
properties: properties:
created_at: created_at:
type: string type: string
firmware_version:
type: string
id: id:
type: integer type: integer
location: location:
@@ -1648,6 +1650,16 @@ definitions:
- quantity - quantity
- treatment_location - treatment_location
type: object type: object
dto.ReviewRecipeResponse:
properties:
ai_model:
allOf:
- $ref: '#/definitions/models.AIModel'
description: 使用的 AI 模型
review_message:
description: 点评内容
type: string
type: object
dto.SellPigsRequest: dto.SellPigsRequest:
properties: properties:
pen_id: pen_id:
@@ -2274,6 +2286,12 @@ definitions:
weight: weight:
type: number type: number
type: object type: object
models.AIModel:
enum:
- Gemini
type: string
x-enum-varnames:
- AI_MODEL_GEMINI
models.AlarmCode: models.AlarmCode:
enum: enum:
- 温度阈值 - 温度阈值
@@ -2622,10 +2640,12 @@ definitions:
- 温度 - 温度
- 湿度 - 湿度
- 重量 - 重量
- 在线状态
type: string type: string
x-enum-comments: x-enum-comments:
SensorTypeBatteryLevel: 电池电量 SensorTypeBatteryLevel: 电池电量
SensorTypeHumidity: 湿度 SensorTypeHumidity: 湿度
SensorTypeOnlineStatus: 在线状态
SensorTypeSignalMetrics: 信号强度 SensorTypeSignalMetrics: 信号强度
SensorTypeTemperature: 温度 SensorTypeTemperature: 温度
SensorTypeWeight: 重量 SensorTypeWeight: 重量
@@ -2635,12 +2655,14 @@ definitions:
- 温度 - 温度
- 湿度 - 湿度
- 重量 - 重量
- 在线状态
x-enum-varnames: x-enum-varnames:
- SensorTypeSignalMetrics - SensorTypeSignalMetrics
- SensorTypeBatteryLevel - SensorTypeBatteryLevel
- SensorTypeTemperature - SensorTypeTemperature
- SensorTypeHumidity - SensorTypeHumidity
- SensorTypeWeight - SensorTypeWeight
- SensorTypeOnlineStatus
models.SeverityLevel: models.SeverityLevel:
enum: enum:
- debug - debug
@@ -2697,6 +2719,7 @@ definitions:
- 等待 - 等待
- 下料 - 下料
- 全量采集 - 全量采集
- 心跳检测
- 告警通知 - 告警通知
- 通知刷新 - 通知刷新
- 设备阈值检查 - 设备阈值检查
@@ -2708,6 +2731,7 @@ definitions:
TaskTypeAreaCollectorThresholdCheck: 区域阈值检查任务 TaskTypeAreaCollectorThresholdCheck: 区域阈值检查任务
TaskTypeDeviceThresholdCheck: 设备阈值检查任务 TaskTypeDeviceThresholdCheck: 设备阈值检查任务
TaskTypeFullCollection: 新增的全量采集任务 TaskTypeFullCollection: 新增的全量采集任务
TaskTypeHeartbeat: 区域主控心跳检测任务
TaskTypeNotificationRefresh: 通知刷新任务 TaskTypeNotificationRefresh: 通知刷新任务
TaskTypeReleaseFeedWeight: 下料口释放指定重量任务 TaskTypeReleaseFeedWeight: 下料口释放指定重量任务
TaskTypeWaiting: 等待任务 TaskTypeWaiting: 等待任务
@@ -2716,6 +2740,7 @@ definitions:
- 等待任务 - 等待任务
- 下料口释放指定重量任务 - 下料口释放指定重量任务
- 新增的全量采集任务 - 新增的全量采集任务
- 区域主控心跳检测任务
- 告警通知任务 - 告警通知任务
- 通知刷新任务 - 通知刷新任务
- 设备阈值检查任务 - 设备阈值检查任务
@@ -2725,6 +2750,7 @@ definitions:
- TaskTypeWaiting - TaskTypeWaiting
- TaskTypeReleaseFeedWeight - TaskTypeReleaseFeedWeight
- TaskTypeFullCollection - TaskTypeFullCollection
- TaskTypeHeartbeat
- TaskTypeAlarmNotification - TaskTypeAlarmNotification
- TaskTypeNotificationRefresh - TaskTypeNotificationRefresh
- TaskTypeDeviceThresholdCheck - TaskTypeDeviceThresholdCheck
@@ -2969,12 +2995,14 @@ paths:
- 温度 - 温度
- 湿度 - 湿度
- 重量 - 重量
- 在线状态
in: query in: query
name: sensor_type name: sensor_type
type: string type: string
x-enum-comments: x-enum-comments:
SensorTypeBatteryLevel: 电池电量 SensorTypeBatteryLevel: 电池电量
SensorTypeHumidity: 湿度 SensorTypeHumidity: 湿度
SensorTypeOnlineStatus: 在线状态
SensorTypeSignalMetrics: 信号强度 SensorTypeSignalMetrics: 信号强度
SensorTypeTemperature: 温度 SensorTypeTemperature: 温度
SensorTypeWeight: 重量 SensorTypeWeight: 重量
@@ -2984,12 +3012,14 @@ paths:
- 温度 - 温度
- 湿度 - 湿度
- 重量 - 重量
- 在线状态
x-enum-varnames: x-enum-varnames:
- SensorTypeSignalMetrics - SensorTypeSignalMetrics
- SensorTypeBatteryLevel - SensorTypeBatteryLevel
- SensorTypeTemperature - SensorTypeTemperature
- SensorTypeHumidity - SensorTypeHumidity
- SensorTypeWeight - SensorTypeWeight
- SensorTypeOnlineStatus
produces: produces:
- application/json - application/json
responses: responses:
@@ -3151,12 +3181,14 @@ paths:
- 温度 - 温度
- 湿度 - 湿度
- 重量 - 重量
- 在线状态
in: query in: query
name: sensor_type name: sensor_type
type: string type: string
x-enum-comments: x-enum-comments:
SensorTypeBatteryLevel: 电池电量 SensorTypeBatteryLevel: 电池电量
SensorTypeHumidity: 湿度 SensorTypeHumidity: 湿度
SensorTypeOnlineStatus: 在线状态
SensorTypeSignalMetrics: 信号强度 SensorTypeSignalMetrics: 信号强度
SensorTypeTemperature: 温度 SensorTypeTemperature: 温度
SensorTypeWeight: 重量 SensorTypeWeight: 重量
@@ -3166,12 +3198,14 @@ paths:
- 温度 - 温度
- 湿度 - 湿度
- 重量 - 重量
- 在线状态
x-enum-varnames: x-enum-varnames:
- SensorTypeSignalMetrics - SensorTypeSignalMetrics
- SensorTypeBatteryLevel - SensorTypeBatteryLevel
- SensorTypeTemperature - SensorTypeTemperature
- SensorTypeHumidity - SensorTypeHumidity
- SensorTypeWeight - SensorTypeWeight
- SensorTypeOnlineStatus
produces: produces:
- application/json - application/json
responses: responses:
@@ -4755,6 +4789,37 @@ paths:
summary: 更新配方 summary: 更新配方
tags: tags:
- 饲料管理-配方 - 饲料管理-配方
/api/v1/feed/recipes/{id}/ai-diagnose:
get:
description: 使用AI对指定配方进行点评并针对目标猪类型给出建议。
parameters:
- description: 配方ID
in: path
name: id
required: true
type: integer
- description: 猪类型ID
in: query
name: pig_type_id
required: true
type: integer
produces:
- application/json
responses:
"200":
description: 业务码为200代表AI点评成功
schema:
allOf:
- $ref: '#/definitions/controller.Response'
- properties:
data:
$ref: '#/definitions/dto.ReviewRecipeResponse'
type: object
security:
- BearerAuth: []
summary: AI点评配方
tags:
- 饲料管理-配方
/api/v1/feed/recipes/generate-from-all-materials/{pig_type_id}: /api/v1/feed/recipes/generate-from-all-materials/{pig_type_id}:
post: post:
description: 根据指定的猪类型ID使用系统中所有可用的原料自动计算并创建一个成本最优的配方。 description: 根据指定的猪类型ID使用系统中所有可用的原料自动计算并创建一个成本最优的配方。

34
go.mod
View File

@@ -9,6 +9,7 @@ require (
github.com/go-openapi/swag v0.25.1 github.com/go-openapi/swag v0.25.1
github.com/go-openapi/validate v0.24.0 github.com/go-openapi/validate v0.24.0
github.com/golang-jwt/jwt/v5 v5.3.0 github.com/golang-jwt/jwt/v5 v5.3.0
github.com/google/generative-ai-go v0.20.1
github.com/google/uuid v1.6.0 github.com/google/uuid v1.6.0
github.com/labstack/echo/v4 v4.13.4 github.com/labstack/echo/v4 v4.13.4
github.com/panjf2000/ants/v2 v2.11.3 github.com/panjf2000/ants/v2 v2.11.3
@@ -20,7 +21,8 @@ require (
go.uber.org/zap v1.27.0 go.uber.org/zap v1.27.0
golang.org/x/crypto v0.43.0 golang.org/x/crypto v0.43.0
gonum.org/v1/gonum v0.16.0 gonum.org/v1/gonum v0.16.0
google.golang.org/protobuf v1.36.9 google.golang.org/api v0.256.0
google.golang.org/protobuf v1.36.10
gopkg.in/natefinch/lumberjack.v2 v2.2.1 gopkg.in/natefinch/lumberjack.v2 v2.2.1
gopkg.in/yaml.v2 v2.4.0 gopkg.in/yaml.v2 v2.4.0
gorm.io/datatypes v1.2.6 gorm.io/datatypes v1.2.6
@@ -29,11 +31,18 @@ require (
) )
require ( require (
cloud.google.com/go v0.115.0 // indirect
cloud.google.com/go/ai v0.8.0 // indirect
cloud.google.com/go/auth v0.17.0 // indirect
cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect
cloud.google.com/go/compute/metadata v0.9.0 // indirect
cloud.google.com/go/longrunning v0.5.7 // indirect
filippo.io/edwards25519 v1.1.0 // indirect filippo.io/edwards25519 v1.1.0 // indirect
github.com/KyleBanks/depth v1.2.1 // indirect github.com/KyleBanks/depth v1.2.1 // indirect
github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 // indirect github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 // indirect
github.com/felixge/httpsnoop v1.0.4 // indirect
github.com/ghodss/yaml v1.0.0 // indirect github.com/ghodss/yaml v1.0.0 // indirect
github.com/go-logr/logr v1.4.1 // indirect github.com/go-logr/logr v1.4.3 // indirect
github.com/go-logr/stdr v1.2.2 // indirect github.com/go-logr/stdr v1.2.2 // indirect
github.com/go-openapi/analysis v0.23.0 // indirect github.com/go-openapi/analysis v0.23.0 // indirect
github.com/go-openapi/jsonpointer v0.22.1 // indirect github.com/go-openapi/jsonpointer v0.22.1 // indirect
@@ -52,7 +61,9 @@ require (
github.com/go-openapi/swag/typeutils v0.25.1 // indirect github.com/go-openapi/swag/typeutils v0.25.1 // indirect
github.com/go-openapi/swag/yamlutils v0.25.1 // indirect github.com/go-openapi/swag/yamlutils v0.25.1 // indirect
github.com/go-sql-driver/mysql v1.8.1 // indirect github.com/go-sql-driver/mysql v1.8.1 // indirect
github.com/google/go-cmp v0.7.0 // indirect github.com/google/s2a-go v0.1.9 // indirect
github.com/googleapis/enterprise-certificate-proxy v0.3.7 // indirect
github.com/googleapis/gax-go/v2 v2.15.0 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/pgx/v5 v5.6.0 // indirect github.com/jackc/pgx/v5 v5.6.0 // indirect
@@ -72,18 +83,25 @@ require (
github.com/valyala/bytebufferpool v1.0.0 // indirect github.com/valyala/bytebufferpool v1.0.0 // indirect
github.com/valyala/fasttemplate v1.2.2 // indirect github.com/valyala/fasttemplate v1.2.2 // indirect
go.mongodb.org/mongo-driver v1.14.0 // indirect go.mongodb.org/mongo-driver v1.14.0 // indirect
go.opentelemetry.io/otel v1.24.0 // indirect go.opentelemetry.io/auto/sdk v1.1.0 // indirect
go.opentelemetry.io/otel/metric v1.24.0 // indirect go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.61.0 // indirect
go.opentelemetry.io/otel/trace v1.24.0 // indirect go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 // indirect
go.opentelemetry.io/otel v1.37.0 // indirect
go.opentelemetry.io/otel/metric v1.37.0 // indirect
go.opentelemetry.io/otel/trace v1.37.0 // indirect
go.uber.org/multierr v1.10.0 // indirect go.uber.org/multierr v1.10.0 // indirect
go.yaml.in/yaml/v3 v3.0.4 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect
golang.org/x/mod v0.29.0 // indirect golang.org/x/mod v0.29.0 // indirect
golang.org/x/net v0.46.0 // indirect golang.org/x/net v0.46.0 // indirect
golang.org/x/sync v0.17.0 // indirect golang.org/x/oauth2 v0.33.0 // indirect
golang.org/x/sync v0.18.0 // indirect
golang.org/x/sys v0.37.0 // indirect golang.org/x/sys v0.37.0 // indirect
golang.org/x/text v0.30.0 // indirect golang.org/x/text v0.30.0 // indirect
golang.org/x/time v0.11.0 // indirect golang.org/x/time v0.14.0 // indirect
golang.org/x/tools v0.38.0 // indirect golang.org/x/tools v0.38.0 // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20250804133106-a7a43d27e69b // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20251103181224-f26f9409b101 // indirect
google.golang.org/grpc v1.76.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect
gorm.io/driver/mysql v1.5.6 // indirect gorm.io/driver/mysql v1.5.6 // indirect
gorm.io/driver/sqlite v1.6.0 // indirect gorm.io/driver/sqlite v1.6.0 // indirect

85
go.sum
View File

@@ -1,17 +1,38 @@
cloud.google.com/go v0.115.0 h1:CnFSK6Xo3lDYRoBKEcAtia6VSC837/ZkJuRduSFnr14=
cloud.google.com/go v0.115.0/go.mod h1:8jIM5vVgoAEoiVxQ/O4BFTfHqulPZgs/ufEzMcFMdWU=
cloud.google.com/go/ai v0.8.0 h1:rXUEz8Wp2OlrM8r1bfmpF2+VKqc1VJpafE3HgzRnD/w=
cloud.google.com/go/ai v0.8.0/go.mod h1:t3Dfk4cM61sytiggo2UyGsDVW3RF1qGZaUKDrZFyqkE=
cloud.google.com/go/auth v0.17.0 h1:74yCm7hCj2rUyyAocqnFzsAYXgJhrG26XCFimrc/Kz4=
cloud.google.com/go/auth v0.17.0/go.mod h1:6wv/t5/6rOPAX4fJiRjKkJCvswLwdet7G8+UGXt7nCQ=
cloud.google.com/go/auth/oauth2adapt v0.2.8 h1:keo8NaayQZ6wimpNSmW5OPc283g65QNIiLpZnkHRbnc=
cloud.google.com/go/auth/oauth2adapt v0.2.8/go.mod h1:XQ9y31RkqZCcwJWNSx2Xvric3RrU88hAYYbjDWYDL+c=
cloud.google.com/go/compute/metadata v0.9.0 h1:pDUj4QMoPejqq20dK0Pg2N4yG9zIkYGdBtwLoEkH9Zs=
cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCBSM6qQI1yTBdEb3C10=
cloud.google.com/go/longrunning v0.5.7 h1:WLbHekDbjK1fVFD3ibpFFVoyizlLRl73I7YKuAKilhU=
cloud.google.com/go/longrunning v0.5.7/go.mod h1:8GClkudohy1Fxm3owmBGid8W0pSgodEMwEAztp38Xng=
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
github.com/KyleBanks/depth v1.2.1 h1:5h8fQADFrWtarTdtDudMmGsC7GPbOAu6RVB3ffsVFHc= github.com/KyleBanks/depth v1.2.1 h1:5h8fQADFrWtarTdtDudMmGsC7GPbOAu6RVB3ffsVFHc=
github.com/KyleBanks/depth v1.2.1/go.mod h1:jzSb9d0L43HxTQfT+oSA1EEp2q+ne2uh6XgeJcm8brE= github.com/KyleBanks/depth v1.2.1/go.mod h1:jzSb9d0L43HxTQfT+oSA1EEp2q+ne2uh6XgeJcm8brE=
github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 h1:DklsrG3dyBCFEj5IhUbnKptjxatkF07cF2ak3yi77so= github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 h1:DklsrG3dyBCFEj5IhUbnKptjxatkF07cF2ak3yi77so=
github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2/go.mod h1:WaHUgvxTVq04UNunO+XhnAqY/wQc+bxr74GqbsZ/Jqw= github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2/go.mod h1:WaHUgvxTVq04UNunO+XhnAqY/wQc+bxr74GqbsZ/Jqw=
github.com/cncf/xds/go v0.0.0-20250501225837-2ac532fd4443 h1:aQ3y1lwWyqYPiWZThqv1aFbZMiM9vblcSArJRf2Irls=
github.com/cncf/xds/go v0.0.0-20250501225837-2ac532fd4443/go.mod h1:W+zGtBO5Y1IgJhy4+A9GOqVhqLpfZi+vwmdNXUehLA8=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/envoyproxy/go-control-plane v0.13.4 h1:zEqyPVyku6IvWCFwux4x9RxkLOMUL+1vC9xUFv5l2/M=
github.com/envoyproxy/go-control-plane/envoy v1.32.4 h1:jb83lalDRZSpPWW2Z7Mck/8kXZ5CQAFYVjQcdVIr83A=
github.com/envoyproxy/go-control-plane/envoy v1.32.4/go.mod h1:Gzjc5k8JcJswLjAx1Zm+wSYE20UrLtt7JZMWiWQXQEw=
github.com/envoyproxy/protoc-gen-validate v1.2.1 h1:DEo3O99U8j4hBFwbJfrz9VtgcDfUKS7KJ7spH3d86P8=
github.com/envoyproxy/protoc-gen-validate v1.2.1/go.mod h1:d/C80l/jxXLdfEIhX1W2TmLfsJ31lvEjwamM4DxlWXU=
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
github.com/ghodss/yaml v1.0.0 h1:wQHKEahhL6wmXdzwWG11gIVCkOv05bNOh+Rxn0yngAk= github.com/ghodss/yaml v1.0.0 h1:wQHKEahhL6wmXdzwWG11gIVCkOv05bNOh+Rxn0yngAk=
github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04= github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04=
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
github.com/go-logr/logr v1.4.1 h1:pKouT5E8xu9zeFC39JXRDukb6JFQPXM5p5I91188VAQ= github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
github.com/go-logr/logr v1.4.1/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
github.com/go-openapi/analysis v0.23.0 h1:aGday7OWupfMs+LbmLZG4k0MYXIANxcuBTYUC03zFCU= github.com/go-openapi/analysis v0.23.0 h1:aGday7OWupfMs+LbmLZG4k0MYXIANxcuBTYUC03zFCU=
@@ -67,10 +88,20 @@ github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9 h1:au07oEsX2xN0kt
github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9/go.mod h1:8vg3r2VgvsThLBIFL93Qb5yWzgyZWhEmBwUJWevAkK0= github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9/go.mod h1:8vg3r2VgvsThLBIFL93Qb5yWzgyZWhEmBwUJWevAkK0=
github.com/golang-sql/sqlexp v0.1.0 h1:ZCD6MBpcuOVfGVqsEmY5/4FtYiKz6tSyUv9LPEDei6A= github.com/golang-sql/sqlexp v0.1.0 h1:ZCD6MBpcuOVfGVqsEmY5/4FtYiKz6tSyUv9LPEDei6A=
github.com/golang-sql/sqlexp v0.1.0/go.mod h1:J4ad9Vo8ZCWQ2GMrC4UCQy1JpCbwU9m3EOqtpKwwwHI= github.com/golang-sql/sqlexp v0.1.0/go.mod h1:J4ad9Vo8ZCWQ2GMrC4UCQy1JpCbwU9m3EOqtpKwwwHI=
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
github.com/google/generative-ai-go v0.20.1 h1:6dEIujpgN2V0PgLhr6c/M1ynRdc7ARtiIDPFzj45uNQ=
github.com/google/generative-ai-go v0.20.1/go.mod h1:TjOnZJmZKzarWbjUJgy+r3Ee7HGBRVLhOIgupnwR4Bg=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/s2a-go v0.1.9 h1:LGD7gtMgezd8a/Xak7mEWL0PjoTQFvpRudN895yqKW0=
github.com/google/s2a-go v0.1.9/go.mod h1:YA0Ei2ZQL3acow2O62kdp9UlnvMmU7kA6Eutn0dXayM=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/googleapis/enterprise-certificate-proxy v0.3.7 h1:zrn2Ee/nWmHulBx5sAVrGgAa0f2/R35S4DJwfFaUPFQ=
github.com/googleapis/enterprise-certificate-proxy v0.3.7/go.mod h1:MkHOF77EYAE7qfSuSS9PU6g4Nt4e11cnsDUowfwewLA=
github.com/googleapis/gax-go/v2 v2.15.0 h1:SyjDc1mGgZU5LncH8gimWo9lW1DtIfPibOG81vgd/bo=
github.com/googleapis/gax-go/v2 v2.15.0/go.mod h1:zVVkkxAQHa1RQpg9z2AUCMnKhi0Qld9rcmyfL1OZhoc=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
@@ -107,6 +138,8 @@ github.com/opentracing/opentracing-go v1.2.0 h1:uEJPy/1a5RIPAJ0Ov+OIO8OxWu77jEv+
github.com/opentracing/opentracing-go v1.2.0/go.mod h1:GxEUsuufX4nBwe+T+Wl9TAgYrxe9dPLANfrWvHYVTgc= github.com/opentracing/opentracing-go v1.2.0/go.mod h1:GxEUsuufX4nBwe+T+Wl9TAgYrxe9dPLANfrWvHYVTgc=
github.com/panjf2000/ants/v2 v2.11.3 h1:AfI0ngBoXJmYOpDh9m516vjqoUu2sLrIVgppI9TZVpg= github.com/panjf2000/ants/v2 v2.11.3 h1:AfI0ngBoXJmYOpDh9m516vjqoUu2sLrIVgppI9TZVpg=
github.com/panjf2000/ants/v2 v2.11.3/go.mod h1:8u92CYMUc6gyvTIw8Ru7Mt7+/ESnJahz5EVtqfrilek= github.com/panjf2000/ants/v2 v2.11.3/go.mod h1:8u92CYMUc6gyvTIw8Ru7Mt7+/ESnJahz5EVtqfrilek=
github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 h1:GFCKgmp0tecUJ0sJuv4pzYCqS9+RGSn52M3FUwPs+uo=
github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10/go.mod h1:t/avpk3KcrXxUnYOhZhMXJlSEyie6gQbtLq5NM3loB8=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs= github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
@@ -138,14 +171,22 @@ github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQ
github.com/valyala/fasttemplate v1.2.2/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ= github.com/valyala/fasttemplate v1.2.2/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ=
go.mongodb.org/mongo-driver v1.14.0 h1:P98w8egYRjYe3XDjxhYJagTokP/H6HzlsnojRgZRd80= go.mongodb.org/mongo-driver v1.14.0 h1:P98w8egYRjYe3XDjxhYJagTokP/H6HzlsnojRgZRd80=
go.mongodb.org/mongo-driver v1.14.0/go.mod h1:Vzb0Mk/pa7e6cWw85R4F/endUC3u0U9jGcNU603k65c= go.mongodb.org/mongo-driver v1.14.0/go.mod h1:Vzb0Mk/pa7e6cWw85R4F/endUC3u0U9jGcNU603k65c=
go.opentelemetry.io/otel v1.24.0 h1:0LAOdjNmQeSTzGBzduGe/rU4tZhMwL5rWgtp9Ku5Jfo= go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA=
go.opentelemetry.io/otel v1.24.0/go.mod h1:W7b9Ozg4nkF5tWI5zsXkaKKDjdVjpD4oAt9Qi/MArHo= go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A=
go.opentelemetry.io/otel/metric v1.24.0 h1:6EhoGWWK28x1fbpA4tYTOWBkPefTDQnb8WSGXlc88kI= go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.61.0 h1:q4XOmH/0opmeuJtPsbFNivyl7bCt7yRBbeEm2sC/XtQ=
go.opentelemetry.io/otel/metric v1.24.0/go.mod h1:VYhLe1rFfxuTXLgj4CBiyz+9WYBA8pNGJgDcSFRKBco= go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.61.0/go.mod h1:snMWehoOh2wsEwnvvwtDyFCxVeDAODenXHtn5vzrKjo=
go.opentelemetry.io/otel/sdk v1.24.0 h1:YMPPDNymmQN3ZgczicBY3B6sf9n62Dlj9pWD3ucgoDw= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 h1:F7Jx+6hwnZ41NSFTO5q4LYDtJRXBf2PD0rNBkeB/lus=
go.opentelemetry.io/otel/sdk v1.24.0/go.mod h1:KVrIYw6tEubO9E96HQpcmpTKDVn9gdv35HoYiQWGDFg= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0/go.mod h1:UHB22Z8QsdRDrnAtX4PntOl36ajSxcdUMt1sF7Y6E7Q=
go.opentelemetry.io/otel/trace v1.24.0 h1:CsKnnL4dUAr/0llH9FKuc698G04IrpWV0MQA/Y1YELI= go.opentelemetry.io/otel v1.37.0 h1:9zhNfelUvx0KBfu/gb+ZgeAfAgtWrfHJZcAqFC228wQ=
go.opentelemetry.io/otel/trace v1.24.0/go.mod h1:HPc3Xr/cOApsBI154IU0OI0HJexz+aw5uPdbs3UCjNU= go.opentelemetry.io/otel v1.37.0/go.mod h1:ehE/umFRLnuLa/vSccNq9oS1ErUlkkK71gMcN34UG8I=
go.opentelemetry.io/otel/metric v1.37.0 h1:mvwbQS5m0tbmqML4NqK+e3aDiO02vsf/WgbsdpcPoZE=
go.opentelemetry.io/otel/metric v1.37.0/go.mod h1:04wGrZurHYKOc+RKeye86GwKiTb9FKm1WHtO+4EVr2E=
go.opentelemetry.io/otel/sdk v1.37.0 h1:ItB0QUqnjesGRvNcmAcU0LyvkVyGJ2xftD29bWdDvKI=
go.opentelemetry.io/otel/sdk v1.37.0/go.mod h1:VredYzxUvuo2q3WRcDnKDjbdvmO0sCzOvVAiY+yUkAg=
go.opentelemetry.io/otel/sdk/metric v1.37.0 h1:90lI228XrB9jCMuSdA0673aubgRobVZFhbjxHHspCPc=
go.opentelemetry.io/otel/sdk/metric v1.37.0/go.mod h1:cNen4ZWfiD37l5NhS+Keb5RXVWZWpRE+9WyVCpbo5ps=
go.opentelemetry.io/otel/trace v1.37.0 h1:HLdcFNbRQBE2imdSEgm/kwqmQj1Or1l/7bW6mxVK7z4=
go.opentelemetry.io/otel/trace v1.37.0/go.mod h1:TlgrlQ+PtQO5XFerSPUYG0JSgGyryXewPGyayAWSBS0=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
go.uber.org/multierr v1.10.0 h1:S0h4aNzvfcFsC3dRF1jLoaov7oRaKqRGC/pUEJ2yvPQ= go.uber.org/multierr v1.10.0 h1:S0h4aNzvfcFsC3dRF1jLoaov7oRaKqRGC/pUEJ2yvPQ=
@@ -160,21 +201,33 @@ golang.org/x/mod v0.29.0 h1:HV8lRxZC4l2cr3Zq1LvtOsi/ThTgWnUk/y64QSs8GwA=
golang.org/x/mod v0.29.0/go.mod h1:NyhrlYXJ2H4eJiRy/WDBO6HMqZQ6q9nk4JzS3NuCK+w= golang.org/x/mod v0.29.0/go.mod h1:NyhrlYXJ2H4eJiRy/WDBO6HMqZQ6q9nk4JzS3NuCK+w=
golang.org/x/net v0.46.0 h1:giFlY12I07fugqwPuWJi68oOnpfqFnJIJzaIIm2JVV4= golang.org/x/net v0.46.0 h1:giFlY12I07fugqwPuWJi68oOnpfqFnJIJzaIIm2JVV4=
golang.org/x/net v0.46.0/go.mod h1:Q9BGdFy1y4nkUwiLvT5qtyhAnEHgnQ/zd8PfU6nc210= golang.org/x/net v0.46.0/go.mod h1:Q9BGdFy1y4nkUwiLvT5qtyhAnEHgnQ/zd8PfU6nc210=
golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug= golang.org/x/oauth2 v0.33.0 h1:4Q+qn+E5z8gPRJfmRy7C2gGG3T4jIprK6aSYgTXGRpo=
golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/oauth2 v0.33.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I=
golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ= golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ=
golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/text v0.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k= golang.org/x/text v0.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k=
golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM= golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM=
golang.org/x/time v0.11.0 h1:/bpjEDfN9tkoN/ryeYHnv5hcMlc8ncjMcM4XBk5NWV0= golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
golang.org/x/time v0.11.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg= golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
golang.org/x/tools v0.38.0 h1:Hx2Xv8hISq8Lm16jvBZ2VQf+RLmbd7wVUsALibYI/IQ= golang.org/x/tools v0.38.0 h1:Hx2Xv8hISq8Lm16jvBZ2VQf+RLmbd7wVUsALibYI/IQ=
golang.org/x/tools v0.38.0/go.mod h1:yEsQ/d/YK8cjh0L6rZlY8tgtlKiBNTL14pGDJPJpYQs= golang.org/x/tools v0.38.0/go.mod h1:yEsQ/d/YK8cjh0L6rZlY8tgtlKiBNTL14pGDJPJpYQs=
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
google.golang.org/protobuf v1.36.9 h1:w2gp2mA27hUeUzj9Ex9FBjsBm40zfaDtEWow293U7Iw= google.golang.org/api v0.256.0 h1:u6Khm8+F9sxbCTYNoBHg6/Hwv0N/i+V94MvkOSor6oI=
google.golang.org/protobuf v1.36.9/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU= google.golang.org/api v0.256.0/go.mod h1:KIgPhksXADEKJlnEoRa9qAII4rXcy40vfI8HRqcU964=
google.golang.org/genproto v0.0.0-20250603155806-513f23925822 h1:rHWScKit0gvAPuOnu87KpaYtjK5zBMLcULh7gxkCXu4=
google.golang.org/genproto v0.0.0-20250603155806-513f23925822/go.mod h1:HubltRL7rMh0LfnQPkMH4NPDFEWp0jw3vixw7jEM53s=
google.golang.org/genproto/googleapis/api v0.0.0-20250804133106-a7a43d27e69b h1:ULiyYQ0FdsJhwwZUwbaXpZF5yUE3h+RA+gxvBu37ucc=
google.golang.org/genproto/googleapis/api v0.0.0-20250804133106-a7a43d27e69b/go.mod h1:oDOGiMSXHL4sDTJvFvIB9nRQCGdLP1o/iVaqQK8zB+M=
google.golang.org/genproto/googleapis/rpc v0.0.0-20251103181224-f26f9409b101 h1:tRPGkdGHuewF4UisLzzHHr1spKw92qLM98nIzxbC0wY=
google.golang.org/genproto/googleapis/rpc v0.0.0-20251103181224-f26f9409b101/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk=
google.golang.org/grpc v1.76.0 h1:UnVkv1+uMLYXoIz6o7chp59WfQUYA2ex/BXQ9rHZu7A=
google.golang.org/grpc v1.76.0/go.mod h1:Ju12QI8M6iQJtbcsV+awF5a4hfJMLi4X0JLo94ULZ6c=
google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=

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

View File

@@ -261,6 +261,7 @@ func (a *API) setupRoutes() {
feedGroup.GET("/recipes", a.recipeController.ListRecipes) feedGroup.GET("/recipes", a.recipeController.ListRecipes)
feedGroup.POST("/recipes/generate-from-all-materials/:pig_type_id", a.recipeController.GenerateFromAllMaterials) feedGroup.POST("/recipes/generate-from-all-materials/:pig_type_id", a.recipeController.GenerateFromAllMaterials)
feedGroup.POST("/recipes/generate-prioritized-stock/:pig_type_id", a.recipeController.GenerateRecipeWithPrioritizedStockRawMaterials) feedGroup.POST("/recipes/generate-prioritized-stock/:pig_type_id", a.recipeController.GenerateRecipeWithPrioritizedStockRawMaterials)
feedGroup.GET("/recipes/:id/ai-diagnose", a.recipeController.AIDiagnoseRecipe)
} }
logger.Debug("饲料管理相关接口注册成功 (需要认证和审计)") logger.Debug("饲料管理相关接口注册成功 (需要认证和审计)")

View File

@@ -256,3 +256,48 @@ func (c *RecipeController) GenerateRecipeWithPrioritizedStockRawMaterials(ctx ec
logger.Infof("%s: 配方生成成功, 新配方ID: %d", actionType, resp.ID) logger.Infof("%s: 配方生成成功, 新配方ID: %d", actionType, resp.ID)
return controller.SendSuccessWithAudit(ctx, controller.CodeCreated, "配方生成成功", resp, actionType, "配方生成成功", resp) return controller.SendSuccessWithAudit(ctx, controller.CodeCreated, "配方生成成功", resp, actionType, "配方生成成功", resp)
} }
// AIDiagnoseRecipe godoc
// @Summary AI点评配方
// @Description 使用AI对指定配方进行点评并针对目标猪类型给出建议。
// @Tags 饲料管理-配方
// @Security BearerAuth
// @Produce json
// @Param id path int true "配方ID"
// @Param pig_type_id query int true "猪类型ID"
// @Success 200 {object} controller.Response{data=dto.ReviewRecipeResponse} "业务码为200代表AI点评成功"
// @Router /api/v1/feed/recipes/{id}/ai-diagnose [get]
func (c *RecipeController) AIDiagnoseRecipe(ctx echo.Context) error {
reqCtx, logger := logs.Trace(ctx.Request().Context(), c.ctx, "AIDiagnoseRecipe")
const actionType = "AI点评配方"
// 从路径参数中获取配方ID
recipeIDStr := ctx.Param("id")
recipeID, err := strconv.ParseUint(recipeIDStr, 10, 32)
if err != nil {
logger.Errorf("%s: 配方ID格式错误: %v, ID: %s", actionType, err, recipeIDStr)
return controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "无效的配方ID格式", actionType, "配方ID格式错误", recipeIDStr)
}
// 从查询参数中获取猪类型ID
pigTypeIDStr := ctx.QueryParam("pig_type_id")
pigTypeID, err := strconv.ParseUint(pigTypeIDStr, 10, 32)
if err != nil {
logger.Errorf("%s: 猪类型ID格式错误: %v, ID: %s", actionType, err, pigTypeIDStr)
return controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "无效的猪类型ID格式", actionType, "猪类型ID格式错误", pigTypeIDStr)
}
// 调用应用服务进行AI点评
reviewResponse, err := c.recipeService.AIDiagnoseRecipe(reqCtx, uint32(recipeID), uint32(pigTypeID))
if err != nil {
logger.Errorf("%s: 服务层AI点评失败: %v, RecipeID: %d, PigTypeID: %d", actionType, err, recipeID, pigTypeID)
if errors.Is(err, service.ErrRecipeNotFound) {
return controller.SendErrorWithAudit(ctx, controller.CodeNotFound, err.Error(), actionType, "配方或猪类型不存在", map[string]uint32{"recipe_id": uint32(recipeID), "pig_type_id": uint32(pigTypeID)})
}
// 对于其他错误,统一返回内部服务器错误
return controller.SendErrorWithAudit(ctx, controller.CodeInternalError, "AI点评失败: "+err.Error(), actionType, "服务层AI点评失败", map[string]uint32{"recipe_id": uint32(recipeID), "pig_type_id": uint32(pigTypeID)})
}
logger.Infof("%s: AI点评成功, RecipeID: %d, PigTypeID: %d", actionType, recipeID, pigTypeID)
return controller.SendSuccessWithAudit(ctx, controller.CodeSuccess, "AI点评成功", reviewResponse, actionType, "AI点评成功", reviewResponse)
}

View File

@@ -1,7 +1,6 @@
package dto package dto
import ( import (
"encoding/json"
"fmt" "fmt"
"time" "time"
@@ -65,10 +64,21 @@ func NewAreaControllerResponse(ac *models.AreaController) (*AreaControllerRespon
return nil, nil 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 len(ac.Properties) > 0 && string(ac.Properties) != "null" {
if err := json.Unmarshal(ac.Properties, &props); err != nil { // 这里我们使用通用的 ParseProperties 方法
return nil, fmt.Errorf("解析区域主控属性失败 (ID: %d): %w", ac.ID, err) if err := ac.ParseProperties(&allProps); err != nil {
return nil, fmt.Errorf("解析区域主控完整属性失败 (ID: %d): %w", ac.ID, err)
} }
} }
@@ -76,9 +86,10 @@ func NewAreaControllerResponse(ac *models.AreaController) (*AreaControllerRespon
ID: ac.ID, ID: ac.ID,
Name: ac.Name, Name: ac.Name,
NetworkID: ac.NetworkID, NetworkID: ac.NetworkID,
FirmwareVersion: firmwareVersion,
Location: ac.Location, Location: ac.Location,
Status: ac.Status, Status: ac.Status,
Properties: props, Properties: allProps, // 填充完整的 properties
CreatedAt: ac.CreatedAt.Format(time.RFC3339), CreatedAt: ac.CreatedAt.Format(time.RFC3339),
UpdatedAt: ac.UpdatedAt.Format(time.RFC3339), UpdatedAt: ac.UpdatedAt.Format(time.RFC3339),
}, nil }, nil

View File

@@ -81,6 +81,7 @@ type AreaControllerResponse struct {
ID uint32 `json:"id"` ID uint32 `json:"id"`
Name string `json:"name"` Name string `json:"name"`
NetworkID string `json:"network_id"` NetworkID string `json:"network_id"`
FirmwareVersion string `json:"firmware_version"`
Location string `json:"location"` Location string `json:"location"`
Status string `json:"status"` Status string `json:"status"`
Properties map[string]interface{} `json:"properties"` Properties map[string]interface{} `json:"properties"`

View File

@@ -1,5 +1,7 @@
package dto package dto
import "git.huangwc.com/pig/pig-farm-controller/internal/infra/models"
// ============================================================================================================= // =============================================================================================================
// 营养种类 (Nutrient) 相关 DTO // 营养种类 (Nutrient) 相关 DTO
// ============================================================================================================= // =============================================================================================================
@@ -335,3 +337,14 @@ type GenerateRecipeResponse struct {
Name string `json:"name"` // 新生成的配方名称 Name string `json:"name"` // 新生成的配方名称
Description string `json:"description"` // 新生成的配方描述 Description string `json:"description"` // 新生成的配方描述
} }
// ReviewRecipeRequest 定义了点评配方的请求体
type ReviewRecipeRequest struct {
PigTypeID uint32 `json:"pig_type_id" binding:"required"` // 猪类型ID
}
// ReviewRecipeResponse 定义了点评配方的响应体
type ReviewRecipeResponse struct {
ReviewMessage string `json:"review_message"` // 点评内容
AIModel models.AIModel `json:"ai_model"` // 使用的 AI 模型
}

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 ( import (
"encoding/json" "encoding/json"

View File

@@ -1,9 +1,10 @@
package webhook package chirp_stack
import ( import (
"context" "context"
"net/http" "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/logs"
) )
@@ -13,8 +14,8 @@ type PlaceholderListener struct {
} }
// NewPlaceholderListener 创建一个新的 PlaceholderListener 实例 // NewPlaceholderListener 创建一个新的 PlaceholderListener 实例
// 它只打印一条日志, 表明 ChirpStack webhook 未被激活 // 它只打印一条日志, 表明 ChirpStack listener 未被激活
func NewPlaceholderListener(ctx context.Context) ListenHandler { func NewPlaceholderListener(ctx context.Context) listener.ListenHandler {
return &PlaceholderListener{ return &PlaceholderListener{
ctx: ctx, ctx: ctx,
} }

View File

@@ -0,0 +1,300 @@
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_Pong:
return l.handlePong(ctx, sourceAddr, p.Pong)
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,
)
}
}
// 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" import "net/http"
// ListenHandler 是一个监听器, 用于监听设备上行事件 // ListenHandler 是一个监听器, 用于监听设备上行事件, 通常用于适配http webhook。
type ListenHandler interface { type ListenHandler interface {
Handler() http.HandlerFunc Handler() http.HandlerFunc
} }

View File

@@ -53,7 +53,7 @@ type deviceService struct {
deviceRepo repository.DeviceRepository deviceRepo repository.DeviceRepository
areaControllerRepo repository.AreaControllerRepository areaControllerRepo repository.AreaControllerRepository
deviceTemplateRepo repository.DeviceTemplateRepository deviceTemplateRepo repository.DeviceTemplateRepository
deviceDomainSvc device.Service deviceDomainSvc device.DeviceOperator
thresholdAlarmService ThresholdAlarmService thresholdAlarmService ThresholdAlarmService
} }
@@ -63,7 +63,7 @@ func NewDeviceService(
deviceRepo repository.DeviceRepository, deviceRepo repository.DeviceRepository,
areaControllerRepo repository.AreaControllerRepository, areaControllerRepo repository.AreaControllerRepository,
deviceTemplateRepo repository.DeviceTemplateRepository, deviceTemplateRepo repository.DeviceTemplateRepository,
deviceDomainSvc device.Service, deviceDomainSvc device.DeviceOperator,
thresholdAlarmService ThresholdAlarmService, thresholdAlarmService ThresholdAlarmService,
) DeviceService { ) DeviceService {
return &deviceService{ return &deviceService{

View File

@@ -29,6 +29,8 @@ type RecipeService interface {
GenerateRecipeWithAllRawMaterials(ctx context.Context, pigTypeID uint32) (*models.Recipe, error) GenerateRecipeWithAllRawMaterials(ctx context.Context, pigTypeID uint32) (*models.Recipe, error)
// GenerateRecipeWithPrioritizedStockRawMaterials 生成新配方,优先使用有库存的原料 // GenerateRecipeWithPrioritizedStockRawMaterials 生成新配方,优先使用有库存的原料
GenerateRecipeWithPrioritizedStockRawMaterials(ctx context.Context, pigTypeID uint32) (*models.Recipe, error) GenerateRecipeWithPrioritizedStockRawMaterials(ctx context.Context, pigTypeID uint32) (*models.Recipe, error)
// AIDiagnoseRecipe 智能诊断配方, 返回智能诊断结果和使用的AI模型
AIDiagnoseRecipe(ctx context.Context, recipeID uint32, pigTypeID uint32) (*dto.ReviewRecipeResponse, error)
} }
// recipeServiceImpl 是 RecipeService 接口的实现 // recipeServiceImpl 是 RecipeService 接口的实现
@@ -175,3 +177,18 @@ func (s *recipeServiceImpl) ListRecipes(ctx context.Context, req *dto.ListRecipe
return dto.ConvertRecipeListToDTO(recipes, total, req.Page, req.PageSize), nil return dto.ConvertRecipeListToDTO(recipes, total, req.Page, req.PageSize), nil
} }
// AIDiagnoseRecipe 实现智能诊断配方的方法
func (s *recipeServiceImpl) AIDiagnoseRecipe(ctx context.Context, recipeID uint32, pigTypeID uint32) (*dto.ReviewRecipeResponse, error) {
serviceCtx := logs.AddFuncName(ctx, s.ctx, "AIDiagnoseRecipe")
reviewMessage, aiModel, err := s.recipeSvc.AIDiagnoseRecipe(serviceCtx, recipeID, pigTypeID)
if err != nil {
return nil, fmt.Errorf("AI 诊断配方失败: %w", err)
}
return &dto.ReviewRecipeResponse{
ReviewMessage: reviewMessage,
AIModel: aiModel,
}, nil
}

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" "fmt"
"time" "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/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/alarm"
"git.huangwc.com/pig/pig-farm-controller/internal/domain/device" "git.huangwc.com/pig/pig-farm-controller/internal/domain/device"
"git.huangwc.com/pig/pig-farm-controller/internal/domain/inventory" "git.huangwc.com/pig/pig-farm-controller/internal/domain/inventory"
@@ -15,6 +16,7 @@ import (
"git.huangwc.com/pig/pig-farm-controller/internal/domain/plan" "git.huangwc.com/pig/pig-farm-controller/internal/domain/plan"
"git.huangwc.com/pig/pig-farm-controller/internal/domain/recipe" "git.huangwc.com/pig/pig-farm-controller/internal/domain/recipe"
"git.huangwc.com/pig/pig-farm-controller/internal/domain/task" "git.huangwc.com/pig/pig-farm-controller/internal/domain/task"
infra_ai "git.huangwc.com/pig/pig-farm-controller/internal/infra/ai"
"git.huangwc.com/pig/pig-farm-controller/internal/infra/config" "git.huangwc.com/pig/pig-farm-controller/internal/infra/config"
"git.huangwc.com/pig/pig-farm-controller/internal/infra/database" "git.huangwc.com/pig/pig-farm-controller/internal/infra/database"
"git.huangwc.com/pig/pig-farm-controller/internal/infra/logs" "git.huangwc.com/pig/pig-farm-controller/internal/infra/logs"
@@ -34,6 +36,7 @@ type Infrastructure struct {
storage database.Storage storage database.Storage
repos *Repositories repos *Repositories
lora *LoraComponents lora *LoraComponents
ai infra_ai.AI
tokenGenerator token.Generator tokenGenerator token.Generator
} }
@@ -53,10 +56,17 @@ func initInfrastructure(ctx context.Context, cfg *config.Config) (*Infrastructur
tokenGenerator := token.NewTokenGenerator([]byte(cfg.App.JWTSecret)) tokenGenerator := token.NewTokenGenerator([]byte(cfg.App.JWTSecret))
// 初始化 AI
ai, err := initAI(ctx, cfg.AI)
if err != nil {
return nil, fmt.Errorf("初始化 AI 管理器失败: %w", err)
}
return &Infrastructure{ return &Infrastructure{
storage: storage, storage: storage,
repos: repos, repos: repos,
lora: lora, lora: lora,
ai: ai,
tokenGenerator: tokenGenerator, tokenGenerator: tokenGenerator,
}, nil }, nil
} }
@@ -88,6 +98,7 @@ type Repositories struct {
rawMaterialRepo repository.RawMaterialRepository rawMaterialRepo repository.RawMaterialRepository
nutrientRepo repository.NutrientRepository nutrientRepo repository.NutrientRepository
recipeRepo repository.RecipeRepository recipeRepo repository.RecipeRepository
otaRepo repository.OtaRepository
unitOfWork repository.UnitOfWork unitOfWork repository.UnitOfWork
} }
@@ -120,6 +131,7 @@ func initRepositories(ctx context.Context, db *gorm.DB) *Repositories {
rawMaterialRepo: repository.NewGormRawMaterialRepository(logs.AddCompName(baseCtx, "RawMaterialRepo"), db), rawMaterialRepo: repository.NewGormRawMaterialRepository(logs.AddCompName(baseCtx, "RawMaterialRepo"), db),
nutrientRepo: repository.NewGormNutrientRepository(logs.AddCompName(baseCtx, "NutrientRepo"), db), nutrientRepo: repository.NewGormNutrientRepository(logs.AddCompName(baseCtx, "NutrientRepo"), db),
recipeRepo: repository.NewGormRecipeRepository(logs.AddCompName(baseCtx, "RecipeRepo"), db), recipeRepo: repository.NewGormRecipeRepository(logs.AddCompName(baseCtx, "RecipeRepo"), db),
otaRepo: repository.NewGormOtaRepository(logs.AddCompName(baseCtx, "OtaRepo"), db),
unitOfWork: repository.NewGormUnitOfWork(logs.AddCompName(baseCtx, "UnitOfWork"), db), unitOfWork: repository.NewGormUnitOfWork(logs.AddCompName(baseCtx, "UnitOfWork"), db),
} }
} }
@@ -130,7 +142,8 @@ type DomainServices struct {
pigTradeManager pig.PigTradeManager pigTradeManager pig.PigTradeManager
pigSickManager pig.SickPigManager pigSickManager pig.SickPigManager
pigBatchDomain pig.PigBatchService pigBatchDomain pig.PigBatchService
generalDeviceService device.Service deviceOperator device.DeviceOperator
deviceCommunicator device.DeviceCommunicator
taskFactory plan.TaskFactory taskFactory plan.TaskFactory
planExecutionManager plan.ExecutionManager planExecutionManager plan.ExecutionManager
analysisPlanTaskManager plan.AnalysisPlanTaskManager analysisPlanTaskManager plan.AnalysisPlanTaskManager
@@ -170,6 +183,7 @@ func initDomainServices(ctx context.Context, cfg *config.Config, infra *Infrastr
generalDeviceService := device.NewGeneralDeviceService( generalDeviceService := device.NewGeneralDeviceService(
logs.AddCompName(baseCtx, "GeneralDeviceService"), logs.AddCompName(baseCtx, "GeneralDeviceService"),
infra.repos.deviceRepo, infra.repos.deviceRepo,
infra.repos.areaControllerRepo,
infra.repos.deviceCommandLogRepo, infra.repos.deviceCommandLogRepo,
infra.repos.pendingCollectionRepo, infra.repos.pendingCollectionRepo,
infra.lora.comm, infra.lora.comm,
@@ -187,6 +201,9 @@ func initDomainServices(ctx context.Context, cfg *config.Config, infra *Infrastr
infra.repos.sensorDataRepo, infra.repos.sensorDataRepo,
infra.repos.deviceRepo, infra.repos.deviceRepo,
infra.repos.alarmRepo, infra.repos.alarmRepo,
infra.repos.areaControllerRepo,
infra.repos.otaRepo,
generalDeviceService,
generalDeviceService, generalDeviceService,
notifyService, notifyService,
alarmService, alarmService,
@@ -205,7 +222,6 @@ func initDomainServices(ctx context.Context, cfg *config.Config, infra *Infrastr
infra.repos.planRepo, infra.repos.planRepo,
analysisPlanTaskManager, analysisPlanTaskManager,
taskFactory, taskFactory,
generalDeviceService,
time.Duration(cfg.Task.Interval)*time.Second, time.Duration(cfg.Task.Interval)*time.Second,
cfg.Task.NumWorkers, cfg.Task.NumWorkers,
) )
@@ -238,6 +254,8 @@ func initDomainServices(ctx context.Context, cfg *config.Config, infra *Infrastr
pigTypeService, pigTypeService,
recipeCoreService, recipeCoreService,
recipeGenerateManager, recipeGenerateManager,
infra.repos.recipeRepo,
infra.ai,
) )
return &DomainServices{ return &DomainServices{
@@ -245,7 +263,8 @@ func initDomainServices(ctx context.Context, cfg *config.Config, infra *Infrastr
pigTradeManager: pigTradeManager, pigTradeManager: pigTradeManager,
pigSickManager: pigSickManager, pigSickManager: pigSickManager,
pigBatchDomain: pigBatchDomain, pigBatchDomain: pigBatchDomain,
generalDeviceService: generalDeviceService, deviceOperator: generalDeviceService,
deviceCommunicator: generalDeviceService,
analysisPlanTaskManager: analysisPlanTaskManager, analysisPlanTaskManager: analysisPlanTaskManager,
taskFactory: taskFactory, taskFactory: taskFactory,
planExecutionManager: planExecutionManager, planExecutionManager: planExecutionManager,
@@ -314,7 +333,7 @@ func initAppServices(ctx context.Context, infra *Infrastructure, domainServices
infra.repos.deviceRepo, infra.repos.deviceRepo,
infra.repos.areaControllerRepo, infra.repos.areaControllerRepo,
infra.repos.deviceTemplateRepo, infra.repos.deviceTemplateRepo,
domainServices.generalDeviceService, domainServices.deviceOperator,
thresholdAlarmService, thresholdAlarmService,
) )
@@ -350,7 +369,7 @@ func initAppServices(ctx context.Context, infra *Infrastructure, domainServices
// LoraComponents 聚合了所有 LoRa 相关组件。 // LoraComponents 聚合了所有 LoRa 相关组件。
type LoraComponents struct { type LoraComponents struct {
listenHandler webhook.ListenHandler listenHandler listener.ListenHandler
comm transport.Communicator comm transport.Communicator
loraListener transport.Listener loraListener transport.Listener
} }
@@ -361,21 +380,44 @@ func initLora(
cfg *config.Config, cfg *config.Config,
repos *Repositories, repos *Repositories,
) (*LoraComponents, error) { ) (*LoraComponents, error) {
var listenHandler webhook.ListenHandler var listenHandler listener.ListenHandler
var comm transport.Communicator var comm transport.Communicator
var loraListener transport.Listener var loraListener transport.Listener
baseCtx := context.Background() baseCtx := context.Background()
logger := logs.GetLogger(ctx) 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 { if cfg.Lora.Mode == config.LoraMode_LoRaWAN {
logger.Info("当前运行模式: lora_wan。初始化 ChirpStack 监听器和传输层。") 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) comm = lora.NewChirpStackTransport(logs.AddCompName(baseCtx, "ChirpStackTransport"), cfg.ChirpStack)
// 2c. LoRaWAN 模式下没有主动监听的 Listener使用占位符
loraListener = lora.NewPlaceholderTransport(logs.AddCompName(baseCtx, "PlaceholderTransport")) loraListener = lora.NewPlaceholderTransport(logs.AddCompName(baseCtx, "PlaceholderTransport"))
} else { } else {
logger.Info("当前运行模式: lora_mesh。初始化 LoRa Mesh 传输层和占位符监听器。") 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 { if err != nil {
return nil, fmt.Errorf("无法初始化 LoRa Mesh 模块: %w", err) return nil, fmt.Errorf("无法初始化 LoRa Mesh 模块: %w", err)
} }
@@ -508,3 +550,12 @@ func initStorage(ctx context.Context, cfg config.DatabaseConfig) (database.Stora
logs.GetLogger(ctx).Info("数据库初始化完成。") logs.GetLogger(ctx).Info("数据库初始化完成。")
return storage, nil return storage, nil
} }
func initAI(ctx context.Context, cfg config.AIConfig) (infra_ai.AI, error) {
switch cfg.Model {
case models.AI_MODEL_GEMINI:
return infra_ai.NewGeminiAI(ctx, cfg.Gemini)
default:
return infra_ai.NewNoneAI(ctx), nil
}
}

View File

@@ -2,6 +2,7 @@ package core
import ( import (
"context" "context"
"encoding/json"
"fmt" "fmt"
"path/filepath" "path/filepath"
@@ -83,6 +84,14 @@ func (app *Application) initializeSystemPlans(ctx context.Context) error {
return err return err
} }
if err := app.initializeHeartbeatCheckPlan(appCtx, existingPlanMap); err != nil {
return err
}
if err := app.initializeOtaCheckPlan(appCtx, existingPlanMap); err != nil {
return err
}
logger.Info("预定义系统计划检查完成。") logger.Info("预定义系统计划检查完成。")
return nil return nil
} }
@@ -244,6 +253,57 @@ func (app *Application) initializeAlarmNotificationPlan(ctx context.Context, exi
return nil 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")
cron := 5
predefinedPlan := &models.Plan{
Name: models.PlanNamePeriodicHeartbeatCheck,
Description: fmt.Sprintf("这是一个系统预定义的计划, 每%d分钟自动触发一次区域主控心跳检测。", cron),
PlanType: models.PlanTypeSystem,
ExecutionType: models.PlanExecutionTypeAutomatic,
CronExpression: fmt.Sprintf("*/%d * * * *", cron),
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 在应用启动时处理所有未完成的采集请求。 // initializePendingCollections 在应用启动时处理所有未完成的采集请求。
// 我们的策略是:任何在程序重启前仍处于“待处理”状态的请求,都应被视为已失败。 // 我们的策略是:任何在程序重启前仍处于“待处理”状态的请求,都应被视为已失败。
// 这保证了系统在每次启动时都处于一个干净、确定的状态。 // 这保证了系统在每次启动时都处于一个干净、确定的状态。
@@ -364,3 +424,72 @@ func (app *Application) cleanupStaleTasksAndLogs(ctx context.Context) error {
logger.Info("过期的任务及日志清理完成。") logger.Info("过期的任务及日志清理完成。")
return nil return nil
} }
// initializeOtaCheckPlan 负责初始化 "定时检查OTA升级超时" 计划。
func (app *Application) initializeOtaCheckPlan(ctx context.Context, existingPlanMap map[models.PlanName]*models.Plan) error {
appCtx, logger := logs.Trace(ctx, app.Ctx, "initializeOtaCheckPlan")
// 1. 从应用配置中获取超时时间,并提供一个安全默认值
timeout := app.Config.OTA.DefaultTimeoutSeconds
if timeout <= 0 {
timeout = 300 // 如果配置不合法,则使用默认值 300 秒
}
// 2. 定义任务参数并序列化
params := task.OtaCheckTaskParams{
TimeoutSeconds: timeout,
}
paramsJSON, err := json.Marshal(params)
if err != nil {
return fmt.Errorf("序列化OTA检查任务参数失败: %w", err)
}
// 3. 构建预定义的计划对象
cron := 10
predefinedPlan := &models.Plan{
Name: models.PlanNameOtaCheck,
Description: fmt.Sprintf("每%d分钟执行一次扫描所有正在进行的OTA升级任务并将超时的任务标记为失败。当前超时时间设置为 %d 秒。", cron, timeout),
PlanType: models.PlanTypeSystem,
ExecutionType: models.PlanExecutionTypeAutomatic,
CronExpression: fmt.Sprintf("*/%d * * * *", cron),
Status: models.PlanStatusEnabled,
ContentType: models.PlanContentTypeTasks,
Tasks: []models.Task{
{
Name: "OTA升级检查",
Description: "扫描并处理超时的OTA升级任务",
ExecutionOrder: 1,
Type: models.TaskTypeOTACheck,
Parameters: paramsJSON,
},
},
}
// 4. 检查计划是否存在,并执行创建或更新操作
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
}

View File

@@ -4,6 +4,7 @@ import (
"context" "context"
"git.huangwc.com/pig/pig-farm-controller/internal/infra/models" "git.huangwc.com/pig/pig-farm-controller/internal/infra/models"
"git.huangwc.com/pig/pig-farm-controller/internal/infra/transport/proto"
) )
// 设备行为 // 设备行为
@@ -21,16 +22,44 @@ var (
MethodSwitch Method = "switch" // 启停指令 MethodSwitch Method = "switch" // 启停指令
) )
// Service 抽象了一组方法用于控制设备行为 // SendOptions 包含了发送通用指令时的可选参数。
type Service interface { type SendOptions struct {
// NotTrackable 如果为 true则指示本次发送无需被追踪。
// 这将阻止系统为本次发送创建 device_command_logs 记录。
// 默认为 false即需要追踪。
NotTrackable bool
}
// Switch 用于切换指定设备的状态, 比如启动和停止 // SendOption 是一个函数类型,用于修改 SendOptions。
// 这是实现 "Functional Options Pattern" 的核心。
type SendOption func(*SendOptions)
// WithoutTracking 是一个公开的选项函数,用于明确指示本次发送无需追踪。
// 调用方在发送 Ping 等无需响应确认的指令时,应使用此选项。
func WithoutTracking() SendOption {
return func(opts *SendOptions) {
opts.NotTrackable = true
}
}
// DeviceOperator 提供了对单个或多个设备进行具体操作的接口,
// 如开关、触发采集等。它通常用于响应用户的直接指令或执行具体的业务任务。
type DeviceOperator interface {
// Switch 用于切换指定设备的状态, 比如启动和停止。
Switch(ctx context.Context, device *models.Device, action DeviceAction) error Switch(ctx context.Context, device *models.Device, action DeviceAction) error
// Collect 用于发起对指定区域主控下的多个设备的批量采集请求。 // Collect 用于发起对指定区域主控下的多个设备的批量采集请求。
Collect(ctx context.Context, areaControllerID uint32, devicesToCollect []*models.Device) error Collect(ctx context.Context, areaControllerID uint32, devicesToCollect []*models.Device) error
} }
// DeviceCommunicator 抽象了与设备进行底层通信的能力。
// 它负责将一个标准的指令载荷发送到指定的区域主控。
type DeviceCommunicator interface {
// Send 是一个通用的发送方法,它负责将载荷包装、序列化、
// 调用底层发送器,并默认记录下行命令日志。
Send(ctx context.Context, areaControllerID uint32, payload proto.InstructionPayload, opts ...SendOption) error
}
// 设备操作指令通用结构(最外层) // 设备操作指令通用结构(最外层)
type DeviceRequest struct { type DeviceRequest struct {
MessageID int // 消息ID, 用于后续匹配响应 MessageID int // 消息ID, 用于后续匹配响应

View File

@@ -20,6 +20,7 @@ import (
type GeneralDeviceService struct { type GeneralDeviceService struct {
ctx context.Context ctx context.Context
deviceRepo repository.DeviceRepository deviceRepo repository.DeviceRepository
areaControllerRepo repository.AreaControllerRepository
deviceCommandLogRepo repository.DeviceCommandLogRepository deviceCommandLogRepo repository.DeviceCommandLogRepository
pendingCollectionRepo repository.PendingCollectionRepository pendingCollectionRepo repository.PendingCollectionRepository
comm transport.Communicator comm transport.Communicator
@@ -29,13 +30,15 @@ type GeneralDeviceService struct {
func NewGeneralDeviceService( func NewGeneralDeviceService(
ctx context.Context, ctx context.Context,
deviceRepo repository.DeviceRepository, deviceRepo repository.DeviceRepository,
areaControllerRepo repository.AreaControllerRepository,
deviceCommandLogRepo repository.DeviceCommandLogRepository, deviceCommandLogRepo repository.DeviceCommandLogRepository,
pendingCollectionRepo repository.PendingCollectionRepository, pendingCollectionRepo repository.PendingCollectionRepository,
comm transport.Communicator, comm transport.Communicator,
) Service { ) *GeneralDeviceService {
return &GeneralDeviceService{ return &GeneralDeviceService{
ctx: ctx, ctx: ctx,
deviceRepo: deviceRepo, deviceRepo: deviceRepo,
areaControllerRepo: areaControllerRepo,
deviceCommandLogRepo: deviceCommandLogRepo, deviceCommandLogRepo: deviceCommandLogRepo,
pendingCollectionRepo: pendingCollectionRepo, pendingCollectionRepo: pendingCollectionRepo,
comm: comm, comm: comm,
@@ -249,3 +252,70 @@ func (g *GeneralDeviceService) Collect(ctx context.Context, areaControllerID uin
logger.Debugf("成功将采集请求 (CorrelationID: %s) 发送到设备 %s", correlationID, networkID) logger.Debugf("成功将采集请求 (CorrelationID: %s) 发送到设备 %s", correlationID, networkID)
return nil 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

@@ -6,7 +6,6 @@ import (
"sync" "sync"
"time" "time"
"git.huangwc.com/pig/pig-farm-controller/internal/domain/device"
"git.huangwc.com/pig/pig-farm-controller/internal/infra/logs" "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/models"
"git.huangwc.com/pig/pig-farm-controller/internal/infra/repository" "git.huangwc.com/pig/pig-farm-controller/internal/infra/repository"
@@ -95,7 +94,6 @@ type planExecutionManagerImpl struct {
taskFactory TaskFactory taskFactory TaskFactory
analysisPlanTaskManager AnalysisPlanTaskManager analysisPlanTaskManager AnalysisPlanTaskManager
progressTracker *ProgressTracker progressTracker *ProgressTracker
deviceService device.Service
pool *ants.Pool // 使用 ants 协程池来管理并发 pool *ants.Pool // 使用 ants 协程池来管理并发
wg sync.WaitGroup wg sync.WaitGroup
@@ -112,7 +110,6 @@ func NewPlanExecutionManager(
planRepo repository.PlanRepository, planRepo repository.PlanRepository,
analysisPlanTaskManager AnalysisPlanTaskManager, analysisPlanTaskManager AnalysisPlanTaskManager,
taskFactory TaskFactory, taskFactory TaskFactory,
deviceService device.Service,
interval time.Duration, interval time.Duration,
numWorkers int, numWorkers int,
) ExecutionManager { ) ExecutionManager {
@@ -125,7 +122,6 @@ func NewPlanExecutionManager(
planRepo: planRepo, planRepo: planRepo,
analysisPlanTaskManager: analysisPlanTaskManager, analysisPlanTaskManager: analysisPlanTaskManager,
taskFactory: taskFactory, taskFactory: taskFactory,
deviceService: deviceService,
pollingInterval: interval, pollingInterval: interval,
workers: numWorkers, workers: numWorkers,
progressTracker: NewProgressTracker(), progressTracker: NewProgressTracker(),

View File

@@ -2,8 +2,11 @@ package recipe
import ( import (
"context" "context"
"encoding/json"
"fmt" "fmt"
"strings"
"git.huangwc.com/pig/pig-farm-controller/internal/infra/ai"
"git.huangwc.com/pig/pig-farm-controller/internal/infra/logs" "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/models"
"git.huangwc.com/pig/pig-farm-controller/internal/infra/repository" "git.huangwc.com/pig/pig-farm-controller/internal/infra/repository"
@@ -22,6 +25,8 @@ type Service interface {
GenerateRecipeWithAllRawMaterials(ctx context.Context, pigTypeID uint32) (*models.Recipe, error) GenerateRecipeWithAllRawMaterials(ctx context.Context, pigTypeID uint32) (*models.Recipe, error)
// GenerateRecipeWithPrioritizedStockRawMaterials 生成新配方,优先使用有库存的原料 // GenerateRecipeWithPrioritizedStockRawMaterials 生成新配方,优先使用有库存的原料
GenerateRecipeWithPrioritizedStockRawMaterials(ctx context.Context, pigTypeID uint32) (*models.Recipe, error) GenerateRecipeWithPrioritizedStockRawMaterials(ctx context.Context, pigTypeID uint32) (*models.Recipe, error)
// AIDiagnoseRecipe 智能诊断配方, 返回智能诊断结果
AIDiagnoseRecipe(ctx context.Context, recipeID uint32, pigTypeID uint32) (string, models.AIModel, error)
} }
// recipeServiceImpl 是 Service 的实现,通过组合各个子服务来实现 // recipeServiceImpl 是 Service 的实现,通过组合各个子服务来实现
@@ -34,6 +39,9 @@ type recipeServiceImpl struct {
PigTypeService PigTypeService
RecipeCoreService RecipeCoreService
RecipeGenerateManager RecipeGenerateManager
recipeRepo repository.RecipeRepository
ai ai.AI
} }
// NewRecipeService 创建一个新的 Service 实例 // NewRecipeService 创建一个新的 Service 实例
@@ -46,6 +54,8 @@ func NewRecipeService(
pigTypeService PigTypeService, pigTypeService PigTypeService,
recipeCoreService RecipeCoreService, recipeCoreService RecipeCoreService,
recipeGenerateManager RecipeGenerateManager, recipeGenerateManager RecipeGenerateManager,
recipeRepo repository.RecipeRepository,
ai ai.AI,
) Service { ) Service {
return &recipeServiceImpl{ return &recipeServiceImpl{
ctx: ctx, ctx: ctx,
@@ -56,6 +66,8 @@ func NewRecipeService(
PigTypeService: pigTypeService, PigTypeService: pigTypeService,
RecipeCoreService: recipeCoreService, RecipeCoreService: recipeCoreService,
RecipeGenerateManager: recipeGenerateManager, RecipeGenerateManager: recipeGenerateManager,
recipeRepo: recipeRepo,
ai: ai,
} }
} }
@@ -225,3 +237,113 @@ func (r *recipeServiceImpl) GenerateRecipeWithPrioritizedStockRawMaterials(ctx c
// 7. 返回创建的配方 // 7. 返回创建的配方
return recipe, nil return recipe, nil
} }
// AIDiagnoseRecipe 使用 AI 为指定食谱生成诊断。
func (s *recipeServiceImpl) AIDiagnoseRecipe(ctx context.Context, recipeID uint32, pigTypeID uint32) (string, models.AIModel, error) {
serviceCtx, logger := logs.Trace(ctx, context.Background(), "AIDiagnoseRecipe")
// 1. 根据 recipeID 获取配方详情
recipe, err := s.recipeRepo.GetRecipeByID(serviceCtx, recipeID)
if err != nil {
logger.Errorf("获取配方详情失败: %v", err)
return "", s.ai.AIModel(), fmt.Errorf("获取配方详情失败: %w", err)
}
if recipe == nil {
logger.Warnf("未找到配方ID: %d", recipeID)
return "", s.ai.AIModel(), fmt.Errorf("未找到配方ID: %d", recipeID)
}
// 2. 获取目标猪只类型信息
pigType, err := s.GetPigTypeByID(serviceCtx, pigTypeID)
if err != nil {
logger.Errorf("获取猪只类型信息失败: %v", err)
return "", s.ai.AIModel(), fmt.Errorf("获取猪只类型信息失败: %w", err)
}
if pigType == nil {
logger.Warnf("未找到猪只类型ID: %d", pigTypeID)
return "", s.ai.AIModel(), fmt.Errorf("未找到猪只类型ID: %d", pigTypeID)
}
// 3. 定义 AI 输入结构体
type ingredientNutrient struct {
NutrientName string `json:"nutrient_name"`
Value float32 `json:"value"`
}
type recipeIngredient struct {
RawMaterialName string `json:"raw_material_name"`
Percentage float32 `json:"percentage"`
Nutrients []ingredientNutrient `json:"nutrients"`
}
type aiDiagnosisInput struct {
RecipeName string `json:"recipe_name"`
TargetPigType struct {
Name string `json:"name"`
} `json:"target_pig_type"`
Ingredients []recipeIngredient `json:"ingredients"`
}
// 4. 填充 AI 输入结构体
input := aiDiagnosisInput{
RecipeName: recipe.Name,
}
input.TargetPigType.Name = fmt.Sprintf("%s-%s", pigType.Breed.Name, pigType.AgeStage.Name)
for _, ingredient := range recipe.RecipeIngredients {
if ingredient.RawMaterial.ID == 0 {
logger.Warnf("配方成分中存在未加载的原料信息RecipeIngredientID: %d", ingredient.ID)
continue
}
ing := recipeIngredient{
RawMaterialName: ingredient.RawMaterial.Name,
Percentage: ingredient.Percentage,
}
for _, rmn := range ingredient.RawMaterial.RawMaterialNutrients {
if rmn.Nutrient.ID == 0 {
logger.Warnf("原料营养成分中存在未加载的营养素信息RawMaterialNutrientID: %d", rmn.ID)
continue
}
ing.Nutrients = append(ing.Nutrients, ingredientNutrient{
NutrientName: rmn.Nutrient.Name,
Value: rmn.Value,
})
}
input.Ingredients = append(input.Ingredients, ing)
}
// 5. 序列化为 JSON 字符串
jsonBytes, err := json.Marshal(input)
if err != nil {
logger.Errorf("序列化配方和猪只类型信息为 JSON 失败: %v", err)
return "", s.ai.AIModel(), fmt.Errorf("序列化数据失败: %w", err)
}
jsonString := string(jsonBytes)
// 6. 构建 AI Prompt
var promptBuilder strings.Builder
promptBuilder.WriteString(`
你是一个专业的动物营养师。请根据以下猪饲料配方数据,生成一份详细的、对养殖户友好的说明报告。
说明报告应包括以下部分:
1. 诊断猪只配方是否合理,如合理需要说明为什么合理, 如不合理需给出详细的改进建议。
2. 关键成分分析:分析主要原料和营养成分的作用
3. 使用建议:提供使用此配方的最佳实践和注意事项。
\n`)
promptBuilder.WriteString("```")
promptBuilder.WriteString(jsonString)
promptBuilder.WriteString("```")
prompt := promptBuilder.String()
logger.Debugf("生成的 AI 诊断 Prompt: \n%s", prompt)
// 7. 调用 AI Manager 进行诊断
diagnosisResult, err := s.ai.GenerateReview(serviceCtx, prompt)
if err != nil {
logger.Errorf("调用 AI Manager 诊断配方失败: %v", err)
return "", s.ai.AIModel(), fmt.Errorf("AI 诊断失败: %w", err)
}
logger.Infof("成功对配方 ID: %d (目标猪只类型 ID: %d) 进行 AI 诊断。", recipeID, pigTypeID)
return diagnosisResult, s.ai.AIModel(), nil
}

View File

@@ -16,7 +16,7 @@ type FullCollectionTask struct {
ctx context.Context ctx context.Context
log *models.TaskExecutionLog log *models.TaskExecutionLog
deviceRepo repository.DeviceRepository deviceRepo repository.DeviceRepository
deviceService device.Service deviceService device.DeviceOperator
} }
// NewFullCollectionTask 创建一个全量采集任务实例 // NewFullCollectionTask 创建一个全量采集任务实例
@@ -24,7 +24,7 @@ func NewFullCollectionTask(
ctx context.Context, ctx context.Context,
log *models.TaskExecutionLog, log *models.TaskExecutionLog,
deviceRepo repository.DeviceRepository, deviceRepo repository.DeviceRepository,
deviceService device.Service, deviceService device.DeviceOperator,
) plan.Task { ) plan.Task {
return &FullCollectionTask{ return &FullCollectionTask{
ctx: ctx, ctx: ctx,

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.DeviceCommunicator
}
// NewHeartbeatTask 创建一个心跳检测任务实例
func NewHeartbeatTask(
ctx context.Context,
log *models.TaskExecutionLog,
areaControllerRepo repository.AreaControllerRepository,
deviceService device.DeviceCommunicator,
) 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

@@ -0,0 +1,141 @@
package task
import (
"context"
"fmt"
"sync"
"time"
"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"
)
// OtaCheckTaskParams 定义了 OTA 检查任务所需的参数。
// 这些参数从任务的 Parameters JSON 字段中解析而来。
type OtaCheckTaskParams struct {
// TimeoutSeconds 定义了任务的全局超时时间(秒)。
// 如果一个升级任务在此时间内没有完成,将被标记为超时。
TimeoutSeconds int `json:"timeout_seconds"`
}
// otaCheckTask 实现了扫描和处理超时 OTA 升级任务的逻辑。
type otaCheckTask struct {
ctx context.Context
onceParse sync.Once
taskLog *models.TaskExecutionLog
params OtaCheckTaskParams
otaRepo repository.OtaRepository
}
// NewOtaCheckTask 创建一个新的 otaCheckTask 实例。
func NewOtaCheckTask(
ctx context.Context,
taskLog *models.TaskExecutionLog,
otaRepo repository.OtaRepository,
) plan.Task {
return &otaCheckTask{
ctx: ctx,
taskLog: taskLog,
otaRepo: otaRepo,
}
}
// Execute 是任务的核心执行逻辑。
func (t *otaCheckTask) Execute(ctx context.Context) error {
taskCtx, logger := logs.Trace(ctx, t.ctx, "Execute")
// 1. 解析并验证任务参数
if err := t.parseParameters(taskCtx); err != nil {
return err
}
logger.Infof("开始执行OTA升级超时检查任务超时设置为 %d 秒...", t.params.TimeoutSeconds)
timeoutDuration := time.Duration(t.params.TimeoutSeconds) * time.Second
timeoutBefore := time.Now().Add(-timeoutDuration)
// 2. 定义需要检查的状态
inProgressStatuses := []models.OTATaskStatus{
models.OTATaskStatusInProgress,
}
// 3. 查找所有超时的、仍在进行中的任务
tasks, err := t.otaRepo.FindTasksByStatusesAndCreationTime(taskCtx, inProgressStatuses, timeoutBefore)
if err != nil {
logger.Errorf("查找超时的OTA升级任务失败: %v", err)
return fmt.Errorf("查找超时的OTA升级任务失败: %w", err)
}
if len(tasks) == 0 {
logger.Info("没有发现超时的OTA升级任务。")
return nil
}
logger.Infof("发现 %d 个超时的OTA升级任务正在逐一处理...", len(tasks))
message := fmt.Sprintf("任务因超过全局超时时间(%d秒)未完成而被系统自动标记为超时。", t.params.TimeoutSeconds)
// 4. 逐一更新任务状态
for _, task := range tasks {
logger.Warnf("正在处理超时的OTA升级任务: ID=%d, 区域主控ID=%d, 目标版本=%s, 创建于=%v",
task.ID, task.AreaControllerID, task.TargetVersion, task.CreatedAt)
task.Status = models.OTATaskStatusTimedOut
task.ErrorMessage = message
completedTime := time.Now()
task.CompletedAt = &completedTime
if err := t.otaRepo.Update(taskCtx, task); err != nil {
// 仅记录错误,不中断整个检查任务,以确保其他超时任务能被处理
logger.Errorf("更新超时的OTA任务 #%d 状态失败: %v", task.ID, err)
}
}
logger.Infof("成功处理了 %d 个超时的OTA升级任务。", len(tasks))
return nil
}
// parseParameters 使用 sync.Once 确保任务参数只被解析一次。
func (t *otaCheckTask) parseParameters(ctx context.Context) error {
logger := logs.TraceLogger(ctx, t.ctx, "parseParameters")
var err error
t.onceParse.Do(func() {
if t.taskLog.Task.Parameters == nil {
err = fmt.Errorf("任务 %d: 缺少参数", t.taskLog.TaskID)
logger.Error(err.Error())
return
}
var params OtaCheckTaskParams
if pErr := t.taskLog.Task.ParseParameters(&params); pErr != nil {
err = fmt.Errorf("任务 %d: 解析参数失败: %w", t.taskLog.TaskID, pErr)
logger.Error(err.Error())
return
}
// 验证参数
if params.TimeoutSeconds <= 0 {
err = fmt.Errorf("任务 %d: 参数 'timeout_seconds' 必须是一个正整数", t.taskLog.TaskID)
logger.Error(err.Error())
return
}
t.params = params
})
return err
}
// OnFailure 定义了当 Execute 方法返回错误时的回滚或清理逻辑。
func (t *otaCheckTask) OnFailure(ctx context.Context, executeErr error) {
logger := logs.TraceLogger(ctx, t.ctx, "OnFailure")
logger.Errorf("OTA升级超时检查任务执行失败, 任务ID: %d: %v", t.taskLog.TaskID, executeErr)
}
// ResolveDeviceIDs 从任务配置中解析并返回所有关联的设备ID列表。
func (t *otaCheckTask) ResolveDeviceIDs(ctx context.Context) ([]uint32, error) {
// 这是一个系统级的任务,不与任何特定设备直接关联。
return []uint32{}, nil
}

View File

@@ -32,7 +32,7 @@ type ReleaseFeedWeightTask struct {
releaseWeight float32 releaseWeight float32
mixingTankDeviceID uint32 mixingTankDeviceID uint32
feedPort device.Service feedPort device.DeviceOperator
// onceParse 保证解析参数只执行一次 // onceParse 保证解析参数只执行一次
onceParse sync.Once onceParse sync.Once
@@ -44,7 +44,7 @@ func NewReleaseFeedWeightTask(
claimedLog *models.TaskExecutionLog, claimedLog *models.TaskExecutionLog,
sensorDataRepo repository.SensorDataRepository, sensorDataRepo repository.SensorDataRepository,
deviceRepo repository.DeviceRepository, deviceRepo repository.DeviceRepository,
deviceService device.Service, deviceService device.DeviceOperator,
) plan.Task { ) plan.Task {
return &ReleaseFeedWeightTask{ return &ReleaseFeedWeightTask{
ctx: ctx, ctx: ctx,

View File

@@ -18,6 +18,11 @@ const (
CompNameReleaseFeedWeight = "ReleaseFeedWeightTask" CompNameReleaseFeedWeight = "ReleaseFeedWeightTask"
CompNameFullCollectionTask = "FullCollectionTask" CompNameFullCollectionTask = "FullCollectionTask"
CompNameAlarmNotification = "AlarmNotificationTask" CompNameAlarmNotification = "AlarmNotificationTask"
CompNameHeartbeatTask = "HeartbeatTask"
CompNameOtaCheck = "OtaCheckTask"
CompNameDeviceThresholdCheck = "DeviceThresholdCheckTask"
CompNameAreaCollectorThresholdCheck = "AreaCollectorThresholdCheckTask"
CompNameNotificationRefresh = "NotificationRefreshTask"
) )
type taskFactory struct { type taskFactory struct {
@@ -26,8 +31,11 @@ type taskFactory struct {
sensorDataRepo repository.SensorDataRepository sensorDataRepo repository.SensorDataRepository
deviceRepo repository.DeviceRepository deviceRepo repository.DeviceRepository
alarmRepo repository.AlarmRepository alarmRepo repository.AlarmRepository
areaControllerRepo repository.AreaControllerRepository
otaRepo repository.OtaRepository
deviceService device.Service deviceOperator device.DeviceOperator
deviceCommunicator device.DeviceCommunicator
notificationService notify.Service notificationService notify.Service
alarmService alarm.AlarmService alarmService alarm.AlarmService
} }
@@ -37,7 +45,10 @@ func NewTaskFactory(
sensorDataRepo repository.SensorDataRepository, sensorDataRepo repository.SensorDataRepository,
deviceRepo repository.DeviceRepository, deviceRepo repository.DeviceRepository,
alarmRepo repository.AlarmRepository, alarmRepo repository.AlarmRepository,
deviceService device.Service, areaControllerRepo repository.AreaControllerRepository,
otaRepo repository.OtaRepository,
deviceOperator device.DeviceOperator,
deviceCommunicator device.DeviceCommunicator,
notifyService notify.Service, notifyService notify.Service,
alarmService alarm.AlarmService, alarmService alarm.AlarmService,
) plan.TaskFactory { ) plan.TaskFactory {
@@ -46,7 +57,10 @@ func NewTaskFactory(
sensorDataRepo: sensorDataRepo, sensorDataRepo: sensorDataRepo,
deviceRepo: deviceRepo, deviceRepo: deviceRepo,
alarmRepo: alarmRepo, alarmRepo: alarmRepo,
deviceService: deviceService, areaControllerRepo: areaControllerRepo,
otaRepo: otaRepo,
deviceOperator: deviceOperator,
deviceCommunicator: deviceCommunicator,
notificationService: notifyService, notificationService: notifyService,
alarmService: alarmService, alarmService: alarmService,
} }
@@ -59,19 +73,22 @@ func (t *taskFactory) Production(ctx context.Context, claimedLog *models.TaskExe
case models.TaskTypeWaiting: case models.TaskTypeWaiting:
return NewDelayTask(logs.AddCompName(baseCtx, CompNameDelayTask), claimedLog) return NewDelayTask(logs.AddCompName(baseCtx, CompNameDelayTask), claimedLog)
case models.TaskTypeReleaseFeedWeight: case models.TaskTypeReleaseFeedWeight:
return NewReleaseFeedWeightTask(logs.AddCompName(baseCtx, CompNameReleaseFeedWeight), claimedLog, t.sensorDataRepo, t.deviceRepo, t.deviceService) return NewReleaseFeedWeightTask(logs.AddCompName(baseCtx, CompNameReleaseFeedWeight), claimedLog, t.sensorDataRepo, t.deviceRepo, t.deviceOperator)
case models.TaskTypeFullCollection: case models.TaskTypeFullCollection:
return NewFullCollectionTask(logs.AddCompName(baseCtx, CompNameFullCollectionTask), claimedLog, t.deviceRepo, t.deviceService) return NewFullCollectionTask(logs.AddCompName(baseCtx, CompNameFullCollectionTask), claimedLog, t.deviceRepo, t.deviceOperator)
case models.TaskTypeHeartbeat:
return NewHeartbeatTask(logs.AddCompName(baseCtx, CompNameHeartbeatTask), claimedLog, t.areaControllerRepo, t.deviceCommunicator)
case models.TaskTypeAlarmNotification: case models.TaskTypeAlarmNotification:
return NewAlarmNotificationTask(logs.AddCompName(baseCtx, CompNameAlarmNotification), claimedLog, t.notificationService, t.alarmRepo) return NewAlarmNotificationTask(logs.AddCompName(baseCtx, CompNameAlarmNotification), claimedLog, t.notificationService, t.alarmRepo)
case models.TaskTypeDeviceThresholdCheck: case models.TaskTypeDeviceThresholdCheck:
return NewDeviceThresholdCheckTask(logs.AddCompName(baseCtx, "DeviceThresholdCheckTask"), claimedLog, t.sensorDataRepo, t.alarmService) return NewDeviceThresholdCheckTask(logs.AddCompName(baseCtx, CompNameDeviceThresholdCheck), claimedLog, t.sensorDataRepo, t.alarmService)
case models.TaskTypeAreaCollectorThresholdCheck: case models.TaskTypeAreaCollectorThresholdCheck:
return NewAreaThresholdCheckTask(logs.AddCompName(baseCtx, "AreaCollectorThresholdCheckTask"), claimedLog, t.sensorDataRepo, t.deviceRepo, t.alarmService) return NewAreaThresholdCheckTask(logs.AddCompName(baseCtx, CompNameAreaCollectorThresholdCheck), claimedLog, t.sensorDataRepo, t.deviceRepo, t.alarmService)
case models.TaskTypeNotificationRefresh: case models.TaskTypeNotificationRefresh:
return NewRefreshNotificationTask(logs.AddCompName(baseCtx, "NotificationRefreshTask"), claimedLog, t.alarmService) return NewRefreshNotificationTask(logs.AddCompName(baseCtx, CompNameNotificationRefresh), claimedLog, t.alarmService)
case models.TaskTypeOTACheck:
return NewOtaCheckTask(logs.AddCompName(baseCtx, CompNameOtaCheck), claimedLog, t.otaRepo)
default: default:
// TODO 这里直接panic合适吗? 不过这个场景确实不该出现任何异常的任务类型
logger.Panicf("不支持的任务类型: %s", claimedLog.Task.Type) logger.Panicf("不支持的任务类型: %s", claimedLog.Task.Type)
panic("不支持的任务类型") // 显式panic防编译器报错 panic("不支持的任务类型") // 显式panic防编译器报错
} }
@@ -79,8 +96,6 @@ func (t *taskFactory) Production(ctx context.Context, claimedLog *models.TaskExe
// CreateTaskFromModel 实现了 TaskFactory 接口,用于从模型创建任务实例。 // CreateTaskFromModel 实现了 TaskFactory 接口,用于从模型创建任务实例。
func (t *taskFactory) CreateTaskFromModel(ctx context.Context, taskModel *models.Task) (plan.TaskDeviceIDResolver, error) { func (t *taskFactory) CreateTaskFromModel(ctx context.Context, taskModel *models.Task) (plan.TaskDeviceIDResolver, error) {
// 这个方法不关心 claimedLog 的其他字段,所以可以构造一个临时的
// 它只用于访问那些不依赖于执行日志的方法,比如 ResolveDeviceIDs
tempLog := &models.TaskExecutionLog{Task: *taskModel} tempLog := &models.TaskExecutionLog{Task: *taskModel}
baseCtx := context.Background() baseCtx := context.Background()
@@ -93,18 +108,22 @@ func (t *taskFactory) CreateTaskFromModel(ctx context.Context, taskModel *models
tempLog, tempLog,
t.sensorDataRepo, t.sensorDataRepo,
t.deviceRepo, t.deviceRepo,
t.deviceService, t.deviceOperator,
), nil ), nil
case models.TaskTypeFullCollection: case models.TaskTypeFullCollection:
return NewFullCollectionTask(logs.AddCompName(baseCtx, CompNameFullCollectionTask), tempLog, t.deviceRepo, t.deviceService), nil return NewFullCollectionTask(logs.AddCompName(baseCtx, CompNameFullCollectionTask), tempLog, t.deviceRepo, t.deviceOperator), nil
case models.TaskTypeHeartbeat:
return NewHeartbeatTask(logs.AddCompName(baseCtx, CompNameHeartbeatTask), tempLog, t.areaControllerRepo, t.deviceCommunicator), nil
case models.TaskTypeAlarmNotification: case models.TaskTypeAlarmNotification:
return NewAlarmNotificationTask(logs.AddCompName(baseCtx, CompNameAlarmNotification), tempLog, t.notificationService, t.alarmRepo), nil return NewAlarmNotificationTask(logs.AddCompName(baseCtx, CompNameAlarmNotification), tempLog, t.notificationService, t.alarmRepo), nil
case models.TaskTypeDeviceThresholdCheck: case models.TaskTypeDeviceThresholdCheck:
return NewDeviceThresholdCheckTask(logs.AddCompName(baseCtx, "DeviceThresholdCheckTask"), tempLog, t.sensorDataRepo, t.alarmService), nil return NewDeviceThresholdCheckTask(logs.AddCompName(baseCtx, CompNameDeviceThresholdCheck), tempLog, t.sensorDataRepo, t.alarmService), nil
case models.TaskTypeAreaCollectorThresholdCheck: case models.TaskTypeAreaCollectorThresholdCheck:
return NewAreaThresholdCheckTask(logs.AddCompName(baseCtx, "AreaCollectorThresholdCheckTask"), tempLog, t.sensorDataRepo, t.deviceRepo, t.alarmService), nil return NewAreaThresholdCheckTask(logs.AddCompName(baseCtx, CompNameAreaCollectorThresholdCheck), tempLog, t.sensorDataRepo, t.deviceRepo, t.alarmService), nil
case models.TaskTypeNotificationRefresh: case models.TaskTypeNotificationRefresh:
return NewRefreshNotificationTask(logs.AddCompName(baseCtx, "NotificationRefreshTask"), tempLog, t.alarmService), nil return NewRefreshNotificationTask(logs.AddCompName(baseCtx, CompNameNotificationRefresh), tempLog, t.alarmService), nil
case models.TaskTypeOTACheck:
return NewOtaCheckTask(logs.AddCompName(baseCtx, CompNameOtaCheck), tempLog, t.otaRepo), nil
default: default:
return nil, fmt.Errorf("不支持为类型 '%s' 的任务创建模型实例", taskModel.Type) return nil, fmt.Errorf("不支持为类型 '%s' 的任务创建模型实例", taskModel.Type)
} }

19
internal/infra/ai/ai.go Normal file
View File

@@ -0,0 +1,19 @@
package ai
import (
"context"
"git.huangwc.com/pig/pig-farm-controller/internal/infra/models"
)
// AI 定义了通用的 AI 管理接口。
// 它可以用于处理各种 AI 相关的任务,例如文本生成、内容审核等。
type AI interface {
// GenerateReview 根据提供的文本内容生成评论。
// prompt: 用于生成评论的输入文本。
// 返回生成的评论字符串和可能发生的错误。
GenerateReview(ctx context.Context, prompt string) (string, error)
// AIModel 返回当前使用的 AI 模型。
AIModel() models.AIModel
}

View File

@@ -0,0 +1,73 @@
package ai
import (
"context"
"fmt"
"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"
"github.com/google/generative-ai-go/genai"
"google.golang.org/api/option"
)
// geminiImpl 是 Gemini AI 服务的实现。
type geminiImpl struct {
client *genai.GenerativeModel
cfg config.Gemini
}
// NewGeminiAI 创建一个新的 geminiImpl 实例。
func NewGeminiAI(ctx context.Context, cfg config.Gemini) (AI, error) {
// 检查 API Key 是否存在
if cfg.APIKey == "" {
return nil, fmt.Errorf("Gemini API Key 未配置")
}
// 创建 Gemini 客户端
genaiClient, err := genai.NewClient(ctx, option.WithAPIKey(cfg.APIKey))
if err != nil {
return nil, fmt.Errorf("创建 Gemini 客户端失败: %w", err)
}
return &geminiImpl{
client: genaiClient.GenerativeModel(cfg.ModelName),
cfg: cfg,
}, nil
}
// GenerateReview 根据提供的文本内容生成评论。
func (g *geminiImpl) GenerateReview(ctx context.Context, prompt string) (string, error) {
serviceCtx, logger := logs.Trace(ctx, context.Background(), "GenerateReview")
logger.Debugf("开始调用 Gemini 生成评论prompt: %s", prompt)
timeoutCtx, cancel := context.WithTimeout(serviceCtx, time.Duration(g.cfg.Timeout)*time.Second)
defer cancel()
resp, err := g.client.GenerateContent(timeoutCtx, genai.Text(prompt))
if err != nil {
logger.Errorf("调用 Gemini API 失败: %v", err)
return "", fmt.Errorf("调用 Gemini API 失败: %w", err)
}
if resp == nil || len(resp.Candidates) == 0 || len(resp.Candidates[0].Content.Parts) == 0 {
logger.Warn("Gemini API 返回空内容或无候选评论")
return "", fmt.Errorf("Gemini API 返回空内容或无候选评论")
}
var review string
for _, part := range resp.Candidates[0].Content.Parts {
if txt, ok := part.(genai.Text); ok {
review += string(txt)
}
}
logger.Debugf("成功从 Gemini 生成评论: %s", review)
return review, nil
}
func (g *geminiImpl) AIModel() models.AIModel {
return models.AI_MODEL_GEMINI
}

View File

@@ -0,0 +1,31 @@
package ai
import (
"context"
"errors"
"git.huangwc.com/pig/pig-farm-controller/internal/infra/logs"
"git.huangwc.com/pig/pig-farm-controller/internal/infra/models"
)
var NoneAIError = errors.New("当前没有配置AI, 暂不支持此功能")
type NoneAI struct {
ctx context.Context
}
func NewNoneAI(ctx context.Context) AI {
return &NoneAI{
ctx: ctx,
}
}
func (n *NoneAI) GenerateReview(ctx context.Context, prompt string) (string, error) {
logger := logs.TraceLogger(ctx, n.ctx, "GenerateReview")
logger.Warnf("当前没有配置AI, 无法处理AI请求, 消息: %s", prompt)
return "", NoneAIError
}
func (n *NoneAI) AIModel() models.AIModel {
return models.AI_MODEL_NONE
}

View File

@@ -7,6 +7,7 @@ import (
"fmt" "fmt"
"os" "os"
"git.huangwc.com/pig/pig-farm-controller/internal/infra/models"
"gopkg.in/yaml.v2" "gopkg.in/yaml.v2"
) )
@@ -50,6 +51,12 @@ type Config struct {
// AlarmNotification 告警通知配置 // AlarmNotification 告警通知配置
AlarmNotification AlarmNotificationConfig `yaml:"alarm_notification"` AlarmNotification AlarmNotificationConfig `yaml:"alarm_notification"`
// AI AI服务配置
AI AIConfig `yaml:"ai"`
// OTA OTA升级配置
OTA OTAConfig `yaml:"ota"`
} }
// AppConfig 代表应用基础配置 // AppConfig 代表应用基础配置
@@ -231,6 +238,29 @@ type AlarmNotificationConfig struct {
NotificationIntervals NotificationIntervalsConfig `yaml:"notification_intervals"` NotificationIntervals NotificationIntervalsConfig `yaml:"notification_intervals"`
} }
// AIConfig AI 服务配置
type AIConfig struct {
Model models.AIModel `yaml:"model"`
Gemini Gemini `yaml:"gemini"`
}
// Gemini 代表 Gemini AI 服务的配置
type Gemini struct {
APIKey string `yaml:"api_key"` // Gemini API Key
ModelName string `yaml:"model_name"` // Gemini 模型名称,例如 "gemini-pro"
Timeout int `yaml:"timeout"` // AI 请求超时时间 (秒)
}
// OTAConfig 代表 OTA 升级配置
type OTAConfig struct {
// DefaultTimeoutSeconds 升级任务的全局超时时间(秒)
DefaultTimeoutSeconds int `yaml:"default_timeout_seconds"`
// DefaultRequestTimeoutSeconds 等待设备响应的单次请求超时时间(秒)
DefaultRequestTimeoutSeconds int `yaml:"default_request_timeout_seconds"`
// DefaultRetryCount 默认的固件块请求重试次数
DefaultRetryCount int `yaml:"default_retry_count"`
}
// NewConfig 创建并返回一个新的配置实例 // NewConfig 创建并返回一个新的配置实例
func NewConfig() *Config { func NewConfig() *Config {
// 默认值可以在这里设置,但我们优先使用配置文件中的值 // 默认值可以在这里设置,但我们优先使用配置文件中的值

View File

@@ -13,6 +13,7 @@ import (
"time" "time"
"git.huangwc.com/pig/pig-farm-controller/internal/infra/config" "git.huangwc.com/pig/pig-farm-controller/internal/infra/config"
"go.uber.org/zap" "go.uber.org/zap"
"go.uber.org/zap/zapcore" "go.uber.org/zap/zapcore"
"gopkg.in/natefinch/lumberjack.v2" "gopkg.in/natefinch/lumberjack.v2"
@@ -65,8 +66,8 @@ func NewLogger(cfg config.LogConfig) *Logger {
// 5. 构建 Logger // 5. 构建 Logger
// zap.AddCaller() 会记录调用日志的代码行 // zap.AddCaller() 会记录调用日志的代码行
// zap.AddCallerSkip(1) 可以向上跳层调用栈,如果我们将 logger.Info 等方法再封装一层,这个选项会很有用 // zap.AddCallerSkip(2) 可以向上跳层调用栈,因为我们的日志方法被封装了两层 (Logger.Info -> Logger.logWithTrace)
zapLogger := zap.New(core, zap.AddCaller(), zap.AddCallerSkip(1)) zapLogger := zap.New(core, zap.AddCaller(), zap.AddCallerSkip(2))
return &Logger{sl: zapLogger.Sugar()} return &Logger{sl: zapLogger.Sugar()}
} }

View File

@@ -3,6 +3,7 @@ package models
import ( import (
"encoding/json" "encoding/json"
"errors" "errors"
"fmt"
"strings" "strings"
"gorm.io/datatypes" "gorm.io/datatypes"
@@ -16,6 +17,11 @@ type Bus485Properties struct {
BusAddress uint8 `json:"bus_address"` // 485 总线地址 BusAddress uint8 `json:"bus_address"` // 485 总线地址
} }
// AreaControllerProperties 定义了区域主控的特有属性
type AreaControllerProperties struct {
FirmwareVersion string `json:"firmware_version,omitempty"` // 主控程序版本
}
// AreaController 是一个LoRa转总线(如485)的通信网关 // AreaController 是一个LoRa转总线(如485)的通信网关
type AreaController struct { type AreaController struct {
Model Model
@@ -45,6 +51,29 @@ func (ac *AreaController) SelfCheck() error {
return nil 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 使用的数据库表名 // TableName 自定义 GORM 使用的数据库表名
func (AreaController) TableName() string { func (AreaController) TableName() string {
return "area_controllers" return "area_controllers"

View File

@@ -153,6 +153,44 @@ func (PendingCollection) TableName() string {
return "pending_collections" return "pending_collections"
} }
// --- OTA 升级任务 ---
// OTATaskStatus 定义 OTA 升级任务的状态
type OTATaskStatus string
const (
OTATaskStatusPending OTATaskStatus = "待开始" // 任务已创建,等待下发
OTATaskStatusInProgress OTATaskStatus = "进行中" // 任务已下发,设备正在处理
OTATaskStatusSuccess OTATaskStatus = "成功" // 设备报告升级成功,新固件已运行
OTATaskStatusAlreadyUpToDate OTATaskStatus = "版本已是最新" // 设备报告版本已是最新,未执行升级
OTATaskStatusFailedPreCheck OTATaskStatus = "预检失败" // 设备报告升级前检查失败 (如拒绝降级、准备分区失败)
OTATaskStatusFailedDownload OTATaskStatus = "下载或校验失败" // 设备报告文件下载或校验失败 (包括清单文件和固件文件)
OTATaskStatusFailedRollback OTATaskStatus = "固件回滚" // 新固件启动失败,设备自动回滚
OTATaskStatusTimedOut OTATaskStatus = "超时" // 平台在超时后仍未收到最终报告
OTATaskStatusPlatformError OTATaskStatus = "平台内部错误" // 平台处理过程中发生的非设备报告错误
)
// OTATask 记录一次 OTA 升级任务的详细信息
type OTATask struct {
// ID 是数据库自增主键,将作为 task_id 在平台与设备间通信
ID uint32 `gorm:"primaryKey"`
// CreatedAt 是任务创建和开始的时间,作为联合主键方便只查询热点数据
CreatedAt time.Time `gorm:"primaryKey"`
AreaControllerID uint32 `gorm:"not null;index;comment:目标区域主控的ID"`
TargetVersion string `gorm:"type:varchar(32);not null;comment:目标固件版本号"`
Status OTATaskStatus `gorm:"type:varchar(32);not null;index;comment:任务状态"`
ErrorMessage string `gorm:"type:text;comment:错误信息,如果任务失败"`
FailedFilePath string `gorm:"type:text;comment:失败时关联的文件路径"`
CompletedAt *time.Time `gorm:"comment:任务完成(成功或失败)的时间"`
FinalReportedVersion string `gorm:"type:varchar(32);comment:任务结束后,设备上报的最终固件版本"`
}
// TableName 自定义 GORM 使用的数据库表名
func (OTATask) TableName() string {
return "ota_tasks"
}
// --- 用户审计日志 --- // --- 用户审计日志 ---
// --- 审计日志状态常量 --- // --- 审计日志状态常量 ---

View File

@@ -12,6 +12,13 @@ import (
"gorm.io/gorm" "gorm.io/gorm"
) )
type AIModel string
const (
AI_MODEL_NONE AIModel = "None"
AI_MODEL_GEMINI AIModel = "Gemini"
)
// Model 用于代替gorm.Model, 使用uint32以节约空间 // Model 用于代替gorm.Model, 使用uint32以节约空间
type Model struct { type Model struct {
ID uint32 `gorm:"primarykey"` ID uint32 `gorm:"primarykey"`
@@ -83,6 +90,9 @@ func GetAllModels() []interface{} {
// Notification Models // Notification Models
&Notification{}, &Notification{},
// OTA Upgrade Models
&OTATask{},
} }
} }

View File

@@ -16,8 +16,12 @@ type PlanName string
const ( const (
// PlanNamePeriodicSystemHealthCheck 是周期性系统健康检查计划的名称 // PlanNamePeriodicSystemHealthCheck 是周期性系统健康检查计划的名称
PlanNamePeriodicSystemHealthCheck PlanName = "周期性系统健康检查" PlanNamePeriodicSystemHealthCheck PlanName = "周期性系统健康检查"
// PlanNamePeriodicHeartbeatCheck 是周期性心跳检测计划的名称
PlanNamePeriodicHeartbeatCheck PlanName = "周期性心跳检测"
// PlanNameAlarmNotification 是告警通知发送计划的名称 // PlanNameAlarmNotification 是告警通知发送计划的名称
PlanNameAlarmNotification PlanName = "告警通知发送" PlanNameAlarmNotification PlanName = "告警通知发送"
// PlanNameOtaCheck 是定时检查OTA升级任务的计划名称
PlanNameOtaCheck PlanName = "定时检查OTA任务"
) )
// PlanExecutionType 定义了计划的执行类型 // PlanExecutionType 定义了计划的执行类型
@@ -44,10 +48,12 @@ const (
TaskTypeWaiting TaskType = "等待" // 等待任务 TaskTypeWaiting TaskType = "等待" // 等待任务
TaskTypeReleaseFeedWeight TaskType = "下料" // 下料口释放指定重量任务 TaskTypeReleaseFeedWeight TaskType = "下料" // 下料口释放指定重量任务
TaskTypeFullCollection TaskType = "全量采集" // 新增的全量采集任务 TaskTypeFullCollection TaskType = "全量采集" // 新增的全量采集任务
TaskTypeHeartbeat TaskType = "心跳检测" // 区域主控心跳检测任务
TaskTypeAlarmNotification TaskType = "告警通知" // 告警通知任务 TaskTypeAlarmNotification TaskType = "告警通知" // 告警通知任务
TaskTypeNotificationRefresh TaskType = "通知刷新" // 通知刷新任务 TaskTypeNotificationRefresh TaskType = "通知刷新" // 通知刷新任务
TaskTypeDeviceThresholdCheck TaskType = "设备阈值检查" // 设备阈值检查任务 TaskTypeDeviceThresholdCheck TaskType = "设备阈值检查" // 设备阈值检查任务
TaskTypeAreaCollectorThresholdCheck TaskType = "区域阈值检查" // 区域阈值检查任务 TaskTypeAreaCollectorThresholdCheck TaskType = "区域阈值检查" // 区域阈值检查任务
TaskTypeOTACheck TaskType = "OTA升级检查任务" // OTA升级超时检查任务
) )
// -- Task Parameters -- // -- Task Parameters --

View File

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

View File

@@ -18,6 +18,8 @@ type AreaControllerRepository interface {
Create(ctx context.Context, ac *models.AreaController) error Create(ctx context.Context, ac *models.AreaController) error
ListAll(ctx context.Context) ([]*models.AreaController, error) ListAll(ctx context.Context) ([]*models.AreaController, error)
Update(ctx context.Context, ac *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 Delete(ctx context.Context, id uint32) error
// IsAreaControllerUsedByTasks 检查区域主控是否被特定任务类型使用,可以忽略指定任务类型 // IsAreaControllerUsedByTasks 检查区域主控是否被特定任务类型使用,可以忽略指定任务类型
IsAreaControllerUsedByTasks(ctx context.Context, areaControllerID uint32, ignoredTaskTypes []models.TaskType) (bool, error) 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 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 记录。 // Delete 删除一个 AreaController 记录。
func (r *gormAreaControllerRepository) Delete(ctx context.Context, id uint32) error { func (r *gormAreaControllerRepository) Delete(ctx context.Context, id uint32) error {
repoCtx := logs.AddFuncName(ctx, r.ctx, "Delete") repoCtx := logs.AddFuncName(ctx, r.ctx, "Delete")

View File

@@ -0,0 +1,52 @@
package repository
import (
"context"
"time"
"git.huangwc.com/pig/pig-farm-controller/internal/infra/logs"
"git.huangwc.com/pig/pig-farm-controller/internal/infra/models"
"gorm.io/gorm"
)
// OtaRepository 定义了与 OTA 升级任务相关的数据库操作接口。
type OtaRepository interface {
// FindTasksByStatusesAndCreationTime 根据状态列表和创建时间查找任务。
FindTasksByStatusesAndCreationTime(ctx context.Context, statuses []models.OTATaskStatus, createdBefore time.Time) ([]*models.OTATask, error)
// Update 更新单个 OTA 任务。
Update(ctx context.Context, task *models.OTATask) error
}
// gormOtaRepository 是 OtaRepository 的 GORM 实现
type gormOtaRepository struct {
ctx context.Context
db *gorm.DB
}
// NewGormOtaRepository 创建一个新的 OtaRepository GORM 实现实例
func NewGormOtaRepository(ctx context.Context, db *gorm.DB) OtaRepository {
return &gormOtaRepository{
ctx: ctx,
db: db,
}
}
// FindTasksByStatusesAndCreationTime 实现了根据状态和创建时间查找任务的逻辑。
func (r *gormOtaRepository) FindTasksByStatusesAndCreationTime(ctx context.Context,
statuses []models.OTATaskStatus,
createdBefore time.Time,
) ([]*models.OTATask, error) {
repoCtx := logs.AddFuncName(ctx, r.ctx, "FindTasksByStatusesAndCreationTime")
var tasks []*models.OTATask
err := r.db.WithContext(repoCtx).
Where("status IN ? AND created_at < ?", statuses, createdBefore).
Find(&tasks).Error
return tasks, err
}
// Update 实现了更新单个 OTA 任务的逻辑。
func (r *gormOtaRepository) Update(ctx context.Context, task *models.OTATask) error {
repoCtx := logs.AddFuncName(ctx, r.ctx, "Update")
return r.db.WithContext(repoCtx).Save(task).Error
}

View File

@@ -297,7 +297,7 @@ type ApplicationServiceCreateIftttIntegrationParamsBodyIntegration struct {
// Event prefix. // Event prefix.
// If set, the event name will be PREFIX_EVENT. For example if 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 // 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. // Note: Only characters in the A-Z, a-z and 0-9 range are allowed.
EventPrefix string `json:"eventPrefix,omitempty"` EventPrefix string `json:"eventPrefix,omitempty"`

View File

@@ -297,7 +297,7 @@ type ApplicationServiceUpdateIftttIntegrationParamsBodyIntegration struct {
// Event prefix. // Event prefix.
// If set, the event name will be PREFIX_EVENT. For example if 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 // 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. // Note: Only characters in the A-Z, a-z and 0-9 range are allowed.
EventPrefix string `json:"eventPrefix,omitempty"` EventPrefix string `json:"eventPrefix,omitempty"`

View File

@@ -28,7 +28,7 @@ type APIIftttIntegration struct {
// Event prefix. // Event prefix.
// If set, the event name will be PREFIX_EVENT. For example if 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 // 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. // Note: Only characters in the A-Z, a-z and 0-9 range are allowed.
EventPrefix string `json:"eventPrefix,omitempty"` EventPrefix string `json:"eventPrefix,omitempty"`

View File

@@ -4,25 +4,20 @@ import (
"bytes" "bytes"
"context" "context"
"encoding/binary" "encoding/binary"
"encoding/json"
"fmt" "fmt"
"io" "io"
"math"
"strconv" "strconv"
"sync" "sync"
"time" "time"
"git.huangwc.com/pig/pig-farm-controller/internal/infra/config" "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/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"
"git.huangwc.com/pig/pig-farm-controller/internal/infra/transport/proto" "git.huangwc.com/pig/pig-farm-controller/internal/infra/transport/proto"
"github.com/google/uuid" "github.com/google/uuid"
"github.com/tarm/serial" "github.com/tarm/serial"
gproto "google.golang.org/protobuf/proto" gproto "google.golang.org/protobuf/proto"
"gorm.io/datatypes"
) )
// transportState 定义了传输层的内部状态 // transportState 定义了传输层的内部状态
@@ -43,9 +38,10 @@ type message struct {
// LoRaMeshUartPassthroughTransport 实现了 transport.Communicator 和 transport.Listener 接口 // LoRaMeshUartPassthroughTransport 实现了 transport.Communicator 和 transport.Listener 接口
type LoRaMeshUartPassthroughTransport struct { type LoRaMeshUartPassthroughTransport struct {
ctx context.Context selfCtx context.Context
config config.LoraMeshConfig config config.LoraMeshConfig
port *serial.Port port *serial.Port
handler transport.UpstreamHandler // 依赖注入的统一业务处理器
mu sync.Mutex // 用于保护对外的公共方法如Send的并发调用 mu sync.Mutex // 用于保护对外的公共方法如Send的并发调用
state transportState state transportState
@@ -59,12 +55,6 @@ type LoRaMeshUartPassthroughTransport struct {
currentRecvSource uint16 // 当前正在接收的源地址 currentRecvSource uint16 // 当前正在接收的源地址
reassemblyTimeout *time.Timer // 分片重组的超时定时器 reassemblyTimeout *time.Timer // 分片重组的超时定时器
reassemblyTimeoutCh chan uint16 // 当超时触发时,用于传递源地址 reassemblyTimeoutCh chan uint16 // 当超时触发时,用于传递源地址
// --- 依赖注入的仓库 ---
areaControllerRepo repository.AreaControllerRepository
pendingCollectionRepo repository.PendingCollectionRepository
deviceRepo repository.DeviceRepository
sensorDataRepo repository.SensorDataRepository
} }
// sendRequest 封装了一次发送请求 // sendRequest 封装了一次发送请求
@@ -91,10 +81,7 @@ type reassemblyBuffer struct {
func NewLoRaMeshUartPassthroughTransport( func NewLoRaMeshUartPassthroughTransport(
ctx context.Context, ctx context.Context,
config config.LoraMeshConfig, config config.LoraMeshConfig,
areaControllerRepo repository.AreaControllerRepository, handler transport.UpstreamHandler,
pendingCollectionRepo repository.PendingCollectionRepository,
deviceRepo repository.DeviceRepository,
sensorDataRepo repository.SensorDataRepository,
) (*LoRaMeshUartPassthroughTransport, error) { ) (*LoRaMeshUartPassthroughTransport, error) {
c := &serial.Config{ c := &serial.Config{
Name: config.UARTPort, Name: config.UARTPort,
@@ -108,20 +95,15 @@ func NewLoRaMeshUartPassthroughTransport(
} }
t := &LoRaMeshUartPassthroughTransport{ t := &LoRaMeshUartPassthroughTransport{
ctx: ctx, selfCtx: logs.AddCompName(ctx, "LoRaMeshUartPassthroughTransport"),
config: config, config: config,
port: port, port: port,
handler: handler,
state: stateIdle, state: stateIdle,
stopChan: make(chan struct{}), stopChan: make(chan struct{}),
sendChan: make(chan *sendRequest), sendChan: make(chan *sendRequest),
reassemblyBuffers: make(map[uint16]*reassemblyBuffer), reassemblyBuffers: make(map[uint16]*reassemblyBuffer),
reassemblyTimeoutCh: make(chan uint16, 1), reassemblyTimeoutCh: make(chan uint16, 1),
// 注入依赖
areaControllerRepo: areaControllerRepo,
pendingCollectionRepo: pendingCollectionRepo,
deviceRepo: deviceRepo,
sensorDataRepo: sensorDataRepo,
} }
return t, nil return t, nil
@@ -129,10 +111,11 @@ func NewLoRaMeshUartPassthroughTransport(
// Listen 启动后台监听协程(非阻塞) // Listen 启动后台监听协程(非阻塞)
func (t *LoRaMeshUartPassthroughTransport) Listen(ctx context.Context) error { 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) t.wg.Add(1)
go t.workerLoop(loraCtx) go t.workerLoop(loraCtx)
logger.Info("LoRa传输层工作协程已启动") logger.Info("LoRa Mesh 传输层工作协程已启动")
return nil return nil
} }
@@ -167,7 +150,7 @@ func (t *LoRaMeshUartPassthroughTransport) Stop(ctx context.Context) error {
// workerLoop 是核心的状态机和调度器 // workerLoop 是核心的状态机和调度器
func (t *LoRaMeshUartPassthroughTransport) workerLoop(ctx context.Context) { 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() defer t.wg.Done()
@@ -218,7 +201,7 @@ func (t *LoRaMeshUartPassthroughTransport) workerLoop(ctx context.Context) {
// runIdleState 处理空闲状态下的逻辑,主要是检查并启动发送任务 // runIdleState 处理空闲状态下的逻辑,主要是检查并启动发送任务
func (t *LoRaMeshUartPassthroughTransport) runIdleState(ctx context.Context) { func (t *LoRaMeshUartPassthroughTransport) runIdleState(ctx context.Context) {
loraCtx := logs.AddFuncName(ctx, t.ctx, "Listen") loraCtx, _ := logs.Trace(ctx, t.selfCtx, "runIdleState")
select { select {
case req := <-t.sendChan: case req := <-t.sendChan:
@@ -234,10 +217,10 @@ func (t *LoRaMeshUartPassthroughTransport) runIdleState(ctx context.Context) {
// runReceivingState 处理接收状态下的逻辑,主要是检查超时 // runReceivingState 处理接收状态下的逻辑,主要是检查超时
func (t *LoRaMeshUartPassthroughTransport) runReceivingState(ctx context.Context) { func (t *LoRaMeshUartPassthroughTransport) runReceivingState(ctx context.Context) {
logger := logs.TraceLogger(ctx, t.ctx, "runReceivingState") _, logger := logs.Trace(ctx, t.selfCtx, "runReceivingState")
select { select {
case sourceAddr := <-t.reassemblyTimeoutCh: case sourceAddr := <-t.reassemblyTimeoutCh:
logger.Warnf("接收来自 0x%04X 的消息超时", sourceAddr) logger.Warnw("接收消息超时", "sourceAddr", fmt.Sprintf("0x%04X", sourceAddr))
delete(t.reassemblyBuffers, sourceAddr) delete(t.reassemblyBuffers, sourceAddr)
t.state = stateIdle t.state = stateIdle
default: default:
@@ -247,7 +230,7 @@ func (t *LoRaMeshUartPassthroughTransport) runReceivingState(ctx context.Context
// executeSend 执行完整的发送流程(分片、构建、写入) // executeSend 执行完整的发送流程(分片、构建、写入)
func (t *LoRaMeshUartPassthroughTransport) executeSend(ctx context.Context, req *sendRequest) (*transport.SendResult, error) { 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) chunks := splitPayload(req.payload, t.config.MaxChunkSize)
totalChunks := uint8(len(chunks)) totalChunks := uint8(len(chunks))
@@ -266,7 +249,7 @@ func (t *LoRaMeshUartPassthroughTransport) executeSend(ctx context.Context, req
frame.WriteByte(currentChunk) // 当前包序号 frame.WriteByte(currentChunk) // 当前包序号
frame.Write(chunk) // 数据块 frame.Write(chunk) // 数据块
logger.Debugf("构建LoRa数据包: %v", frame.Bytes()) logger.Debugw("构建LoRa数据包", "bytes", frame.Bytes())
_, err := t.port.Write(frame.Bytes()) _, err := t.port.Write(frame.Bytes())
if err != nil { if err != nil {
return nil, fmt.Errorf("写入串口失败: %w", err) return nil, fmt.Errorf("写入串口失败: %w", err)
@@ -282,9 +265,9 @@ func (t *LoRaMeshUartPassthroughTransport) executeSend(ctx context.Context, req
// handleFrame 处理一个从串口解析出的完整物理帧 // handleFrame 处理一个从串口解析出的完整物理帧
func (t *LoRaMeshUartPassthroughTransport) handleFrame(ctx context.Context, frame []byte) { 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 { if len(frame) < 8 {
logger.Warnf("收到了一个无效长度的帧: %d", len(frame)) logger.Warnw("收到了一个无效长度的帧", "length", len(frame))
return return
} }
@@ -301,7 +284,9 @@ func (t *LoRaMeshUartPassthroughTransport) handleFrame(ctx context.Context, fram
DestAddr: fmt.Sprintf("%04X", destAddr), DestAddr: fmt.Sprintf("%04X", destAddr),
Payload: chunkData, Payload: chunkData,
} }
go t.handleUpstreamMessage(loraCtx, msg) // 使用分离的上下文进行异步处理
detachedCtx := logs.DetachContext(reqCtx)
go t.handleUpstreamMessage(detachedCtx, msg)
return return
} }
@@ -326,18 +311,21 @@ func (t *LoRaMeshUartPassthroughTransport) handleFrame(ctx context.Context, fram
t.reassemblyTimeoutCh <- sourceAddr t.reassemblyTimeoutCh <- sourceAddr
}) })
} else { } else {
logger.Warnf("在空闲状态下收到了一个来自 0x%04X 的非首包分片,已忽略。", sourceAddr) logger.Warnw("在空闲状态下收到了一个非首包分片,已忽略", "sourceAddr", fmt.Sprintf("0x%04X", sourceAddr))
} }
case stateReceiving: case stateReceiving:
if sourceAddr != t.currentRecvSource { 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 return
} }
buffer, ok := t.reassemblyBuffers[sourceAddr] buffer, ok := t.reassemblyBuffers[sourceAddr]
if !ok { if !ok {
logger.Errorf("内部错误: 处于接收状态,但没有为 0x%04X 找到缓冲区", sourceAddr) logger.Errorw("内部错误: 处于接收状态,但没有找到缓冲区", "sourceAddr", fmt.Sprintf("0x%04X", sourceAddr))
t.state = stateIdle // 重置状态 t.state = stateIdle // 重置状态
return return
} }
@@ -362,165 +350,43 @@ func (t *LoRaMeshUartPassthroughTransport) handleFrame(ctx context.Context, fram
DestAddr: fmt.Sprintf("%04X", destAddr), DestAddr: fmt.Sprintf("%04X", destAddr),
Payload: fullPayload.Bytes(), Payload: fullPayload.Bytes(),
} }
go t.handleUpstreamMessage(loraCtx, msg) // 使用分离的上下文进行异步处理
detachedCtx := logs.DetachContext(reqCtx)
go t.handleUpstreamMessage(detachedCtx, msg)
// 清理并返回空闲状态 // 清理并返回空闲状态
delete(t.reassemblyBuffers, sourceAddr) delete(t.reassemblyBuffers, sourceAddr)
t.state = stateIdle t.state = stateIdle
} }
default: default:
logger.Errorf("内部错误: 状态机处于未知状态 %d", t.state) logger.Errorw("内部错误: 状态机处于未知状态", "state", t.state)
} }
} }
// handleUpstreamMessage 在独立的协程中处理单个上行的、完整的消息。 // handleUpstreamMessage 在独立的协程中处理单个上行的、完整的消息。
// 【已重构】此方法现在只负责解析和委托,不包含任何业务逻辑。
func (t *LoRaMeshUartPassthroughTransport) handleUpstreamMessage(ctx context.Context, msg *message) { func (t *LoRaMeshUartPassthroughTransport) handleUpstreamMessage(ctx context.Context, msg *message) {
loraCtx, logger := logs.Trace(ctx, t.ctx, "handleUpstreamMessage") reqCtx, logger := logs.Trace(ctx, t.selfCtx, "handleUpstreamMessage")
logger.Infow("开始适配上行消息并委托", "sourceAddr", msg.SourceAddr)
logger.Infof("开始处理来自 %s 的上行消息", msg.SourceAddr)
// 1. 解析外层 "信封" // 1. 解析外层 "信封"
var instruction proto.Instruction var instruction proto.Instruction
if err := gproto.Unmarshal(msg.Payload, &instruction); err != nil { 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 return
} }
// 2. 使用 type switch 从 oneof payload 中提取 CollectResult // 2. 委托给统一处理器
var collectResp *proto.CollectResult // 注意:对于 LoRa Mesh目前只处理业务指令没有单独的状态或ACK事件。
switch p := instruction.GetPayload().(type) { if err := t.handler.HandleInstruction(reqCtx, msg.SourceAddr, &instruction); err != nil {
case *proto.Instruction_CollectResult: logger.Errorw("委托上行指令给统一处理器失败",
collectResp = p.CollectResult "sourceAddr", msg.SourceAddr,
default: "error", err,
// 如果上行的数据不是采集结果,记录日志并忽略 )
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)
} }
} }

View File

@@ -21,6 +21,70 @@ const (
_ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20)
) )
type UpdateStatusReport_Status int32
const (
UpdateStatusReport_STATUS_UNSPECIFIED UpdateStatusReport_Status = 0 // 未指定protobuf3 要求枚举从0开始
UpdateStatusReport_SUCCESS UpdateStatusReport_Status = 1 // 升级成功,新固件已运行
UpdateStatusReport_SUCCESS_ALREADY_UP_TO_DATE UpdateStatusReport_Status = 2 // 版本已是最新,未执行升级
UpdateStatusReport_FAILED_PRE_CHECK UpdateStatusReport_Status = 3 // 升级前检查失败 (例如拒绝降级、准备分区失败)
UpdateStatusReport_FAILED_MANIFEST_VERIFY UpdateStatusReport_Status = 4 // 清单文件下载或校验失败
UpdateStatusReport_FAILED_DOWNLOAD UpdateStatusReport_Status = 5 // 固件文件下载或校验失败
UpdateStatusReport_FAILED_ROLLED_BACK UpdateStatusReport_Status = 6 // 新固件启动失败,已自动回滚
UpdateStatusReport_FAILED_TIMEOUT UpdateStatusReport_Status = 7 // 平台在超时后仍未收到SUCCESS报告将任务标记为此状态 (平台推断)
)
// Enum value maps for UpdateStatusReport_Status.
var (
UpdateStatusReport_Status_name = map[int32]string{
0: "STATUS_UNSPECIFIED",
1: "SUCCESS",
2: "SUCCESS_ALREADY_UP_TO_DATE",
3: "FAILED_PRE_CHECK",
4: "FAILED_MANIFEST_VERIFY",
5: "FAILED_DOWNLOAD",
6: "FAILED_ROLLED_BACK",
7: "FAILED_TIMEOUT",
}
UpdateStatusReport_Status_value = map[string]int32{
"STATUS_UNSPECIFIED": 0,
"SUCCESS": 1,
"SUCCESS_ALREADY_UP_TO_DATE": 2,
"FAILED_PRE_CHECK": 3,
"FAILED_MANIFEST_VERIFY": 4,
"FAILED_DOWNLOAD": 5,
"FAILED_ROLLED_BACK": 6,
"FAILED_TIMEOUT": 7,
}
)
func (x UpdateStatusReport_Status) Enum() *UpdateStatusReport_Status {
p := new(UpdateStatusReport_Status)
*p = x
return p
}
func (x UpdateStatusReport_Status) String() string {
return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x))
}
func (UpdateStatusReport_Status) Descriptor() protoreflect.EnumDescriptor {
return file_device_proto_enumTypes[0].Descriptor()
}
func (UpdateStatusReport_Status) Type() protoreflect.EnumType {
return &file_device_proto_enumTypes[0]
}
func (x UpdateStatusReport_Status) Number() protoreflect.EnumNumber {
return protoreflect.EnumNumber(x)
}
// Deprecated: Use UpdateStatusReport_Status.Descriptor instead.
func (UpdateStatusReport_Status) EnumDescriptor() ([]byte, []int) {
return file_device_proto_rawDescGZIP(), []int{9, 0}
}
// 平台生成的原始485指令单片机直接发送到总线 // 平台生成的原始485指令单片机直接发送到总线
type Raw485Command struct { type Raw485Command struct {
state protoimpl.MessageState `protogen:"open.v1"` state protoimpl.MessageState `protogen:"open.v1"`
@@ -74,7 +138,6 @@ func (x *Raw485Command) GetCommandBytes() []byte {
return nil return nil
} }
// BatchCollectCommand
// 一个完整的、包含所有元数据的批量采集任务。 // 一个完整的、包含所有元数据的批量采集任务。
type BatchCollectCommand struct { type BatchCollectCommand struct {
state protoimpl.MessageState `protogen:"open.v1"` state protoimpl.MessageState `protogen:"open.v1"`
@@ -128,7 +191,6 @@ func (x *BatchCollectCommand) GetTasks() []*CollectTask {
return nil return nil
} }
// CollectTask
// 定义了单个采集任务的“意图”。 // 定义了单个采集任务的“意图”。
type CollectTask struct { type CollectTask struct {
state protoimpl.MessageState `protogen:"open.v1"` state protoimpl.MessageState `protogen:"open.v1"`
@@ -174,7 +236,6 @@ func (x *CollectTask) GetCommand() *Raw485Command {
return nil return nil
} }
// CollectResult
// 这是设备响应的、极致精简的数据包。 // 这是设备响应的、极致精简的数据包。
type CollectResult struct { type CollectResult struct {
state protoimpl.MessageState `protogen:"open.v1"` state protoimpl.MessageState `protogen:"open.v1"`
@@ -228,16 +289,373 @@ func (x *CollectResult) GetValues() []float32 {
return nil return nil
} }
// 指令 (所有从平台下发到设备的数据都应该被包装在这里面) // 平台向设备发送的Ping指令用于检查存活性。
// 使用 oneof 来替代 google.protobuf.Any这是嵌入式环境下的标准做法。 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[4]
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[4]
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{4}
}
// 设备对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[5]
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[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 Pong.ProtoReflect.Descriptor instead.
func (*Pong) Descriptor() ([]byte, []int) {
return file_device_proto_rawDescGZIP(), []int{5}
}
func (x *Pong) GetFirmwareVersion() string {
if x != nil {
return x.FirmwareVersion
}
return ""
}
// PrepareUpdateReq: 平台发送给设备,通知设备准备开始 OTA 升级 (下行)
type PrepareUpdateReq struct {
state protoimpl.MessageState `protogen:"open.v1"`
Version string `protobuf:"bytes,1,opt,name=version,proto3" json:"version,omitempty"` // 新固件版本号
TaskId uint32 `protobuf:"varint,2,opt,name=task_id,json=taskId,proto3" json:"task_id,omitempty"` // 升级任务唯一ID
ManifestMd5 string `protobuf:"bytes,3,opt,name=manifest_md5,json=manifestMd5,proto3" json:"manifest_md5,omitempty"` // 清单文件的 MD5 校验和,用于设备初步校验清单文件完整性
RetryCount uint32 `protobuf:"varint,4,opt,name=retry_count,json=retryCount,proto3" json:"retry_count,omitempty"` // 建议的重试次数
RequestTimeoutSeconds uint32 `protobuf:"varint,5,opt,name=request_timeout_seconds,json=requestTimeoutSeconds,proto3" json:"request_timeout_seconds,omitempty"` // 建议的单次请求超时时间
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *PrepareUpdateReq) Reset() {
*x = PrepareUpdateReq{}
mi := &file_device_proto_msgTypes[6]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *PrepareUpdateReq) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*PrepareUpdateReq) ProtoMessage() {}
func (x *PrepareUpdateReq) 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 PrepareUpdateReq.ProtoReflect.Descriptor instead.
func (*PrepareUpdateReq) Descriptor() ([]byte, []int) {
return file_device_proto_rawDescGZIP(), []int{6}
}
func (x *PrepareUpdateReq) GetVersion() string {
if x != nil {
return x.Version
}
return ""
}
func (x *PrepareUpdateReq) GetTaskId() uint32 {
if x != nil {
return x.TaskId
}
return 0
}
func (x *PrepareUpdateReq) GetManifestMd5() string {
if x != nil {
return x.ManifestMd5
}
return ""
}
func (x *PrepareUpdateReq) GetRetryCount() uint32 {
if x != nil {
return x.RetryCount
}
return 0
}
func (x *PrepareUpdateReq) GetRequestTimeoutSeconds() uint32 {
if x != nil {
return x.RequestTimeoutSeconds
}
return 0
}
// RequestFile: 设备向平台请求特定文件 (包括清单文件和固件文件) (上行)
type RequestFile struct {
state protoimpl.MessageState `protogen:"open.v1"`
TaskId uint32 `protobuf:"varint,1,opt,name=task_id,json=taskId,proto3" json:"task_id,omitempty"` // 升级任务ID
Filepath string `protobuf:"bytes,2,opt,name=filepath,proto3" json:"filepath,omitempty"` // 请求的文件路径 (例如 "/manifest.json" 或 "/main.py")
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *RequestFile) Reset() {
*x = RequestFile{}
mi := &file_device_proto_msgTypes[7]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *RequestFile) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*RequestFile) ProtoMessage() {}
func (x *RequestFile) 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 RequestFile.ProtoReflect.Descriptor instead.
func (*RequestFile) Descriptor() ([]byte, []int) {
return file_device_proto_rawDescGZIP(), []int{7}
}
func (x *RequestFile) GetTaskId() uint32 {
if x != nil {
return x.TaskId
}
return 0
}
func (x *RequestFile) GetFilepath() string {
if x != nil {
return x.Filepath
}
return ""
}
// FileResponse: 平台响应设备请求,发送单个文件的完整内容 (下行)
// LoRa 传输层会自动处理分片和重组,因此应用层可以直接发送完整的单个文件内容
type FileResponse struct {
state protoimpl.MessageState `protogen:"open.v1"`
TaskId uint32 `protobuf:"varint,1,opt,name=task_id,json=taskId,proto3" json:"task_id,omitempty"` // 升级任务ID
Filepath string `protobuf:"bytes,2,opt,name=filepath,proto3" json:"filepath,omitempty"` // 设备上的目标路径 (例如 "/manifest.json" 或 "/main.py")
Content []byte `protobuf:"bytes,3,opt,name=content,proto3" json:"content,omitempty"` // 文件的完整内容
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *FileResponse) Reset() {
*x = FileResponse{}
mi := &file_device_proto_msgTypes[8]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *FileResponse) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*FileResponse) ProtoMessage() {}
func (x *FileResponse) 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 FileResponse.ProtoReflect.Descriptor instead.
func (*FileResponse) Descriptor() ([]byte, []int) {
return file_device_proto_rawDescGZIP(), []int{8}
}
func (x *FileResponse) GetTaskId() uint32 {
if x != nil {
return x.TaskId
}
return 0
}
func (x *FileResponse) GetFilepath() string {
if x != nil {
return x.Filepath
}
return ""
}
func (x *FileResponse) GetContent() []byte {
if x != nil {
return x.Content
}
return nil
}
// UpdateStatusReport: 设备向平台报告升级状态 (上行)
type UpdateStatusReport struct {
state protoimpl.MessageState `protogen:"open.v1"`
TaskId uint32 `protobuf:"varint,1,opt,name=task_id,json=taskId,proto3" json:"task_id,omitempty"` // 升级任务ID
CurrentVersion string `protobuf:"bytes,2,opt,name=current_version,json=currentVersion,proto3" json:"current_version,omitempty"` // 操作完成后的当前版本
Status UpdateStatusReport_Status `protobuf:"varint,3,opt,name=status,proto3,enum=device.UpdateStatusReport_Status" json:"status,omitempty"` // 升级的最终状态
ErrorMessage string `protobuf:"bytes,4,opt,name=error_message,json=errorMessage,proto3" json:"error_message,omitempty"` // 人类可读的详细错误信息
FailedFile string `protobuf:"bytes,5,opt,name=failed_file,json=failedFile,proto3" json:"failed_file,omitempty"` // 失败时关联的文件路径 (可选)
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *UpdateStatusReport) Reset() {
*x = UpdateStatusReport{}
mi := &file_device_proto_msgTypes[9]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *UpdateStatusReport) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*UpdateStatusReport) ProtoMessage() {}
func (x *UpdateStatusReport) 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 UpdateStatusReport.ProtoReflect.Descriptor instead.
func (*UpdateStatusReport) Descriptor() ([]byte, []int) {
return file_device_proto_rawDescGZIP(), []int{9}
}
func (x *UpdateStatusReport) GetTaskId() uint32 {
if x != nil {
return x.TaskId
}
return 0
}
func (x *UpdateStatusReport) GetCurrentVersion() string {
if x != nil {
return x.CurrentVersion
}
return ""
}
func (x *UpdateStatusReport) GetStatus() UpdateStatusReport_Status {
if x != nil {
return x.Status
}
return UpdateStatusReport_STATUS_UNSPECIFIED
}
func (x *UpdateStatusReport) GetErrorMessage() string {
if x != nil {
return x.ErrorMessage
}
return ""
}
func (x *UpdateStatusReport) GetFailedFile() string {
if x != nil {
return x.FailedFile
}
return ""
}
// Instruction 封装了所有与设备间的通信。
// 使用 oneof 来确保每个消息只有一个负载类型,这在嵌入式系统中是高效且类型安全的。
type Instruction struct { type Instruction struct {
state protoimpl.MessageState `protogen:"open.v1"` state protoimpl.MessageState `protogen:"open.v1"`
// Types that are valid to be assigned to Payload: // Types that are valid to be assigned to Payload:
// //
// *Instruction_Raw_485Command // *Instruction_Raw_485Command
// *Instruction_BatchCollectCommand // *Instruction_BatchCollectCommand
// *Instruction_Ping
// *Instruction_PrepareUpdateReq
// *Instruction_FileResponse
// *Instruction_CollectResult // *Instruction_CollectResult
// *Instruction_Pong
// *Instruction_RequestFile
// *Instruction_UpdateStatusReport
Payload isInstruction_Payload `protobuf_oneof:"payload"` Payload isInstruction_Payload `protobuf_oneof:"payload"`
unknownFields protoimpl.UnknownFields unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache sizeCache protoimpl.SizeCache
@@ -245,7 +663,7 @@ type Instruction struct {
func (x *Instruction) Reset() { func (x *Instruction) Reset() {
*x = Instruction{} *x = Instruction{}
mi := &file_device_proto_msgTypes[4] mi := &file_device_proto_msgTypes[10]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi) ms.StoreMessageInfo(mi)
} }
@@ -257,7 +675,7 @@ func (x *Instruction) String() string {
func (*Instruction) ProtoMessage() {} func (*Instruction) ProtoMessage() {}
func (x *Instruction) ProtoReflect() protoreflect.Message { func (x *Instruction) ProtoReflect() protoreflect.Message {
mi := &file_device_proto_msgTypes[4] mi := &file_device_proto_msgTypes[10]
if x != nil { if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil { if ms.LoadMessageInfo() == nil {
@@ -270,7 +688,7 @@ func (x *Instruction) ProtoReflect() protoreflect.Message {
// Deprecated: Use Instruction.ProtoReflect.Descriptor instead. // Deprecated: Use Instruction.ProtoReflect.Descriptor instead.
func (*Instruction) Descriptor() ([]byte, []int) { func (*Instruction) Descriptor() ([]byte, []int) {
return file_device_proto_rawDescGZIP(), []int{4} return file_device_proto_rawDescGZIP(), []int{10}
} }
func (x *Instruction) GetPayload() isInstruction_Payload { func (x *Instruction) GetPayload() isInstruction_Payload {
@@ -298,6 +716,33 @@ func (x *Instruction) GetBatchCollectCommand() *BatchCollectCommand {
return nil 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) GetPrepareUpdateReq() *PrepareUpdateReq {
if x != nil {
if x, ok := x.Payload.(*Instruction_PrepareUpdateReq); ok {
return x.PrepareUpdateReq
}
}
return nil
}
func (x *Instruction) GetFileResponse() *FileResponse {
if x != nil {
if x, ok := x.Payload.(*Instruction_FileResponse); ok {
return x.FileResponse
}
}
return nil
}
func (x *Instruction) GetCollectResult() *CollectResult { func (x *Instruction) GetCollectResult() *CollectResult {
if x != nil { if x != nil {
if x, ok := x.Payload.(*Instruction_CollectResult); ok { if x, ok := x.Payload.(*Instruction_CollectResult); ok {
@@ -307,11 +752,39 @@ func (x *Instruction) GetCollectResult() *CollectResult {
return nil return nil
} }
func (x *Instruction) GetPong() *Pong {
if x != nil {
if x, ok := x.Payload.(*Instruction_Pong); ok {
return x.Pong
}
}
return nil
}
func (x *Instruction) GetRequestFile() *RequestFile {
if x != nil {
if x, ok := x.Payload.(*Instruction_RequestFile); ok {
return x.RequestFile
}
}
return nil
}
func (x *Instruction) GetUpdateStatusReport() *UpdateStatusReport {
if x != nil {
if x, ok := x.Payload.(*Instruction_UpdateStatusReport); ok {
return x.UpdateStatusReport
}
}
return nil
}
type isInstruction_Payload interface { type isInstruction_Payload interface {
isInstruction_Payload() isInstruction_Payload()
} }
type Instruction_Raw_485Command struct { type Instruction_Raw_485Command struct {
// --- 下行指令 (平台 -> 设备) ---
Raw_485Command *Raw485Command `protobuf:"bytes,1,opt,name=raw_485_command,json=raw485Command,proto3,oneof"` Raw_485Command *Raw485Command `protobuf:"bytes,1,opt,name=raw_485_command,json=raw485Command,proto3,oneof"`
} }
@@ -319,16 +792,53 @@ type Instruction_BatchCollectCommand struct {
BatchCollectCommand *BatchCollectCommand `protobuf:"bytes,2,opt,name=batch_collect_command,json=batchCollectCommand,proto3,oneof"` BatchCollectCommand *BatchCollectCommand `protobuf:"bytes,2,opt,name=batch_collect_command,json=batchCollectCommand,proto3,oneof"`
} }
type Instruction_Ping struct {
Ping *Ping `protobuf:"bytes,3,opt,name=ping,proto3,oneof"`
}
type Instruction_PrepareUpdateReq struct {
PrepareUpdateReq *PrepareUpdateReq `protobuf:"bytes,4,opt,name=prepare_update_req,json=prepareUpdateReq,proto3,oneof"`
}
type Instruction_FileResponse struct {
FileResponse *FileResponse `protobuf:"bytes,5,opt,name=file_response,json=fileResponse,proto3,oneof"`
}
type Instruction_CollectResult struct { 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_Pong struct {
Pong *Pong `protobuf:"bytes,102,opt,name=pong,proto3,oneof"`
}
type Instruction_RequestFile struct {
RequestFile *RequestFile `protobuf:"bytes,103,opt,name=request_file,json=requestFile,proto3,oneof"`
}
type Instruction_UpdateStatusReport struct {
UpdateStatusReport *UpdateStatusReport `protobuf:"bytes,104,opt,name=update_status_report,json=updateStatusReport,proto3,oneof"`
} }
func (*Instruction_Raw_485Command) isInstruction_Payload() {} func (*Instruction_Raw_485Command) isInstruction_Payload() {}
func (*Instruction_BatchCollectCommand) isInstruction_Payload() {} func (*Instruction_BatchCollectCommand) isInstruction_Payload() {}
func (*Instruction_Ping) isInstruction_Payload() {}
func (*Instruction_PrepareUpdateReq) isInstruction_Payload() {}
func (*Instruction_FileResponse) isInstruction_Payload() {}
func (*Instruction_CollectResult) isInstruction_Payload() {} func (*Instruction_CollectResult) isInstruction_Payload() {}
func (*Instruction_Pong) isInstruction_Payload() {}
func (*Instruction_RequestFile) isInstruction_Payload() {}
func (*Instruction_UpdateStatusReport) isInstruction_Payload() {}
var File_device_proto protoreflect.FileDescriptor var File_device_proto protoreflect.FileDescriptor
const file_device_proto_rawDesc = "" + const file_device_proto_rawDesc = "" +
@@ -345,11 +855,50 @@ const file_device_proto_rawDesc = "" +
"\acommand\x18\x01 \x01(\v2\x15.device.Raw485CommandR\acommand\"N\n" + "\acommand\x18\x01 \x01(\v2\x15.device.Raw485CommandR\acommand\"N\n" +
"\rCollectResult\x12%\n" + "\rCollectResult\x12%\n" +
"\x0ecorrelation_id\x18\x01 \x01(\tR\rcorrelationId\x12\x16\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\"\x06\n" +
"\x04Ping\"1\n" +
"\x04Pong\x12)\n" +
"\x10firmware_version\x18\x01 \x01(\tR\x0ffirmwareVersion\"\xc1\x01\n" +
"\x10PrepareUpdateReq\x12\x18\n" +
"\aversion\x18\x01 \x01(\tR\aversion\x12\x17\n" +
"\atask_id\x18\x02 \x01(\rR\x06taskId\x12!\n" +
"\fmanifest_md5\x18\x03 \x01(\tR\vmanifestMd5\x12\x1f\n" +
"\vretry_count\x18\x04 \x01(\rR\n" +
"retryCount\x126\n" +
"\x17request_timeout_seconds\x18\x05 \x01(\rR\x15requestTimeoutSeconds\"B\n" +
"\vRequestFile\x12\x17\n" +
"\atask_id\x18\x01 \x01(\rR\x06taskId\x12\x1a\n" +
"\bfilepath\x18\x02 \x01(\tR\bfilepath\"]\n" +
"\fFileResponse\x12\x17\n" +
"\atask_id\x18\x01 \x01(\rR\x06taskId\x12\x1a\n" +
"\bfilepath\x18\x02 \x01(\tR\bfilepath\x12\x18\n" +
"\acontent\x18\x03 \x01(\fR\acontent\"\x9a\x03\n" +
"\x12UpdateStatusReport\x12\x17\n" +
"\atask_id\x18\x01 \x01(\rR\x06taskId\x12'\n" +
"\x0fcurrent_version\x18\x02 \x01(\tR\x0ecurrentVersion\x129\n" +
"\x06status\x18\x03 \x01(\x0e2!.device.UpdateStatusReport.StatusR\x06status\x12#\n" +
"\rerror_message\x18\x04 \x01(\tR\ferrorMessage\x12\x1f\n" +
"\vfailed_file\x18\x05 \x01(\tR\n" +
"failedFile\"\xc0\x01\n" +
"\x06Status\x12\x16\n" +
"\x12STATUS_UNSPECIFIED\x10\x00\x12\v\n" +
"\aSUCCESS\x10\x01\x12\x1e\n" +
"\x1aSUCCESS_ALREADY_UP_TO_DATE\x10\x02\x12\x14\n" +
"\x10FAILED_PRE_CHECK\x10\x03\x12\x1a\n" +
"\x16FAILED_MANIFEST_VERIFY\x10\x04\x12\x13\n" +
"\x0fFAILED_DOWNLOAD\x10\x05\x12\x16\n" +
"\x12FAILED_ROLLED_BACK\x10\x06\x12\x12\n" +
"\x0eFAILED_TIMEOUT\x10\a\"\xc5\x04\n" +
"\vInstruction\x12?\n" + "\vInstruction\x12?\n" +
"\x0fraw_485_command\x18\x01 \x01(\v2\x15.device.Raw485CommandH\x00R\rraw485Command\x12Q\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" + "\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" + "\x04ping\x18\x03 \x01(\v2\f.device.PingH\x00R\x04ping\x12H\n" +
"\x12prepare_update_req\x18\x04 \x01(\v2\x18.device.PrepareUpdateReqH\x00R\x10prepareUpdateReq\x12;\n" +
"\rfile_response\x18\x05 \x01(\v2\x14.device.FileResponseH\x00R\ffileResponse\x12>\n" +
"\x0ecollect_result\x18e \x01(\v2\x15.device.CollectResultH\x00R\rcollectResult\x12\"\n" +
"\x04pong\x18f \x01(\v2\f.device.PongH\x00R\x04pong\x128\n" +
"\frequest_file\x18g \x01(\v2\x13.device.RequestFileH\x00R\vrequestFile\x12N\n" +
"\x14update_status_report\x18h \x01(\v2\x1a.device.UpdateStatusReportH\x00R\x12updateStatusReportB\t\n" +
"\apayloadB\x1eZ\x1cinternal/domain/device/protob\x06proto3" "\apayloadB\x1eZ\x1cinternal/domain/device/protob\x06proto3"
var ( var (
@@ -364,25 +913,40 @@ func file_device_proto_rawDescGZIP() []byte {
return file_device_proto_rawDescData 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, 11)
var file_device_proto_goTypes = []any{ var file_device_proto_goTypes = []any{
(*Raw485Command)(nil), // 0: device.Raw485Command (UpdateStatusReport_Status)(0), // 0: device.UpdateStatusReport.Status
(*BatchCollectCommand)(nil), // 1: device.BatchCollectCommand (*Raw485Command)(nil), // 1: device.Raw485Command
(*CollectTask)(nil), // 2: device.CollectTask (*BatchCollectCommand)(nil), // 2: device.BatchCollectCommand
(*CollectResult)(nil), // 3: device.CollectResult (*CollectTask)(nil), // 3: device.CollectTask
(*Instruction)(nil), // 4: device.Instruction (*CollectResult)(nil), // 4: device.CollectResult
(*Ping)(nil), // 5: device.Ping
(*Pong)(nil), // 6: device.Pong
(*PrepareUpdateReq)(nil), // 7: device.PrepareUpdateReq
(*RequestFile)(nil), // 8: device.RequestFile
(*FileResponse)(nil), // 9: device.FileResponse
(*UpdateStatusReport)(nil), // 10: device.UpdateStatusReport
(*Instruction)(nil), // 11: device.Instruction
} }
var file_device_proto_depIdxs = []int32{ var file_device_proto_depIdxs = []int32{
2, // 0: device.BatchCollectCommand.tasks:type_name -> device.CollectTask 3, // 0: device.BatchCollectCommand.tasks:type_name -> device.CollectTask
0, // 1: device.CollectTask.command:type_name -> device.Raw485Command 1, // 1: device.CollectTask.command:type_name -> device.Raw485Command
0, // 2: device.Instruction.raw_485_command:type_name -> device.Raw485Command 0, // 2: device.UpdateStatusReport.status:type_name -> device.UpdateStatusReport.Status
1, // 3: device.Instruction.batch_collect_command:type_name -> device.BatchCollectCommand 1, // 3: device.Instruction.raw_485_command:type_name -> device.Raw485Command
3, // 4: device.Instruction.collect_result:type_name -> device.CollectResult 2, // 4: device.Instruction.batch_collect_command:type_name -> device.BatchCollectCommand
5, // [5:5] is the sub-list for method output_type 5, // 5: device.Instruction.ping:type_name -> device.Ping
5, // [5:5] is the sub-list for method input_type 7, // 6: device.Instruction.prepare_update_req:type_name -> device.PrepareUpdateReq
5, // [5:5] is the sub-list for extension type_name 9, // 7: device.Instruction.file_response:type_name -> device.FileResponse
5, // [5:5] is the sub-list for extension extendee 4, // 8: device.Instruction.collect_result:type_name -> device.CollectResult
0, // [0:5] is the sub-list for field type_name 6, // 9: device.Instruction.pong:type_name -> device.Pong
8, // 10: device.Instruction.request_file:type_name -> device.RequestFile
10, // 11: device.Instruction.update_status_report:type_name -> device.UpdateStatusReport
12, // [12:12] is the sub-list for method output_type
12, // [12:12] is the sub-list for method input_type
12, // [12:12] is the sub-list for extension type_name
12, // [12:12] is the sub-list for extension extendee
0, // [0:12] is the sub-list for field type_name
} }
func init() { file_device_proto_init() } func init() { file_device_proto_init() }
@@ -390,23 +954,30 @@ func file_device_proto_init() {
if File_device_proto != nil { if File_device_proto != nil {
return return
} }
file_device_proto_msgTypes[4].OneofWrappers = []any{ file_device_proto_msgTypes[10].OneofWrappers = []any{
(*Instruction_Raw_485Command)(nil), (*Instruction_Raw_485Command)(nil),
(*Instruction_BatchCollectCommand)(nil), (*Instruction_BatchCollectCommand)(nil),
(*Instruction_Ping)(nil),
(*Instruction_PrepareUpdateReq)(nil),
(*Instruction_FileResponse)(nil),
(*Instruction_CollectResult)(nil), (*Instruction_CollectResult)(nil),
(*Instruction_Pong)(nil),
(*Instruction_RequestFile)(nil),
(*Instruction_UpdateStatusReport)(nil),
} }
type x struct{} type x struct{}
out := protoimpl.TypeBuilder{ out := protoimpl.TypeBuilder{
File: protoimpl.DescBuilder{ File: protoimpl.DescBuilder{
GoPackagePath: reflect.TypeOf(x{}).PkgPath(), GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
RawDescriptor: unsafe.Slice(unsafe.StringData(file_device_proto_rawDesc), len(file_device_proto_rawDesc)), RawDescriptor: unsafe.Slice(unsafe.StringData(file_device_proto_rawDesc), len(file_device_proto_rawDesc)),
NumEnums: 0, NumEnums: 1,
NumMessages: 5, NumMessages: 11,
NumExtensions: 0, NumExtensions: 0,
NumServices: 0, NumServices: 0,
}, },
GoTypes: file_device_proto_goTypes, GoTypes: file_device_proto_goTypes,
DependencyIndexes: file_device_proto_depIdxs, DependencyIndexes: file_device_proto_depIdxs,
EnumInfos: file_device_proto_enumTypes,
MessageInfos: file_device_proto_msgTypes, MessageInfos: file_device_proto_msgTypes,
}.Build() }.Build()
File_device_proto = out.File File_device_proto = out.File

View File

@@ -2,11 +2,9 @@ syntax = "proto3";
package device; package device;
// import "google/protobuf/any.proto"; // REMOVED: Not suitable for embedded systems.
option go_package = "internal/domain/device/proto"; option go_package = "internal/domain/device/proto";
// --- Concrete Command & Data Structures --- // --- 核心指令与数据结构 ---
// 平台生成的原始485指令单片机直接发送到总线 // 平台生成的原始485指令单片机直接发送到总线
message Raw485Command { message Raw485Command {
@@ -14,38 +12,97 @@ message Raw485Command {
bytes command_bytes = 2; // 原始485指令的字节数组 bytes command_bytes = 2; // 原始485指令的字节数组
} }
// BatchCollectCommand
// 一个完整的、包含所有元数据的批量采集任务。 // 一个完整的、包含所有元数据的批量采集任务。
message BatchCollectCommand { message BatchCollectCommand {
string correlation_id = 1; // 用于关联请求和响应的唯一ID string correlation_id = 1; // 用于关联请求和响应的唯一ID
repeated CollectTask tasks = 2; // 采集任务列表 repeated CollectTask tasks = 2; // 采集任务列表
} }
// CollectTask
// 定义了单个采集任务的“意图”。 // 定义了单个采集任务的“意图”。
message CollectTask { message CollectTask {
Raw485Command command = 1; // 平台生成的原始485指令 Raw485Command command = 1; // 平台生成的原始485指令
} }
// CollectResult
// 这是设备响应的、极致精简的数据包。 // 这是设备响应的、极致精简的数据包。
message CollectResult { message CollectResult {
string correlation_id = 1; // 从下行指令中原样返回的关联ID string correlation_id = 1; // 从下行指令中原样返回的关联ID
repeated float values = 2; // 按预定顺序排列的采集值 repeated float values = 2; // 按预定顺序排列的采集值
} }
// 平台向设备发送的Ping指令用于检查存活性。
message Ping {
// 可以留空,指令本身即代表意图
}
// --- Main Downlink Instruction Wrapper --- // 设备对Ping的响应或设备主动上报的心跳。
// 它包含了设备的关键状态信息。
message Pong {
string firmware_version = 1; // 当前固件版本
// 可以扩展更多状态, e.g., int32 uptime_seconds = 2;
}
// 指令 (所有从平台下发到设备的数据都应该被包装在这里面) // --- OTA 升级相关 ---
// 使用 oneof 来替代 google.protobuf.Any这是嵌入式环境下的标准做法。
// 它高效、类型安全,且只解码一次。 // PrepareUpdateReq: 平台发送给设备,通知设备准备开始 OTA 升级 (下行)
message PrepareUpdateReq {
string version = 1; // 新固件版本号
uint32 task_id = 2; // 升级任务唯一ID
string manifest_md5 = 3; // 清单文件的 MD5 校验和,用于设备初步校验清单文件完整性
uint32 retry_count = 4; // 建议的重试次数
uint32 request_timeout_seconds = 5; // 建议的单次请求超时时间
}
// RequestFile: 设备向平台请求特定文件 (包括清单文件和固件文件) (上行)
message RequestFile {
uint32 task_id = 1; // 升级任务ID
string filepath = 2; // 请求的文件路径 (例如 "/manifest.json" 或 "/main.py")
}
// FileResponse: 平台响应设备请求,发送单个文件的完整内容 (下行)
// LoRa 传输层会自动处理分片和重组,因此应用层可以直接发送完整的单个文件内容
message FileResponse {
uint32 task_id = 1; // 升级任务ID
string filepath = 2; // 设备上的目标路径 (例如 "/manifest.json" 或 "/main.py")
bytes content = 3; // 文件的完整内容
}
// UpdateStatusReport: 设备向平台报告升级状态 (上行)
message UpdateStatusReport {
uint32 task_id = 1; // 升级任务ID
string current_version = 2; // 操作完成后的当前版本
enum Status {
STATUS_UNSPECIFIED = 0; // 未指定protobuf3 要求枚举从0开始
SUCCESS = 1; // 升级成功,新固件已运行
SUCCESS_ALREADY_UP_TO_DATE = 2; // 版本已是最新,未执行升级
FAILED_PRE_CHECK = 3; // 升级前检查失败 (例如拒绝降级、准备分区失败)
FAILED_MANIFEST_VERIFY = 4; // 清单文件下载或校验失败
FAILED_DOWNLOAD = 5; // 固件文件下载或校验失败
FAILED_ROLLED_BACK = 6; // 新固件启动失败,已自动回滚
FAILED_TIMEOUT = 7; // 平台在超时后仍未收到SUCCESS报告将任务标记为此状态 (平台推断)
}
Status status = 3; // 升级的最终状态
string error_message = 4; // 人类可读的详细错误信息
string failed_file = 5; // 失败时关联的文件路径 (可选)
}
// --- 顶层指令包装器 ---
// Instruction 封装了所有与设备间的通信。
// 使用 oneof 来确保每个消息只有一个负载类型,这在嵌入式系统中是高效且类型安全的。
message Instruction { message Instruction {
oneof payload { oneof payload {
// --- 下行指令 (平台 -> 设备) ---
Raw485Command raw_485_command = 1; Raw485Command raw_485_command = 1;
BatchCollectCommand batch_collect_command = 2; BatchCollectCommand batch_collect_command = 2;
CollectResult collect_result = 3; // ADDED用于上行数据 Ping ping = 3;
// 如果未来有其他指令类型,比如开关控制,可以直接在这里添加 PrepareUpdateReq prepare_update_req = 4;
// SwitchCommand switch_command = 3; FileResponse file_response = 5;
// --- 上行数据 (设备 -> 平台) ---
CollectResult collect_result = 101;
Pong pong = 102;
RequestFile request_file = 103;
UpdateStatusReport update_status_report = 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 ( import (
"context" "context"
"time" "time"
"git.huangwc.com/pig/pig-farm-controller/internal/infra/transport/proto"
) )
// Communicator 用于其他设备通信 // Communicator 用于其他设备通信
@@ -35,3 +37,17 @@ type Listener interface {
// Stop 用于停止监听 // Stop 用于停止监听
Stop(ctx context.Context) error 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,10 @@ 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-health-check-routing/index.md
design/archive/2025-11-06-system-plan-continuously-triggered/index.md design/archive/2025-11-06-system-plan-continuously-triggered/index.md
design/archive/2025-11-10-exceeding-threshold-alarm/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
design/ota-upgrade-and-log-monitoring/ota_upgrade_solution.md
docs/docs.go docs/docs.go
docs/swagger.json docs/swagger.json
docs/swagger.yaml docs/swagger.yaml
@@ -84,6 +87,11 @@ internal/app/dto/pig_farm_dto.go
internal/app/dto/plan_converter.go internal/app/dto/plan_converter.go
internal/app/dto/plan_dto.go internal/app/dto/plan_dto.go
internal/app/dto/user_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/audit.go
internal/app/middleware/auth.go internal/app/middleware/auth.go
internal/app/service/audit_service.go internal/app/service/audit_service.go
@@ -102,10 +110,6 @@ internal/app/service/raw_material_service.go
internal/app/service/recipe_service.go internal/app/service/recipe_service.go
internal/app/service/threshold_alarm_service.go internal/app/service/threshold_alarm_service.go
internal/app/service/user_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/application.go
internal/core/component_initializers.go internal/core/component_initializers.go
internal/core/data_initializer.go internal/core/data_initializer.go
@@ -139,9 +143,14 @@ internal/domain/task/area_threshold_check_task.go
internal/domain/task/delay_task.go internal/domain/task/delay_task.go
internal/domain/task/device_threshold_check_task.go internal/domain/task/device_threshold_check_task.go
internal/domain/task/full_collection_task.go internal/domain/task/full_collection_task.go
internal/domain/task/heartbeat_task.go
internal/domain/task/ota_check_task.go
internal/domain/task/refresh_notification_task.go internal/domain/task/refresh_notification_task.go
internal/domain/task/release_feed_weight_task.go internal/domain/task/release_feed_weight_task.go
internal/domain/task/task.go internal/domain/task/task.go
internal/infra/ai/ai.go
internal/infra/ai/gemini.go
internal/infra/ai/no_ai.go
internal/infra/config/config.go internal/infra/config/config.go
internal/infra/database/postgres.go internal/infra/database/postgres.go
internal/infra/database/seeder.go internal/infra/database/seeder.go
@@ -187,6 +196,7 @@ internal/infra/repository/execution_log_repository.go
internal/infra/repository/medication_log_repository.go internal/infra/repository/medication_log_repository.go
internal/infra/repository/notification_repository.go internal/infra/repository/notification_repository.go
internal/infra/repository/nutrient_repository.go internal/infra/repository/nutrient_repository.go
internal/infra/repository/ota_repository.go
internal/infra/repository/pending_collection_repository.go internal/infra/repository/pending_collection_repository.go
internal/infra/repository/pending_task_repository.go internal/infra/repository/pending_task_repository.go
internal/infra/repository/pig_batch_log_repository.go internal/infra/repository/pig_batch_log_repository.go
@@ -210,6 +220,7 @@ internal/infra/transport/lora/lora_mesh_uart_passthrough_transport.go
internal/infra/transport/lora/placeholder_transport.go internal/infra/transport/lora/placeholder_transport.go
internal/infra/transport/proto/device.pb.go internal/infra/transport/proto/device.pb.go
internal/infra/transport/proto/device.proto internal/infra/transport/proto/device.proto
internal/infra/transport/proto/exported.go
internal/infra/transport/transport.go internal/infra/transport/transport.go
internal/infra/utils/command_generater/modbus_rtu.go internal/infra/utils/command_generater/modbus_rtu.go
internal/infra/utils/time.go internal/infra/utils/time.go