feat: scaffold go vehicle gateway core
This commit is contained in:
237
go/vehicle-gateway/internal/protocol/gb32960/parser.go
Normal file
237
go/vehicle-gateway/internal/protocol/gb32960/parser.go
Normal 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
|
||||
}
|
||||
106
go/vehicle-gateway/internal/protocol/gb32960/parser_test.go
Normal file
106
go/vehicle-gateway/internal/protocol/gb32960/parser_test.go
Normal 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:]))
|
||||
}
|
||||
Reference in New Issue
Block a user