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.
985 lines
32 KiB
Go
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
|
|
}
|