feat: build vehicle data platform and production pipeline

This commit is contained in:
lingniu
2026-07-14 12:35:33 +08:00
parent b452be3b94
commit bb59303a4b
270 changed files with 88016 additions and 1975 deletions

View File

@@ -0,0 +1,170 @@
package telemetry
import (
"strings"
"lingniu-vehicle-ingest/go/vehicle-gateway/internal/envelope"
)
type LocationProjection struct {
Latitude float64
Longitude float64
SpeedKMH *float64
SOCPercent *float64
AltitudeM *float64
DirectionDeg *float64
AlarmFlag *int64
StatusFlag *int64
}
type locationFieldMapping struct {
Latitude []string
Longitude []string
SpeedKMH []string
SOCPercent []string
AltitudeM []string
DirectionDeg []string
AlarmFlag []string
StatusFlag []string
}
// HasRealtimeFields separates protocol telemetry from headers, identity
// annotations, and transport metadata before realtime/stat projections.
func HasRealtimeFields(protocol envelope.Protocol, fields map[string]any) bool {
switch protocol {
case envelope.ProtocolGB32960:
for field := range fields {
if !strings.HasPrefix(field, "gb32960.") {
continue
}
if strings.HasPrefix(field, "gb32960.header.") ||
strings.HasPrefix(field, "gb32960.platform.") ||
strings.HasPrefix(field, "gb32960.identity.") ||
strings.HasPrefix(field, "gb32960.device_time.") {
continue
}
return true
}
return false
case envelope.ProtocolJT808:
return hasPrefix(fields, "jt808.location.")
case envelope.ProtocolYutongMQTT:
return hasPrefix(fields, "yutong_mqtt.data.") ||
hasPrefix(fields, "yutong_mqtt.root.data.")
default:
return false
}
}
// LocationProjectionForProtocol is the single protocol-field contract used by
// storage projections. It intentionally ignores bare standardized field names.
func LocationProjectionForProtocol(protocol envelope.Protocol, fields map[string]any) (LocationProjection, bool) {
mapping := locationMapping(protocol)
latitude, hasLatitude := firstNumber(fields, mapping.Latitude)
longitude, hasLongitude := firstNumber(fields, mapping.Longitude)
if !hasLatitude || !hasLongitude {
return LocationProjection{}, false
}
return LocationProjection{
Latitude: latitude,
Longitude: longitude,
SpeedKMH: numberPointer(fields, mapping.SpeedKMH),
SOCPercent: numberPointer(fields, mapping.SOCPercent),
AltitudeM: numberPointer(fields, mapping.AltitudeM),
DirectionDeg: numberPointer(fields, mapping.DirectionDeg),
AlarmFlag: integerPointer(fields, mapping.AlarmFlag),
StatusFlag: integerPointer(fields, mapping.StatusFlag),
}, true
}
func locationMapping(protocol envelope.Protocol) locationFieldMapping {
switch protocol {
case envelope.ProtocolGB32960:
return locationFieldMapping{
Latitude: []string{"gb32960.position.latitude"},
Longitude: []string{"gb32960.position.longitude"},
SpeedKMH: []string{"gb32960.vehicle.speed_kmh"},
SOCPercent: []string{"gb32960.vehicle.soc_percent"},
}
case envelope.ProtocolJT808:
return locationFieldMapping{
Latitude: []string{"jt808.location.latitude"},
Longitude: []string{"jt808.location.longitude"},
SpeedKMH: []string{"jt808.location.speed_kmh"},
AltitudeM: []string{"jt808.location.altitude_m"},
DirectionDeg: []string{"jt808.location.direction_deg"},
AlarmFlag: []string{"jt808.location.alarm_flag"},
StatusFlag: []string{"jt808.location.status_flag"},
}
case envelope.ProtocolYutongMQTT:
return locationFieldMapping{
Latitude: []string{
"yutong_mqtt.data.latitude",
"yutong_mqtt.root.data.latitude",
},
Longitude: []string{
"yutong_mqtt.data.longitude",
"yutong_mqtt.root.data.longitude",
},
SpeedKMH: []string{
"yutong_mqtt.data.meter_speed",
"yutong_mqtt.root.data.meter_speed",
"yutong_mqtt.data.speed_kmh",
"yutong_mqtt.root.data.speed_kmh",
"yutong_mqtt.data.speed",
"yutong_mqtt.root.data.speed",
},
SOCPercent: []string{
"yutong_mqtt.data.battery_capacity_soc",
"yutong_mqtt.root.data.battery_capacity_soc",
"yutong_mqtt.data.soc_percent",
"yutong_mqtt.root.data.soc_percent",
},
DirectionDeg: []string{
"yutong_mqtt.data.gpsdirection",
"yutong_mqtt.root.data.gpsdirection",
"yutong_mqtt.data.direction_deg",
"yutong_mqtt.root.data.direction_deg",
"yutong_mqtt.data.direction",
"yutong_mqtt.root.data.direction",
},
}
default:
return locationFieldMapping{}
}
}
func firstNumber(fields map[string]any, keys []string) (float64, bool) {
for _, key := range keys {
if value, ok := Number(fields, key); ok {
return value, true
}
}
return 0, false
}
func numberPointer(fields map[string]any, keys []string) *float64 {
value, ok := firstNumber(fields, keys)
if !ok {
return nil
}
return &value
}
func integerPointer(fields map[string]any, keys []string) *int64 {
value, ok := firstNumber(fields, keys)
if !ok {
return nil
}
integer := int64(value)
return &integer
}
func hasPrefix(fields map[string]any, prefix string) bool {
for field := range fields {
if strings.HasPrefix(field, prefix) {
return true
}
}
return false
}

