231 lines
5.9 KiB
Go
231 lines
5.9 KiB
Go
package yutongmqtt
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"lingniu-vehicle-ingest/go/vehicle-gateway/internal/envelope"
|
|
)
|
|
|
|
var ErrInvalidJSON = errors.New("yutong mqtt invalid json")
|
|
|
|
func ParseMessage(endpoint string, topic string, payload []byte, receivedAtMS int64) (envelope.FrameEnvelope, error) {
|
|
root := map[string]any{}
|
|
decoder := json.NewDecoder(bytes.NewReader(payload))
|
|
decoder.UseNumber()
|
|
if err := decoder.Decode(&root); err != nil {
|
|
return envelope.FrameEnvelope{}, fmt.Errorf("%w: %v", ErrInvalidJSON, err)
|
|
}
|
|
|
|
data := objectValue(root, "data")
|
|
if data == nil {
|
|
data = root
|
|
}
|
|
deviceID := firstText(root, "device", "deviceId", "terminalId", "terminalID", "imei", "IMEI", "mqttDeviceId")
|
|
externalVIN := firstText(root, "vin", "VIN", "externalVin", "vehicleVin")
|
|
vin := externalVIN
|
|
if vin == "" && looksLikeVIN(deviceID) {
|
|
vin = deviceID
|
|
}
|
|
plate := firstText(root, "plateNo", "carNo", "plate", "vehicleNo")
|
|
phone := firstText(root, "sim", "phone", "mobile")
|
|
eventTimeMS := parseTimeMS(firstText(root, "time", "deviceTime", "eventTime", "gpsTime"), receivedAtMS)
|
|
fields := fieldsFromData(data)
|
|
|
|
parsed := map[string]any{
|
|
"endpoint": endpoint,
|
|
"topic": topic,
|
|
"root": root,
|
|
"data": data,
|
|
}
|
|
env := envelope.FrameEnvelope{
|
|
Protocol: envelope.ProtocolYutongMQTT,
|
|
MessageID: "MQTT",
|
|
VIN: vin,
|
|
VehicleKeyHint: deviceID,
|
|
Phone: phone,
|
|
DeviceID: deviceID,
|
|
Plate: plate,
|
|
SourceEndpoint: "mqtt://" + endpoint + topic,
|
|
SourceCode: sourceCodeFromEndpoint(endpoint),
|
|
PlatformName: platformNameFromEndpoint(endpoint),
|
|
SourceKind: "PLATFORM",
|
|
EventTimeMS: eventTimeMS,
|
|
ReceivedAtMS: receivedAtMS,
|
|
RawText: string(payload),
|
|
Parsed: parsed,
|
|
Fields: fields,
|
|
ParseStatus: envelope.ParseOK,
|
|
}
|
|
env.EventID = env.StableEventID()
|
|
return env, nil
|
|
}
|
|
|
|
func platformNameFromEndpoint(endpoint string) string {
|
|
endpoint = strings.TrimSpace(endpoint)
|
|
if strings.EqualFold(endpoint, "yutong") {
|
|
return "宇通"
|
|
}
|
|
return endpoint
|
|
}
|
|
|
|
func sourceCodeFromEndpoint(endpoint string) string {
|
|
endpoint = strings.TrimSpace(endpoint)
|
|
var builder strings.Builder
|
|
for _, r := range endpoint {
|
|
switch {
|
|
case r >= 'a' && r <= 'z':
|
|
builder.WriteRune(r)
|
|
case r >= 'A' && r <= 'Z':
|
|
builder.WriteRune(r)
|
|
case r >= '0' && r <= '9':
|
|
builder.WriteRune(r)
|
|
case r == '_' || r == '-' || r == '.':
|
|
builder.WriteRune(r)
|
|
}
|
|
}
|
|
return builder.String()
|
|
}
|
|
|
|
func fieldsFromData(data map[string]any) map[string]any {
|
|
fields := map[string]any{}
|
|
if speed, ok := firstFloat(data, "METER_SPEED", "speed", "speed_kmh"); ok {
|
|
fields[envelope.FieldSpeedKMH] = speed
|
|
}
|
|
if mileage, ok := firstFloat(data, "TOTAL_MILEAGE", "totalMileage", "total_mileage_km"); ok {
|
|
fields[envelope.FieldTotalMileageKM] = normalizeTotalMileageKM(mileage)
|
|
}
|
|
if soc, ok := firstFloat(data, "BATTERY_CAPACITY_SOC", "soc", "SOC", "soc_percent"); ok {
|
|
fields[envelope.FieldSOCPercent] = soc
|
|
}
|
|
if longitude, ok := firstCoord(data, "LONGITUDE", "longitude", "lng"); ok {
|
|
fields[envelope.FieldLongitude] = longitude
|
|
}
|
|
if latitude, ok := firstCoord(data, "LATITUDE", "latitude", "lat"); ok {
|
|
fields[envelope.FieldLatitude] = latitude
|
|
}
|
|
if direction, ok := firstFloat(data, "GPSDirection", "direction", "direction_deg"); ok {
|
|
fields["direction_deg"] = direction
|
|
}
|
|
if gear, ok := firstFloat(data, "ON_GEAR", "gear"); ok {
|
|
fields["gear"] = gear
|
|
}
|
|
return fields
|
|
}
|
|
|
|
func objectValue(root map[string]any, key string) map[string]any {
|
|
value, ok := root[key]
|
|
if !ok {
|
|
return nil
|
|
}
|
|
typed, ok := value.(map[string]any)
|
|
if !ok {
|
|
return nil
|
|
}
|
|
return typed
|
|
}
|
|
|
|
func firstText(root map[string]any, keys ...string) string {
|
|
for _, key := range keys {
|
|
if value, ok := root[key]; ok && value != nil {
|
|
text := strings.TrimSpace(fmt.Sprint(value))
|
|
if text != "" && text != "<nil>" {
|
|
return text
|
|
}
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func firstFloat(root map[string]any, keys ...string) (float64, bool) {
|
|
for _, key := range keys {
|
|
value, ok := root[key]
|
|
if !ok || value == nil {
|
|
continue
|
|
}
|
|
if parsed, ok := toFloat(value); ok {
|
|
return parsed, true
|
|
}
|
|
}
|
|
return 0, false
|
|
}
|
|
|
|
func firstCoord(root map[string]any, keys ...string) (float64, bool) {
|
|
value, ok := firstFloat(root, keys...)
|
|
if !ok {
|
|
return 0, false
|
|
}
|
|
if value == 0 || int64(value) == 999999 {
|
|
return 0, false
|
|
}
|
|
if value > 1000 || value < -1000 {
|
|
value = value / 1_000_000
|
|
}
|
|
return value, true
|
|
}
|
|
|
|
func toFloat(value any) (float64, bool) {
|
|
switch typed := value.(type) {
|
|
case json.Number:
|
|
parsed, err := typed.Float64()
|
|
return parsed, err == nil
|
|
case float64:
|
|
return typed, true
|
|
case float32:
|
|
return float64(typed), true
|
|
case int:
|
|
return float64(typed), true
|
|
case int64:
|
|
return float64(typed), true
|
|
case string:
|
|
parsed, err := strconv.ParseFloat(strings.TrimSpace(typed), 64)
|
|
return parsed, err == nil
|
|
default:
|
|
return 0, false
|
|
}
|
|
}
|
|
|
|
func parseTimeMS(raw string, fallback int64) int64 {
|
|
raw = strings.TrimSpace(raw)
|
|
if raw == "" {
|
|
return fallback
|
|
}
|
|
for _, layout := range []string{
|
|
"2006-01-02 15:04:05.000",
|
|
"2006-01-02 15:04:05",
|
|
} {
|
|
if parsed, err := time.ParseInLocation(layout, raw, time.FixedZone("Asia/Shanghai", 8*3600)); err == nil {
|
|
return parsed.UnixMilli()
|
|
}
|
|
}
|
|
if len(raw) == 14 {
|
|
if parsed, err := time.ParseInLocation("20060102150405", raw, time.FixedZone("Asia/Shanghai", 8*3600)); err == nil {
|
|
return parsed.UnixMilli()
|
|
}
|
|
}
|
|
if parsed, err := time.Parse(time.RFC3339, raw); err == nil {
|
|
return parsed.UnixMilli()
|
|
}
|
|
if epoch, err := strconv.ParseInt(raw, 10, 64); err == nil {
|
|
if epoch > 10_000_000_000 {
|
|
return epoch
|
|
}
|
|
return epoch * 1000
|
|
}
|
|
return fallback
|
|
}
|
|
|
|
func looksLikeVIN(value string) bool {
|
|
value = strings.TrimSpace(value)
|
|
return len(value) == 17
|
|
}
|
|
|
|
func normalizeTotalMileageKM(value float64) float64 {
|
|
return value / 1000
|
|
}
|