301 lines
8.0 KiB
Go
301 lines
8.0 KiB
Go
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
|
|
}
|