238 lines
7.0 KiB
Go
238 lines
7.0 KiB
Go
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
|
|
}
|