feat: build vehicle data platform and production pipeline
This commit is contained in:
170
go/vehicle-gateway/internal/telemetry/location.go
Normal file
170
go/vehicle-gateway/internal/telemetry/location.go
Normal 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
|
||||
}
|
||||
131
go/vehicle-gateway/internal/telemetry/location_test.go
Normal file
131
go/vehicle-gateway/internal/telemetry/location_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
89
go/vehicle-gateway/internal/telemetry/mileage.go
Normal file
89
go/vehicle-gateway/internal/telemetry/mileage.go
Normal 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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user