Files
lingniu-vehicle-ingest/go/vehicle-gateway/internal/protocol/gb32960/parser.go
T
lingniu d6b957dfc8 feat: sync vehicle monitoring and accumulated platform updates
Add monitoring metrics, numeric sorting, report-time display, vehicle number labels, and map layout fixes. Include accumulated mileage, hydrogen, OneOS authentication, open platform, ingestion, deployment, and UI updates.

Exclude temporary files, generated reports, and credentials. Validate frontend 632 tests and production build, API packages, vehicle-map tests, and release installer tests. Gateway suite has one pre-existing date-sensitive identity throttling test failure reproduced on the parent commit.
2026-10-26 18:05:59 +08:00

985 lines
32 KiB
Go

package gb32960
import (
"encoding/binary"
"encoding/hex"
"errors"
"fmt"
"strings"
"time"
"lingniu-vehicle-ingest/go/vehicle-gateway/internal/envelope"
)
var (
ErrFrameTooShort = errors.New("gb32960 frame too short")
ErrBadStartSymbol = errors.New("gb32960 bad start symbol")
ErrBodyLength = errors.New("gb32960 body length mismatch")
ErrBCC = errors.New("gb32960 bcc mismatch")
)
const (
minFrameLen = 25
headerLen = 24
)
// ExtractFrames splits GB/T 32960 TCP streams using the protocol length field.
// Returned frames include the start symbols and trailing BCC byte.
func ExtractFrames(stream []byte) (frames [][]byte, remainder []byte, err error) {
offset := 0
for {
start := indexStart(stream[offset:])
if start < 0 {
return frames, nil, nil
}
offset += start
remaining := stream[offset:]
if len(remaining) < headerLen {
return frames, append([]byte(nil), remaining...), nil
}
bodyLen := int(binary.BigEndian.Uint16(remaining[22:24]))
frameLen := frameLength(remaining, bodyLen)
if len(remaining) < frameLen {
return frames, append([]byte(nil), remaining...), nil
}
frames = append(frames, append([]byte(nil), remaining[:frameLen]...))
offset += frameLen
if offset >= len(stream) {
return frames, nil, nil
}
}
}
func ParseFrame(raw []byte, receivedAtMS int64, sourceEndpoint string) (envelope.FrameEnvelope, error) {
return parseFrame(raw, receivedAtMS, sourceEndpoint, false)
}
// ParseFrameForPlatform selects Feichi's opaque, length-prefixed 0x80 format
// using the account established by this TCP connection's platform login.
// All other accounts retain the existing Guangdong extension decoder.
func ParseFrameForPlatform(raw []byte, receivedAtMS int64, sourceEndpoint, platformAccount string) (envelope.FrameEnvelope, error) {
return parseFrame(raw, receivedAtMS, sourceEndpoint, strings.TrimSpace(platformAccount) == "Feichi")
}
func parseFrame(raw []byte, receivedAtMS int64, sourceEndpoint string, feichiCustom80 bool) (envelope.FrameEnvelope, error) {
if len(raw) < minFrameLen {
return envelope.FrameEnvelope{}, fmt.Errorf("%w: len=%d", ErrFrameTooShort, len(raw))
}
version, ok := version(raw[0], raw[1])
if !ok {
return envelope.FrameEnvelope{}, fmt.Errorf("%w: %s", ErrBadStartSymbol, hex.EncodeToString(raw[:2]))
}
bodyLen := int(binary.BigEndian.Uint16(raw[22:24]))
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 {
return envelope.FrameEnvelope{}, fmt.Errorf("%w: got=0x%02x want=0x%02x", ErrBCC, got, want)
}
command := raw[2]
responseFlag := raw[3]
vin := strings.TrimRight(string(raw[4:21]), "\x00 ")
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,
"actual_body_length": actualBodyLen,
},
}
fields := map[string]any{}
eventTimeMS := receivedAtMS
if command == 0x02 || command == 0x03 {
eventTime, units := parseDataBodyWithCustom80(version, body, fields, feichiCustom80)
if !eventTime.IsZero() {
eventTimeMS = eventTime.UnixMilli()
fields["device_time"] = eventTime.Format(time.RFC3339)
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{
Protocol: envelope.ProtocolGB32960,
MessageID: fmt.Sprintf("0x%02X", command),
VIN: vin,
SourceEndpoint: sourceEndpoint,
EventTimeMS: eventTimeMS,
ReceivedAtMS: receivedAtMS,
RawHex: strings.ToUpper(hex.EncodeToString(raw)),
Parsed: parsed,
Fields: fields,
ParseStatus: envelope.ParseOK,
}
if feichiCustom80 {
if units, ok := parsed["data_units"].([]map[string]any); ok {
for _, unit := range units {
if unit["name"] == "feichi_custom_80" && unit["error"] != nil {
env.ParseStatus = envelope.ParsePartial
env.ParseError = "Feichi 0x80: " + fmt.Sprint(unit["error"])
break
}
}
}
}
env.EventID = env.StableEventID()
return env, nil
}
func parseDataBody(version string, body []byte, fields map[string]any) (time.Time, []map[string]any) {
return parseDataBodyWithCustom80(version, body, fields, false)
}
func parseDataBodyWithCustom80(version string, body []byte, fields map[string]any, feichiCustom80 bool) (time.Time, []map[string]any) {
if len(body) < 6 {
return time.Time{}, nil
}
eventTime := parseGBTime(body[:6])
cursor := 6
var units []map[string]any
custom80Blocks := 0
for cursor < len(body) {
unitType := body[cursor]
cursor++
switch unitType {
case 0x01:
size := 20
if version == "V2025" {
size = 18
}
if len(body[cursor:]) < size {
units = append(units, map[string]any{"type": "0x01", "error": "truncated"})
return eventTime, units
}
unit := parseVehicleData(body[cursor : cursor+size])
units = append(units, map[string]any{"type": "0x01", "name": "vehicle", "value": unit})
fields["vehicle_status"] = unit["vehicle_status"]
fields["charge_status"] = unit["charge_status"]
fields[envelope.FieldVehicleRunningMode] = unit["running_mode"]
fields[envelope.FieldSpeedKMH] = unit["speed_kmh"]
fields[envelope.FieldTotalMileageKM] = unit["total_mileage_km"]
fields[envelope.FieldSOCPercent] = unit["soc_percent"]
cursor += size
case 0x05:
size := 9
if version == "V2025" {
size = 10
}
if len(body[cursor:]) < size {
units = append(units, map[string]any{"type": "0x05", "error": "truncated"})
return eventTime, units
}
unit := parsePositionData(version, body[cursor:cursor+size])
units = append(units, map[string]any{"type": "0x05", "name": "position", "value": unit})
fields["position_status"] = unit["position_status"]
if coordinateSystem, ok := unit["coordinate_system"]; ok {
fields["coordinate_system"] = coordinateSystem
}
fields[envelope.FieldLongitude] = unit["longitude"]
fields[envelope.FieldLatitude] = unit["latitude"]
cursor += size
case 0x02:
if len(body[cursor:]) < 1 {
units = append(units, map[string]any{"type": "0x02", "error": "truncated"})
return eventTime, units
}
size := 1 + int(body[cursor])*12
if len(body[cursor:]) < size {
units = append(units, map[string]any{"type": "0x02", "error": "truncated"})
return eventTime, units
}
unit := parseDriveMotorData(body[cursor : cursor+size])
units = append(units, map[string]any{"type": "0x02", "name": "drive_motor", "value": unit})
cursor += size
case 0x03:
if len(body[cursor:]) < 8 {
units = append(units, map[string]any{"type": "0x03", "error": "truncated"})
return eventTime, units
}
probeCount := int(binary.BigEndian.Uint16(body[cursor+6 : cursor+8]))
size := 8 + probeCount + 10
if len(body[cursor:]) < size {
units = append(units, map[string]any{"type": "0x03", "error": "truncated"})
return eventTime, units
}
unit := parseFuelCellData(body[cursor : cursor+size])
units = append(units, map[string]any{"type": "0x03", "name": "fuel_cell", "value": unit})
fields["fuel_cell_hydrogen_consumption_kg_per_100km"] = unit["hydrogen_consumption_kg_per_100km"]
fields["fuel_cell_voltage_v"] = unit["fuel_cell_voltage_v"]
fields["fuel_cell_current_a"] = unit["fuel_cell_current_a"]
cursor += size
case 0x04:
if len(body[cursor:]) < 5 {
units = append(units, map[string]any{"type": "0x04", "error": "truncated"})
return eventTime, units
}
unit := parseEngineData(body[cursor : cursor+5])
units = append(units, map[string]any{"type": "0x04", "name": "engine", "value": unit})
cursor += 5
case 0x06:
if len(body[cursor:]) < 14 {
units = append(units, map[string]any{"type": "0x06", "error": "truncated"})
return eventTime, units
}
unit := parseExtremeData(body[cursor : cursor+14])
units = append(units, map[string]any{"type": "0x06", "name": "extreme", "value": unit})
cursor += 14
case 0x07:
size, ok := alarmDataSize(body[cursor:])
if !ok {
units = append(units, map[string]any{"type": "0x07", "error": "truncated"})
return eventTime, units
}
unit := parseAlarmData(body[cursor : cursor+size])
units = append(units, map[string]any{"type": "0x07", "name": "alarm", "value": unit})
cursor += size
case 0x08:
size, ok := voltageDataSize(body[cursor:])
if !ok {
units = append(units, map[string]any{"type": "0x08", "error": "truncated"})
return eventTime, units
}
unit := parseVoltageData(body[cursor : cursor+size])
units = append(units, map[string]any{"type": "0x08", "name": "voltage", "value": unit})
cursor += size
case 0x09:
size, ok := temperatureDataSize(body[cursor:])
if !ok {
units = append(units, map[string]any{"type": "0x09", "error": "truncated"})
return eventTime, units
}
unit := parseTemperatureData(body[cursor : cursor+size])
units = append(units, map[string]any{"type": "0x09", "name": "temperature", "value": unit})
cursor += size
case 0x30:
size, ok := gdFCStackDataSize(body[cursor:])
if !ok {
units = append(units, map[string]any{"type": "0x30", "error": "truncated"})
return eventTime, units
}
unit := parseGdFCStackData(body[cursor : cursor+size])
units = append(units, map[string]any{"type": "0x30", "name": "gd_fc_stack", "value": unit})
if summaries, ok := unit["summaries"].([]map[string]any); ok && len(summaries) > 0 {
fields[envelope.FieldFuelCellWorkMode] = summaries[0]["engine_work_state"]
fields["gd_fc_stack_hydrogen_inlet_pressure_kpa"] = summaries[0]["hydrogen_inlet_pressure_kpa"]
fields["gd_fc_stack_water_outlet_temp_c"] = summaries[0]["stack_water_outlet_temp_c"]
fields["gd_fc_stack_cell_count"] = summaries[0]["cell_count"]
fields["gd_fc_stack_frame_cell_count"] = summaries[0]["frame_cell_count"]
}
cursor += size
case 0x31:
size, ok := gdFCAuxiliaryDataSize(body[cursor:])
if !ok {
units = append(units, map[string]any{"type": "0x31", "error": "truncated"})
return eventTime, units
}
unit := parseGdFCAuxiliaryData(body[cursor : cursor+size])
units = append(units, map[string]any{"type": "0x31", "name": "gd_fc_auxiliary", "value": unit})
cursor += size
case 0x32:
if len(body[cursor:]) < 9 {
units = append(units, map[string]any{"type": "0x32", "error": "truncated"})
return eventTime, units
}
unit := parseGdFCDcDcData(body[cursor : cursor+9])
units = append(units, map[string]any{"type": "0x32", "name": "gd_fc_dcdc", "value": unit})
fields["gd_fc_dcdc_controller_temp_c"] = unit["controller_temp_c"]
cursor += 9
case 0x33:
if len(body[cursor:]) < 5 {
units = append(units, map[string]any{"type": "0x33", "error": "truncated"})
return eventTime, units
}
unit := parseGdFCAirConditionerData(body[cursor : cursor+5])
units = append(units, map[string]any{"type": "0x33", "name": "gd_fc_air_conditioner", "value": unit})
cursor += 5
case 0x34:
if len(body[cursor:]) < 7 {
units = append(units, map[string]any{"type": "0x34", "error": "truncated"})
return eventTime, units
}
unit := parseGdFCVehicleInfoData(body[cursor : cursor+7])
units = append(units, map[string]any{"type": "0x34", "name": "gd_fc_vehicle_info", "value": unit})
fields["gd_fc_vehicle_hydrogen_mass_kg"] = unit["hydrogen_mass_kg"]
cursor += 7
case 0x80:
if feichiCustom80 {
custom80Blocks++
unit, size := parseFeichiCustom80Data(body[cursor:], custom80Blocks)
units = append(units, unit)
cursor += size
if unit["error"] != nil {
return eventTime, units
}
continue
}
if len(body[cursor:]) < 11 {
units = append(units, map[string]any{"type": "0x80", "error": "truncated"})
return eventTime, units
}
unit := parseGdFCDemoExtensionData(body[cursor : cursor+11])
units = append(units, map[string]any{"type": "0x80", "name": "gd_fc_demo_extension", "value": unit})
fields["gd_fc_demo_stack_temp_c"] = unit["stack_temp_c"]
cursor += 11
case 0x83:
size, ok := gdFCVendorTLVDataSize(body[cursor:])
if !ok {
units = append(units, map[string]any{"type": "0x83", "error": "truncated"})
return eventTime, units
}
unit := parseGdFCVendorTLVData(0x83, body[cursor:cursor+size])
units = append(units, map[string]any{"type": "0x83", "name": "gd_fc_vendor_tlv", "value": unit})
cursor += size
default:
units = append(units, map[string]any{
"type": fmt.Sprintf("0x%02X", unitType),
"raw_tail": strings.ToUpper(hex.EncodeToString(body[cursor:])),
"parse": "unknown_unit",
"byte_size": len(body) - cursor,
})
return eventTime, units
}
}
return eventTime, units
}
func parseFeichiCustom80Data(data []byte, blockNumber int) (map[string]any, int) {
size := len(data)
lengthSize := min(2, len(data))
payload := []byte(nil)
block := map[string]any{
"length_hex": strings.ToUpper(hex.EncodeToString(data[:lengthSize])),
}
parseError := ""
if len(data) < 2 {
parseError = "truncated_length"
} else {
declared := int(binary.BigEndian.Uint16(data[:2]))
block["declared_length"] = declared
size = min(2+declared, len(data))
payload = data[2:size]
if size < 2+declared {
parseError = "truncated_payload"
}
}
block["actual_length"] = len(payload)
block["payload_hex"] = strings.ToUpper(hex.EncodeToString(payload))
block["raw_hex"] = "80" + strings.ToUpper(hex.EncodeToString(data[:size]))
unit := map[string]any{
"type": "0x80",
"name": "feichi_custom_80",
"value": map[string]any{
// Distinct keys preserve repeated 0x80 blocks in flattened RAW fields.
"blocks": map[string]any{fmt.Sprintf("block_%d", blockNumber): block},
},
}
if parseError != "" {
unit["error"] = parseError
block["parse_error"] = parseError
}
return unit, size
}
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 {
out := map[string]any{
"vehicle_status": int(data[0]),
"charge_status": int(data[1]),
"running_mode": int(data[2]),
"speed_kmh": nullableScaledU16(data[3:5], 10),
"total_mileage_km": nullableScaledU32(data[5:9], 10),
"total_voltage_v": nullableScaledU16(data[9:11], 10),
"total_current_a": nullableScaledU16WithOffset(data[11:13], 10, -1000),
"soc_percent": nullableByte(data[13]),
"dc_dc_status": int(data[14]),
"gear": int(data[15]),
"insulation_kohm": nullableU16(data[16:18]),
}
// GB/T 32960.3-2025 removes the 2016 accelerator and brake bytes from
// the 0x01 vehicle unit. Keep them only when the frame actually carries
// the 20-byte 2016 payload.
if len(data) >= 20 {
out["accelerator_pct"] = nullableByte(data[18])
out["brake_pct"] = nullableByte(data[19])
}
return out
}
func parseDriveMotorData(data []byte) map[string]any {
count := int(data[0])
motors := make([]map[string]any, 0, count)
cursor := 1
for i := 0; i < count; i++ {
motors = append(motors, map[string]any{
"serial_no": int(data[cursor]),
"state": int(data[cursor+1]),
"controller_temperature_c": nullableTempOffset40(data[cursor+2]),
"speed_rpm": nullableU16WithOffset(data[cursor+3:cursor+5], -20000),
"torque_nm": nullableScaledU16WithOffset(data[cursor+5:cursor+7], 10, -20000),
"motor_temperature_c": nullableTempOffset40(data[cursor+7]),
"controller_voltage_v": nullableScaledU16(data[cursor+8:cursor+10], 10),
"controller_current_a": nullableScaledU16WithOffset(data[cursor+10:cursor+12], 10, -1000),
})
cursor += 12
}
return map[string]any{
"count": count,
"motors": motors,
}
}
func parseFuelCellData(data []byte) map[string]any {
probeCount := int(binary.BigEndian.Uint16(data[6:8]))
probes := make([]any, 0, probeCount)
cursor := 8
for i := 0; i < probeCount; i++ {
probes = append(probes, nullableTempOffset40(data[cursor]))
cursor++
}
out := map[string]any{
"fuel_cell_voltage_v": nullableScaledU16(data[0:2], 10),
"fuel_cell_current_a": nullableScaledU16(data[2:4], 10),
"hydrogen_consumption_kg_per_100km": nullableScaledU16(data[4:6], 100),
"temperature_probe_count": probeCount,
"temperature_probe_values_c": probes,
"max_hydrogen_temperature_c": nullableScaledU16WithOffset(data[cursor:cursor+2], 10, -40),
"max_hydrogen_temperature_probe_id": int(data[cursor+2]),
"max_hydrogen_concentration_probe_id": int(data[cursor+5]),
"max_hydrogen_pressure_mpa": nullableScaledU16(data[cursor+6:cursor+8], 10),
"max_hydrogen_pressure_probe_id": int(data[cursor+8]),
"dc_dc_status": int(data[cursor+9]),
}
if concentration := nullableU16(data[cursor+3 : cursor+5]); concentration != nil {
raw := concentration.(int)
out["max_hydrogen_concentration_fraction"] = float64(raw) / 1_000_000
out["max_hydrogen_concentration_ppm"] = raw
out["max_hydrogen_concentration_percent"] = float64(raw) / 10_000
} else {
out["max_hydrogen_concentration_fraction"] = nil
out["max_hydrogen_concentration_ppm"] = nil
out["max_hydrogen_concentration_percent"] = nil
}
return out
}
func parseEngineData(data []byte) map[string]any {
return map[string]any{
"engine_status": int(data[0]),
"crank_speed_rpm": nullableU16(data[1:3]),
// The supplied authoritative reference does not define this field's
// business unit or scale, so preserve the decoded protocol integer.
"fuel_rate": nullableU16(data[3:5]),
}
}
func parseExtremeData(data []byte) map[string]any {
return map[string]any{
"max_voltage_subsystem_no": int(data[0]),
"max_voltage_cell_no": int(data[1]),
"max_voltage_v": scaledU16(data[2:4], 1000),
"min_voltage_subsystem_no": int(data[4]),
"min_voltage_cell_no": int(data[5]),
"min_voltage_v": scaledU16(data[6:8], 1000),
"max_temp_subsystem_no": int(data[8]),
"max_temp_probe_no": int(data[9]),
"max_temp_c": int(data[10]) - 40,
"min_temp_subsystem_no": int(data[11]),
"min_temp_probe_no": int(data[12]),
"min_temp_c": int(data[13]) - 40,
}
}
func alarmDataSize(data []byte) (int, bool) {
if len(data) < 5 {
return 0, false
}
cursor := 5
for i := 0; i < 4; i++ {
if len(data[cursor:]) < 1 {
return 0, false
}
count := int(data[cursor])
cursor++
cursor += count * 4
if cursor > len(data) {
return 0, false
}
}
return cursor, true
}
func parseAlarmData(data []byte) map[string]any {
cursor := 5
readList := func() []string {
count := int(data[cursor])
cursor++
values := make([]string, 0, count)
for i := 0; i < count; i++ {
values = append(values, fmt.Sprintf("0x%08X", binary.BigEndian.Uint32(data[cursor:cursor+4])))
cursor += 4
}
return values
}
return map[string]any{
"max_alarm_level": int(data[0]),
"general_alarm_flag": fmt.Sprintf("0x%08X", binary.BigEndian.Uint32(data[1:5])),
"battery_faults": readList(),
"motor_faults": readList(),
"engine_faults": readList(),
"other_faults": readList(),
}
}
func voltageDataSize(data []byte) (int, bool) {
if len(data) < 1 {
return 0, false
}
cursor := 1
for i := 0; i < int(data[0]); i++ {
if len(data[cursor:]) < 10 {
return 0, false
}
frameCellCount := int(data[cursor+9])
cursor += 10 + frameCellCount*2
if cursor > len(data) {
return 0, false
}
}
return cursor, true
}
func parseVoltageData(data []byte) map[string]any {
subCount := int(data[0])
cursor := 1
totalVoltageTenths := int64(0)
maxCell := 0.0
minCell := 0.0
cellSeen := false
for i := 0; i < subCount; i++ {
totalVoltageTenths += int64(binary.BigEndian.Uint16(data[cursor+1 : cursor+3]))
cellCount := int(binary.BigEndian.Uint16(data[cursor+5 : cursor+7]))
frameCellCount := int(data[cursor+9])
cursor += 10
for c := 0; c < frameCellCount; c++ {
voltage := scaledU16(data[cursor:cursor+2], 1000)
if !cellSeen || voltage > maxCell {
maxCell = voltage
}
if !cellSeen || voltage < minCell {
minCell = voltage
}
cellSeen = true
cursor += 2
}
_ = cellCount
}
return map[string]any{
"subsystem_count": subCount,
"total_voltage_v": scaledInt(totalVoltageTenths, 10),
"max_cell_v": maxCell,
"min_cell_v": minCell,
}
}
func temperatureDataSize(data []byte) (int, bool) {
if len(data) < 1 {
return 0, false
}
cursor := 1
for i := 0; i < int(data[0]); i++ {
if len(data[cursor:]) < 3 {
return 0, false
}
probeCount := int(binary.BigEndian.Uint16(data[cursor+1 : cursor+3]))
cursor += 3 + probeCount
if cursor > len(data) {
return 0, false
}
}
return cursor, true
}
func parseTemperatureData(data []byte) map[string]any {
subCount := int(data[0])
cursor := 1
maxTemp := 0
minTemp := 0
seen := false
for i := 0; i < subCount; i++ {
probeCount := int(binary.BigEndian.Uint16(data[cursor+1 : cursor+3]))
cursor += 3
for p := 0; p < probeCount; p++ {
temp := int(data[cursor]) - 40
if !seen || temp > maxTemp {
maxTemp = temp
}
if !seen || temp < minTemp {
minTemp = temp
}
seen = true
cursor++
}
}
return map[string]any{
"subsystem_count": subCount,
"max_temp_c": maxTemp,
"min_temp_c": minTemp,
}
}
func gdFCStackDataSize(data []byte) (int, bool) {
if len(data) < 1 {
return 0, false
}
cursor := 1
for i := 0; i < int(data[0]); i++ {
if len(data[cursor:]) < 22 {
return 0, false
}
frameCellCount := int(data[cursor+21])
cursor += 22 + frameCellCount*2
if cursor > len(data) {
return 0, false
}
}
return cursor, true
}
func parseGdFCStackData(data []byte) map[string]any {
count := int(data[0])
cursor := 1
summaries := make([]map[string]any, 0, count)
for i := 0; i < count; i++ {
frameCellCount := int(data[cursor+21])
summary := map[string]any{
"engine_work_state": int(data[cursor]),
"stack_water_outlet_temp_c": nullableTempOffset40(data[cursor+1]),
"hydrogen_inlet_pressure_kpa": nullableScaledU16WithOffset(data[cursor+2:cursor+4], 10, -100),
"air_inlet_pressure_kpa": nullableScaledU16WithOffset(data[cursor+4:cursor+6], 10, -100),
"air_inlet_temp_c": nullableTempOffset40(data[cursor+6]),
"max_cell_voltage_id": nullableU16(data[cursor+7 : cursor+9]),
"min_cell_voltage_id": nullableU16(data[cursor+9 : cursor+11]),
"max_cell_voltage_v": nullableScaledU16(data[cursor+11:cursor+13], 1000),
"min_cell_voltage_v": nullableScaledU16(data[cursor+13:cursor+15], 1000),
"avg_cell_voltage_v": nullableScaledU16(data[cursor+15:cursor+17], 1000),
"cell_count": nullableU16(data[cursor+17 : cursor+19]),
"frame_cell_start": nullableU16(data[cursor+19 : cursor+21]),
"frame_cell_count": frameCellCount,
}
cursor += 22
frameVoltages := make([]float64, 0, frameCellCount)
maxCell := 0.0
minCell := 0.0
seen := false
for c := 0; c < frameCellCount; c++ {
voltage := scaledU16(data[cursor:cursor+2], 1000)
frameVoltages = append(frameVoltages, voltage)
if !seen || voltage > maxCell {
maxCell = voltage
}
if !seen || voltage < minCell {
minCell = voltage
}
seen = true
cursor += 2
}
if seen {
summary["frame_max_cell_voltage_v"] = maxCell
summary["frame_min_cell_voltage_v"] = minCell
}
summary["frame_cell_voltages_v"] = frameVoltages
summaries = append(summaries, summary)
}
return map[string]any{
"stack_count": count,
"summaries": summaries,
}
}
func gdFCAuxiliaryDataSize(data []byte) (int, bool) {
if len(data) < 1 {
return 0, false
}
size := 1 + int(data[0])*16
return size, len(data) >= size
}
func parseGdFCAuxiliaryData(data []byte) map[string]any {
count := int(data[0])
cursor := 1
subsystems := make([]map[string]any, 0, count)
for i := 0; i < count; i++ {
subsystems = append(subsystems, map[string]any{
"air_compressor_motor_voltage_v": nullableScaledU16(data[cursor:cursor+2], 10),
"air_compressor_power_kw": nullableU16WithOffset(data[cursor+2:cursor+4], -5),
"hydrogen_pump_motor_voltage_v": nullableScaledU16(data[cursor+4:cursor+6], 5),
"hydrogen_pump_power_kw": nullableU16WithOffset(data[cursor+6:cursor+8], -5),
"water_pump_voltage_v": nullableScaledU16(data[cursor+8:cursor+10], 10),
"ptc_voltage_v": nullableScaledU16(data[cursor+10:cursor+12], 10),
"ptc_power_kw": nullableU16WithOffset(data[cursor+12:cursor+14], -5),
"low_voltage_battery_voltage_v": nullableScaledU16(data[cursor+14:cursor+16], 10),
})
cursor += 16
}
return map[string]any{
"subsystem_count": count,
"subsystems": subsystems,
}
}
func parseGdFCDcDcData(data []byte) map[string]any {
return map[string]any{
"input_voltage_v": nullableScaledU16(data[0:2], 10),
"input_current_a": nullableScaledU16(data[2:4], 10),
"output_voltage_v": nullableScaledU16(data[4:6], 10),
"output_current_a": nullableScaledU16(data[6:8], 10),
"controller_temp_c": nullableTempOffset40(data[8]),
}
}
func parseGdFCAirConditionerData(data []byte) map[string]any {
return map[string]any{
"status": int(data[0]),
"power_kw": nullableU16WithOffset(data[1:3], -5),
"compressor_input_voltage_v": nullableScaledU16(data[3:5], 10),
}
}
func parseGdFCVehicleInfoData(data []byte) map[string]any {
return map[string]any{
"collision_alarm": int(data[0]),
"ambient_temp_c": nullableU16WithOffset(data[1:3], -40),
"ambient_pressure_kpa": nullableScaledU16WithOffset(data[3:5], 10, -100),
"hydrogen_mass_kg": nullableScaledU16(data[5:7], 10),
}
}
func parseGdFCDemoExtensionData(data []byte) map[string]any {
return map[string]any{
"declared_length": int(binary.BigEndian.Uint16(data[0:2])),
"stack_temp_c": nullableTempOffset40(data[2]),
"air_compressor_voltage_v": nullableScaledU16(data[3:5], 10),
"air_compressor_current_a": nullableScaledU16WithOffset(data[5:7], 10, -1000),
"hydrogen_pump_voltage_v": nullableScaledU16(data[7:9], 10),
"hydrogen_pump_current_a": nullableScaledU16WithOffset(data[9:11], 10, -1000),
}
}
func gdFCVendorTLVDataSize(data []byte) (int, bool) {
if len(data) < 2 {
return 0, false
}
size := 2 + int(binary.BigEndian.Uint16(data[0:2]))
return size, len(data) >= size
}
func parseGdFCVendorTLVData(typeCode byte, data []byte) map[string]any {
length := int(binary.BigEndian.Uint16(data[0:2]))
return map[string]any{
"type": fmt.Sprintf("0x%02X", typeCode),
"declared_length": length,
"payload_hex": strings.ToUpper(hex.EncodeToString(data[2:])),
}
}
func nullableU16(data []byte) any {
value := binary.BigEndian.Uint16(data)
if value == 0xfffe || value == 0xffff {
return nil
}
return int(value)
}
func nullableU16WithOffset(data []byte, offset int) any {
value := binary.BigEndian.Uint16(data)
if value == 0xfffe || value == 0xffff {
return nil
}
return int(value) + offset
}
func nullableByte(value byte) any {
if value == 0xfe || value == 0xff {
return nil
}
return int(value)
}
func scaledU16(data []byte, divisor int64) float64 {
return scaledInt(int64(binary.BigEndian.Uint16(data)), divisor)
}
func scaledU32(data []byte, divisor int64) float64 {
return scaledInt(int64(binary.BigEndian.Uint32(data)), divisor)
}
func scaledU16WithOffset(data []byte, divisor int64, offset int64) float64 {
return scaledIntWithOffset(int64(binary.BigEndian.Uint16(data)), divisor, offset)
}
func scaledIntWithOffset(value int64, divisor int64, offset int64) float64 {
return scaledInt(value+offset*divisor, divisor)
}
func scaledInt(value int64, divisor int64) float64 {
if divisor <= 0 {
return float64(value)
}
return float64(value) / float64(divisor)
}
func nullableScaledU16(data []byte, divisor int64) any {
value := binary.BigEndian.Uint16(data)
if value == 0xfffe || value == 0xffff {
return nil
}
return scaledInt(int64(value), divisor)
}
func nullableScaledU32(data []byte, divisor int64) any {
value := binary.BigEndian.Uint32(data)
if value == 0xfffffffe || value == 0xffffffff {
return nil
}
return scaledInt(int64(value), divisor)
}
func nullableScaledU16WithOffset(data []byte, divisor int64, offset int64) any {
value := binary.BigEndian.Uint16(data)
if value == 0xfffe || value == 0xffff {
return nil
}
return scaledIntWithOffset(int64(value), divisor, offset)
}
func nullableTempOffset40(value byte) any {
if value == 0xfe || value == 0xff {
return nil
}
return int(value) - 40
}
func parsePositionData(version string, data []byte) map[string]any {
offset := 1
out := map[string]any{"position_status": int(data[0])}
if version == "V2025" {
out["coordinate_system"] = int(data[1])
offset = 2
}
out["longitude"] = float64(binary.BigEndian.Uint32(data[offset:offset+4])) / 1_000_000
out["latitude"] = float64(binary.BigEndian.Uint32(data[offset+4:offset+8])) / 1_000_000
return out
}
func indexStart(data []byte) int {
for i := 0; i+1 < len(data); i++ {
if _, ok := version(data[i], data[i+1]); ok {
return i
}
}
return -1
}
func version(a byte, b byte) (string, bool) {
switch {
case a == '#' && b == '#':
return "V2016", true
case a == '$' && b == '$':
return "V2025", true
default:
return "", false
}
}
func parseGBTime(data []byte) time.Time {
if len(data) != 6 {
return time.Time{}
}
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 trimASCII(data []byte) string {
return strings.TrimRight(string(data), "\x00 ")
}
func bcc(data []byte) byte {
var out byte
for _, value := range data {
out ^= value
}
return out
}