feat: add yutong mqtt ingestion

This commit is contained in:
lingniu
2026-07-01 21:53:39 +08:00
parent b55b075a97
commit d8c77d7882
7 changed files with 506 additions and 6 deletions

View File

@@ -0,0 +1,129 @@
package gateway
import (
"context"
"encoding/hex"
"errors"
"log/slog"
"strings"
"time"
mqtt "github.com/eclipse/paho.mqtt.golang"
"lingniu-vehicle-ingest/go/vehicle-gateway/internal/envelope"
"lingniu-vehicle-ingest/go/vehicle-gateway/internal/eventbus"
"lingniu-vehicle-ingest/go/vehicle-gateway/internal/protocol/yutongmqtt"
)
type MQTTClientConfig struct {
EndpointName string
Broker string
ClientID string
Username string
Password string
Topics []string
QoS byte
Sink eventbus.Sink
Logger *slog.Logger
}
type MQTTClient struct {
cfg MQTTClientConfig
client mqtt.Client
}
func NewMQTTClient(cfg MQTTClientConfig) (*MQTTClient, error) {
if strings.TrimSpace(cfg.Broker) == "" {
return nil, errors.New("mqtt broker is required")
}
if strings.TrimSpace(cfg.ClientID) == "" {
return nil, errors.New("mqtt client id is required")
}
if len(cfg.Topics) == 0 {
return nil, errors.New("mqtt topics are required")
}
if cfg.Sink == nil {
return nil, errors.New("sink is required")
}
if cfg.Logger == nil {
cfg.Logger = slog.Default()
}
if cfg.EndpointName == "" {
cfg.EndpointName = "yutong"
}
return &MQTTClient{cfg: cfg}, nil
}
func (c *MQTTClient) Start(ctx context.Context) error {
opts := mqtt.NewClientOptions().
AddBroker(c.cfg.Broker).
SetClientID(c.cfg.ClientID).
SetUsername(c.cfg.Username).
SetPassword(c.cfg.Password).
SetCleanSession(false).
SetAutoReconnect(true).
SetConnectRetry(true).
SetConnectRetryInterval(5 * time.Second).
SetKeepAlive(20 * time.Second).
SetConnectTimeout(10 * time.Second)
opts.SetDefaultPublishHandler(func(_ mqtt.Client, message mqtt.Message) {
c.handleMessage(ctx, message.Topic(), message.Payload())
})
opts.OnConnect = func(client mqtt.Client) {
for _, topic := range c.cfg.Topics {
token := client.Subscribe(topic, c.cfg.QoS, nil)
if token.Wait() && token.Error() != nil {
c.cfg.Logger.Error("mqtt subscribe failed", "broker", c.cfg.Broker, "topic", topic, "error", token.Error())
continue
}
c.cfg.Logger.Info("mqtt subscribed", "broker", c.cfg.Broker, "topic", topic, "qos", c.cfg.QoS)
}
}
opts.OnConnectionLost = func(_ mqtt.Client, err error) {
c.cfg.Logger.Warn("mqtt connection lost", "broker", c.cfg.Broker, "error", err)
}
c.client = mqtt.NewClient(opts)
token := c.client.Connect()
if token.Wait() && token.Error() != nil {
return token.Error()
}
go func() {
<-ctx.Done()
if c.client != nil && c.client.IsConnected() {
c.client.Disconnect(250)
}
}()
return nil
}
func (c *MQTTClient) handleMessage(ctx context.Context, topic string, payload []byte) {
receivedAtMS := time.Now().UnixMilli()
env, err := yutongmqtt.ParseMessage(c.cfg.EndpointName, topic, payload, receivedAtMS)
if err != nil {
env = envelope.FrameEnvelope{
Protocol: envelope.ProtocolYutongMQTT,
MessageID: "MQTT",
VehicleKeyHint: "unknown",
SourceEndpoint: "mqtt://" + c.cfg.EndpointName + topic,
EventTimeMS: receivedAtMS,
ReceivedAtMS: receivedAtMS,
RawText: string(payload),
RawHex: strings.ToUpper(hex.EncodeToString(payload)),
ParseStatus: envelope.ParseBadFrame,
ParseError: err.Error(),
}
env.EventID = env.StableEventID()
}
if err := c.cfg.Sink.PublishRaw(ctx, env); err != nil {
c.cfg.Logger.Error("publish mqtt raw failed", "topic", topic, "event_id", env.StableEventID(), "error", err)
return
}
if env.ParseStatus == envelope.ParseBadFrame {
return
}
if err := c.cfg.Sink.PublishUnified(ctx, env); err != nil {
c.cfg.Logger.Error("publish mqtt unified failed", "topic", topic, "event_id", env.StableEventID(), "error", err)
}
}

View File

@@ -0,0 +1,61 @@
package gateway
import (
"context"
"log/slog"
"testing"
"lingniu-vehicle-ingest/go/vehicle-gateway/internal/envelope"
)
func TestMQTTClientHandleMessagePublishesRawAndUnified(t *testing.T) {
sink := &recordingSink{}
client, err := NewMQTTClient(MQTTClientConfig{
EndpointName: "endpoint-a",
Broker: "tcp://127.0.0.1:1883",
ClientID: "test-client",
Topics: []string{"/ytforward/shln/+"},
Sink: sink,
Logger: slog.New(slog.NewTextHandler(testWriter{t: t}, nil)),
})
if err != nil {
t.Fatalf("NewMQTTClient() error = %v", err)
}
client.handleMessage(context.Background(), "/ytforward/shln/dev1", []byte(`{
"device":"LTEST000000000001",
"time":"20260413100000",
"data":{"METER_SPEED":52.3,"TOTAL_MILEAGE":123456.7}
}`))
if len(sink.raw) != 1 || len(sink.unified) != 1 {
t.Fatalf("raw=%d unified=%d", len(sink.raw), len(sink.unified))
}
if sink.raw[0].Protocol != envelope.ProtocolYutongMQTT || sink.raw[0].VIN != "LTEST000000000001" {
t.Fatalf("unexpected raw envelope: %#v", sink.raw[0])
}
}
func TestMQTTClientHandleBadPayloadPublishesOnlyRaw(t *testing.T) {
sink := &recordingSink{}
client, err := NewMQTTClient(MQTTClientConfig{
EndpointName: "endpoint-a",
Broker: "tcp://127.0.0.1:1883",
ClientID: "test-client",
Topics: []string{"/ytforward/shln/+"},
Sink: sink,
Logger: slog.New(slog.NewTextHandler(testWriter{t: t}, nil)),
})
if err != nil {
t.Fatalf("NewMQTTClient() error = %v", err)
}
client.handleMessage(context.Background(), "/ytforward/shln/bad", []byte("{bad-json"))
if len(sink.raw) != 1 || len(sink.unified) != 0 {
t.Fatalf("raw=%d unified=%d", len(sink.raw), len(sink.unified))
}
if sink.raw[0].ParseStatus != envelope.ParseBadFrame {
t.Fatalf("parse status = %q", sink.raw[0].ParseStatus)
}
}