View File

@@ -0,0 +1,131 @@
package telemetry
import (
"testing"
"lingniu-vehicle-ingest/go/vehicle-gateway/internal/envelope"
)
func TestLocationProjectionForProtocolUsesProtocolFields(t *testing.T) {
tests := []struct {
name string
protocol envelope.Protocol
fields map[string]any
assert func(*testing.T, LocationProjection)
}{
{
name: "gb32960",
protocol: envelope.ProtocolGB32960,
fields: map[string]any{
"gb32960.position.latitude": "30.56",
"gb32960.position.longitude": "121.0",
"gb32960.vehicle.speed_kmh": "54.3",
"gb32960.vehicle.soc_percent": "85",
"gb32960.vehicle.total_mileage": "123",
},
assert: func(t *testing.T, location LocationProjection) {
assertFloatPointer(t, location.SpeedKMH, 54.3)
assertFloatPointer(t, location.SOCPercent, 85)
},
},
{
name: "jt808",
protocol: envelope.ProtocolJT808,
fields: map[string]any{
"jt808.location.latitude": "30.590151",
"jt808.location.longitude": "121.069881",
"jt808.location.speed_kmh": "23",
"jt808.location.altitude_m": "5",
"jt808.location.direction_deg": "79",
"jt808.location.alarm_flag": "2",
"jt808.location.status_flag": "786435",
},
assert: func(t *testing.T, location LocationProjection) {
assertFloatPointer(t, location.SpeedKMH, 23)
assertFloatPointer(t, location.AltitudeM, 5)
assertFloatPointer(t, location.DirectionDeg, 79)
assertIntPointer(t, location.AlarmFlag, 2)
assertIntPointer(t, location.StatusFlag, 786435)
},
},
{
name: "yutong root fallback",
protocol: envelope.ProtocolYutongMQTT,
fields: map[string]any{
"yutong_mqtt.root.data.latitude": "30.590921",
"yutong_mqtt.root.data.longitude": "121.075044",
"yutong_mqtt.root.data.meter_speed": "27",
"yutong_mqtt.root.data.battery_capacity_soc": "78.4",
"yutong_mqtt.root.data.gpsdirection": "88",
},
assert: func(t *testing.T, location LocationProjection) {
assertFloatPointer(t, location.SpeedKMH, 27)
assertFloatPointer(t, location.SOCPercent, 78.4)
assertFloatPointer(t, location.DirectionDeg, 88)
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
location, ok := LocationProjectionForProtocol(tt.protocol, tt.fields)
if !ok {
t.Fatal("LocationProjectionForProtocol() did not find coordinates")
}
if location.Latitude == 0 || location.Longitude == 0 {
t.Fatalf("coordinates = %v,%v", location.Latitude, location.Longitude)
}
tt.assert(t, location)
})
}
}
func TestLocationProjectionForProtocolRejectsBareStandardizedFields(t *testing.T) {
for _, protocol := range []envelope.Protocol{
envelope.ProtocolGB32960,
envelope.ProtocolJT808,
envelope.ProtocolYutongMQTT,
} {
if location, ok := LocationProjectionForProtocol(protocol, map[string]any{
"latitude": 30.5,
"longitude": 121.0,
"speed_kmh": 20,
}); ok {
t.Fatalf("protocol %s unexpectedly accepted bare fields: %#v", protocol, location)
}
}
}
func TestHasRealtimeFieldsExcludesMetadataOnlyPayloads(t *testing.T) {
tests := []struct {
protocol envelope.Protocol
fields map[string]any
want bool
}{
{protocol: envelope.ProtocolGB32960, fields: map[string]any{"gb32960.header.command": "0x02"}, want: false},
{protocol: envelope.ProtocolGB32960, fields: map[string]any{"gb32960.gd_fc_stack.stack_count": "1"}, want: true},
{protocol: envelope.ProtocolJT808, fields: map[string]any{"jt808.registration.plate": "粤A00001"}, want: false},
{protocol: envelope.ProtocolJT808, fields: map[string]any{"jt808.location.speed_kmh": "20"}, want: true},
{protocol: envelope.ProtocolYutongMQTT, fields: map[string]any{"yutong_mqtt.metadata.topic": "/ytforward/shln/3"}, want: false},
{protocol: envelope.ProtocolYutongMQTT, fields: map[string]any{"yutong_mqtt.data.meter_speed": "20"}, want: true},
}
for _, tt := range tests {
if got := HasRealtimeFields(tt.protocol, tt.fields); got != tt.want {
t.Fatalf("HasRealtimeFields(%s, %#v) = %v, want %v", tt.protocol, tt.fields, got, tt.want)
}
}
}
func assertFloatPointer(t *testing.T, value *float64, want float64) {
t.Helper()
if value == nil || *value != want {
t.Fatalf("float pointer = %v, want %v", value, want)
}
}
func assertIntPointer(t *testing.T, value *int64, want int64) {
t.Helper()
if value == nil || *value != want {
t.Fatalf("int pointer = %v, want %v", value, want)
}
}

