diff --git a/go.mod b/go.mod index f557452..02ee73c 100644 --- a/go.mod +++ b/go.mod @@ -1,4 +1,4 @@ -module github.com/adrianmo/go-nmea +module github.com/klyve/go-nmea require ( github.com/davecgh/go-spew v1.1.1 // indirect diff --git a/sentence.go b/sentence.go index 9258d05..ae9c601 100644 --- a/sentence.go +++ b/sentence.go @@ -34,6 +34,7 @@ type BaseSentence struct { Fields []string // Array of fields Checksum string // The Checksum Raw string // The raw NMEA sentence received + TagBlock TagBlock // NMEA tagblock } // Prefix returns the talker and type of message @@ -57,6 +58,11 @@ func (s BaseSentence) String() string { return s.Raw } // parseSentence parses a raw message into it's fields func parseSentence(raw string) (BaseSentence, error) { raw = strings.TrimSpace(raw) + tagBlock, raw, err := parseTagBlock(raw) + if err != nil { + return BaseSentence{}, err + } + startIndex := strings.IndexAny(raw, SentenceStart+SentenceStartEncapsulated) if startIndex != 0 { return BaseSentence{}, fmt.Errorf("nmea: sentence does not start with a '$' or '!'") @@ -77,12 +83,14 @@ func parseSentence(raw string) (BaseSentence, error) { "nmea: sentence checksum mismatch [%s != %s]", checksum, checksumRaw) } talker, typ := parsePrefix(fields[0]) + return BaseSentence{ Talker: talker, Type: typ, Fields: fields[1:], Checksum: checksumRaw, Raw: raw, + TagBlock: tagBlock, }, nil } diff --git a/tagblock.go b/tagblock.go new file mode 100644 index 0000000..864b34c --- /dev/null +++ b/tagblock.go @@ -0,0 +1,137 @@ +package nmea + +import ( + "errors" + "fmt" + "regexp" + "strconv" + "strings" + "time" +) + +const ( + // TypeUnixTime unix timestamp, parameter: -c + TypeUnixTime = "c" + // TypeDestinationID destination identification 15char max, parameter: -d + TypeDestinationID = "d" + // TypeGrouping sentence grouping, parameter: -g + TypeGrouping = "g" + // TypeLineCount linecount, parameter: -n + TypeLineCount = "n" + // TypeRelativeTime relative time time, paremeter: -r + TypeRelativeTime = "r" + // TypeSourceID source identification 15char max, paremter: -s + TypeSourceID = "s" + // TypeTextString valid character string, parameter -t + TypeTextString = "t" +) + +var ( + // tagBlockRegexp matches nmea tag blocks + tagBlockRegexp = regexp.MustCompile(`^(.*)\\(\S+)\\(.*)`) +) + +// TagBlock struct +type TagBlock struct { + Head string // * + Time int64 // -c + RelativeTime int64 // -r + Destination string // -d 15 char max + Grouping string // -g nummeric string + LineCount int64 // -n int + Source string // -s 15 char max + Text string // -t Variable length text +} + +func parseInt64(raw string) (int64, error) { + i, err := strconv.ParseInt(raw[2:], 10, 64) + if err != nil { + return 0, fmt.Errorf("nmea: tagblock unable to parse uint32 [%s]", raw) + } + return i, nil +} + +// Timestamp can come as milliseconds or seconds +func validUnixTimestamp(timestamp int64) (int64, error) { + if timestamp < 0 { + return 0, errors.New("nmea: Tagblock timestamp is not valid must be between 0 and now + 24h") + } + now := time.Now() + unix := now.Unix() + 24*3600 + if timestamp > unix { + if timestamp > unix*1000 { + return 0, errors.New("nmea: Tagblock timestamp is not valid") + } + return timestamp / 1000, nil + } + + return timestamp, nil +} + +// parseTagBlock adds support for tagblocks +// https://rietman.wordpress.com/2016/09/17/nemastudio-now-supports-the-nmea-0183-tag-block/ +func parseTagBlock(raw string) (TagBlock, string, error) { + matches := tagBlockRegexp.FindStringSubmatch(raw) + if matches == nil { + return TagBlock{}, raw, nil + } + + tagBlock := TagBlock{} + raw = matches[3] + tags := matches[2] + tagBlock.Head = matches[1] + + sumSepIndex := strings.Index(tags, ChecksumSep) + if sumSepIndex == -1 { + return tagBlock, "", fmt.Errorf("nmea: tagblock does not contain checksum separator") + } + + var ( + fieldsRaw = tags[0:sumSepIndex] + checksumRaw = strings.ToUpper(tags[sumSepIndex+1:]) + checksum = Checksum(fieldsRaw) + err error + ) + + // Validate the checksum + if checksum != checksumRaw { + return tagBlock, "", fmt.Errorf("nmea: tagblock checksum mismatch [%s != %s]", checksum, checksumRaw) + } + + items := strings.Split(tags[:sumSepIndex], ",") + for _, item := range items { + if len(item) == 0 { + continue + } + switch item[:1] { + case TypeUnixTime: + tagBlock.Time, err = parseInt64(item) + if err != nil { + return tagBlock, raw, err + } + tagBlock.Time, err = validUnixTimestamp(tagBlock.Time) + if err != nil { + return tagBlock, raw, err + } + case TypeDestinationID: + tagBlock.Destination = item[2:] + case TypeGrouping: + tagBlock.Grouping = item[2:] + case TypeLineCount: + tagBlock.LineCount, err = parseInt64(item) + if err != nil { + return tagBlock, raw, err + } + case TypeRelativeTime: + tagBlock.RelativeTime, err = parseInt64(item) + if err != nil { + return tagBlock, raw, err + } + case TypeSourceID: + tagBlock.Source = item[2:] + case TypeTextString: + tagBlock.Text = item[2:] + } + } + return tagBlock, raw, nil +} diff --git a/tagblock_test.go b/tagblock_test.go new file mode 100644 index 0000000..7d5cc42 --- /dev/null +++ b/tagblock_test.go @@ -0,0 +1,152 @@ +package nmea + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +var tagblocktests = []struct { + name string + raw string + err string + msg TagBlock +}{ + { + + name: "Test NMEA tag block", + raw: "\\s:Satelite_1,c:1553390539*62\\!AIVDM,1,1,,A,13M@ah0025QdPDTCOl`K6`nV00Sv,0*52", + msg: TagBlock{ + Time: 1553390539, + Source: "Satelite_1", + }, + }, + { + + name: "Test NMEA tag block with head", + raw: "UdPbC?\\s:satelite,c:1564827317*25\\!AIVDM,1,1,,A,19NSRaP02A0fo91kwnaMKbjR08:J,0*15", + msg: TagBlock{ + Time: 1564827317, + Source: "satelite", + Head: "UdPbC?", + }, + }, + { + + name: "Test unknown tag", + raw: "UdPbC?\\x:NorSat_1,c:1564827317*42\\!AIVDM,1,1,,A,19NSRaP02A0fo91kwnaMKbjR08:J,0*15", + msg: TagBlock{ + Time: 1564827317, + Source: "", + Head: "UdPbC?", + }, + }, + { + name: "Test unix timestamp", + raw: "UdPbC?\\x:NorSat_1,c:1564827317*42\\!AIVDM,1,1,,A,19NSRaP02A0fo91kwnaMKbjR08:J,0*15", + msg: TagBlock{ + Time: 1564827317, + Source: "", + Head: "UdPbC?", + }, + }, + { + + name: "Test milliseconds timestamp", + raw: "UdPbC?\\x:NorSat_1,c:1564827317000*72\\!AIVDM,1,1,,A,19NSRaP02A0fo91kwnaMKbjR08:J,0*15", + msg: TagBlock{ + Time: 1564827317, + Source: "", + Head: "UdPbC?", + }, + }, + { + + name: "Test invalid high timestamp", + raw: "UdPbC?\\x:NorSat_1,c:25648273170000000*71\\!AIVDM,1,1,,A,19NSRaP02A0fo91kwnaMKbjR08:J,0*15", + err: "nmea: Tagblock timestamp is not valid", + }, + { + + name: "Test invalid low timestamp", + raw: "UdPbC?\\x:NorSat_1,c:-10*60\\!AIVDM,1,1,,A,19NSRaP02A0fo91kwnaMKbjR08:J,0*15", + err: "nmea: Tagblock timestamp is not valid must be between 0 and now + 24h", + }, + { + + name: "Test all input types", + raw: "UdPbC?\\s:satelite,c:1564827317,r:1553390539,d:ara,g:bulk,n:13,t:helloworld*3F\\!AIVDM,1,1,,A,19NSRaP02A0fo91kwnaMKbjR08:J,0*15", + msg: TagBlock{ + Time: 1564827317, + RelativeTime: 1553390539, + Destination: "ara", + Grouping: "bulk", + Source: "satelite", + Head: "UdPbC?", + Text: "helloworld", + LineCount: 13, + }, + }, + { + + name: "Test empty tag in tagblock", + raw: "UdPbC?\\s:satelite,,r:1553390539,d:ara,g:bulk,n:13,t:helloworld*68\\!AIVDM,1,1,,A,19NSRaP02A0fo91kwnaMKbjR08:J,0*15", + msg: TagBlock{ + Time: 0, + RelativeTime: 1553390539, + Destination: "ara", + Grouping: "bulk", + Source: "satelite", + Head: "UdPbC?", + Text: "helloworld", + LineCount: 13, + }, + //err: "nmea: tagblock checksum mismatch [25 != 49]", + }, + { + + name: "Test Invalid checksum", + raw: "UdPbC?\\s:satelite,c:1564827317*49\\!AIVDM,1,1,,A,19NSRaP02A0fo91kwnaMKbjR08:J,0*15", + err: "nmea: tagblock checksum mismatch [25 != 49]", + }, + { + + name: "Test no checksum", + raw: "UdPbC?\\s:satelite,c:156482731749\\!AIVDM,1,1,,A,19NSRaP02A0fo91kwnaMKbjR08:J,0*15", + err: "nmea: tagblock does not contain checksum separator", + }, + { + + name: "Test invalid timestamp", + raw: "UdPbC?\\s:satelite,c:gjadslkg*30\\!AIVDM,1,1,,A,19NSRaP02A0fo91kwnaMKbjR08:J,0*15", + err: "nmea: tagblock unable to parse uint32 [c:gjadslkg]", + }, + { + + name: "Test invalid linecount", + raw: "UdPbC?\\s:satelite,n:gjadslkg*3D\\!AIVDM,1,1,,A,19NSRaP02A0fo91kwnaMKbjR08:J,0*15", + err: "nmea: tagblock unable to parse uint32 [n:gjadslkg]", + }, + { + + name: "Test invalid relative time", + raw: "UdPbC?\\s:satelite,r:gjadslkg*21\\!AIVDM,1,1,,A,19NSRaP02A0fo91kwnaMKbjR08:J,0*15", + err: "nmea: tagblock unable to parse uint32 [r:gjadslkg]", + }, +} + +func TestTagBlock(t *testing.T) { + for _, tt := range tagblocktests { + t.Run(tt.name, func(t *testing.T) { + m, err := Parse(tt.raw) + if tt.err != "" { + assert.Error(t, err) + assert.EqualError(t, err, tt.err) + } else { + assert.NoError(t, err) + vdm := m.(VDMVDO) + assert.Equal(t, tt.msg, vdm.BaseSentence.TagBlock) + } + }) + } +}