diff --git a/go/vehicle-gateway/internal/protocol/gb32960/parser.go b/go/vehicle-gateway/internal/protocol/gb32960/parser.go index e7eb82cc..66638889 100644 --- a/go/vehicle-gateway/internal/protocol/gb32960/parser.go +++ b/go/vehicle-gateway/internal/protocol/gb32960/parser.go @@ -222,6 +222,74 @@ func parseDataBody(version string, body []byte, fields map[string]any) (time.Tim 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["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 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), @@ -464,6 +532,197 @@ func parseTemperatureData(data []byte) map[string]any { } } +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], 0.1, -100), + "air_inlet_pressure_kpa": nullableScaledU16WithOffset(data[cursor+4:cursor+6], 0.1, -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], 0.001), + "min_cell_voltage_v": nullableScaledU16(data[cursor+13:cursor+15], 0.001), + "avg_cell_voltage_v": nullableScaledU16(data[cursor+15:cursor+17], 0.001), + "cell_count": nullableU16(data[cursor+17 : cursor+19]), + "frame_cell_start": nullableU16(data[cursor+19 : cursor+21]), + "frame_cell_count": frameCellCount, + } + cursor += 22 + maxCell := 0.0 + minCell := 0.0 + seen := false + for c := 0; c < frameCellCount; c++ { + voltage := float64(binary.BigEndian.Uint16(data[cursor:cursor+2])) / 1000 + 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 + } + 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], 0.1), + "air_compressor_power_kw": nullableU16WithOffset(data[cursor+2:cursor+4], -5), + "hydrogen_pump_motor_voltage_v": nullableScaledU16(data[cursor+4:cursor+6], 0.2), + "hydrogen_pump_power_kw": nullableU16WithOffset(data[cursor+6:cursor+8], -5), + "water_pump_voltage_v": nullableScaledU16(data[cursor+8:cursor+10], 0.1), + "ptc_voltage_v": nullableScaledU16(data[cursor+10:cursor+12], 0.1), + "ptc_power_kw": nullableU16WithOffset(data[cursor+12:cursor+14], -5), + "low_voltage_battery_voltage_v": nullableScaledU16(data[cursor+14:cursor+16], 0.1), + }) + 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], 0.1), + "input_current_a": nullableScaledU16(data[2:4], 0.1), + "output_voltage_v": nullableScaledU16(data[4:6], 0.1), + "output_current_a": nullableScaledU16(data[6:8], 0.1), + "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], 0.1), + } +} + +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], 0.1, -100), + "hydrogen_mass_kg": nullableScaledU16(data[5:7], 0.1), + } +} + +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], 0.1), + "air_compressor_current_a": nullableScaledU16WithOffset(data[5:7], 0.1, -1000), + "hydrogen_pump_voltage_v": nullableScaledU16(data[7:9], 0.1), + "hydrogen_pump_current_a": nullableScaledU16WithOffset(data[9:11], 0.1, -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 nullableScaledU16(data []byte, scale float64) any { + value := binary.BigEndian.Uint16(data) + if value == 0xfffe || value == 0xffff { + return nil + } + return float64(value) * scale +} + +func nullableScaledU16WithOffset(data []byte, scale float64, offset float64) any { + value := binary.BigEndian.Uint16(data) + if value == 0xfffe || value == 0xffff { + return nil + } + return float64(value)*scale + offset +} + +func nullableTempOffset40(value byte) any { + if value == 0xfe || value == 0xff { + return nil + } + return int(value) - 40 +} + func parsePositionData(data []byte) map[string]any { return map[string]any{ "position_status": int(data[0]), diff --git a/go/vehicle-gateway/internal/protocol/gb32960/parser_test.go b/go/vehicle-gateway/internal/protocol/gb32960/parser_test.go index 14cb8669..60dc27b2 100644 --- a/go/vehicle-gateway/internal/protocol/gb32960/parser_test.go +++ b/go/vehicle-gateway/internal/protocol/gb32960/parser_test.go @@ -3,6 +3,7 @@ package gb32960 import ( "encoding/hex" "fmt" + "math" "testing" "lingniu-vehicle-ingest/go/vehicle-gateway/internal/envelope" @@ -102,6 +103,9 @@ func TestParseFrameKeepsParsingRealFuelCellReportUntilUnknownExtension(t *testin assertFloatField(t, env, "fuel_cell_hydrogen_consumption_kg_per_100km", 1.8) assertFloatField(t, env, envelope.FieldLongitude, 120.800326) assertFloatField(t, env, envelope.FieldLatitude, 31.634907) + assertFloatField(t, env, "gd_fc_stack_hydrogen_inlet_pressure_kpa", 97.0) + assertFloatField(t, env, "gd_fc_vehicle_hydrogen_mass_kg", 4.3) + assertIntField(t, env, "gd_fc_demo_stack_temp_c", 65) units, ok := env.Parsed["data_units"].([]map[string]any) if !ok { @@ -111,7 +115,7 @@ func TestParseFrameKeepsParsingRealFuelCellReportUntilUnknownExtension(t *testin for _, unit := range units { gotTypes = append(gotTypes, fmt.Sprint(unit["type"])) } - wantTypes := []string{"0x01", "0x02", "0x03", "0x04", "0x05", "0x06", "0x07", "0x08", "0x09", "0x30"} + wantTypes := []string{"0x01", "0x02", "0x03", "0x04", "0x05", "0x06", "0x07", "0x08", "0x09", "0x30", "0x31", "0x32", "0x33", "0x34", "0x80", "0x83"} if fmt.Sprint(gotTypes) != fmt.Sprint(wantTypes) { t.Fatalf("unit types = %v, want %v", gotTypes, wantTypes) } @@ -121,8 +125,18 @@ func TestParseFrameKeepsParsingRealFuelCellReportUntilUnknownExtension(t *testin if units[2]["name"] != "fuel_cell" { t.Fatalf("unexpected fuel cell unit: %#v", units[2]) } - if units[9]["parse"] != "unknown_unit" { - t.Fatalf("unexpected unknown unit: %#v", units[9]) + if units[9]["name"] != "gd_fc_stack" { + t.Fatalf("unexpected gd stack unit: %#v", units[9]) + } + if units[10]["name"] != "gd_fc_auxiliary" { + t.Fatalf("unexpected gd auxiliary unit: %#v", units[10]) + } + if units[15]["name"] != "gd_fc_vendor_tlv" { + t.Fatalf("unexpected gd vendor tlv unit: %#v", units[15]) + } + tlv := units[15]["value"].(map[string]any) + if tlv["declared_length"] != 36 { + t.Fatalf("unexpected vendor tlv: %#v", tlv) } } @@ -132,6 +146,17 @@ func assertFloatField(t *testing.T, env envelope.FrameEnvelope, key string, want if !ok { t.Fatalf("field %s missing or not float64: %#v", key, env.Fields[key]) } + if math.Abs(got-want) > 0.000001 { + t.Fatalf("field %s = %v, want %v", key, got, want) + } +} + +func assertIntField(t *testing.T, env envelope.FrameEnvelope, key string, want int) { + t.Helper() + got, ok := env.Fields[key].(int) + if !ok { + t.Fatalf("field %s missing or not int: %#v", key, env.Fields[key]) + } if got != want { t.Fatalf("field %s = %v, want %v", key, got, want) }