feat: replace iot adapter with mqtt command pipeline

This commit is contained in:
2026-08-03 13:18:11 +08:00
parent 6ed417c8c1
commit fefdba470a
48 changed files with 1620 additions and 338 deletions

View File

@@ -0,0 +1,173 @@
// Package protocol 实现《气体探测器通讯协议》V1.8 的二进制帧编解码。
package protocol
import (
"crypto/aes"
"encoding/binary"
"errors"
"fmt"
"time"
)
const (
StartByte = byte(0x5E)
EndByte = byte(0x5B)
FixedBodyBytes = 29 // key 到有效数据长度,不含载荷和校验。
)
var (
ErrFrameTooShort = errors.New("数据帧长度不足")
ErrBoundary = errors.New("起始符或结束符无效")
ErrLength = errors.New("帧长度不匹配")
ErrChecksum = errors.New("LRC8 校验失败")
ErrPayloadLength = errors.New("数据包有效长度无效")
ErrEncryptedLength = errors.New("密文长度不是 16 字节的倍数")
)
// Frame 是设备原始帧的强类型表示;多字节整数均按大端序传输。
type Frame struct {
KeyID byte
Version byte
Control byte
DeviceKind byte
DeviceType byte
DeviceModel [3]byte
DeviceID [8]byte
PacketNumber uint16
Sequence byte
Final bool
DeviceTime time.Time
Payload []byte
}
// Keyring 按协议支持 0 号明文和 1/2/3 号 AES-128 密钥。
type Keyring map[byte][]byte
// Encode 构造可直接作为 MQTT payload 发布的厂商二进制帧。
func Encode(frame Frame, keys Keyring) ([]byte, error) {
payload, err := cryptPayload(frame.KeyID, frame.Payload, keys, false)
if err != nil {
return nil, err
}
body := make([]byte, FixedBodyBytes+len(payload))
body[0], body[1], body[2] = frame.KeyID, frame.Version, frame.Control
body[3], body[4] = frame.DeviceKind, frame.DeviceType
copy(body[5:8], frame.DeviceModel[:])
copy(body[8:16], frame.DeviceID[:])
binary.BigEndian.PutUint16(body[16:18], frame.PacketNumber)
body[18] = frame.Sequence
if frame.Final {
body[19] = 1
}
encodeBCDTime(body[20:27], frame.DeviceTime)
binary.BigEndian.PutUint16(body[27:29], uint16(len(frame.Payload)))
copy(body[29:], payload)
frameLength := len(body) + 1 // 加上校验字节,不含起始符、长度字段和结束符。
if frameLength > 0xffff {
return nil, fmt.Errorf("帧过长: %d", frameLength)
}
result := make([]byte, 0, frameLength+4)
result = append(result, StartByte, byte(frameLength>>8), byte(frameLength))
result = append(result, body...)
result = append(result, LRC8(result[1:]), EndByte)
return result, nil
}
// Decode 校验边界、长度、LRC8 和 AES 后返回有效载荷。
func Decode(raw []byte, keys Keyring) (Frame, error) {
var frame Frame
if len(raw) < FixedBodyBytes+5 {
return frame, ErrFrameTooShort
}
if raw[0] != StartByte || raw[len(raw)-1] != EndByte {
return frame, ErrBoundary
}
declared := int(binary.BigEndian.Uint16(raw[1:3]))
if declared+4 != len(raw) {
return frame, ErrLength
}
if LRC8(raw[1:len(raw)-2]) != raw[len(raw)-2] {
return frame, ErrChecksum
}
body := raw[3 : len(raw)-2]
frame.KeyID, frame.Version, frame.Control = body[0], body[1], body[2]
frame.DeviceKind, frame.DeviceType = body[3], body[4]
copy(frame.DeviceModel[:], body[5:8])
copy(frame.DeviceID[:], body[8:16])
frame.PacketNumber = binary.BigEndian.Uint16(body[16:18])
frame.Sequence, frame.Final = body[18], body[19] == 1
frame.DeviceTime = decodeBCDTime(body[20:27])
validLength := int(binary.BigEndian.Uint16(body[27:29]))
plain, err := cryptPayload(frame.KeyID, body[29:], keys, true)
if err != nil {
return frame, err
}
if validLength > len(plain) {
return frame, ErrPayloadLength
}
frame.Payload = append([]byte(nil), plain[:validLength]...)
return frame, nil
}
// LRC8 返回连续字节和的二进制补码低字节。
func LRC8(data []byte) byte {
var sum byte
for _, value := range data {
sum += value
}
return ^sum + 1
}
func cryptPayload(keyID byte, input []byte, keys Keyring, decrypt bool) ([]byte, error) {
if keyID == 0 {
size := len(input)
if !decrypt && size%aes.BlockSize != 0 {
size += aes.BlockSize - size%aes.BlockSize
}
result := make([]byte, size)
copy(result, input)
return result, nil
}
key := keys[keyID]
if len(key) != aes.BlockSize {
return nil, fmt.Errorf("%d 号 AES 密钥必须为 16 字节", keyID)
}
if decrypt && len(input)%aes.BlockSize != 0 {
return nil, ErrEncryptedLength
}
size := len(input)
if !decrypt && size%aes.BlockSize != 0 {
size += aes.BlockSize - size%aes.BlockSize
}
output := make([]byte, size)
copy(output, input)
block, err := aes.NewCipher(key)
if err != nil {
return nil, err
}
for offset := 0; offset < len(output); offset += aes.BlockSize {
if decrypt {
block.Decrypt(output[offset:offset+aes.BlockSize], input[offset:offset+aes.BlockSize])
} else {
block.Encrypt(output[offset:offset+aes.BlockSize], output[offset:offset+aes.BlockSize])
}
}
return output, nil
}
func encodeBCDTime(dst []byte, value time.Time) {
if value.IsZero() {
value = time.Now()
}
parts := []int{value.Year() / 100, value.Year() % 100, int(value.Month()), value.Day(), value.Hour(), value.Minute(), value.Second()}
for index, part := range parts {
dst[index] = byte((part/10)<<4 | part%10)
}
}
func decodeBCDTime(src []byte) time.Time {
n := func(value byte) int { return int(value>>4)*10 + int(value&0x0f) }
year := n(src[0])*100 + n(src[1])
return time.Date(year, time.Month(n(src[2])), n(src[3]), n(src[4]), n(src[5]), n(src[6]), 0, time.Local)
}

