adrianmo / go-nmea

A NMEA parser library in pure Go
MIT License
227 stars 78 forks source link

feature: could sentence expose list of field names it has #86

Closed aldas closed 2 years ago

aldas commented 2 years ago

I am creating application to read nmea values and send them to NATS message bus and I one feature that I have is that user can provide configuration (file) that has list of sentences and fields that should filter out of stream of NMEA messages it receives.

For example - we have GPS, Echosounder, Wind sensor all multiplexed into NMEA0183 bus we read. Sometimes we only want to filter GPS RMC messages but sometimes we want other hardware messages also. As this library evolves more and more supported sentences will be added. For each of those messages we would need to create if/switch to be able to read it - pack all fields of message into custom struct we have. We can not just marshall current nmea.Sentence as our schema is different from what marshalling nmea.Sentence creates.

So it would be nice if there would be way to get list of field names that sentence has and then we can pair them with nmea.BaseSentence.Fields slice values to our own schema.

Maybe that context argument that is used when adding field to sentence, could be stored and exposed as public slice https://github.com/adrianmo/go-nmea/blob/a4c9590277d76127778f281a1d388d3c3fd651ee/rmc.go#L34

or there would be some method on Sentence what would return list/map of fieldname+fieldvalue (ala map[string]interface{}) so you could "dynamically" get all fields and filter what you need.

aldas commented 2 years ago

I could create implementation but first I would need to know from maintainers what would be the acceptable solution.

icholy commented 2 years ago

This feature seems too special cased to be included. You can always use reflection to get at the data though. The easiest way would be using the json package. Here's an example of an RMC message marshalled to json: https://go.dev/play/p/5FMyH_Ag733

{
  "Talker": "GN",
  "Type": "RMC",
  "Fields": [
    "220516",
    "A",
    "5133.82",
    "N",
    "00042.24",
    "W",
    "173.8",
    "231.8",
    "130694",
    "004.2",
    "W"
  ],
  "Checksum": "6E",
  "Raw": "$GNRMC,220516,A,5133.82,N,00042.24,W,173.8,231.8,130694,004.2,W*6E",
  "TagBlock": {
    "Time": 0,
    "RelativeTime": 0,
    "Destination": "",
    "Grouping": "",
    "LineCount": 0,
    "Source": "",
    "Text": ""
  },
  "Time": {
    "Valid": true,
    "Hour": 22,
    "Minute": 5,
    "Second": 16,
    "Millisecond": 0
  },
  "Validity": "A",
  "Latitude": 51.56366666666666,
  "Longitude": -0.7040000000000001,
  "Speed": 173.8,
  "Course": 231.8,
  "Date": {
    "Valid": true,
    "DD": 13,
    "MM": 6,
    "YY": 94
  },
  "Variation": -4.2
}
aldas commented 2 years ago

well, with the growing popularity of MQTT etc and (workflow/automation)tools like Node-Red, Home assistant. Go is quite nice way to scrape data from hardware devices and pass data to other IOT thingies or workflow/automation tools. Sometimes you want to filter out data that is not necessary. Sometimes you do not need to care about schema (Node-Red is flexible) but sometimes ala https://signalk.org/ messages that you send need to have specific schema.
In these cases it starts to get maintenance problem when you need to program specific cases for each possible sentence on every library update.

Anyway, I solved it for myself with reflection to get list of fields and their values that I can structure/filter as needed.

