调整 device和模板

This commit is contained in:
2025-09-29 23:46:28 +08:00
parent bc97c6bfed
commit 35c2d03602
3 changed files with 52 additions and 312 deletions

View File

@@ -9,30 +9,12 @@ import (
"gorm.io/gorm" "gorm.io/gorm"
) )
// 设备属性名大全
var (
// 普通开关式设备
BusNumber = "bus_number" // 总线号
BusAddress = "bus_address" // 总线地址
RelayChannel = "relay_channel" // 继电器通道号
// 区域主控
LoRaAddress = "lora_address" // 区域主控 LoRa 地址, 如果使用LoRa网关也可能是LoRa网关记录的设备ID
)
// --- Properties 结构体定义 --- // --- Properties 结构体定义 ---
// LoraProperties 定义了区域主控的特有属性 // Bus485Properties 定义了总线设备的特有属性
type LoraProperties struct { type Bus485Properties struct {
LoraAddress string `json:"lora_address"` // LoRa 地址 BusNumber uint8 `json:"bus_number"` // 485 总线号
} BusAddress uint8 `json:"bus_address"` // 485 总线地址
// BusProperties 定义了总线设备的特有属性
type BusProperties struct {
BusNumber int `json:"bus_number"` // 485 总线号
BusAddress int `json:"bus_address"` // 485 总线地址
RelayChannel int `json:"relay_channel"` // 继电器通道号
} }
// AreaController 是一个LoRa转总线(如485)的通信网关 // AreaController 是一个LoRa转总线(如485)的通信网关
@@ -93,7 +75,7 @@ type Device struct {
Location string `gorm:"index" json:"location"` Location string `gorm:"index" json:"location"`
// Properties 用于存储特定类型设备的独有属性采用JSON格式。 // Properties 用于存储特定类型设备的独有属性采用JSON格式。
// 建议在应用层为不同子类型的设备定义专用的属性结构体(如 LoraProperties, BusProperties,以保证数据一致性。 // 建议在应用层为不同子类型的设备定义专用的属性结构体,以保证数据一致性。
Properties datatypes.JSON `json:"properties"` Properties datatypes.JSON `json:"properties"`
} }
@@ -111,12 +93,12 @@ func (d *Device) SelfCheck() error {
return errors.New("设备属性 (Properties) 不能为空") return errors.New("设备属性 (Properties) 不能为空")
} }
var props map[string]interface{} var props Bus485Properties
if err := json.Unmarshal(d.Properties, &props); err != nil { if err := json.Unmarshal(d.Properties, &props); err != nil {
return errors.New("无法解析设备属性 (Properties)") return errors.New("无法解析设备属性 (Properties)")
} }
if _, ok := props[BusAddress]; !ok { if props.BusAddress == 0 {
return errors.New("设备属性 (Properties) 中缺少总线地址 (bus_address)") return errors.New("设备属性 (Properties) 中缺少总线地址 (bus_address)")
} }

View File

