feat: scaffold go vehicle gateway core

This commit is contained in:
lingniu
2026-07-01 21:43:16 +08:00
parent d148a4e3af
commit 1fdbc29e27
12 changed files with 1107 additions and 0 deletions

View File

@@ -0,0 +1,237 @@
package gb32960
import (
"encoding/binary"
"encoding/hex"
"errors"
"fmt"
"strings"
"time"
"lingniu-vehicle-ingest/go/vehicle-gateway/internal/envelope"
)
var (
ErrFrameTooShort = errors.New("gb32960 frame too short")
ErrBadStartSymbol = errors.New("gb32960 bad start symbol")
ErrBodyLength = errors.New("gb32960 body length mismatch")
ErrBCC = errors.New("gb32960 bcc mismatch")
)
const (
minFrameLen = 25
headerLen = 24
)
// ExtractFrames splits GB/T 32960 TCP streams using the protocol length field.
// Returned frames include the start symbols and trailing BCC byte.
func ExtractFrames(stream []byte) (frames [][]byte, remainder []byte, err error) {
offset := 0
for {
start := indexStart(stream[offset:])
if start < 0 {
return frames, nil, nil
}
offset += start
remaining := stream[offset:]
if len(remaining) < headerLen {
return frames, append([]byte(nil), remaining...), nil
}
bodyLen := int(binary.BigEndian.Uint16(remaining[22:24]))
frameLen := headerLen + bodyLen + 1
if len(remaining) < frameLen {
return frames, append([]byte(nil), remaining...), nil
}
frames = append(frames, append([]byte(nil), remaining[:frameLen]...))
offset += frameLen
if offset >= len(stream) {
return frames, nil, nil
}
}
}
func ParseFrame(raw []byte, receivedAtMS int64, sourceEndpoint string) (envelope.FrameEnvelope, error) {
if len(raw) < minFrameLen {
return envelope.FrameEnvelope{}, fmt.Errorf("%w: len=%d", ErrFrameTooShort, len(raw))
}
version, ok := version(raw[0], raw[1])
if !ok {
return envelope.FrameEnvelope{}, fmt.Errorf("%w: %s", ErrBadStartSymbol, hex.EncodeToString(raw[:2]))
}
bodyLen := int(binary.BigEndian.Uint16(raw[22:24]))
if len(raw) != headerLen+bodyLen+1 {
return envelope.FrameEnvelope{}, fmt.Errorf("%w: declared=%d actual=%d", ErrBodyLength, bodyLen, len(raw)-headerLen-1)
}
if got, want := bcc(raw[2:len(raw)-1]), raw[len(raw)-1]; got != want {
return envelope.FrameEnvelope{}, fmt.Errorf("%w: got=0x%02x want=0x%02x", ErrBCC, got, want)
}
command := raw[2]
responseFlag := raw[3]
vin := strings.TrimRight(string(raw[4:21]), "\x00 ")
body := raw[headerLen : headerLen+bodyLen]
parsed := map[string]any{
"header": map[string]any{
"version": version,
"command": fmt.Sprintf("0x%02X", command),
"response_flag": fmt.Sprintf("0x%02X", responseFlag),
"vin": vin,
"encrypt": raw[21],
"body_length": bodyLen,
},
}
fields := map[string]any{}
eventTimeMS := receivedAtMS
if command == 0x02 || command == 0x03 {
eventTime, units := parseDataBody(version, body, fields)
if !eventTime.IsZero() {
eventTimeMS = eventTime.UnixMilli()
fields["device_time"] = eventTime.Format(time.RFC3339)
parsed["device_time"] = eventTime.Format(time.RFC3339)
}
parsed["data_units"] = units
}
env := envelope.FrameEnvelope{
Protocol: envelope.ProtocolGB32960,
MessageID: fmt.Sprintf("0x%02X", command),
VIN: vin,
SourceEndpoint: sourceEndpoint,
EventTimeMS: eventTimeMS,
ReceivedAtMS: receivedAtMS,
RawHex: strings.ToUpper(hex.EncodeToString(raw)),
Parsed: parsed,
Fields: fields,
ParseStatus: envelope.ParseOK,
}
env.EventID = env.StableEventID()
return env, nil
}
func parseDataBody(version string, body []byte, fields map[string]any) (time.Time, []map[string]any) {
if len(body) < 6 {
return time.Time{}, nil
}
eventTime := parseBCDTime(body[:6])
cursor := 6
var units []map[string]any
for cursor < len(body) {
unitType := body[cursor]
cursor++
switch unitType {
case 0x01:
size := 20
if version == "V2025" {
size = 18
}
if len(body[cursor:]) < size {
units = append(units, map[string]any{"type": "0x01", "error": "truncated"})
return eventTime, units
}
unit := parseVehicleData(body[cursor : cursor+size])
units = append(units, map[string]any{"type": "0x01", "name": "vehicle", "value": unit})
fields["vehicle_status"] = unit["vehicle_status"]
fields["charge_status"] = unit["charge_status"]
fields["running_mode"] = unit["running_mode"]
fields[envelope.FieldSpeedKMH] = unit["speed_kmh"]
fields[envelope.FieldTotalMileageKM] = unit["total_mileage_km"]
fields[envelope.FieldSOCPercent] = unit["soc_percent"]
cursor += size
case 0x05:
if len(body[cursor:]) < 9 {
units = append(units, map[string]any{"type": "0x05", "error": "truncated"})
return eventTime, units
}
unit := parsePositionData(body[cursor : cursor+9])
units = append(units, map[string]any{"type": "0x05", "name": "position", "value": unit})
fields["position_status"] = unit["position_status"]
fields[envelope.FieldLongitude] = unit["longitude"]
fields[envelope.FieldLatitude] = unit["latitude"]
cursor += 9
default:
units = append(units, map[string]any{
"type": fmt.Sprintf("0x%02X", unitType),
"raw_tail": strings.ToUpper(hex.EncodeToString(body[cursor:])),
"parse": "unsupported_unit",
"byte_size": len(body) - cursor,
})
return eventTime, units
}
}
return eventTime, units
}
func parseVehicleData(data []byte) map[string]any {
return map[string]any{
"vehicle_status": int(data[0]),
"charge_status": int(data[1]),
"running_mode": int(data[2]),
"speed_kmh": float64(binary.BigEndian.Uint16(data[3:5])) / 10,
"total_mileage_km": float64(binary.BigEndian.Uint32(data[5:9])) / 10,
"total_voltage_v": float64(binary.BigEndian.Uint16(data[9:11])) / 10,
"total_current_a": float64(binary.BigEndian.Uint16(data[11:13]))/10 - 1000,
"soc_percent": int(data[13]),
"dc_dc_status": int(data[14]),
"gear": int(data[15]),
"insulation_kohm": binary.BigEndian.Uint16(data[16:18]),
"accelerator_pct": int(data[18]),
"brake_pct": int(data[19]),
}
}
func parsePositionData(data []byte) map[string]any {
return map[string]any{
"position_status": int(data[0]),
"longitude": float64(binary.BigEndian.Uint32(data[1:5])) / 1_000_000,
"latitude": float64(binary.BigEndian.Uint32(data[5:9])) / 1_000_000,
}
}
func indexStart(data []byte) int {
for i := 0; i+1 < len(data); i++ {
if _, ok := version(data[i], data[i+1]); ok {
return i
}
}
return -1
}
func version(a byte, b byte) (string, bool) {
switch {
case a == '#' && b == '#':
return "V2016", true
case a == '$' && b == '$':
return "V2025", true
default:
return "", false
}
}
func parseBCDTime(data []byte) time.Time {
if len(data) != 6 {
return time.Time{}
}
year := 2000 + bcdByte(data[0])
month := time.Month(bcdByte(data[1]))
day := bcdByte(data[2])
hour := bcdByte(data[3])
minute := bcdByte(data[4])
second := bcdByte(data[5])
if month < 1 || month > 12 || day < 1 || day > 31 || hour > 23 || minute > 59 || second > 59 {
return time.Time{}
}
return time.Date(year, month, day, hour, minute, second, 0, time.FixedZone("Asia/Shanghai", 8*3600))
}
func bcdByte(value byte) int {
return int(value>>4)*10 + int(value&0x0f)
}
func bcc(data []byte) byte {
var out byte
for _, value := range data {
out ^= value
}
return out
}

