Compare commits
27 Commits
de68151539
...
issue_71
| Author | SHA1 | Date | |
|---|---|---|---|
| 3cfe532517 | |||
| b26b7ee0f3 | |||
| 9d9b5f801f | |||
| a1deb0011b | |||
| 4a3c82fc25 | |||
| 7974955335 | |||
| 72d70e90f1 | |||
| 3bede99cc6 | |||
| 95aaf80f3a | |||
| 77ed812901 | |||
| 2e6a0abac3 | |||
| d5056af676 | |||
| 1e685340f8 | |||
| 7ec9fb3f0b | |||
| d430307b48 | |||
| 5113e5953a | |||
| 766fda7292 | |||
| 1bc49ea249 | |||
| 70fad51f40 | |||
| 6764684fe7 | |||
| da2c296c05 | |||
| bdf74652b3 | |||
| 70e8627a96 | |||
| c2c6577064 | |||
| 260c7d054c | |||
| d25933cf26 | |||
| 4aa56441ce |
11
Makefile
11
Makefile
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -46,6 +46,20 @@ http://git.huangwc.com/pig/pig-farm-controller/issues/66
|
|||||||
7. 简单查看功能
|
7. 简单查看功能
|
||||||
- 两个配方对比页面(营养+成本对比)
|
- 两个配方对比页面(营养+成本对比)
|
||||||
|
|
||||||
|
# 实现总结
|
||||||
|
|
||||||
|
## 实现内容
|
||||||
|
|
||||||
|
实现库存和原料和营养和猪营养需求的管理, 支持根据库存和已录入原料和猪营养需求生成配方
|
||||||
|
|
||||||
|
## TODO
|
||||||
|
|
||||||
|
1. 发酵料管理考虑到发酵目前没有自动化流程, 不好追踪, 遂暂时不做
|
||||||
|
2. 目前的价格是根据原料的参考价设置的, 后续应当实现一个在服务供平台采集参考价, 以及使用原料采购价计算
|
||||||
|
3. 原料应该加上膨润土等, 比如膨润土的黄曲霉素含量应该是负数以表示减少饲料里的含量
|
||||||
|
4. 饲料保质期考虑到批次间管理暂时不方便, 等可以实现同一原料先进先出后再实现
|
||||||
|
5. 暂时不支持指定原料列表然后自动生成, 也不支持告诉用户当前生成不出是为什么, 等以后再做
|
||||||
|
|
||||||
# 完成事项
|
# 完成事项
|
||||||
|
|
||||||
1. 定义原料表, 营养表, 原料营养表, 原料库存变更表
|
1. 定义原料表, 营养表, 原料营养表, 原料库存变更表
|
||||||
20
design/ota-upgrade-and-log-monitoring/index.md
Normal file
20
design/ota-upgrade-and-log-monitoring/index.md
Normal 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)
|
||||||
199
design/ota-upgrade-and-log-monitoring/lora_refactoring_plan.md
Normal file
199
design/ota-upgrade-and-log-monitoring/lora_refactoring_plan.md
Normal 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` 进行单元测试,无需真实的硬件或网络。
|
||||||
304
design/ota-upgrade-and-log-monitoring/ota_upgrade_solution.md
Normal file
304
design/ota-upgrade-and-log-monitoring/ota_upgrade_solution.md
Normal 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)。
|
||||||
116
docs/docs.go
116
docs/docs.go
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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
34
go.mod
@@ -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
85
go.sum
@@ -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=
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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("饲料管理相关接口注册成功 (需要认证和审计)")
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
package dto
|
package dto
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -65,22 +64,34 @@ 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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return &AreaControllerResponse{
|
return &AreaControllerResponse{
|
||||||
ID: ac.ID,
|
ID: ac.ID,
|
||||||
Name: ac.Name,
|
Name: ac.Name,
|
||||||
NetworkID: ac.NetworkID,
|
NetworkID: ac.NetworkID,
|
||||||
Location: ac.Location,
|
FirmwareVersion: firmwareVersion,
|
||||||
Status: ac.Status,
|
Location: ac.Location,
|
||||||
Properties: props,
|
Status: ac.Status,
|
||||||
CreatedAt: ac.CreatedAt.Format(time.RFC3339),
|
Properties: allProps, // 填充完整的 properties
|
||||||
UpdatedAt: ac.UpdatedAt.Format(time.RFC3339),
|
CreatedAt: ac.CreatedAt.Format(time.RFC3339),
|
||||||
|
UpdatedAt: ac.UpdatedAt.Format(time.RFC3339),
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -78,14 +78,15 @@ type DeviceResponse struct {
|
|||||||
|
|
||||||
// AreaControllerResponse 定义了返回给客户端的单个区域主控信息的结构
|
// AreaControllerResponse 定义了返回给客户端的单个区域主控信息的结构
|
||||||
type AreaControllerResponse struct {
|
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"`
|
||||||
Location string `json:"location"`
|
FirmwareVersion string `json:"firmware_version"`
|
||||||
Status string `json:"status"`
|
Location string `json:"location"`
|
||||||
Properties map[string]interface{} `json:"properties"`
|
Status string `json:"status"`
|
||||||
CreatedAt string `json:"created_at"`
|
Properties map[string]interface{} `json:"properties"`
|
||||||
UpdatedAt string `json:"updated_at"`
|
CreatedAt string `json:"created_at"`
|
||||||
|
UpdatedAt string `json:"updated_at"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// DeviceTemplateResponse 定义了返回给客户端的单个设备模板信息的结构
|
// DeviceTemplateResponse 定义了返回给客户端的单个设备模板信息的结构
|
||||||
|
|||||||
@@ -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 模型
|
||||||
|
}
|
||||||
|
|||||||
175
internal/app/listener/chirp_stack/chirp_stack.go
Normal file
175
internal/app/listener/chirp_stack/chirp_stack.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package webhook
|
package chirp_stack
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
@@ -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,
|
||||||
}
|
}
|
||||||
300
internal/app/listener/lora_listener.go
Normal file
300
internal/app/listener/lora_listener.go
Normal 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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
}
|
}
|
||||||
@@ -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{
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
|||||||
@@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
|||||||
@@ -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, 用于后续匹配响应
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
93
internal/domain/task/heartbeat_task.go
Normal file
93
internal/domain/task/heartbeat_task.go
Normal 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
|
||||||
|
}
|
||||||
141
internal/domain/task/ota_check_task.go
Normal file
141
internal/domain/task/ota_check_task.go
Normal 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(¶ms); 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
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -14,20 +14,28 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
CompNameDelayTask = "DelayTask"
|
CompNameDelayTask = "DelayTask"
|
||||||
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 {
|
||||||
ctx context.Context
|
ctx context.Context
|
||||||
|
|
||||||
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
19
internal/infra/ai/ai.go
Normal 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
|
||||||
|
}
|
||||||
73
internal/infra/ai/gemini.go
Normal file
73
internal/infra/ai/gemini.go
Normal 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
|
||||||
|
}
|
||||||
31
internal/infra/ai/no_ai.go
Normal file
31
internal/infra/ai/no_ai.go
Normal 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
|
||||||
|
}
|
||||||
@@ -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 {
|
||||||
// 默认值可以在这里设置,但我们优先使用配置文件中的值
|
// 默认值可以在这里设置,但我们优先使用配置文件中的值
|
||||||
|
|||||||
@@ -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()}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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"
|
||||||
|
}
|
||||||
|
|
||||||
// --- 用户审计日志 ---
|
// --- 用户审计日志 ---
|
||||||
|
|
||||||
// --- 审计日志状态常量 ---
|
// --- 审计日志状态常量 ---
|
||||||
|
|||||||
@@ -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{},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 定义了计划的执行类型
|
||||||
@@ -40,14 +44,16 @@ const (
|
|||||||
type TaskType string
|
type TaskType string
|
||||||
|
|
||||||
const (
|
const (
|
||||||
TaskPlanAnalysis TaskType = "计划分析" // 解析Plan的Task列表并添加到待执行队列的特殊任务
|
TaskPlanAnalysis TaskType = "计划分析" // 解析Plan的Task列表并添加到待执行队列的特殊任务
|
||||||
TaskTypeWaiting TaskType = "等待" // 等待任务
|
TaskTypeWaiting TaskType = "等待" // 等待任务
|
||||||
TaskTypeReleaseFeedWeight TaskType = "下料" // 下料口释放指定重量任务
|
TaskTypeReleaseFeedWeight TaskType = "下料" // 下料口释放指定重量任务
|
||||||
TaskTypeFullCollection TaskType = "全量采集" // 新增的全量采集任务
|
TaskTypeFullCollection TaskType = "全量采集" // 新增的全量采集任务
|
||||||
TaskTypeAlarmNotification TaskType = "告警通知" // 告警通知任务
|
TaskTypeHeartbeat TaskType = "心跳检测" // 区域主控心跳检测任务
|
||||||
TaskTypeNotificationRefresh TaskType = "通知刷新" // 通知刷新任务
|
TaskTypeAlarmNotification TaskType = "告警通知" // 告警通知任务
|
||||||
TaskTypeDeviceThresholdCheck TaskType = "设备阈值检查" // 设备阈值检查任务
|
TaskTypeNotificationRefresh TaskType = "通知刷新" // 通知刷新任务
|
||||||
TaskTypeAreaCollectorThresholdCheck TaskType = "区域阈值检查" // 区域阈值检查任务
|
TaskTypeDeviceThresholdCheck TaskType = "设备阈值检查" // 设备阈值检查任务
|
||||||
|
TaskTypeAreaCollectorThresholdCheck TaskType = "区域阈值检查" // 区域阈值检查任务
|
||||||
|
TaskTypeOTACheck TaskType = "OTA升级检查任务" // OTA升级超时检查任务
|
||||||
)
|
)
|
||||||
|
|
||||||
// -- Task Parameters --
|
// -- Task Parameters --
|
||||||
|
|||||||
@@ -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 是数据记录的时间戳,作为复合主键的一部分。
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
52
internal/infra/repository/ota_repository.go
Normal file
52
internal/infra/repository/ota_repository.go
Normal 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
|
||||||
|
}
|
||||||
@@ -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"`
|
||||||
|
|
||||||
|
|||||||
@@ -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"`
|
||||||
|
|
||||||
|
|||||||
@@ -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"`
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
13
internal/infra/transport/proto/exported.go
Normal file
13
internal/infra/transport/proto/exported.go
Normal 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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user