If someone stumbles on similar requirement - here is my implementation (but there are more complete libraries https://github.com/fatih/structs):


// SentenceAsValueMap converts sentence to map of values, keyed by field names.
// Only public fields are included. Only following types of fields are supported:
// * basic types (int/float etc)
// * struct (including nested structs)
// * slice (of structs, of basic type)
func SentenceAsValueMap(s nmea.Sentence) map[string]interface{} {
    return asValueMap(reflect.ValueOf(s))
}

func asValueMap(v reflect.Value) map[string]interface{} {
    result := map[string]interface{}{}

    if v.Kind() == reflect.Ptr {
        v = v.Elem()
    }

    t := v.Type()
    for i := 0; i < t.NumField(); i++ {
        f := t.Field(i)
        if f.Anonymous { // ignore embedded types/fields (i.e. BaseSentence parts)
            continue
        }
        name := strings.ToLower(f.Name) // for me names are in lower case

        fValue := v.Field(i)
        val := fValue.Interface()
        switch fValue.Kind() {
        case reflect.Struct:
            val = asValueMap(fValue)
        case reflect.Slice:
            tmp := make([]interface{}, 0)
            for j := 0; j < fValue.Len(); j++ {
                elem := fValue.Index(j)
                if elem.Kind() == reflect.Struct {
                    tmp = append(tmp, asValueMap(elem))
                } else {
                    tmp = append(tmp, elem.Interface())
                }
            }
            val = tmp
        }
        result[name] = val
    }

    return result
}

Example:

func TestSentenceAsValueMap(t *testing.T) {
    var testCases = []struct {
        name         string
        whenSentence nmea.Sentence
        expect       map[string]interface{}
    }{
        {
            name: "rmc",
            whenSentence: nmea.RMC{
                BaseSentence: nmea.BaseSentence{
                    Talker:   "GN",
                    Type:     "RMC",
                    Fields:   []string{"134253.00", "A", "5919.47429", "N", "02433.48604", "E", "0.069", "", "061221", "", "", "A"},
                    Checksum: "65",
                    Raw:      "$GNRMC,134253.00,A,5919.47429,N,02433.48604,E,0.069,,061221,,,A*65",
                    TagBlock: nmea.TagBlock{Time: 0, RelativeTime: 0, Destination: "", Grouping: "", LineCount: 0, Source: "", Text: ""},
                },
                Time:      nmea.Time{Valid: true, Hour: 13, Minute: 42, Second: 53, Millisecond: 0},
                Validity:  "A",
                Latitude:  59.324571500000005,
                Longitude: 24.558100666666665,
                Speed:     0.069,
                Course:    0,
                Date:      nmea.Date{Valid: true, DD: 6, MM: 12, YY: 21},
                Variation: 0,
            },
            expect: map[string]interface{}{
                "course":    0.0,
                "date":      map[string]interface{}{"dd": 6, "mm": 12, "valid": true, "yy": 21},
                "latitude":  59.324571500000005,
                "longitude": 24.558100666666665,
                "speed":     0.069,
                "time":      map[string]interface{}{"hour": 13, "millisecond": 0, "minute": 42, "second": 53, "valid": true},
                "validity":  "A",
                "variation": 0.0,
            },
        },
        {
            name: "gsa",
            whenSentence: nmea.GSA{
                BaseSentence: nmea.BaseSentence{},
                Mode:         "A",
                FixType:      "3",
                SV:           []string{"22", "19", "18", "27", "14", "03"},
                PDOP:         3.1,
                HDOP:         2,
                VDOP:         2.4,
            },
            expect: map[string]interface{}{
                "fixtype": "3",
                "hdop":    2.0,
                "mode":    "A",
                "pdop":    3.1,
                "sv":      []interface{}{"22", "19", "18", "27", "14", "03"},
                "vdop":    2.4,
            },
        },
        {
            name: "GSV",
            whenSentence: nmea.GSV{
                BaseSentence:    nmea.BaseSentence{},
                TotalMessages:   3,
                MessageNumber:   1,
                NumberSVsInView: 11,
                Info: []nmea.GSVInfo{
                    {SVPRNNumber: 3, Elevation: 3, Azimuth: 111, SNR: 0},
                    {SVPRNNumber: 4, Elevation: 15, Azimuth: 270, SNR: 0},
                    {SVPRNNumber: 6, Elevation: 1, Azimuth: 10, SNR: 12},
                    {SVPRNNumber: 13, Elevation: 6, Azimuth: 292, SNR: 0},
                },
            },
            expect: map[string]interface{}{
                "info": []interface{}{
                    map[string]interface{}{"azimuth": int64(111), "elevation": int64(3), "snr": int64(0), "svprnnumber": int64(3)},
                    map[string]interface{}{"azimuth": int64(270), "elevation": int64(15), "snr": int64(0), "svprnnumber": int64(4)},
                    map[string]interface{}{"azimuth": int64(10), "elevation": int64(1), "snr": int64(12), "svprnnumber": int64(6)},
                    map[string]interface{}{"azimuth": int64(292), "elevation": int64(6), "snr": int64(0), "svprnnumber": int64(13)},
                },
                "messagenumber":   int64(1),
                "numbersvsinview": int64(11),
                "totalmessages":   int64(3),
            },
        },
    }

    for _, tc := range testCases {
        t.Run(tc.name, func(t *testing.T) {
            s := SentenceAsValueMap(tc.whenSentence)
            assert.Equal(t, tc.expect, s)
        })
    }
}