View File

@@ -0,0 +1,106 @@
package gb32960
import (
"encoding/hex"
"testing"
"lingniu-vehicle-ingest/go/vehicle-gateway/internal/envelope"
)
func TestExtractFramesKeepsPartialRemainderAndParsesHeader(t *testing.T) {
first := buildFrame(0x02, 0xfe, "LNBSCB3D4R1234567", []byte{0x1a, 0x06, 0x30, 0x16, 0x23, 0x57})
second := buildFrame(0x05, 0xfe, "PLATFORM-LOGIN001", nil)
stream := append([]byte{0x00, 0x01}, first...)
stream = append(stream, second[:len(second)-3]...)
frames, remainder, err := ExtractFrames(stream)
if err != nil {
t.Fatalf("ExtractFrames() error = %v", err)
}
if len(frames) != 1 {
t.Fatalf("expected one complete frame, got %d", len(frames))
}
if string(remainder) != string(second[:len(second)-3]) {
t.Fatalf("expected partial second frame as remainder, got %s", hex.EncodeToString(remainder))
}
env, err := ParseFrame(frames[0], 1782745114999, "115.29.187.205:32960")
if err != nil {
t.Fatalf("ParseFrame() error = %v", err)
}
if env.Protocol != envelope.ProtocolGB32960 || env.MessageID != "0x02" {
t.Fatalf("unexpected envelope header: %#v", env)
}
if env.VIN != "LNBSCB3D4R1234567" {
t.Fatalf("unexpected vin: %q", env.VIN)
}
}
func TestParseFrameRejectsBadBCC(t *testing.T) {
frame := buildFrame(0x02, 0xfe, "LNBSCB3D4R1234567", nil)
frame[len(frame)-1] ^= 0xff
if _, err := ParseFrame(frame, 1782745114999, ""); err == nil {
t.Fatal("expected bad bcc error")
}
}
func TestParseFrameExtractsRealtimeVehicleMileageAndPosition(t *testing.T) {
body := []byte{0x1a, 0x06, 0x30, 0x16, 0x23, 0x57}
body = append(body, 0x01)
body = append(body,
0x01, // vehicle status
0x03, // charge status
0x02, // running mode
0x01, 0x2c, // speed: 30.0km/h
0x00, 0x01, 0x86, 0xa0, // total mileage: 10000.0km
0x15, 0xe5, // total voltage: 560.5V
0x27, 0x10, // total current: 0A after -1000 offset
85, 0x01, 0x00,
0x27, 0x10,
0x00, 0x00)
body = append(body, 0x05)
body = append(body,
0x00,
0x07, 0x36, 0x50, 0x40, // longitude: 121.000000
0x01, 0xd2, 0x4f, 0x00) // latitude: 30.560000
frame := buildFrame(0x02, 0xfe, "LNBSCB3D4R1234567", body)
env, err := ParseFrame(frame, 1782745114999, "115.29.187.205:32960")
if err != nil {
t.Fatalf("ParseFrame() error = %v", err)
}
assertFloatField(t, env, envelope.FieldSpeedKMH, 30.0)
assertFloatField(t, env, envelope.FieldTotalMileageKM, 10000.0)
assertFloatField(t, env, envelope.FieldLongitude, 121.0)
assertFloatField(t, env, envelope.FieldLatitude, 30.56)
if env.Fields[envelope.FieldSOCPercent] != 85 {
t.Fatalf("unexpected soc: %#v", env.Fields[envelope.FieldSOCPercent])
}
if env.EventTimeMS == 1782745114999 {
t.Fatal("event time should come from GB32960 device timestamp")
}
}
func assertFloatField(t *testing.T, env envelope.FrameEnvelope, key string, want float64) {
t.Helper()
got, ok := env.Fields[key].(float64)
if !ok {
t.Fatalf("field %s missing or not float64: %#v", key, env.Fields[key])
}
if got != want {
t.Fatalf("field %s = %v, want %v", key, got, want)
}
}
func buildFrame(command byte, response byte, vin string, body []byte) []byte {
frame := []byte{'#', '#', command, response}
vinBytes := []byte(vin)
if len(vinBytes) < 17 {
vinBytes = append(vinBytes, make([]byte, 17-len(vinBytes))...)
}
frame = append(frame, vinBytes[:17]...)
frame = append(frame, 0x01, byte(len(body)>>8), byte(len(body)))
frame = append(frame, body...)
return append(frame, bcc(frame[2:]))
}