View File

@@ -0,0 +1,55 @@
package protocol
import (
"bytes"
"testing"
"time"
)
func TestFrameRoundTripEncrypted(t *testing.T) {
keys := Keyring{2: []byte("0123456789abcdef")}
payload, _ := ValveCommand(1, 2, 3, false)
want := Frame{KeyID: 2, Version: 1, Control: 0x10, DeviceKind: 1, DeviceType: 1, PacketNumber: 7, Sequence: 1, Final: true, DeviceTime: time.Date(2025, 5, 7, 8, 9, 10, 0, time.Local), Payload: payload}
copy(want.DeviceID[:], []byte{0x12, 0x34, 0x56, 0x78, 0x90, 0x12, 0x34, 0x56})
raw, err := Encode(want, keys)
if err != nil {
t.Fatal(err)
}
got, err := Decode(raw, keys)
if err != nil {
t.Fatal(err)
}
if !bytes.Equal(got.Payload, want.Payload) || got.DeviceID != want.DeviceID || got.PacketNumber != want.PacketNumber {
t.Fatalf("round trip mismatch: %#v", got)
}
}
func TestDecodeRejectsTampering(t *testing.T) {
raw, err := Encode(Frame{Version: 1, Final: true, Payload: []byte{1, 0, 0}}, nil)
if err != nil {
t.Fatal(err)
}
raw[5] ^= 1
if _, err = Decode(raw, nil); err != ErrChecksum {
t.Fatalf("got %v", err)
}
}
func TestValveCommand(t *testing.T) {
got, _ := ValveCommand(1, 2, 3, true)
want := []byte{0x40, 1, 0x0A, 1, 2, 3, 0xA0, 0}
if !bytes.Equal(got, want) {
t.Fatalf("got %x want %x", got, want)
}
}
func TestDecodeRealtimeSensor(t *testing.T) {
payload := []byte{0x50, 1, 0x01, 0, 1, 0, 0, 0, 1, 0x08, 0, 0xE4, 0, 123, 0x0C, 2, 1, 0}
got, err := DecodeRealtime(payload)
if err != nil {
t.Fatal(err)
}
if len(got.Sensors) != 1 || got.Sensors[0].Number != 1 || got.Sensors[0].Value != 123 || got.Sensors[0].Decimal != 2 {
t.Fatalf("unexpected result: %#v", got)
}
}

View File

