feat: scaffold go vehicle gateway core
This commit is contained in:
300
go/vehicle-gateway/internal/protocol/jt808/parser.go
Normal file
300
go/vehicle-gateway/internal/protocol/jt808/parser.go
Normal file
@@ -0,0 +1,300 @@
|
||||
package jt808
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"lingniu-vehicle-ingest/go/vehicle-gateway/internal/envelope"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrFrameTooShort = errors.New("jt808 frame too short")
|
||||
ErrChecksum = errors.New("jt808 checksum mismatch")
|
||||
ErrEscape = errors.New("jt808 invalid escape sequence")
|
||||
)
|
||||
|
||||
type Location struct {
|
||||
AlarmFlag uint32
|
||||
StatusFlag uint32
|
||||
Latitude float64
|
||||
Longitude float64
|
||||
AltitudeM uint16
|
||||
SpeedKMH float64
|
||||
DirectionDeg uint16
|
||||
Time time.Time
|
||||
TotalMileageKM *float64
|
||||
Additional []AdditionalItem
|
||||
}
|
||||
|
||||
type AdditionalItem struct {
|
||||
ID byte
|
||||
Length int
|
||||
ValueHex string
|
||||
Parsed any
|
||||
}
|
||||
|
||||
// ExtractFrames splits JT/T 808 TCP streams by 0x7e delimiters and unescapes
|
||||
// complete frame payloads. Returned frames exclude the leading and trailing 0x7e.
|
||||
func ExtractFrames(stream []byte) (frames [][]byte, remainder []byte, err error) {
|
||||
start := -1
|
||||
for i, value := range stream {
|
||||
if value != 0x7e {
|
||||
continue
|
||||
}
|
||||
if start < 0 {
|
||||
start = i
|
||||
continue
|
||||
}
|
||||
if i == start+1 {
|
||||
start = i
|
||||
continue
|
||||
}
|
||||
frame, err := unescape(stream[start+1 : i])
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
frames = append(frames, frame)
|
||||
start = i
|
||||
}
|
||||
if start >= 0 && start < len(stream)-1 {
|
||||
return frames, append([]byte(nil), stream[start:]...), nil
|
||||
}
|
||||
return frames, nil, nil
|
||||
}
|
||||
|
||||
func ParseFrame(raw []byte, receivedAtMS int64, sourceEndpoint string) (envelope.FrameEnvelope, error) {
|
||||
if len(raw) < 13 {
|
||||
return envelope.FrameEnvelope{}, fmt.Errorf("%w: len=%d", ErrFrameTooShort, len(raw))
|
||||
}
|
||||
if got, want := checksum(raw[:len(raw)-1]), raw[len(raw)-1]; got != want {
|
||||
return envelope.FrameEnvelope{}, fmt.Errorf("%w: got=0x%02x want=0x%02x", ErrChecksum, got, want)
|
||||
}
|
||||
|
||||
messageID := binary.BigEndian.Uint16(raw[0:2])
|
||||
props := binary.BigEndian.Uint16(raw[2:4])
|
||||
bodySize := props & 0x03ff
|
||||
headerLen := 12
|
||||
packageInfo := map[string]any(nil)
|
||||
if props&0x2000 != 0 {
|
||||
headerLen = 16
|
||||
packageInfo = map[string]any{
|
||||
"total": binary.BigEndian.Uint16(raw[12:14]),
|
||||
"index": binary.BigEndian.Uint16(raw[14:16]),
|
||||
}
|
||||
}
|
||||
if len(raw) < headerLen+int(bodySize)+1 {
|
||||
return envelope.FrameEnvelope{}, fmt.Errorf("%w: bodySize=%d len=%d", ErrFrameTooShort, bodySize, len(raw))
|
||||
}
|
||||
|
||||
phoneBCD := raw[4:10]
|
||||
phone := bcdString(phoneBCD)
|
||||
body := raw[headerLen : headerLen+int(bodySize)]
|
||||
parsed := map[string]any{
|
||||
"header": map[string]any{
|
||||
"message_id": fmt.Sprintf("0x%04X", messageID),
|
||||
"properties": props,
|
||||
"body_size": bodySize,
|
||||
"phone_bcd_hex": strings.ToUpper(hex.EncodeToString(phoneBCD)),
|
||||
"phone": phone,
|
||||
"phone_trim_zero": strings.TrimLeft(phone, "0"),
|
||||
"sequence": binary.BigEndian.Uint16(raw[10:12]),
|
||||
"subpackage": props&0x2000 != 0,
|
||||
"package_info": packageInfo,
|
||||
"encryption_flags": (props >> 10) & 0x07,
|
||||
},
|
||||
}
|
||||
fields := map[string]any{}
|
||||
eventTimeMS := receivedAtMS
|
||||
|
||||
if messageID == 0x0200 {
|
||||
location, err := parseLocation(body)
|
||||
if err != nil {
|
||||
return envelope.FrameEnvelope{}, err
|
||||
}
|
||||
parsed["location"] = locationToMap(location)
|
||||
fields[envelope.FieldLatitude] = location.Latitude
|
||||
fields[envelope.FieldLongitude] = location.Longitude
|
||||
fields[envelope.FieldSpeedKMH] = location.SpeedKMH
|
||||
fields["altitude_m"] = location.AltitudeM
|
||||
fields["direction_deg"] = location.DirectionDeg
|
||||
fields["alarm_flag"] = location.AlarmFlag
|
||||
fields["status_flag"] = location.StatusFlag
|
||||
if !location.Time.IsZero() {
|
||||
eventTimeMS = location.Time.UnixMilli()
|
||||
fields["device_time"] = location.Time.Format(time.RFC3339)
|
||||
}
|
||||
if location.TotalMileageKM != nil {
|
||||
fields[envelope.FieldTotalMileageKM] = *location.TotalMileageKM
|
||||
}
|
||||
}
|
||||
|
||||
env := envelope.FrameEnvelope{
|
||||
Protocol: envelope.ProtocolJT808,
|
||||
MessageID: fmt.Sprintf("0x%04X", messageID),
|
||||
Sequence: binary.BigEndian.Uint16(raw[10:12]),
|
||||
Phone: phone,
|
||||
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 parseLocation(body []byte) (Location, error) {
|
||||
if len(body) < 28 {
|
||||
return Location{}, fmt.Errorf("%w: location body len=%d", ErrFrameTooShort, len(body))
|
||||
}
|
||||
location := Location{
|
||||
AlarmFlag: binary.BigEndian.Uint32(body[0:4]),
|
||||
StatusFlag: binary.BigEndian.Uint32(body[4:8]),
|
||||
Latitude: float64(binary.BigEndian.Uint32(body[8:12])) / 1_000_000,
|
||||
Longitude: float64(binary.BigEndian.Uint32(body[12:16])) / 1_000_000,
|
||||
AltitudeM: binary.BigEndian.Uint16(body[16:18]),
|
||||
SpeedKMH: float64(binary.BigEndian.Uint16(body[18:20])) / 10,
|
||||
DirectionDeg: binary.BigEndian.Uint16(body[20:22]),
|
||||
Time: parseBCDTime(body[22:28]),
|
||||
}
|
||||
location.Additional = parseAdditional(body[28:], &location)
|
||||
return location, nil
|
||||
}
|
||||
|
||||
func parseAdditional(data []byte, location *Location) []AdditionalItem {
|
||||
var out []AdditionalItem
|
||||
for len(data) >= 2 {
|
||||
id := data[0]
|
||||
size := int(data[1])
|
||||
data = data[2:]
|
||||
if len(data) < size {
|
||||
out = append(out, AdditionalItem{
|
||||
ID: id,
|
||||
Length: size,
|
||||
ValueHex: strings.ToUpper(hex.EncodeToString(data)),
|
||||
Parsed: "truncated",
|
||||
})
|
||||
return out
|
||||
}
|
||||
value := data[:size]
|
||||
item := AdditionalItem{
|
||||
ID: id,
|
||||
Length: size,
|
||||
ValueHex: strings.ToUpper(hex.EncodeToString(value)),
|
||||
}
|
||||
// JT/T 808-2011 table 27: id 0x01 is GPS total mileage, DWORD, 0.1 km.
|
||||
if id == 0x01 && size == 4 {
|
||||
mileage := float64(binary.BigEndian.Uint32(value)) / 10
|
||||
location.TotalMileageKM = &mileage
|
||||
item.Parsed = map[string]any{
|
||||
"name": envelope.FieldTotalMileageKM,
|
||||
"value": mileage,
|
||||
"unit": "km",
|
||||
}
|
||||
}
|
||||
out = append(out, item)
|
||||
data = data[size:]
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func locationToMap(location Location) map[string]any {
|
||||
out := map[string]any{
|
||||
"alarm_flag": location.AlarmFlag,
|
||||
"status_flag": location.StatusFlag,
|
||||
"latitude": location.Latitude,
|
||||
"longitude": location.Longitude,
|
||||
"altitude_m": location.AltitudeM,
|
||||
"speed_kmh": location.SpeedKMH,
|
||||
"direction_deg": location.DirectionDeg,
|
||||
"additional": additionalToMaps(location.Additional),
|
||||
}
|
||||
if !location.Time.IsZero() {
|
||||
out["device_time"] = location.Time.Format(time.RFC3339)
|
||||
}
|
||||
if location.TotalMileageKM != nil {
|
||||
out[envelope.FieldTotalMileageKM] = *location.TotalMileageKM
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func additionalToMaps(items []AdditionalItem) []map[string]any {
|
||||
out := make([]map[string]any, 0, len(items))
|
||||
for _, item := range items {
|
||||
value := map[string]any{
|
||||
"id": fmt.Sprintf("0x%02X", item.ID),
|
||||
"length": item.Length,
|
||||
"value_hex": item.ValueHex,
|
||||
}
|
||||
if item.Parsed != nil {
|
||||
value["parsed"] = item.Parsed
|
||||
}
|
||||
out = append(out, value)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func bcdString(data []byte) string {
|
||||
out := make([]byte, 0, len(data)*2)
|
||||
for _, value := range data {
|
||||
out = append(out, '0'+((value>>4)&0x0f), '0'+(value&0x0f))
|
||||
}
|
||||
return string(out)
|
||||
}
|
||||
|
||||
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 unescape(data []byte) ([]byte, error) {
|
||||
out := make([]byte, 0, len(data))
|
||||
for i := 0; i < len(data); i++ {
|
||||
if data[i] != 0x7d {
|
||||
out = append(out, data[i])
|
||||
continue
|
||||
}
|
||||
if i+1 >= len(data) {
|
||||
return nil, ErrEscape
|
||||
}
|
||||
i++
|
||||
switch data[i] {
|
||||
case 0x01:
|
||||
out = append(out, 0x7d)
|
||||
case 0x02:
|
||||
out = append(out, 0x7e)
|
||||
default:
|
||||
return nil, ErrEscape
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func checksum(data []byte) byte {
|
||||
var out byte
|
||||
for _, value := range data {
|
||||
out ^= value
|
||||
}
|
||||
return out
|
||||
}
|
||||
107
go/vehicle-gateway/internal/protocol/jt808/parser_test.go
Normal file
107
go/vehicle-gateway/internal/protocol/jt808/parser_test.go
Normal file
@@ -0,0 +1,107 @@
|
||||
package jt808
|
||||
|
||||
import (
|
||||
"encoding/hex"
|
||||
"testing"
|
||||
|
||||
"lingniu-vehicle-ingest/go/vehicle-gateway/internal/envelope"
|
||||
)
|
||||
|
||||
const sampleLocationFrame = "7E020000320133077954250001000000000048000301D2C4C707376139000A00E6004F26063016235701040001900C2504000000000202000030011F31010F867E"
|
||||
|
||||
func TestExtractFramesUnescapesAndParsesLocationWithTotalMileage(t *testing.T) {
|
||||
stream, err := hex.DecodeString(sampleLocationFrame + "7E02000000")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
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 hex.EncodeToString(remainder) != "7e02000000" {
|
||||
t.Fatalf("expected partial remainder, got %s", hex.EncodeToString(remainder))
|
||||
}
|
||||
|
||||
env, err := ParseFrame(frames[0], 1782745114999, "115.231.168.135:43625")
|
||||
if err != nil {
|
||||
t.Fatalf("ParseFrame() error = %v", err)
|
||||
}
|
||||
if env.Protocol != envelope.ProtocolJT808 {
|
||||
t.Fatalf("protocol = %q", env.Protocol)
|
||||
}
|
||||
if env.MessageID != "0x0200" {
|
||||
t.Fatalf("message id = %q", env.MessageID)
|
||||
}
|
||||
if env.Phone != "013307795425" {
|
||||
t.Fatalf("phone = %q", env.Phone)
|
||||
}
|
||||
if env.Sequence != 1 {
|
||||
t.Fatalf("sequence = %d", env.Sequence)
|
||||
}
|
||||
assertFloatField(t, env, envelope.FieldLatitude, 30.590151)
|
||||
assertFloatField(t, env, envelope.FieldLongitude, 121.069881)
|
||||
assertFloatField(t, env, envelope.FieldSpeedKMH, 23.0)
|
||||
assertFloatField(t, env, envelope.FieldTotalMileageKM, 10241.2)
|
||||
if env.Fields["direction_deg"] != uint16(79) {
|
||||
t.Fatalf("direction = %#v", env.Fields["direction_deg"])
|
||||
}
|
||||
|
||||
location, ok := env.Parsed["location"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("parsed location missing: %#v", env.Parsed)
|
||||
}
|
||||
additional, ok := location["additional"].([]map[string]any)
|
||||
if !ok || len(additional) == 0 {
|
||||
t.Fatalf("additional fields missing: %#v", location["additional"])
|
||||
}
|
||||
if additional[0]["id"] != "0x01" || additional[0]["value_hex"] != "0001900C" {
|
||||
t.Fatalf("unexpected first additional item: %#v", additional[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractFramesHandlesEscapedPayload(t *testing.T) {
|
||||
payload := []byte{0x02, 0x00, 0x00, 0x02, 0x01, 0x33, 0x07, 0x79, 0x54, 0x25, 0x00, 0x01, 0x7e, 0x7d}
|
||||
frame := append([]byte{0x7e}, escape(append(payload, checksum(payload)))...)
|
||||
frame = append(frame, 0x7e)
|
||||
|
||||
frames, remainder, err := ExtractFrames(frame)
|
||||
if err != nil {
|
||||
t.Fatalf("ExtractFrames() error = %v", err)
|
||||
}
|
||||
if len(frames) != 1 || len(remainder) != 0 {
|
||||
t.Fatalf("unexpected split result frames=%d remainder=%s", len(frames), hex.EncodeToString(remainder))
|
||||
}
|
||||
if hex.EncodeToString(frames[0][:len(payload)]) != hex.EncodeToString(payload) {
|
||||
t.Fatalf("frame was not unescaped: %s", hex.EncodeToString(frames[0]))
|
||||
}
|
||||
}
|
||||
|
||||
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 escape(payload []byte) []byte {
|
||||
var out []byte
|
||||
for _, value := range payload {
|
||||
switch value {
|
||||
case 0x7e:
|
||||
out = append(out, 0x7d, 0x02)
|
||||
case 0x7d:
|
||||
out = append(out, 0x7d, 0x01)
|
||||
default:
|
||||
out = append(out, value)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
Reference in New Issue
Block a user