diff --git a/go/vehicle-gateway/internal/protocol/gb32960/parser.go b/go/vehicle-gateway/internal/protocol/gb32960/parser.go index 66638889..fb66299e 100644 --- a/go/vehicle-gateway/internal/protocol/gb32960/parser.go +++ b/go/vehicle-gateway/internal/protocol/gb32960/parser.go @@ -38,7 +38,7 @@ func ExtractFrames(stream []byte) (frames [][]byte, remainder []byte, err error) return frames, append([]byte(nil), remaining...), nil } bodyLen := int(binary.BigEndian.Uint16(remaining[22:24])) - frameLen := headerLen + bodyLen + 1 + frameLen := frameLength(remaining, bodyLen) if len(remaining) < frameLen { return frames, append([]byte(nil), remaining...), nil } @@ -59,7 +59,8 @@ func ParseFrame(raw []byte, receivedAtMS int64, sourceEndpoint string) (envelope 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 { + actualBodyLen := len(raw) - headerLen - 1 + if len(raw) != headerLen+bodyLen+1 && !isExtendedPlatformLogin(raw[2], bodyLen, actualBodyLen) { 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 { @@ -69,15 +70,16 @@ func ParseFrame(raw []byte, receivedAtMS int64, sourceEndpoint string) (envelope command := raw[2] responseFlag := raw[3] vin := strings.TrimRight(string(raw[4:21]), "\x00 ") - body := raw[headerLen : headerLen+bodyLen] + body := raw[headerLen : headerLen+actualBodyLen] 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, + "version": version, + "command": fmt.Sprintf("0x%02X", command), + "response_flag": fmt.Sprintf("0x%02X", responseFlag), + "vin": vin, + "encrypt": raw[21], + "body_length": bodyLen, + "actual_body_length": actualBodyLen, }, } fields := map[string]any{} @@ -91,6 +93,16 @@ func ParseFrame(raw []byte, receivedAtMS int64, sourceEndpoint string) (envelope parsed["device_time"] = eventTime.Format(time.RFC3339) } parsed["data_units"] = units + } else if command == 0x05 { + login := parsePlatformLogin(body) + parsed["platform_login"] = login + if deviceTime, ok := login["login_time"].(string); ok && deviceTime != "" { + fields["device_time"] = deviceTime + parsed["device_time"] = deviceTime + } + if account, ok := login["username"].(string); ok && account != "" { + fields["platform_account"] = account + } } env := envelope.FrameEnvelope{ @@ -113,7 +125,7 @@ func parseDataBody(version string, body []byte, fields map[string]any) (time.Tim if len(body) < 6 { return time.Time{}, nil } - eventTime := parseBCDTime(body[:6]) + eventTime := parseGBTime(body[:6]) cursor := 6 var units []map[string]any for cursor < len(body) { @@ -303,6 +315,54 @@ func parseDataBody(version string, body []byte, fields map[string]any) (time.Tim return eventTime, units } +func frameLength(frameStart []byte, bodyLen int) int { + standardLen := headerLen + bodyLen + 1 + if len(frameStart) < headerLen || frameStart[2] != 0x05 || bodyLen != 41 { + return standardLen + } + // Some field platform-login frames declare the standard 41-byte body but + // carry a 32-byte password, making the actual body 53 bytes. Only consume + // the extended form when its own BCC verifies so the splitter does not eat + // bytes from the next frame. + extendedLen := headerLen + 53 + 1 + if len(frameStart) < extendedLen { + return standardLen + } + extended := frameStart[:extendedLen] + if bcc(extended[2:len(extended)-1]) == extended[len(extended)-1] { + return extendedLen + } + return standardLen +} + +func isExtendedPlatformLogin(command byte, declaredBodyLen int, actualBodyLen int) bool { + return command == 0x05 && declaredBodyLen == 41 && actualBodyLen == 53 +} + +func parsePlatformLogin(body []byte) map[string]any { + login := map[string]any{ + "raw_body_length": len(body), + } + if len(body) < 9 { + login["error"] = "truncated" + return login + } + loginTime := parseGBTime(body[:6]) + if !loginTime.IsZero() { + login["login_time"] = loginTime.Format(time.RFC3339) + } + login["serial_no"] = binary.BigEndian.Uint16(body[6:8]) + if len(body) >= 21 { + login["username"] = trimASCII(body[8:20]) + } + if len(body) > 21 { + passwordEnd := len(body) - 1 + login["password"] = trimASCII(body[20:passwordEnd]) + login["encrypt_rule"] = body[passwordEnd] + } + return login +} + func parseVehicleData(data []byte) map[string]any { return map[string]any{ "vehicle_status": int(data[0]), @@ -751,24 +811,24 @@ func version(a byte, b byte) (string, bool) { } } -func parseBCDTime(data []byte) time.Time { +func parseGBTime(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]) + year := 2000 + int(data[0]) + month := time.Month(data[1]) + day := int(data[2]) + hour := int(data[3]) + minute := int(data[4]) + second := int(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 trimASCII(data []byte) string { + return strings.TrimRight(string(data), "\x00 ") } func bcc(data []byte) byte { diff --git a/go/vehicle-gateway/internal/protocol/gb32960/parser_test.go b/go/vehicle-gateway/internal/protocol/gb32960/parser_test.go index 60dc27b2..d172df72 100644 --- a/go/vehicle-gateway/internal/protocol/gb32960/parser_test.go +++ b/go/vehicle-gateway/internal/protocol/gb32960/parser_test.go @@ -12,7 +12,7 @@ import ( const realFuelCellFrame = "232302FE4C423941333241323152304C53313730370102ED1A070116091C0102030100000008297D161F27104C020007D0000002010104564E204E205815CA271003000A000000B40002666902800200000100A101000400FFFF001205000733444601E2B5DB06013A0F6C018E0F4001014B01054A07000000000000000000080101161F271000900001900F520F530F520F640F660F650F650F650F650F660F650F650F640F640F550F550F540F560F580F570F540F570F560F550F5A0F550F580F570F5C0F5F0F5E0F610F5E0F5D0F5E0F5F0F5E0F5D0F610F5F0F610F600F600F610F610F610F600F630F630F600F610F610F610F620F620F610F6A0F6C0F6B0F6A0F6C0F6B0F6A0F6B0F6B0F6B0F6A0F610F630F630F630F640F660F640F630F630F630F580F5A0F580F5B0F550F580F5A0F5A0F590F580F5A0F620F650F650F650F660F640F640F640F640F640F660F650F640F650F660F650F670F670F670F670F630F620F680F670F650F650F670F650F610F5F0F600F620F5E0F5C0F590F4C0F490F480F440F470F480F470F420F430F450F420F450F440F440F430F530F540F520F400F410F4109010100084B4B4B4B4A4A4B4A3001026907B2FF00FF00380002002800000008006C00016C00080008000000080008000800080008000800080008000800080008000800080008000800080008000800080008000800080008000800080008000800080008000800080008000800080008000800080008000800080008000800080008000800080008000800080008000800080008002800070007000700070007000700070007000700070007000700070007000700070007000700070007000700070007000700080008000800080008000800080008000800080008000800080008000800080008000800080008000800080008000800080008000831010E9C0005FFFFFFFF0E9CFFFFFFFF0083320E9C1B6115E212465733FFFFFFFFFF34FFFFFFFFFF002B800009690E9C2710000D000E83002402020500002700080001000000FD00000000FFFFFFFFFFFFFFFFFFFFFFFF1FFE1FF3001DBA" func TestExtractFramesKeepsPartialRemainderAndParsesHeader(t *testing.T) { - first := buildFrame(0x02, 0xfe, "LNBSCB3D4R1234567", []byte{0x1a, 0x06, 0x30, 0x16, 0x23, 0x57}) + first := buildFrame(0x02, 0xfe, "LNBSCB3D4R1234567", []byte{0x1a, 0x06, 0x1e, 0x16, 0x17, 0x39}) second := buildFrame(0x05, 0xfe, "PLATFORM-LOGIN001", nil) stream := append([]byte{0x00, 0x01}, first...) stream = append(stream, second[:len(second)-3]...) @@ -49,8 +49,49 @@ func TestParseFrameRejectsBadBCC(t *testing.T) { } } +func TestExtractFramesSupportsExtendedPlatformLogin(t *testing.T) { + body := []byte{0x1a, 0x07, 0x02, 0x00, 0x26, 0x1f, 0x00, 0x01} + body = append(body, fixedASCII("Hyundai", 12)...) + body = append(body, fixedASCII("f2e3445d7cda409fb4f278f6fb890734", 32)...) + body = append(body, 0x01) + frame := buildFrameWithDeclaredLength(0x05, 0xfe, "\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00", body, 41) + + frames, remainder, err := ExtractFrames(append(frame, []byte{0x23, 0x23, 0x05}...)) + if err != nil { + t.Fatalf("ExtractFrames() error = %v", err) + } + if len(frames) != 1 || len(remainder) != 3 { + t.Fatalf("unexpected split frames=%d remainder=%s", len(frames), hex.EncodeToString(remainder)) + } + if len(frames[0]) != headerLen+53+1 { + t.Fatalf("extended frame len = %d", len(frames[0])) + } + + env, err := ParseFrame(frames[0], 1782745114999, "8.134.95.166:39376") + if err != nil { + t.Fatalf("ParseFrame() error = %v", err) + } + if env.MessageID != "0x05" || env.ParseStatus != envelope.ParseOK { + t.Fatalf("unexpected env: %#v", env) + } + header := env.Parsed["header"].(map[string]any) + if header["body_length"] != 41 || header["actual_body_length"] != 53 { + t.Fatalf("unexpected length header: %#v", header) + } + login := env.Parsed["platform_login"].(map[string]any) + if login["username"] != "Hyundai" || login["password"] != "f2e3445d7cda409fb4f278f6fb890734" { + t.Fatalf("unexpected platform login: %#v", login) + } + if login["login_time"] != "2026-07-02T00:38:31+08:00" { + t.Fatalf("unexpected login time: %#v", login) + } + if env.Fields["platform_account"] != "Hyundai" { + t.Fatalf("platform account not exposed: %#v", env.Fields) + } +} + func TestParseFrameExtractsRealtimeVehicleMileageAndPosition(t *testing.T) { - body := []byte{0x1a, 0x06, 0x30, 0x16, 0x23, 0x57} + body := []byte{0x1a, 0x06, 0x1e, 0x16, 0x17, 0x39} body = append(body, 0x01) body = append(body, 0x01, // vehicle status @@ -163,13 +204,23 @@ func assertIntField(t *testing.T, env envelope.FrameEnvelope, key string, want i } func buildFrame(command byte, response byte, vin string, body []byte) []byte { + return buildFrameWithDeclaredLength(command, response, vin, body, len(body)) +} + +func buildFrameWithDeclaredLength(command byte, response byte, vin string, body []byte, declaredBodyLen int) []byte { frame := []byte{'#', '#', command, response} vinBytes := []byte(vin) if len(vinBytes) < 17 { vinBytes = append(vinBytes, make([]byte, 17-len(vinBytes))...) } frame = append(frame, vinBytes[:17]...) - frame = append(frame, 0x01, byte(len(body)>>8), byte(len(body))) + frame = append(frame, 0x01, byte(declaredBodyLen>>8), byte(declaredBodyLen)) frame = append(frame, body...) return append(frame, bcc(frame[2:])) } + +func fixedASCII(value string, size int) []byte { + out := make([]byte, size) + copy(out, []byte(value)) + return out +}