View File

@@ -0,0 +1,89 @@
package telemetry
import (
"encoding/json"
"strconv"
"strings"
"lingniu-vehicle-ingest/go/vehicle-gateway/internal/envelope"
)
type MileageFieldMapping struct {
Key string
Scale float64
}
// MileageFieldMappings is the single protocol-to-unit contract used by every
// projection that exposes or calculates total mileage.
func MileageFieldMappings(protocol envelope.Protocol) []MileageFieldMapping {
switch protocol {
case envelope.ProtocolGB32960:
return []MileageFieldMapping{{Key: "gb32960.vehicle.total_mileage_km", Scale: 1}}
case envelope.ProtocolJT808:
return []MileageFieldMapping{{Key: "jt808.location.total_mileage_km", Scale: 1}}
case envelope.ProtocolYutongMQTT:
return []MileageFieldMapping{
{Key: "yutong_mqtt.data.total_mileage_km", Scale: 1},
{Key: "yutong_mqtt.root.data.total_mileage_km", Scale: 1},
{Key: "yutong_mqtt.data.total_mileage", Scale: 0.001},
{Key: "yutong_mqtt.root.data.total_mileage", Scale: 0.001},
}
default:
return nil
}
}
func TotalMileageKM(protocol envelope.Protocol, fields map[string]any) (float64, bool) {
for _, mapping := range MileageFieldMappings(protocol) {
value, ok := Number(fields, mapping.Key)
if !ok {
continue
}
return value * mapping.Scale, true
}
return 0, false
}
func Number(fields map[string]any, key string) (float64, bool) {
if len(fields) == 0 {
return 0, false
}
value, ok := fields[key]
if !ok || value == nil {
return 0, false
}
switch typed := value.(type) {
case float64:
return typed, true
case float32:
return float64(typed), true
case int:
return float64(typed), true
case int8:
return float64(typed), true
case int16:
return float64(typed), true
case int32:
return float64(typed), true
case int64:
return float64(typed), true
case uint:
return float64(typed), true
case uint8:
return float64(typed), true
case uint16:
return float64(typed), true
case uint32:
return float64(typed), true
case uint64:
return float64(typed), true
case json.Number:
parsed, err := typed.Float64()
return parsed, err == nil
case string:
parsed, err := strconv.ParseFloat(strings.TrimSpace(typed), 64)
return parsed, err == nil
default:
return 0, false
}
}