From 35c2d036021cba4dde91e1956b39654af14388fa Mon Sep 17 00:00:00 2001 From: huang <1724659546@qq.com> Date: Mon, 29 Sep 2025 23:46:28 +0800 Subject: [PATCH] =?UTF-8?q?=E8=B0=83=E6=95=B4=20device=E5=92=8C=E6=A8=A1?= =?UTF-8?q?=E6=9D=BF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/infra/models/device.go | 32 +-- internal/infra/models/device_template.go | 70 +++-- .../repository/device_repository_test.go | 262 ------------------ 3 files changed, 52 insertions(+), 312 deletions(-) delete mode 100644 internal/infra/repository/device_repository_test.go diff --git a/internal/infra/models/device.go b/internal/infra/models/device.go index 187f804..2ac9fa7 100644 --- a/internal/infra/models/device.go +++ b/internal/infra/models/device.go @@ -9,30 +9,12 @@ import ( "gorm.io/gorm" ) -// 设备属性名大全 -var ( - - // 普通开关式设备 - BusNumber = "bus_number" // 总线号 - BusAddress = "bus_address" // 总线地址 - RelayChannel = "relay_channel" // 继电器通道号 - - // 区域主控 - LoRaAddress = "lora_address" // 区域主控 LoRa 地址, 如果使用LoRa网关也可能是LoRa网关记录的设备ID -) - // --- Properties 结构体定义 --- -// LoraProperties 定义了区域主控的特有属性 -type LoraProperties struct { - LoraAddress string `json:"lora_address"` // LoRa 地址 -} - -// BusProperties 定义了总线设备的特有属性 -type BusProperties struct { - BusNumber int `json:"bus_number"` // 485 总线号 - BusAddress int `json:"bus_address"` // 485 总线地址 - RelayChannel int `json:"relay_channel"` // 继电器通道号 +// Bus485Properties 定义了总线设备的特有属性 +type Bus485Properties struct { + BusNumber uint8 `json:"bus_number"` // 485 总线号 + BusAddress uint8 `json:"bus_address"` // 485 总线地址 } // AreaController 是一个LoRa转总线(如485)的通信网关 @@ -93,7 +75,7 @@ type Device struct { Location string `gorm:"index" json:"location"` // Properties 用于存储特定类型设备的独有属性,采用JSON格式。 - // 建议在应用层为不同子类型的设备定义专用的属性结构体(如 LoraProperties, BusProperties),以保证数据一致性。 + // 建议在应用层为不同子类型的设备定义专用的属性结构体,以保证数据一致性。 Properties datatypes.JSON `json:"properties"` } @@ -111,12 +93,12 @@ func (d *Device) SelfCheck() error { return errors.New("设备属性 (Properties) 不能为空") } - var props map[string]interface{} + var props Bus485Properties if err := json.Unmarshal(d.Properties, &props); err != nil { return errors.New("无法解析设备属性 (Properties)") } - if _, ok := props[BusAddress]; !ok { + if props.BusAddress == 0 { return errors.New("设备属性 (Properties) 中缺少总线地址 (bus_address)") } diff --git a/internal/infra/models/device_template.go b/internal/infra/models/device_template.go index 28c8aab..5eae4f5 100644 --- a/internal/infra/models/device_template.go +++ b/internal/infra/models/device_template.go @@ -3,12 +3,14 @@ package models import ( "encoding/json" "errors" + "fmt" + "git.huangwc.com/pig/pig-farm-controller/internal/infra/utils/command_generater" "gorm.io/datatypes" "gorm.io/gorm" ) -// DeviceCategory 定义了设备模板的宽泛类别 (移除了 Compound) +// DeviceCategory 定义了设备模板的宽泛类别 type DeviceCategory string const ( @@ -22,38 +24,52 @@ const ( // 它提供了必要的元数据,以便应用程序能够正确解释从设备读取的原始数据。 type ValueDescriptor struct { Type SensorType `json:"type"` - Multiplier float64 `json:"multiplier"` - Offset float64 `json:"offset"` + Multiplier float64 `json:"multiplier"` // 乘数,用于原始数据转换 + Offset float64 `json:"offset"` // 偏移量,用于原始数据转换 } // --- 指令结构体 (Command Structs) --- -// SwitchCommands 定义了开关类指令 +// SwitchCommands 定义了开关类指令所需的Modbus参数 type SwitchCommands struct { - On string `json:"on"` - Off string `json:"off"` + // ModbusStartAddress 记录Modbus寄存器的起始地址,用于生成指令。 + ModbusStartAddress uint16 `json:"modbus_start_address"` + // ModbusQuantity 记录Modbus寄存器的数量,对于开关通常为1。 + ModbusQuantity uint16 `json:"modbus_quantity"` } -// SelfCheck 校验开关指令的有效性 +// SelfCheck 校验开关指令参数的有效性 func (sc *SwitchCommands) SelfCheck() error { - if sc.On == "" { - return errors.New("'switch' 指令集缺少 'on' 指令") - } - if sc.Off == "" { - return errors.New("'switch' 指令集缺少 'off' 指令") + // 对于开关,数量通常为1 + if sc.ModbusQuantity != 1 { + return errors.New("'switch' 指令集 ModbusQuantity 必须为1") } return nil } -// SensorCommands 定义了传感器读取指令 +// SensorCommands 定义了传感器读取指令所需的Modbus参数 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 { - if sc.Read == "" { - return errors.New("'sensor' 指令集缺少 'read' 指令") + // 校验ModbusFunctionCode是否为读取类型 + 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 } @@ -74,14 +90,14 @@ type DeviceTemplate struct { // Category 将模板分类为传感器、执行器 Category DeviceCategory `gorm:"not null;index" json:"category"` - // Commands 存储了一个从“动作名称”到“原始指令”的映射。 + // Commands 存储了生成Modbus指令所需的参数,而不是原始指令字符串。 // 使用 JSON 格式,具有良好的可扩展性。 - // 例如,对于风机: {"ON": "01050000FF008C3A", "OFF": "010500000000CDCA"} - // 例如,对于传感器: {"READ": "010300000001840A"} + // 例如,对于执行器 (开关): {"modbus_start_address": 0, "modbus_quantity": 1} + // 例如,对于传感器: {"modbus_function_code": 3, "modbus_start_address": 0, "modbus_quantity": 1} Commands datatypes.JSON `json:"commands"` // Values 描述了传感器模板所能提供的数据点。 - // 当 Category 是 "sensor" 或 "compound" 时,此字段尤为重要。 + // 当 Category 是 "sensor" 时,此字段尤为重要。 // 它是一个 ValueDescriptor 对象的 JSON 数组。 Values datatypes.JSON `json:"values"` } @@ -117,7 +133,7 @@ func (dt *DeviceTemplate) SelfCheck() error { case CategoryActuator: var cmd SwitchCommands 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 { return err @@ -126,7 +142,7 @@ func (dt *DeviceTemplate) SelfCheck() error { case CategorySensor: var cmd SensorCommands 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 { return err @@ -135,9 +151,13 @@ func (dt *DeviceTemplate) SelfCheck() error { if dt.Values == nil { return errors.New("传感器类型的设备模板缺少 Values 定义") } - var values *ValueDescriptor + // 这里应该解析为 ValueDescriptor 的切片,因为传感器可能提供多个数据点 + var values []ValueDescriptor 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: return errors.New("未知的设备模板类别") diff --git a/internal/infra/repository/device_repository_test.go b/internal/infra/repository/device_repository_test.go deleted file mode 100644 index 03e1b7e..0000000 --- a/internal/infra/repository/device_repository_test.go +++ /dev/null @@ -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") - }) -}