feat(platform): harden telemetry pipeline and unify Semi UI workspaces
This commit is contained in:
@@ -151,16 +151,23 @@ func parseDataBody(version string, body []byte, fields map[string]any) (time.Tim
|
||||
fields[envelope.FieldSOCPercent] = unit["soc_percent"]
|
||||
cursor += size
|
||||
case 0x05:
|
||||
if len(body[cursor:]) < 9 {
|
||||
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(body[cursor : cursor+9])
|
||||
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 += 9
|
||||
cursor += size
|
||||
case 0x02:
|
||||
if len(body[cursor:]) < 1 {
|
||||
units = append(units, map[string]any{"type": "0x02", "error": "truncated"})
|
||||
@@ -364,21 +371,27 @@ func parsePlatformLogin(body []byte) map[string]any {
|
||||
}
|
||||
|
||||
func parseVehicleData(data []byte) map[string]any {
|
||||
return map[string]any{
|
||||
out := map[string]any{
|
||||
"vehicle_status": int(data[0]),
|
||||
"charge_status": int(data[1]),
|
||||
"running_mode": int(data[2]),
|
||||
"speed_kmh": scaledU16(data[3:5], 10),
|
||||
"total_mileage_km": scaledU32(data[5:9], 10),
|
||||
"total_voltage_v": scaledU16(data[9:11], 10),
|
||||
"total_current_a": scaledU16WithOffset(data[11:13], 10, -1000),
|
||||
"soc_percent": int(data[13]),
|
||||
"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": binary.BigEndian.Uint16(data[16:18]),
|
||||
"accelerator_pct": int(data[18]),
|
||||
"brake_pct": int(data[19]),
|
||||
"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 {
|
||||
@@ -389,12 +402,12 @@ func parseDriveMotorData(data []byte) map[string]any {
|
||||
motors = append(motors, map[string]any{
|
||||
"serial_no": int(data[cursor]),
|
||||
"state": int(data[cursor+1]),
|
||||
"controller_temperature_c": int(data[cursor+2]) - 40,
|
||||
"speed_rpm": int(binary.BigEndian.Uint16(data[cursor+3:cursor+5])) - 20000,
|
||||
"torque_nm": scaledIntWithOffset(int64(binary.BigEndian.Uint16(data[cursor+5:cursor+7])), 10, -20000),
|
||||
"motor_temperature_c": int(data[cursor+7]) - 40,
|
||||
"controller_voltage_v": scaledU16(data[cursor+8:cursor+10], 10),
|
||||
"controller_current_a": scaledU16WithOffset(data[cursor+10:cursor+12], 10, -1000),
|
||||
"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
|
||||
}
|
||||
@@ -406,34 +419,45 @@ func parseDriveMotorData(data []byte) map[string]any {
|
||||
|
||||
func parseFuelCellData(data []byte) map[string]any {
|
||||
probeCount := int(binary.BigEndian.Uint16(data[6:8]))
|
||||
probes := make([]int, 0, probeCount)
|
||||
probes := make([]any, 0, probeCount)
|
||||
cursor := 8
|
||||
for i := 0; i < probeCount; i++ {
|
||||
probes = append(probes, int(data[cursor])-40)
|
||||
probes = append(probes, nullableTempOffset40(data[cursor]))
|
||||
cursor++
|
||||
}
|
||||
out := map[string]any{
|
||||
"fuel_cell_voltage_v": scaledU16(data[0:2], 10),
|
||||
"fuel_cell_current_a": scaledU16(data[2:4], 10),
|
||||
"hydrogen_consumption_kg_per_100km": scaledU16(data[4:6], 100),
|
||||
"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": scaledU16WithOffset(data[cursor:cursor+2], 10, -40),
|
||||
"max_hydrogen_temperature_c": nullableScaledU16WithOffset(data[cursor:cursor+2], 10, -40),
|
||||
"max_hydrogen_temperature_probe_id": int(data[cursor+2]),
|
||||
"max_hydrogen_concentration_fraction": scaledU16(data[cursor+3:cursor+5], 1_000_000),
|
||||
"max_hydrogen_concentration_probe_id": int(data[cursor+5]),
|
||||
"max_hydrogen_pressure_mpa": scaledU16(data[cursor+6:cursor+8], 10),
|
||||
"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": binary.BigEndian.Uint16(data[1:3]),
|
||||
"fuel_rate": binary.BigEndian.Uint16(data[3:5]),
|
||||
"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]),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -632,11 +656,13 @@ func parseGdFCStackData(data []byte) map[string]any {
|
||||
"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
|
||||
}
|
||||
@@ -650,6 +676,7 @@ func parseGdFCStackData(data []byte) map[string]any {
|
||||
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{
|
||||
@@ -760,6 +787,13 @@ func nullableU16WithOffset(data []byte, offset int) any {
|
||||
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)
|
||||
}
|
||||
@@ -791,6 +825,14 @@ func nullableScaledU16(data []byte, divisor int64) any {
|
||||
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 {
|
||||
@@ -806,12 +848,16 @@ func nullableTempOffset40(value byte) any {
|
||||
return int(value) - 40
|
||||
}
|
||||
|
||||
func parsePositionData(data []byte) map[string]any {
|
||||
return map[string]any{
|
||||
"position_status": int(data[0]),
|
||||
"longitude": float64(binary.BigEndian.Uint32(data[1:5])) / 1_000_000,
|
||||
"latitude": float64(binary.BigEndian.Uint32(data[5:9])) / 1_000_000,
|
||||
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 {
|
||||
|
||||
@@ -222,6 +222,13 @@ func TestParseFrameKeepsParsingRealFuelCellReportUntilUnknownExtension(t *testin
|
||||
if units[2]["name"] != "fuel_cell" {
|
||||
t.Fatalf("unexpected fuel cell unit: %#v", units[2])
|
||||
}
|
||||
fuelCell, ok := units[2]["value"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("fuel cell payload missing: %#v", units[2])
|
||||
}
|
||||
if _, ok := fuelCell["max_hydrogen_concentration_ppm"].(int); !ok {
|
||||
t.Fatalf("hydrogen concentration ppm missing: %#v", fuelCell["max_hydrogen_concentration_ppm"])
|
||||
}
|
||||
if units[9]["name"] != "gd_fc_stack" {
|
||||
t.Fatalf("unexpected gd stack unit: %#v", units[9])
|
||||
}
|
||||
@@ -237,6 +244,69 @@ func TestParseFrameKeepsParsingRealFuelCellReportUntilUnknownExtension(t *testin
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseFuelCellDataExposesHydrogenConcentrationInReferencePercent(t *testing.T) {
|
||||
data := make([]byte, 18)
|
||||
data[11] = 0x01
|
||||
data[12] = 0xf4
|
||||
fuelCell := parseFuelCellData(data)
|
||||
if concentration, ok := fuelCell["max_hydrogen_concentration_ppm"].(int); !ok || concentration != 500 {
|
||||
t.Fatalf("hydrogen concentration ppm = %#v, want int(500)", fuelCell["max_hydrogen_concentration_ppm"])
|
||||
}
|
||||
if fraction, ok := fuelCell["max_hydrogen_concentration_fraction"].(float64); !ok || math.Abs(fraction-0.0005) > 0.0000001 {
|
||||
t.Fatalf("hydrogen concentration fraction = %#v, want 0.0005", fuelCell["max_hydrogen_concentration_fraction"])
|
||||
}
|
||||
if percent, ok := fuelCell["max_hydrogen_concentration_percent"].(float64); !ok || math.Abs(percent-0.05) > 0.0000001 {
|
||||
t.Fatalf("hydrogen concentration percent = %#v, want 0.05", fuelCell["max_hydrogen_concentration_percent"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseFuelCellDataTreatsProtocolInvalidMarkersAsMissing(t *testing.T) {
|
||||
data := make([]byte, 18)
|
||||
for index := range data {
|
||||
data[index] = 0xff
|
||||
}
|
||||
data[6], data[7] = 0, 0
|
||||
fuelCell := parseFuelCellData(data)
|
||||
for _, key := range []string{"fuel_cell_voltage_v", "fuel_cell_current_a", "hydrogen_consumption_kg_per_100km", "max_hydrogen_temperature_c", "max_hydrogen_concentration_percent", "max_hydrogen_pressure_mpa"} {
|
||||
if fuelCell[key] != nil {
|
||||
t.Fatalf("%s should be nil for protocol invalid marker: %#v", key, fuelCell[key])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseV2025VehicleAndPositionUsesCoordinateSystemByte(t *testing.T) {
|
||||
body := []byte{0x1a, 0x07, 0x11, 0x0a, 0x00, 0x00, 0x01}
|
||||
body = append(body,
|
||||
0x01, 0x03, 0x01,
|
||||
0x00, 0x64,
|
||||
0x00, 0x00, 0x03, 0xe8,
|
||||
0x13, 0x88,
|
||||
0x27, 0x10,
|
||||
80, 0x01, 0x00,
|
||||
0x03, 0xe8)
|
||||
body = append(body,
|
||||
0x05,
|
||||
0x00, 0x02,
|
||||
0x07, 0x36, 0x50, 0x40,
|
||||
0x01, 0xd2, 0x4f, 0x00)
|
||||
fields := map[string]any{}
|
||||
_, units := parseDataBody("V2025", body, fields)
|
||||
if len(units) != 2 {
|
||||
t.Fatalf("units = %#v", units)
|
||||
}
|
||||
vehicle := units[0]["value"].(map[string]any)
|
||||
if _, exists := vehicle["accelerator_pct"]; exists {
|
||||
t.Fatalf("2025 vehicle unit must not invent removed pedal fields: %#v", vehicle)
|
||||
}
|
||||
position := units[1]["value"].(map[string]any)
|
||||
if position["coordinate_system"] != 2 {
|
||||
t.Fatalf("coordinate system = %#v", position["coordinate_system"])
|
||||
}
|
||||
if longitude := position["longitude"].(float64); math.Abs(longitude-121) > 0.000001 {
|
||||
t.Fatalf("longitude = %v", longitude)
|
||||
}
|
||||
}
|
||||
|
||||
func TestScaledDecimalFieldsDoNotLeakBinaryFloatTails(t *testing.T) {
|
||||
unit := parseDriveMotorData([]byte{
|
||||
0x01,
|
||||
|
||||
Reference in New Issue
Block a user