From 668b1ccb7453b4bcf09fa21e90c748d3c1f44df4 Mon Sep 17 00:00:00 2001 From: Totto16 Date: Sun, 12 Mar 2023 18:48:27 +0100 Subject: [PATCH] feat: added TXT parser --- gps.d.ts | 10 ++- src/gps.js | 182 ++++++++++++++++++++++++++++++++++++++++++++++-- tests/parser.js | 65 +++++++++++++++++ 3 files changed, 252 insertions(+), 5 deletions(-) diff --git a/gps.d.ts b/gps.d.ts index e5319af..1fa5a41 100644 --- a/gps.d.ts +++ b/gps.d.ts @@ -39,7 +39,7 @@ declare class GPS { * @param line NMEA string * @returns NMEA object or False */ - static Parse(line: string): false | T; + static Parse(line: string, GPSObject?: GPS | undefined): false | T; /** * Calculates the distance between two geo-coordinates using Haversine formula @@ -90,6 +90,7 @@ declare namespace GPS { [key: string]: any; processed: number; errors: number; + txtBuffer: Record time?: Date; lat?: number; @@ -229,4 +230,11 @@ declare namespace GPS { valid: boolean; type: 'HDT'; } + + export interface TXT { + message: string | null + completed: boolean, + rawMessages: string[], + sentenceAmount: number, + } } diff --git a/src/gps.js b/src/gps.js index e30974d..29a5949 100644 --- a/src/gps.js +++ b/src/gps.js @@ -338,6 +338,50 @@ function parseDist(num, unit) { throw new Error('Unknown unit: ' + unit); } + +/** + * @description Escapes a string, according to spec + * @see https://www.plaisance-pratique.com/IMG/pdf/NMEA0183-2.pdf Section 5.1.3 + * + * @param {string} str string to escape + * @returns {string} + */ +function escapeString(str){ + // invalid characters according to: + // https://www.plaisance-pratique.com/IMG/pdf/NMEA0183-2.pdf + // Section 6.1 - Table 1 + const invalidCharacters = [ + "\r", + "\n", + "$", + "*", + ",", + "!", + "\\", + // this is excluded, as it is removed later, since identifies escape sequences + //"^" + "~", + "\u007F", // + ] + + for (const invalidCharacter of invalidCharacters) { + if (str.includes(invalidCharacter)) { + throw new Error( + `Message may not contain invalid Character '${invalidCharacter}'` + ) + } + } + + // escaping according to https://www.plaisance-pratique.com/IMG/pdf/NMEA0183-2.pdf + // Section 5.1.3 + return str.replaceAll( + /\^([a-zA-Z0-9]{2})/g, + (fullMatch, escapeSequence) => { + return String.fromCharCode(parseInt(escapeSequence, 16)) + } + ) +} + /** * * @constructor @@ -349,7 +393,7 @@ function GPS() { } this['events'] = {}; - this['state'] = { 'errors': 0, 'processed': 0 }; + this['state'] = { 'errors': 0, 'processed': 0, 'txtBuffer': {}}; } GPS.prototype['events'] = null; @@ -448,6 +492,136 @@ GPS['mod'] = { 'system': gsa.length > 19 ? parseSystemId(parseNumber(gsa[18])) : 'unknown' }; }, + // Text Transmission + // according to https://www.plaisance-pratique.com/IMG/pdf/NMEA0183-2.pdf + // Section 6.3 - Site 69 + 'TXT': function(str, txt,gps) { + + if (txt.length !== 6) { + throw new Error("Invalid TXT length: " + str) + } + + /* + 1 2 3 4 5 + | | | | | + $--TXT,xx,xx,xx,c--c*hh + + 1) Total number of sentences, 01 to 99 + 2) Sentence number, 01 to 99 + 3) Text identifier, 01 to 99 + 4) Text message (with ^ escapes, see below) + 5) Checksum + + eg1. $--TXT,01,01,02,GPS;GLO;GAL;BDS*77 + eg2. $--TXT,01,01,02,SBAS;IMES;QZSS*49 + */ + + if (txt[1].length !== 2) { + throw new Error("Invalid TXT Number of sequences Length: " + txt[1]) + } + + const sequenceLength = parseInt(txt[1], 10) + + if (txt[2].length !== 2) { + throw new Error("Invalid TXT Sentence number Length: " + txt[2]) + } + + const sentenceNumber = parseInt(txt[2], 10) + + if (txt[3].length !== 2) { + throw new Error("Invalid TXT Text identifier Length: " + txt[3]) + } + + //this is used to identify the multiple sentence messages, it doesn't mean anything, when there is only one sentence + const textIdentifier = `identifier_${parseInt(txt[3], 10)}` + + if (txt[4].length > 61) { + throw new Error("Invalid TXT Message Length: " + txt[4]) + } + + const message = escapeString(txt[4]) + + if (message === "") { + throw new Error("Invalid empty TXT message: " + message) + } + + // this tries to parse a sentence that is more than one message, it doesn't assume, that all sentences arrive in order, but it has a timeout for receiving all! + if (sequenceLength != 1) { + if(gps === undefined){ + throw new Error(`Can't parse multi sequence with the static function, it can't store partial messages!`) + } + + if (gps["state"]["txtBuffer"][textIdentifier] === undefined) { + // the map is necessary, otherwise the values in there refer all to the same value, and if you change one, you change all + gps["state"]["txtBuffer"][textIdentifier] = new Array( + sequenceLength + 1 + ).map((_, i) => { + if (i === sequenceLength) { + const SECONDS = 20 + // the timeout ID is stored in that array at the last position, it gets cancelled, when all sentences arrived, otherwise it fires and sets an error! + return setTimeout( + (_identifier, _SECONDS,_gps) => { + const errorMessage = `The multi sentence messsage with the identifier ${_identifier} timed out while waiting fro all pieces of the sentence for ${_SECONDS} seconds` + _gps["state"]["errors"]++ + + _gps["emit"]("data", null) + console.error(errorMessage) + }, + SECONDS * 1000, + textIdentifier, + SECONDS, + gps + ) // 20 seconds is the arbitrary timeout + } + return "" + }) + } + + gps["state"]["txtBuffer"][textIdentifier][sentenceNumber - 1] = message; + + const receivedMessages = gps["state"]["txtBuffer"][textIdentifier].reduce( + (acc, elem, i) => { + if (i === sequenceLength) { + return acc + } + return acc + (elem === "" ? 0 : 1) + }, + 0 + ) + + if (receivedMessages === sequenceLength) { + const rawMessages = gps["state"]["txtBuffer"][textIdentifier].filter( + (_, i) => i !== sequenceLength + ) + + const timerID = gps["state"]["txtBuffer"][textIdentifier][sequenceLength] + clearTimeout(timerID) + + delete gps["state"]["txtBuffer"][textIdentifier] + + return { + message: rawMessages.join(""), + completed: true, + rawMessages: rawMessages, + sentenceAmount: sequenceLength, + } + } else { + return { + message: null, + completed: false, + rawMessages: [], + sentenceAmount: sequenceLength, + } + } + } + + return { + message: message, + completed: true, + rawMessages: [message], + sentenceAmount: sequenceLength, + } + }, // Recommended Minimum data for gps 'RMC': function (str, rmc) { @@ -782,7 +956,7 @@ GPS['mod'] = { } }; -GPS['Parse'] = function (line) { +GPS['Parse'] = function (line, gps) { if (typeof line !== 'string') return false; @@ -805,7 +979,7 @@ GPS['Parse'] = function (line) { if (GPS['mod'][nmea[0]] !== undefined) { // set raw data here as well? - var data = this['mod'][nmea[0]](line, nmea); + var data = this['mod'][nmea[0]](line, nmea, gps); data['raw'] = line; data['valid'] = isValid(line, nmea[nmea.length - 1]); data['type'] = nmea[0]; @@ -883,7 +1057,7 @@ GPS['TotalDistance'] = function (path) { GPS.prototype['update'] = function (line) { - var parsed = GPS['Parse'](line); + var parsed = GPS['Parse'](line, this); this['state']['processed']++; diff --git a/tests/parser.js b/tests/parser.js index 4d01fc7..9d93f08 100644 --- a/tests/parser.js +++ b/tests/parser.js @@ -1152,6 +1152,71 @@ const tests = { "signalId": null, "type": "GSV", "valid": true + }, + '$GNTXT,01,01,02,PF=3FF*4B':{ + "completed": true, + "message": "PF=3FF", + "raw": "$GNTXT,01,01,02,PF=3FF*4B", + "rawMessages": [ + "PF=3FF", + ], + "sentenceAmount": 1, + "type": "TXT", + "valid": true + }, + '$GNTXT,01,01,02,ANTSTATUS=OK*25':{ + "completed": true, + "message": "ANTSTATUS=OK", + "raw": "$GNTXT,01,01,02,ANTSTATUS=OK*25", + "rawMessages": [ + "ANTSTATUS=OK", + ], + "sentenceAmount": 1, + "type": "TXT", + "valid": true + }, + '$GNTXT,01,01,02,LLC=FFFFFFFF-FFFFFFFF-FFFFFFFF-FFFFFFFF-FFFFFFFD*2F':{ + "completed": true, + "message": "LLC=FFFFFFFF-FFFFFFFF-FFFFFFFF-FFFFFFFF-FFFFFFFD", + "raw": "$GNTXT,01,01,02,LLC=FFFFFFFF-FFFFFFFF-FFFFFFFF-FFFFFFFF-FFFFFFFD*2F", + "rawMessages": [ + "LLC=FFFFFFFF-FFFFFFFF-FFFFFFFF-FFFFFFFF-FFFFFFFD", + ], + "sentenceAmount": 1, + "type": "TXT", + "valid": true + }, + '$GNTXT,01,01,02,some escape chars: ^21*2F':{ + "completed": true, + "message": "some escape chars: !", + "raw": "$GNTXT,01,01,02,some escape chars: ^21*2F", + "rawMessages": [ + "some escape chars: !", + ], + "sentenceAmount": 1, + "type": "TXT", + "valid": false + }, + '$GNTXT,02,01,02,a multipart message^2C this is part 1^0D^0A*34':{ + "completed": false, + "message": null, + "raw": "$GNTXT,02,01,02,a multipart message^2C this is part 1^0D^0A*34", + "rawMessages": [], + "sentenceAmount": 2, + "type": "TXT", + "valid": true + }, + '$GNTXT,02,02,02,a multipart message^2C this is part 2^0D^0A*34':{ + "completed": true, + "message": "a multipart message, this is part 1\r\na multipart message, this is part 2\r\n", + "raw": "$GNTXT,02,02,02,a multipart message^2C this is part 2^0D^0A*34", + "rawMessages": [ + "a multipart message, this is part 1\r\n", + "a multipart message, this is part 2\r\n", + ], + "sentenceAmount": 2, + "type": "TXT", + "valid": true } }; var collect = {};