512 lines
14 KiB
Go
512 lines
14 KiB
Go
package jt808
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/binary"
|
|
"encoding/hex"
|
|
"errors"
|
|
"fmt"
|
|
"strings"
|
|
"time"
|
|
"unicode/utf8"
|
|
|
|
"golang.org/x/text/encoding/simplifiedchinese"
|
|
"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
|
|
versioned := props&0x4000 != 0
|
|
headerLen := 12
|
|
phoneStart := 4
|
|
phoneEnd := 10
|
|
serialStart := 10
|
|
packageInfo := map[string]any(nil)
|
|
protocolVersion := byte(0)
|
|
if versioned {
|
|
headerLen = 17
|
|
if len(raw) < headerLen {
|
|
return envelope.FrameEnvelope{}, fmt.Errorf("%w: versioned header len=%d", ErrFrameTooShort, len(raw))
|
|
}
|
|
protocolVersion = raw[4]
|
|
phoneStart = 5
|
|
phoneEnd = 15
|
|
serialStart = 15
|
|
}
|
|
subpackage := props&0x2000 != 0
|
|
if subpackage {
|
|
headerLen += 4
|
|
}
|
|
if len(raw) < headerLen+int(bodySize)+1 {
|
|
return envelope.FrameEnvelope{}, fmt.Errorf("%w: bodySize=%d len=%d", ErrFrameTooShort, bodySize, len(raw))
|
|
}
|
|
if subpackage {
|
|
packageInfo = map[string]any{
|
|
"total": binary.BigEndian.Uint16(raw[serialStart+2 : serialStart+4]),
|
|
"index": binary.BigEndian.Uint16(raw[serialStart+4 : serialStart+6]),
|
|
}
|
|
}
|
|
|
|
phoneBCD := raw[phoneStart:phoneEnd]
|
|
phone := bcdString(phoneBCD)
|
|
phoneTrimZero := strings.TrimLeft(phone, "0")
|
|
if versioned && phoneTrimZero != "" {
|
|
phone = phoneTrimZero
|
|
}
|
|
body := raw[headerLen : headerLen+int(bodySize)]
|
|
parsed := map[string]any{
|
|
"header": map[string]any{
|
|
"message_id": fmt.Sprintf("0x%04X", messageID),
|
|
"protocol_version": protocolVersion,
|
|
"versioned": versioned,
|
|
"properties": props,
|
|
"body_size": bodySize,
|
|
"phone_bcd_hex": strings.ToUpper(hex.EncodeToString(phoneBCD)),
|
|
"phone": phone,
|
|
"phone_trim_zero": phoneTrimZero,
|
|
"sequence": binary.BigEndian.Uint16(raw[serialStart : serialStart+2]),
|
|
"subpackage": subpackage,
|
|
"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
|
|
}
|
|
} else if messageID == 0x0100 {
|
|
registration, err := parseRegistration(body, versioned)
|
|
if err != nil {
|
|
return envelope.FrameEnvelope{}, err
|
|
}
|
|
parsed["registration"] = registration
|
|
if deviceID, ok := registration["device_id"].(string); ok {
|
|
fields["device_id"] = deviceID
|
|
}
|
|
if plate, ok := registration["plate"].(string); ok {
|
|
fields["plate"] = plate
|
|
}
|
|
} else if messageID == 0x0102 {
|
|
authentication := parseAuthentication(body, versioned)
|
|
parsed["authentication"] = authentication
|
|
if token, ok := authentication["token"].(string); ok {
|
|
fields["auth_token"] = token
|
|
}
|
|
}
|
|
|
|
env := envelope.FrameEnvelope{
|
|
Protocol: envelope.ProtocolJT808,
|
|
MessageID: fmt.Sprintf("0x%04X", messageID),
|
|
Sequence: binary.BigEndian.Uint16(raw[serialStart : serialStart+2]),
|
|
Phone: phone,
|
|
SourceEndpoint: sourceEndpoint,
|
|
EventTimeMS: eventTimeMS,
|
|
ReceivedAtMS: receivedAtMS,
|
|
RawHex: strings.ToUpper(hex.EncodeToString(raw)),
|
|
Parsed: parsed,
|
|
Fields: fields,
|
|
ParseStatus: envelope.ParseOK,
|
|
}
|
|
if registration, ok := parsed["registration"].(map[string]any); ok {
|
|
env.DeviceID, _ = registration["device_id"].(string)
|
|
env.Plate, _ = registration["plate"].(string)
|
|
}
|
|
env.EventID = env.StableEventID()
|
|
return env, nil
|
|
}
|
|
|
|
func parseRegistration(body []byte, versioned bool) (map[string]any, error) {
|
|
schemaVersion := "2011_2013"
|
|
manufacturerSize := 5
|
|
deviceTypeSize := 20
|
|
deviceIDSize := 7
|
|
if versioned {
|
|
schemaVersion = "2019"
|
|
manufacturerSize = 11
|
|
deviceTypeSize = 30
|
|
deviceIDSize = 30
|
|
}
|
|
minLen := 2 + 2 + manufacturerSize + deviceTypeSize + deviceIDSize + 1
|
|
if len(body) < minLen {
|
|
return nil, fmt.Errorf("%w: registration body len=%d", ErrFrameTooShort, len(body))
|
|
}
|
|
manufacturerStart := 4
|
|
deviceTypeStart := manufacturerStart + manufacturerSize
|
|
deviceIDStart := deviceTypeStart + deviceTypeSize
|
|
plateColorStart := deviceIDStart + deviceIDSize
|
|
registration := map[string]any{
|
|
"schema_version": schemaVersion,
|
|
"province": binary.BigEndian.Uint16(body[0:2]),
|
|
"city": binary.BigEndian.Uint16(body[2:4]),
|
|
"manufacturer": fixedText(body[manufacturerStart:deviceTypeStart]),
|
|
"device_type": fixedText(body[deviceTypeStart:deviceIDStart]),
|
|
"device_id": fixedText(body[deviceIDStart:plateColorStart]),
|
|
"plate_color": body[plateColorStart],
|
|
"plate": freeText(body[plateColorStart+1:]),
|
|
}
|
|
return registration, nil
|
|
}
|
|
|
|
func parseAuthentication(body []byte, versioned bool) map[string]any {
|
|
if versioned {
|
|
if out, ok := parseVersionedAuthentication(body); ok {
|
|
return out
|
|
}
|
|
if out, ok := parseDelimitedVersionedAuthentication(body); ok {
|
|
return out
|
|
}
|
|
}
|
|
return map[string]any{"token": freeText(body)}
|
|
}
|
|
|
|
func parseVersionedAuthentication(body []byte) (map[string]any, bool) {
|
|
if len(body) < 17 {
|
|
return nil, false
|
|
}
|
|
tokenLen := int(body[0])
|
|
imeiStart := 1 + tokenLen
|
|
softwareLenStart := imeiStart + 15
|
|
if tokenLen <= 0 || len(body) < softwareLenStart+1 {
|
|
return nil, false
|
|
}
|
|
softwareLen := int(body[softwareLenStart])
|
|
softwareStart := softwareLenStart + 1
|
|
if len(body) < softwareStart+softwareLen {
|
|
return nil, false
|
|
}
|
|
return map[string]any{
|
|
"schema_version": "2019",
|
|
"token": freeText(body[1:imeiStart]),
|
|
"imei": fixedText(body[imeiStart:softwareLenStart]),
|
|
"software_version": freeText(body[softwareStart : softwareStart+softwareLen]),
|
|
}, true
|
|
}
|
|
|
|
func parseDelimitedVersionedAuthentication(body []byte) (map[string]any, bool) {
|
|
parts := bytes.Split(body, []byte{0xff, 0xff})
|
|
if len(parts) < 2 {
|
|
return nil, false
|
|
}
|
|
out := map[string]any{
|
|
"schema_version": "2019_delimited",
|
|
"token": freeText(parts[0]),
|
|
"imei": freeText(parts[1]),
|
|
}
|
|
if len(parts) > 2 {
|
|
out["software_version"] = freeText(parts[2])
|
|
}
|
|
return out, true
|
|
}
|
|
|
|
func parseLocation(body []byte) (Location, error) {
|
|
if len(body) < 28 {
|
|
return Location{}, fmt.Errorf("%w: location body len=%d", ErrFrameTooShort, len(body))
|
|
}
|
|
status := 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
|
|
if status&(1<<2) != 0 {
|
|
latitude = -latitude
|
|
}
|
|
if status&(1<<3) != 0 {
|
|
longitude = -longitude
|
|
}
|
|
location := Location{
|
|
AlarmFlag: binary.BigEndian.Uint32(body[0:4]),
|
|
StatusFlag: status,
|
|
Latitude: latitude,
|
|
Longitude: longitude,
|
|
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)),
|
|
}
|
|
switch {
|
|
case 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",
|
|
}
|
|
case id == 0x02 && size == 2:
|
|
item.Parsed = map[string]any{
|
|
"name": "fuel_l",
|
|
"value": float64(binary.BigEndian.Uint16(value)) / 10,
|
|
"unit": "L",
|
|
}
|
|
case id == 0x03 && size == 2:
|
|
item.Parsed = map[string]any{
|
|
"name": "recorder_speed_kmh",
|
|
"value": float64(binary.BigEndian.Uint16(value)) / 10,
|
|
"unit": "km/h",
|
|
}
|
|
case id == 0x04 && size == 2:
|
|
item.Parsed = map[string]any{
|
|
"name": "manual_alarm_event_id",
|
|
"value": binary.BigEndian.Uint16(value),
|
|
}
|
|
case id == 0x06 && size == 2:
|
|
item.Parsed = map[string]any{
|
|
"name": "carriage_temperature_c",
|
|
"value": int16(binary.BigEndian.Uint16(value)),
|
|
"unit": "C",
|
|
}
|
|
case id == 0x25 && size == 4:
|
|
item.Parsed = map[string]any{
|
|
"name": "extended_vehicle_signal_status",
|
|
"value": binary.BigEndian.Uint32(value),
|
|
}
|
|
case id == 0x2a && size == 2:
|
|
item.Parsed = map[string]any{
|
|
"name": "io_status",
|
|
"value": binary.BigEndian.Uint16(value),
|
|
}
|
|
case id == 0x2b && size == 4:
|
|
analog := binary.BigEndian.Uint32(value)
|
|
item.Parsed = map[string]any{
|
|
"name": "analog",
|
|
"ad0": analog & 0xffff,
|
|
"ad1": analog >> 16,
|
|
"value": analog,
|
|
}
|
|
case id == 0x30 && size == 1:
|
|
item.Parsed = map[string]any{
|
|
"name": "network_signal_strength",
|
|
"value": value[0],
|
|
}
|
|
case id == 0x31 && size == 1:
|
|
item.Parsed = map[string]any{
|
|
"name": "gnss_satellite_count",
|
|
"value": value[0],
|
|
}
|
|
}
|
|
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 fixedText(data []byte) string {
|
|
return strings.Trim(freeText(data), "\x00 ")
|
|
}
|
|
|
|
func freeText(data []byte) string {
|
|
data = bytes.Trim(data, "\x00 ")
|
|
if len(data) == 0 {
|
|
return ""
|
|
}
|
|
if utf8.Valid(data) {
|
|
return string(data)
|
|
}
|
|
decoded, err := simplifiedchinese.GBK.NewDecoder().Bytes(data)
|
|
if err == nil && utf8.Valid(decoded) {
|
|
return string(decoded)
|
|
}
|
|
return strings.ToValidUTF8(string(data), "")
|
|
}
|
|
|
|
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
|
|
}
|