@@ -3,12 +3,14 @@ package models
import ( import (
"encoding/json" "encoding/json"
"errors" "errors"
"fmt"
"git.huangwc.com/pig/pig-farm-controller/internal/infra/utils/command_generater"
"gorm.io/datatypes" "gorm.io/datatypes"
"gorm.io/gorm" "gorm.io/gorm"
) )
// DeviceCategory 定义了设备模板的宽泛类别 (移除了 Compound) // DeviceCategory 定义了设备模板的宽泛类别
type DeviceCategory string type DeviceCategory string
const ( const (
@@ -22,38 +24,52 @@ const (
// 它提供了必要的元数据,以便应用程序能够正确解释从设备读取的原始数据。 // 它提供了必要的元数据,以便应用程序能够正确解释从设备读取的原始数据。
type ValueDescriptor struct { type ValueDescriptor struct {
Type SensorType `json:"type"` Type SensorType `json:"type"`
Multiplier float64 `json:"multiplier"` Multiplier float64 `json:"multiplier"` // 乘数,用于原始数据转换
Offset float64 `json:"offset"` Offset float64 `json:"offset"` // 偏移量,用于原始数据转换
} }
// --- 指令结构体 (Command Structs) --- // --- 指令结构体 (Command Structs) ---
// SwitchCommands 定义了开关类指令 // SwitchCommands 定义了开关类指令所需的Modbus参数
type SwitchCommands struct { type SwitchCommands struct {
On string `json:"on"` // ModbusStartAddress 记录Modbus寄存器的起始地址用于生成指令。
Off string `json:"off"` ModbusStartAddress uint16 `json:"modbus_start_address"`
// ModbusQuantity 记录Modbus寄存器的数量对于开关通常为1。
ModbusQuantity uint16 `json:"modbus_quantity"`
} }
// SelfCheck 校验开关指令的有效性 // SelfCheck 校验开关指令参数的有效性
func (sc *SwitchCommands) SelfCheck() error { func (sc *SwitchCommands) SelfCheck() error {
if sc.On == "" { // 对于开关数量通常为1
return errors.New("'switch' 指令集缺少 'on' 指令") if sc.ModbusQuantity != 1 {
} return errors.New("'switch' 指令集 ModbusQuantity 必须为1")
if sc.Off == "" {
return errors.New("'switch' 指令集缺少 'off' 指令")
} }
return nil return nil
} }
// SensorCommands 定义了传感器读取指令 // SensorCommands 定义了传感器读取指令所需的Modbus参数
type SensorCommands struct { type SensorCommands struct {
Read string `json:"read"` // ModbusFunctionCode 记录Modbus功能码例如 ReadHoldingRegisters。
ModbusFunctionCode command_generater.ModbusFunctionCode `json:"modbus_function_code"`
// ModbusStartAddress 记录Modbus寄存器的起始地址用于生成指令。
ModbusStartAddress uint16 `json:"modbus_start_address"`
// ModbusQuantity 记录Modbus寄存器的数量用于生成指令。
ModbusQuantity uint16 `json:"modbus_quantity"`
} }
// SelfCheck 校验读取指令的有效性 // SelfCheck 校验读取指令参数的有效性
func (sc *SensorCommands) SelfCheck() error { func (sc *SensorCommands) SelfCheck() error {
if sc.Read == "" { // 校验ModbusFunctionCode是否为读取类型
return errors.New("'sensor' 指令集缺少 'read' 指令") switch sc.ModbusFunctionCode {
case command_generater.ReadCoils, command_generater.ReadDiscreteInputs, command_generater.ReadHoldingRegisters, command_generater.ReadInputRegisters:
// 支持的读取功能码
default:
return fmt.Errorf("'sensor' 指令集 ModbusFunctionCode %X 无效或不是读取类型", sc.ModbusFunctionCode)
}
// 校验ModbusQuantity的合理性例如不能为0且在常见Modbus读取数量限制内
if sc.ModbusQuantity == 0 || sc.ModbusQuantity > 125 {
return fmt.Errorf("'sensor' 指令集 ModbusQuantity 无效: %d, 必须在1-125之间", sc.ModbusQuantity)
} }
return nil return nil
} }
@@ -74,14 +90,14 @@ type DeviceTemplate struct {
// Category 将模板分类为传感器、执行器 // Category 将模板分类为传感器、执行器
Category DeviceCategory `gorm:"not null;index" json:"category"` Category DeviceCategory `gorm:"not null;index" json:"category"`
// Commands 存储了一个从“动作名称”到“原始指令”的映射 // Commands 存储了生成Modbus指令所需的参数而不是原始指令字符串
// 使用 JSON 格式,具有良好的可扩展性。 // 使用 JSON 格式,具有良好的可扩展性。
// 例如,对于风机: {"ON": "01050000FF008C3A", "OFF": "010500000000CDCA"} // 例如,对于执行器 (开关): {"modbus_start_address": 0, "modbus_quantity": 1}
// 例如,对于传感器: {"READ": "010300000001840A"} // 例如,对于传感器: {"modbus_function_code": 3, "modbus_start_address": 0, "modbus_quantity": 1}
Commands datatypes.JSON `json:"commands"` Commands datatypes.JSON `json:"commands"`
// Values 描述了传感器模板所能提供的数据点。 // Values 描述了传感器模板所能提供的数据点。
// 当 Category 是 "sensor" 或 "compound" 时,此字段尤为重要。 // 当 Category 是 "sensor" 时,此字段尤为重要。
// 它是一个 ValueDescriptor 对象的 JSON 数组。 // 它是一个 ValueDescriptor 对象的 JSON 数组。
Values datatypes.JSON `json:"values"` Values datatypes.JSON `json:"values"`
} }
@@ -117,7 +133,7 @@ func (dt *DeviceTemplate) SelfCheck() error {
case CategoryActuator: case CategoryActuator:
var cmd SwitchCommands var cmd SwitchCommands
if err := dt.ParseCommands(&cmd); err != nil { if err := dt.ParseCommands(&cmd); err != nil {
return errors.New("执行器模板的 Commands 无法被解析为 'switch' 指令集") return errors.New("执行器模板的 Commands 无法被解析为 'switch' 指令集: " + err.Error())
} }
if err := cmd.SelfCheck(); err != nil { if err := cmd.SelfCheck(); err != nil {
return err return err
@@ -126,7 +142,7 @@ func (dt *DeviceTemplate) SelfCheck() error {
case CategorySensor: case CategorySensor:
var cmd SensorCommands var cmd SensorCommands
if err := dt.ParseCommands(&cmd); err != nil { if err := dt.ParseCommands(&cmd); err != nil {
return errors.New("传感器模板的 Commands 无法被解析为 'sensor' 指令集") return errors.New("传感器模板的 Commands 无法被解析为 'sensor' 指令集: " + err.Error())
} }
if err := cmd.SelfCheck(); err != nil { if err := cmd.SelfCheck(); err != nil {
return err return err
@@ -135,9 +151,13 @@ func (dt *DeviceTemplate) SelfCheck() error {
if dt.Values == nil { if dt.Values == nil {
return errors.New("传感器类型的设备模板缺少 Values 定义") return errors.New("传感器类型的设备模板缺少 Values 定义")
} }
var values *ValueDescriptor // 这里应该解析为 ValueDescriptor 的切片,因为传感器可能提供多个数据点
var values []ValueDescriptor
if err := dt.ParseValues(&values); err != nil { if err := dt.ParseValues(&values); err != nil {
return errors.New("无法解析传感器模板的 Values 属性") return errors.New("无法解析传感器模板的 Values 属性: " + err.Error())
}
if len(values) == 0 {
return errors.New("传感器类型的设备模板 Values 属性不能为空")
} }
default: default:
return errors.New("未知的设备模板类别") return errors.New("未知的设备模板类别")

View File

@@ -1,262 +0,0 @@
package repository_test
import (
"encoding/json"
"strconv"
"testing"
"git.huangwc.com/pig/pig-farm-controller/internal/infra/models"
"git.huangwc.com/pig/pig-farm-controller/internal/infra/repository"
"github.com/stretchr/testify/assert"
"gorm.io/gorm"
)
// createTestDevice 辅助函数,用于创建测试设备
func createTestDevice(t *testing.T, db *gorm.DB, name string, deviceType models.DeviceType, parentID *uint) *models.Device {
device := &models.Device{
Name: name,
Type: deviceType,
ParentID: parentID,
// 其他字段可以根据需要添加
}
err := db.Create(device).Error
assert.NoError(t, err)
return device
}
func TestRepoCreate(t *testing.T) {
db := setupTestDB(t)
repo := repository.NewGormDeviceRepository(db)
loraProps, _ := json.Marshal(models.LoraProperties{LoraAddress: "0xABCD"})
t.Run("成功创建区域主控", func(t *testing.T) {
device := &models.Device{
Name: "主控A",
Type: models.DeviceTypeAreaController,
Location: "猪舍1",
Properties: loraProps,
}
err := repo.Create(device)
assert.NoError(t, err)
assert.NotZero(t, device.ID, "创建后应获得一个非零ID")
assert.Nil(t, device.ParentID, "区域主控的 ParentID 应为 nil")
})
t.Run("成功创建子设备", func(t *testing.T) {
parent := createTestDevice(t, db, "父设备", models.DeviceTypeAreaController, nil)
child := &models.Device{
Name: "子设备A",
Type: models.DeviceTypeDevice,
ParentID: &parent.ID,
}
err := repo.Create(child)
assert.NoError(t, err)
assert.NotZero(t, child.ID)
assert.NotNil(t, child.ParentID)
assert.Equal(t, parent.ID, *child.ParentID)
})
}
func TestRepoFindByID(t *testing.T) {
db := setupTestDB(t)
repo := repository.NewGormDeviceRepository(db)
device := createTestDevice(t, db, "测试设备", models.DeviceTypeAreaController, nil)
t.Run("成功通过ID查找", func(t *testing.T) {
foundDevice, err := repo.FindByID(device.ID)
assert.NoError(t, err)
assert.NotNil(t, foundDevice)
assert.Equal(t, device.ID, foundDevice.ID)
assert.Equal(t, device.Name, foundDevice.Name)
})
t.Run("查找不存在的ID", func(t *testing.T) {
_, err := repo.FindByID(9999) // 不存在的ID
assert.Error(t, err)
assert.ErrorIs(t, err, gorm.ErrRecordNotFound)
})
t.Run("数据库查询失败", func(t *testing.T) {
// 模拟数据库连接关闭,强制查询失败
sqlDB, _ := db.DB()
sqlDB.Close()
_, err := repo.FindByID(device.ID)
assert.Error(t, err)
assert.Contains(t, err.Error(), "database is closed")
})
}
func TestRepoFindByIDString(t *testing.T) {
db := setupTestDB(t)
repo := repository.NewGormDeviceRepository(db)
device := createTestDevice(t, db, "测试设备", models.DeviceTypeAreaController, nil)
t.Run("成功通过字符串ID查找", func(t *testing.T) {
idStr := strconv.FormatUint(uint64(device.ID), 10)
foundDevice, err := repo.FindByIDString(idStr)
assert.NoError(t, err)
assert.NotNil(t, foundDevice)
assert.Equal(t, device.ID, foundDevice.ID)
})
t.Run("无效的字符串ID格式", func(t *testing.T) {
_, err := repo.FindByIDString("invalid-id")
assert.Error(t, err)
assert.Contains(t, err.Error(), "无效的设备ID格式")
})
t.Run("查找不存在的字符串ID", func(t *testing.T) {
idStr := strconv.FormatUint(uint64(9999), 10) // 不存在的ID
_, err := repo.FindByIDString(idStr)
assert.Error(t, err)
assert.ErrorIs(t, err, gorm.ErrRecordNotFound)
})
t.Run("数据库查询失败", func(t *testing.T) {
sqlDB, _ := db.DB()
sqlDB.Close()
idStr := strconv.FormatUint(uint64(device.ID), 10)
_, err := repo.FindByIDString(idStr)
assert.Error(t, err)
assert.Contains(t, err.Error(), "database is closed")
})
}
func TestRepoListAll(t *testing.T) {
db := setupTestDB(t)
repo := repository.NewGormDeviceRepository(db)
t.Run("成功获取空列表", func(t *testing.T) {
devices, err := repo.ListAll()
assert.NoError(t, err)
assert.Empty(t, devices)
})
t.Run("成功获取包含设备的列表", func(t *testing.T) {
createTestDevice(t, db, "设备1", models.DeviceTypeAreaController, nil)
createTestDevice(t, db, "设备2", models.DeviceTypeDevice, nil)
devices, err := repo.ListAll()
assert.NoError(t, err)
assert.Len(t, devices, 2)
assert.Equal(t, "设备1", devices[0].Name)
assert.Equal(t, "设备2", devices[1].Name)
})
t.Run("数据库查询失败", func(t *testing.T) {
sqlDB, _ := db.DB()
sqlDB.Close()
_, err := repo.ListAll()
assert.Error(t, err)
assert.Contains(t, err.Error(), "database is closed")
})
}
func TestRepoListByParentID(t *testing.T) {
db := setupTestDB(t)
repo := repository.NewGormDeviceRepository(db)
parent1 := createTestDevice(t, db, "父设备1", models.DeviceTypeAreaController, nil)
parent2 := createTestDevice(t, db, "父设备2", models.DeviceTypeAreaController, nil)
child1_1 := createTestDevice(t, db, "子设备1-1", models.DeviceTypeDevice, &parent1.ID)
child1_2 := createTestDevice(t, db, "子设备1-2", models.DeviceTypeDevice, &parent1.ID)
_ = createTestDevice(t, db, "子设备2-1", models.DeviceTypeDevice, &parent2.ID)
t.Run("成功通过父ID查找子设备", func(t *testing.T) {
children, err := repo.ListByParentID(&parent1.ID)
assert.NoError(t, err)
assert.Len(t, children, 2)
assert.Contains(t, []uint{child1_1.ID, child1_2.ID}, children[0].ID)
assert.Contains(t, []uint{child1_1.ID, child1_2.ID}, children[1].ID)
})
t.Run("成功通过nil父ID查找顶层设备", func(t *testing.T) {
parents, err := repo.ListByParentID(nil)
assert.NoError(t, err)
assert.Len(t, parents, 2)
assert.Contains(t, []uint{parent1.ID, parent2.ID}, parents[0].ID)
assert.Contains(t, []uint{parent1.ID, parent2.ID}, parents[1].ID)
})
t.Run("查找不存在的父ID", func(t *testing.T) {
nonExistentParentID := uint(9999)
children, err := repo.ListByParentID(&nonExistentParentID)
assert.NoError(t, err) // GORM 在未找到时返回空列表而不是错误
assert.Empty(t, children)
})
t.Run("数据库查询失败", func(t *testing.T) {
sqlDB, _ := db.DB()
sqlDB.Close()
_, err := repo.ListByParentID(&parent1.ID)
assert.Error(t, err)
assert.Contains(t, err.Error(), "database is closed")
})
}
func TestRepoUpdate(t *testing.T) {
db := setupTestDB(t)
repo := repository.NewGormDeviceRepository(db)
device := createTestDevice(t, db, "原始设备", models.DeviceTypeAreaController, nil)
t.Run("成功更新设备信息", func(t *testing.T) {
device.Name = "更新后的设备"
device.Location = "新地点"
err := repo.Update(device)
assert.NoError(t, err)
updatedDevice, err := repo.FindByID(device.ID)
assert.NoError(t, err)
assert.Equal(t, "更新后的设备", updatedDevice.Name)
assert.Equal(t, "新地点", updatedDevice.Location)
})
t.Run("数据库更新失败", func(t *testing.T) {
sqlDB, _ := db.DB()
sqlDB.Close()
device.Name = "更新失败的设备"
err := repo.Update(device)
assert.Error(t, err)
assert.Contains(t, err.Error(), "database is closed")
})
}
func TestRepoDelete(t *testing.T) {
db := setupTestDB(t)
repo := repository.NewGormDeviceRepository(db)
device := createTestDevice(t, db, "待删除设备", models.DeviceTypeAreaController, nil)
t.Run("成功删除设备", func(t *testing.T) {
err := repo.Delete(device.ID)
assert.NoError(t, err)
// 验证设备已被软删除
_, err = repo.FindByID(device.ID)
assert.Error(t, err, "删除后应无法找到设备")
assert.ErrorIs(t, err, gorm.ErrRecordNotFound, "错误类型应为 RecordNotFound")
})
t.Run("删除不存在的设备", func(t *testing.T) {
err := repo.Delete(9999) // 不存在的ID
assert.NoError(t, err) // GORM 的 Delete 方法在删除不存在的记录时不会报错
})
t.Run("数据库删除失败", func(t *testing.T) {
sqlDB, _ := db.DB()
sqlDB.Close()
err := repo.Delete(device.ID)
assert.Error(t, err)
assert.Contains(t, err.Error(), "database is closed")
})
}