@@ -0,0 +1,155 @@
package protocol
import (
"encoding/binary"
"fmt"
)
const (
MainBasicInfo = byte(0x01)
MainRuntime = byte(0x20)
MainQuery = byte(0x30)
MainSetting = byte(0x40)
MainRealtime = byte(0x50)
MainHistorical = byte(0x60)
SubValve = byte(0x0A)
)
// DataBlock 是主标识下的一个子标识数据块。
type DataBlock struct {
SubID byte
Data []byte
}
type RealtimeData struct {
Sensors []SensorReading `json:"sensors,omitempty"`
Events []EventReading `json:"events,omitempty"`
IO []IOReading `json:"io,omitempty"`
Parameters []ParameterReading `json:"parameters,omitempty"`
}
type SensorReading struct {
Number uint32 `json:"number"`
SensorType byte `json:"sensor_type"`
Object uint16 `json:"object"`
Value int16 `json:"value"`
Unit byte `json:"unit"`
Decimal byte `json:"decimal"`
Status byte `json:"status"`
}
type EventReading struct {
Type uint16 `json:"type"`
Number uint32 `json:"number"`
Value int16 `json:"value"`
}
type IOReading struct {
Type uint16 `json:"type"`
Number uint32 `json:"number"`
Status byte `json:"status"`
}
type ParameterReading struct {
Code byte `json:"code"`
Value int16 `json:"value"`
}
// EncodePayload 按“主标识、块数量、子标识、定长数据”编码。
// 由于厂商协议没有携带块长度,本函数用于已知命令;上行解析由业务标识专用解析器完成。
func EncodePayload(mainID byte, blocks ...DataBlock) ([]byte, error) {
if len(blocks) > 255 {
return nil, fmt.Errorf("数据块数量超过 255")
}
result := []byte{mainID, byte(len(blocks))}
for _, block := range blocks {
result = append(result, block.SubID)
result = append(result, block.Data...)
}
result = append(result, 0x00)
return result, nil
}
// ValveCommand 编码 0x40/0x0A 电磁阀控制0x01 关闭0xA0 开启。
func ValveCommand(controller, loop, component byte, open bool) ([]byte, error) {
action := byte(0x01)
if open {
action = 0xA0
}
return EncodePayload(MainSetting, DataBlock{SubID: SubValve, Data: []byte{controller, loop, component, action}})
}
// DecodeRealtime 解析协议 0x50 的传感器、事件、IO 和 AI 阀参数定长数据块。
func DecodeRealtime(payload []byte) (RealtimeData, error) {
var result RealtimeData
if len(payload) < 3 || payload[0] != MainRealtime {
return result, fmt.Errorf("不是实时数据包")
}
offset := 2
for blockIndex := 0; blockIndex < int(payload[1]); blockIndex++ {
if offset >= len(payload) {
return result, fmt.Errorf("实时数据块被截断")
}
subID := payload[offset]
offset++
switch subID {
case 0x01:
count, next, err := readCount(payload, offset, 12)
if err != nil {
return result, err
}
offset = next
for range count {
item := payload[offset : offset+12]
result.Sensors = append(result.Sensors, SensorReading{Number: binary.BigEndian.Uint32(item[0:4]), SensorType: item[4], Object: binary.BigEndian.Uint16(item[5:7]), Value: int16(binary.BigEndian.Uint16(item[7:9])), Unit: item[9], Decimal: item[10], Status: item[11]})
offset += 12
}
case 0x02:
count, next, err := readCount(payload, offset, 8)
if err != nil {
return result, err
}
offset = next
for range count {
item := payload[offset : offset+8]
result.Events = append(result.Events, EventReading{Type: binary.BigEndian.Uint16(item[0:2]), Number: binary.BigEndian.Uint32(item[2:6]), Value: int16(binary.BigEndian.Uint16(item[6:8]))})
offset += 8
}
case 0x03:
count, next, err := readCount(payload, offset, 7)
if err != nil {
return result, err
}
offset = next
for range count {
item := payload[offset : offset+7]
result.IO = append(result.IO, IOReading{Type: binary.BigEndian.Uint16(item[0:2]), Number: binary.BigEndian.Uint32(item[2:6]), Status: item[6]})
offset += 7
}
case 0x04:
if offset >= len(payload) {
return result, fmt.Errorf("参数块被截断")
}
count := int(payload[offset])
offset++
if offset+count*3 > len(payload) {
return result, fmt.Errorf("参数块长度无效")
}
for range count {
result.Parameters = append(result.Parameters, ParameterReading{Code: payload[offset], Value: int16(binary.BigEndian.Uint16(payload[offset+1 : offset+3]))})
offset += 3
}
default:
return result, fmt.Errorf("未知实时数据子标识 0x%02X", subID)
}
}
return result, nil
}
func readCount(payload []byte, offset, recordSize int) (int, int, error) {
if offset+2 > len(payload) {
return 0, offset, fmt.Errorf("数据块数量被截断")
}
count := int(binary.BigEndian.Uint16(payload[offset : offset+2]))
offset += 2
if offset+count*recordSize > len(payload) {
return 0, offset, fmt.Errorf("数据块记录长度无效")
}
return count, offset, nil
}