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 }