feat: replace iot adapter with mqtt command pipeline
This commit is contained in:
83
backend/iot-server/internal/config/config.go
Normal file
83
backend/iot-server/internal/config/config.go
Normal file
@@ -0,0 +1,83 @@
|
||||
// Package config 加载 IoT Server 的 MQTT、协议密钥和内部接口配置。
|
||||
package config
|
||||
|
||||
import (
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
type MQTT struct {
|
||||
Broker string `yaml:"Broker"`
|
||||
ClientID string `yaml:"ClientID"`
|
||||
Username string `yaml:"Username"`
|
||||
Password string `yaml:"Password"`
|
||||
UpTopic string `yaml:"UpTopic"`
|
||||
DownTopic string `yaml:"DownTopic"`
|
||||
AckTopic string `yaml:"AckTopic"`
|
||||
QoS byte `yaml:"QoS"`
|
||||
TLS bool `yaml:"TLS"`
|
||||
CAFile string `yaml:"CAFile"`
|
||||
CertificateFile string `yaml:"CertificateFile"`
|
||||
PrivateKeyFile string `yaml:"PrivateKeyFile"`
|
||||
}
|
||||
type HTTP struct {
|
||||
Address string `yaml:"Address"`
|
||||
InternalToken string `yaml:"InternalToken"`
|
||||
CallbackURL string `yaml:"CallbackURL"`
|
||||
}
|
||||
type Protocol struct {
|
||||
Key1 string `yaml:"Key1"`
|
||||
Key2 string `yaml:"Key2"`
|
||||
Key3 string `yaml:"Key3"`
|
||||
}
|
||||
type Config struct {
|
||||
Service string `yaml:"Service"`
|
||||
MQTT MQTT `yaml:"MQTT"`
|
||||
HTTP HTTP `yaml:"HTTP"`
|
||||
Protocol Protocol `yaml:"Protocol"`
|
||||
}
|
||||
|
||||
func Load(path string) (Config, error) {
|
||||
var cfg Config
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return cfg, err
|
||||
}
|
||||
if err = yaml.Unmarshal(data, &cfg); err != nil {
|
||||
return cfg, err
|
||||
}
|
||||
override(&cfg.MQTT.Password, "HEQI_IOT_MQTT_PASSWORD")
|
||||
override(&cfg.HTTP.InternalToken, "HEQI_IOT_INTERNAL_TOKEN")
|
||||
override(&cfg.Protocol.Key1, "HEQI_IOT_KEY_1")
|
||||
override(&cfg.Protocol.Key2, "HEQI_IOT_KEY_2")
|
||||
override(&cfg.Protocol.Key3, "HEQI_IOT_KEY_3")
|
||||
if cfg.MQTT.Broker == "" || cfg.HTTP.Address == "" || cfg.HTTP.InternalToken == "" {
|
||||
return cfg, fmt.Errorf("MQTT.Broker、HTTP.Address 和 HTTP.InternalToken 必填")
|
||||
}
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
func (cfg Config) Keys() (map[byte][]byte, error) {
|
||||
result := map[byte][]byte{}
|
||||
for id, value := range map[byte]string{1: cfg.Protocol.Key1, 2: cfg.Protocol.Key2, 3: cfg.Protocol.Key3} {
|
||||
if strings.TrimSpace(value) == "" {
|
||||
continue
|
||||
}
|
||||
decoded, err := hex.DecodeString(value)
|
||||
if err != nil || len(decoded) != 16 {
|
||||
return nil, fmt.Errorf("Protocol.Key%d 必须是 32 位十六进制 AES-128 密钥", id)
|
||||
}
|
||||
result[id] = decoded
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func override(target *string, name string) {
|
||||
if value := os.Getenv(name); value != "" {
|
||||
*target = value
|
||||
}
|
||||
}
|
||||
173
backend/iot-server/internal/protocol/frame.go
Normal file
173
backend/iot-server/internal/protocol/frame.go
Normal 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)
|
||||
}
|
||||
55
backend/iot-server/internal/protocol/frame_test.go
Normal file
55
backend/iot-server/internal/protocol/frame_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
155
backend/iot-server/internal/protocol/payload.go
Normal file
155
backend/iot-server/internal/protocol/payload.go
Normal 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
|
||||
}
|
||||
236
backend/iot-server/internal/service/service.go
Normal file
236
backend/iot-server/internal/service/service.go
Normal file
@@ -0,0 +1,236 @@
|
||||
// Package service 连接外部 MQTT Broker,并在内部 HTTP 边界接收待下发命令。
|
||||
package service
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/subtle"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"git.apinb.com/heqiapp/platforms/backend/iot-server/internal/config"
|
||||
"git.apinb.com/heqiapp/platforms/backend/iot-server/internal/protocol"
|
||||
mqtt "github.com/eclipse/paho.mqtt.golang"
|
||||
)
|
||||
|
||||
type Service struct {
|
||||
cfg config.Config
|
||||
keys protocol.Keyring
|
||||
mqtt mqtt.Client
|
||||
packet atomic.Uint32
|
||||
http *http.Server
|
||||
}
|
||||
type Command struct {
|
||||
Identity string `json:"identity"`
|
||||
IdempotencyKey string `json:"idempotency_key"`
|
||||
DeviceID string `json:"device_id"`
|
||||
Action string `json:"action"`
|
||||
ExpiresAt time.Time `json:"expires_at"`
|
||||
KeyID byte `json:"key_id"`
|
||||
DeviceKind byte `json:"device_kind"`
|
||||
DeviceType byte `json:"device_type"`
|
||||
DeviceModel [3]byte `json:"device_model"`
|
||||
Controller byte `json:"controller"`
|
||||
Loop byte `json:"loop"`
|
||||
Component byte `json:"component"`
|
||||
}
|
||||
type Envelope struct {
|
||||
Type, Topic, DeviceID, ReceivedAt string
|
||||
PayloadHex string
|
||||
Frame *DecodedFrame `json:"frame,omitempty"`
|
||||
}
|
||||
type DecodedFrame struct {
|
||||
KeyID, Version, Control, MainID byte
|
||||
PacketNumber uint16
|
||||
Sequence byte
|
||||
Final bool
|
||||
DeviceTime string
|
||||
PayloadHex string
|
||||
Realtime *protocol.RealtimeData `json:"realtime,omitempty"`
|
||||
}
|
||||
|
||||
func New(cfg config.Config) (*Service, error) {
|
||||
keys, err := cfg.Keys()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
options := mqtt.NewClientOptions().AddBroker(cfg.MQTT.Broker).SetClientID(cfg.MQTT.ClientID).SetUsername(cfg.MQTT.Username).SetPassword(cfg.MQTT.Password).SetAutoReconnect(true).SetConnectRetry(true)
|
||||
if cfg.MQTT.TLS {
|
||||
tlsConfig, tlsErr := makeTLSConfig(cfg)
|
||||
if tlsErr != nil {
|
||||
return nil, tlsErr
|
||||
}
|
||||
options.SetTLSConfig(tlsConfig)
|
||||
}
|
||||
client := mqtt.NewClient(options)
|
||||
return &Service{cfg: cfg, keys: keys, mqtt: client}, nil
|
||||
}
|
||||
|
||||
func makeTLSConfig(cfg config.Config) (*tls.Config, error) {
|
||||
roots, err := x509.SystemCertPool()
|
||||
if err != nil {
|
||||
roots = x509.NewCertPool()
|
||||
}
|
||||
if cfg.MQTT.CAFile != "" {
|
||||
data, readErr := os.ReadFile(cfg.MQTT.CAFile)
|
||||
if readErr != nil {
|
||||
return nil, readErr
|
||||
}
|
||||
if !roots.AppendCertsFromPEM(data) {
|
||||
return nil, fmt.Errorf("MQTT CA 证书无效")
|
||||
}
|
||||
}
|
||||
result := &tls.Config{MinVersion: tls.VersionTLS12, RootCAs: roots}
|
||||
if cfg.MQTT.CertificateFile != "" || cfg.MQTT.PrivateKeyFile != "" {
|
||||
certificate, loadErr := tls.LoadX509KeyPair(cfg.MQTT.CertificateFile, cfg.MQTT.PrivateKeyFile)
|
||||
if loadErr != nil {
|
||||
return nil, loadErr
|
||||
}
|
||||
result.Certificates = []tls.Certificate{certificate}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (s *Service) Run(ctx context.Context) error {
|
||||
if token := s.mqtt.Connect(); !token.WaitTimeout(15 * time.Second) {
|
||||
return fmt.Errorf("MQTT 连接超时")
|
||||
} else if token.Error() != nil {
|
||||
return token.Error()
|
||||
}
|
||||
for _, topic := range []string{s.cfg.MQTT.UpTopic, s.cfg.MQTT.AckTopic} {
|
||||
if token := s.mqtt.Subscribe(topic, s.cfg.MQTT.QoS, s.onMessage); token.Wait() && token.Error() != nil {
|
||||
return token.Error()
|
||||
}
|
||||
}
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/health", s.health)
|
||||
mux.HandleFunc("/internal/v1/commands", s.command)
|
||||
s.http = &http.Server{Addr: s.cfg.HTTP.Address, Handler: mux, ReadHeaderTimeout: 5 * time.Second}
|
||||
go func() {
|
||||
<-ctx.Done()
|
||||
shutdown, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
_ = s.http.Shutdown(shutdown)
|
||||
s.mqtt.Disconnect(250)
|
||||
}()
|
||||
err := s.http.ListenAndServe()
|
||||
if err == http.ErrServerClosed {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Service) health(w http.ResponseWriter, _ *http.Request) {
|
||||
if !s.mqtt.IsConnectionOpen() {
|
||||
http.Error(w, "mqtt disconnected", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func (s *Service) command(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
if !secureEqual(r.Header.Get("X-Heqi-Iot-Token"), s.cfg.HTTP.InternalToken) {
|
||||
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
var cmd Command
|
||||
if err := json.NewDecoder(io.LimitReader(r.Body, 64<<10)).Decode(&cmd); err != nil {
|
||||
http.Error(w, "invalid json", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if cmd.Identity == "" || cmd.IdempotencyKey == "" || cmd.DeviceID == "" || time.Now().After(cmd.ExpiresAt) {
|
||||
http.Error(w, "invalid or expired command", http.StatusUnprocessableEntity)
|
||||
return
|
||||
}
|
||||
deviceID, err := decodeDeviceID(cmd.DeviceID)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
var payload []byte
|
||||
switch cmd.Action {
|
||||
case "open_valve":
|
||||
payload, err = protocol.ValveCommand(cmd.Controller, cmd.Loop, cmd.Component, true)
|
||||
case "close_valve":
|
||||
payload, err = protocol.ValveCommand(cmd.Controller, cmd.Loop, cmd.Component, false)
|
||||
default:
|
||||
err = fmt.Errorf("unsupported action")
|
||||
}
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
n := s.packet.Add(1)
|
||||
frame := protocol.Frame{KeyID: cmd.KeyID, Version: 1, Control: 0x10, DeviceKind: cmd.DeviceKind, DeviceType: cmd.DeviceType, DeviceModel: cmd.DeviceModel, DeviceID: deviceID, PacketNumber: uint16(n%65535 + 1), Sequence: 1, Final: true, DeviceTime: time.Now(), Payload: payload}
|
||||
raw, err := protocol.Encode(frame, s.keys)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusUnprocessableEntity)
|
||||
return
|
||||
}
|
||||
topic := strings.ReplaceAll(s.cfg.MQTT.DownTopic, "{deviceId}", cmd.DeviceID)
|
||||
token := s.mqtt.Publish(topic, s.cfg.MQTT.QoS, false, raw)
|
||||
if !token.WaitTimeout(10*time.Second) || token.Error() != nil {
|
||||
http.Error(w, "mqtt publish failed", http.StatusBadGateway)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusAccepted)
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"command_identity": cmd.Identity, "packet_number": frame.PacketNumber, "status": "dispatched"})
|
||||
}
|
||||
|
||||
func (s *Service) onMessage(_ mqtt.Client, message mqtt.Message) {
|
||||
raw := append([]byte(nil), message.Payload()...)
|
||||
envelope := Envelope{Type: "device_message", Topic: message.Topic(), ReceivedAt: time.Now().UTC().Format(time.RFC3339Nano), PayloadHex: hex.EncodeToString(raw)}
|
||||
if frame, err := protocol.Decode(raw, s.keys); err == nil {
|
||||
deviceID := hex.EncodeToString(frame.DeviceID[:])
|
||||
envelope.DeviceID = deviceID
|
||||
main := byte(0)
|
||||
if len(frame.Payload) > 0 {
|
||||
main = frame.Payload[0]
|
||||
}
|
||||
decoded := &DecodedFrame{KeyID: frame.KeyID, Version: frame.Version, Control: frame.Control, MainID: main, PacketNumber: frame.PacketNumber, Sequence: frame.Sequence, Final: frame.Final, DeviceTime: frame.DeviceTime.Format(time.RFC3339), PayloadHex: hex.EncodeToString(frame.Payload)}
|
||||
if main == protocol.MainRealtime {
|
||||
if realtime, parseErr := protocol.DecodeRealtime(frame.Payload); parseErr == nil {
|
||||
decoded.Realtime = &realtime
|
||||
}
|
||||
}
|
||||
envelope.Frame = decoded
|
||||
}
|
||||
data, _ := json.Marshal(envelope)
|
||||
request, err := http.NewRequest(http.MethodPost, s.cfg.HTTP.CallbackURL, bytes.NewReader(data))
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
request.Header.Set("X-Heqi-Iot-Token", s.cfg.HTTP.InternalToken)
|
||||
response, err := http.DefaultClient.Do(request)
|
||||
if err == nil {
|
||||
_ = response.Body.Close()
|
||||
}
|
||||
}
|
||||
|
||||
func decodeDeviceID(value string) ([8]byte, error) {
|
||||
var result [8]byte
|
||||
decoded, err := hex.DecodeString(value)
|
||||
if err != nil || len(decoded) != 8 {
|
||||
return result, fmt.Errorf("device_id 必须是 16 位 BCD/十六进制字符串")
|
||||
}
|
||||
copy(result[:], decoded)
|
||||
return result, nil
|
||||
}
|
||||
func secureEqual(left, right string) bool {
|
||||
return len(left) == len(right) && subtle.ConstantTimeCompare([]byte(left), []byte(right)) == 1
|
||||
}
|
||||
Reference in New Issue
Block a user