diff --git a/.gitignore b/.gitignore index 9daa824..8a34424 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,2 @@ -.DS_Store -node_modules +.DS_Store +node_modules diff --git a/README.md b/README.md index 01e7fd4..40b4175 100644 --- a/README.md +++ b/README.md @@ -1,396 +1,396 @@ - - -[](https://npmjs.org/package/gps "View this project on npm") -[](http://opensource.org/licenses/MIT) - -GPS.js is an extensible parser for [NMEA](http://www.gpsinformation.org/dale/nmea.htm) sentences, given by any common GPS receiver. The output is tried to be as high-level as possible to make it more useful than simply splitting the information. The aim is, that you don't have to understand NMEA, just plug in your receiver and you're ready to go. - - -## Usage - - -The interface of GPS.js is as simple as the following few lines. You need to add an event-listener for the completion of the task and invoke the update method with a sentence you want to process. There are much more examples in the examples folder. - -```javascript -const gps = new GPS; - -// Add an event listener on all protocols -gps.on('data', parsed => { - console.log(parsed); -}); - -// Call the update routine directly with a NMEA sentence, which would -// come from the serial port or stream-reader normally -gps.update("$GPGGA,224900.000,4832.3762,N,00903.5393,E,1,04,7.8,498.6,M,48.0,M,,0000*5E"); -``` - -It's also possible to add event-listeners only on one of the following protocols, by stating `gps.on('GGA', ...)` for example. - -## State - - -The real advantage over other NMEA implementations is, that the GPS information is interpreted and normalized. The most high-level API is the state object, which changes with every new event. You can use this information with: - -```javascript -gps.on('data', () => { - console.log(gps.state); -}); -``` - -## Installation - -You can install `GPS.js` via npm: - -```bash -npm install gps -``` - -Or with yarn: - -```bash -yarn add gps -``` - -Alternatively, download or clone the repository: - -```bash -git clone https://github.com/rawify/GPS.js -``` - -## Usage - -Include the `gps.min.js` file in your project: - -```html - -``` - -Or in a Node.js project: - -```javascript -const GPS = require('gps'); -``` - -or - -```javascript -import GPS from 'gps'; -``` - -## Find the serial device - - -On Linux serial devices typically have names like `/dev/ttyS1`, on OSX `/dev/tty.usbmodem1411` after installing a USB to serial driver and on Windows, you're probably fine by using the highest COM device you can find in the device manager. Please note that if you have multiple USB ports on your computer and use them randomly, you have to lookup the path/device again. - -Register device on a BeagleBone ---- - -If you find yourself on a BeagleBone, the serial device must be registered manually. Luckily, this can be done within node quite easily using [octalbonescript](https://www.npmjs.com/package/octalbonescript): - -```javascript -const obs = require('octalbonescript'); -obs.serial.enable('/dev/ttyS1', () => { - console.log('serial device activated'); -}); -``` - -## Examples - - -GPS.js comes with some examples, like drawing the current latitude and longitude to Google Maps, displaying a persistent state and displaying the parsed raw data. In some cases you have to adjust the serial path to your own GPS receiver to make it work. - -Simple serial example ---- - -```javascript -const SerialPort = require('serialport'); -const GPS = require('gps'); - -const port = new SerialPort('/dev/tty.usbmodem11401', { // change path - baudRate: 9600, - parser: new SerialPort.parsers.Readline({ - delimiter: '\r\n' - }) -}); - -const gps = new GPS; - -gps.on('data', data => { - console.log(data, gps.state); -}) - -port.on('data', data => { - gps.updatePartial(data); -}) -``` - -Dashboard ---- -Go into the folder `examples/dashboard` and start the server with - -``` -node server -``` - -After that you can open the browser and go to http://localhost:3000. The result should look like the following, which in principle is just a visualization of the state object `gps.state` - - - -Google Maps ---- -Go into the folder `examples/maps` and start the server with - -``` -node server -``` - -After that you can open the browser and go to http://localhost:3000 The result should look like - - - -Confluence ---- -[Confluence](http://www.confluence.org/) is a project, which tries to travel to and document all integer GPS coordinates. GPS.js can assist on that goal. Go into the examples folder and run: - -``` -node confluence -``` - -You should see something like the following, updating as you move around - -``` -You are at (48.53, 9.05951), -The closest confluence point (49, 9) is in 51.36 km. -You have to go 355.2° N -``` - -Set Time ---- -On systems without a RTC - like Raspberry PI - you need to update the time yourself at runtime. If the device has an internet connection, it's quite easy to use an NTP server. An alternative for disconnected projects with access to a GPS receiver can be the high-precision time signal, sent by satellites. Go to the examples folder and run the following to update the time: - -``` -node set-date -``` - -## Available Methods - - -update(line) ---- -The update method is the most important function, it parses a NMEA sentence and forces the callbacks to trigger - -updatePartial(chunk) ---- -Will call `update()` when a full NMEA sentence has been arrived - -on(event, callback) ---- -Adds an event listener for a protocol to occur (see implemented protocols, simply use the name - upper case) or for all sentences with `data`. Because GPS.js should be more general, it doesn't inherit `EventEmitter`, but simply invokes the callback. - -off(event) ---- -Removes an event listener - -## Implemented Protocols - - -GGA - Fix information ---- -Gets the data, you're most probably looking for: *latitude and longitude* - -The parsed object will have the following attributes: - -- type: "GGA" -- time: The time given as a JavaScript Date object -- lat: The latitude -- lon: The longitude -- alt: The altitude -- quality: Fix quality (either invalid, fix or diff) -- satellites: Number of satellites being tracked -- hdop: Horizontal [dilution of precision](https://en.wikipedia.org/wiki/Dilution_of_precision_(GPS)) -- geoidal: Height of geoid in meters (mean sea level) -- age: time in seconds since last DGPS update -- stationID: DGPS station ID number -- valid: Indicates if the checksum is okay - -RMC - NMEAs own version of essential GPS data ---- -Similar to GGA but gives also delivers the velocity - -The parsed object will have the following attributes: - -- type: "RMC" -- time: The time given as a JavaScript Date object -- status: Status active or void -- lat: The latitude -- lon: The longitude -- speed: Speed over the ground in km/h -- track: Track angle in degrees -- variation: Magnetic Variation -- faa: The FAA mode, introduced with NMEA 2.3 -- valid: Indicates if the checksum is okay - - -GSA - Active satellites ---- -The parsed object will have the following attributes: - -- type: "GSA" -- mode: Auto selection of 2D or 3D fix (either auto or manual) -- fix: The selected fix mode (either 2D or 3D) -- satellites: Array of satellite IDs -- pdop: Position [dilution of precision](https://en.wikipedia.org/wiki/Dilution_of_precision_(GPS)) -- vdop: Vertical [dilution of precision](https://en.wikipedia.org/wiki/Dilution_of_precision_(GPS)) -- hdop: Horizontal [dilution of precision](https://en.wikipedia.org/wiki/Dilution_of_precision_(GPS)) -- valid: Indicates if the checksum is okay - -GLL - Geographic Position - Latitude/Longitude ---- -The parsed object will have the following attributes: - -- type: "GLL" -- lat: The latitude -- lon: The longitude -- status: Status active or void -- time: The time given as a JavaScript Date object -- valid: Indicates if the checksum is okay - -GSV - List of Satellites in view ---- -GSV messages are paginated. `msgNumber` indicates the current page and `msgsTotal` is the total number of pages. - -The parsed object will have the following attributes: - -- type: "GSV" -- msgNumber: Current page -- msgsTotal: Number of pages -- satellites: Array of satellite objects with the following attributes: - - prn: Satellite PRN number - - elevation: Elevation in degrees - - azimuth: Azimuth in degrees - - snr: Signal to Noise Ratio (higher is better) -- valid: Indicates if the checksum is okay - - -VTG - vector track and speed over ground ---- - -The parsed object will have the following attributes: - -- type: "VTG" -- track: Track in degrees -- speed: Speed over ground in km/h -- faa: The FAA mode, introduced with NMEA 2.3 -- valid: Indicates if the checksum is okay - -ZDA - UTC day, month, and year, and local time zone offset ---- - -The parsed object will have the following attributes: - -- type: "ZDA" -- time: The time given as a JavaScript Date object - -HDT - Heading ---- - -The parsed object will have the following attributes: - -- type: "HDT" -- heading: Heading in degrees -- trueNorth: Indicates heading relative to True North -- valid: Indicates if the checksum is okay - -GST - Position error statistics ---- - -The parsed object will have the following attributes: - -- type: "GST" -- time: The time given as a JavaScript Date object -- rms: RMS value of the pseudorange residuals; includes carrier phase residuals during periods of RTK (float) and RTK (fixed) -- ellipseMajor: Error ellipse semi-major axis 1 sigma error, in meters -- ellipseMinor: Error ellipse semi-minor axis 1 sigma error, in meters -- ellipseOrientation: Error ellipse orientation, degrees from true north -- latitudeError: Latitude 1 sigma error, in meters -- longitudeError: Longitude 1 sigma error, in meters -- heightError: Height 1 sigma error, in meters -- valid: Indicates if the checksum is okay - -## GPS State - -If the streaming API is not needed, but a solid state of the system, the `gps.state` object can be used. It has the following properties: - -- time: Current time -- lat: Latitude -- lon: Longitude -- alt: Altitude -- satsActive: Array of active satellites -- speed: Speed over ground in km/h -- track: Track in degrees -- satsVisible: Array of all visible satellites - -Adding new protocols is a matter of minutes. If you need a protocol which isn't implemented, I'm happy to see a pull request or a new ticket. - - -## Troubleshooting - -If you don't get valid position information after turning on the receiver, chances are high you simply have to wait as it takes some [time to first fix](https://en.wikipedia.org/wiki/Time_to_first_fix). - -## Functions - - -GPS.js comes with a few static functions, which helps working with geo-coordinates. - -GPS.Parse(line) ---- -Parses a single line and returns the resulting object, in case the callback system isn't needed/wanted - -GPS.Distance(latFrom, lonFrom, latTo, lonTo) ---- -Calculates the distance between two geo-coordinates using Haversine formula - -GPS.TotalDistance(points) ---- -Calculates the length of a traveled route, given as an array of {lat: x, lon: y} point objects - -GPS.Heading(latFrom, lonFrom, latTo, lonTo) ---- -Calculates the angle from one coordinate to another. Heading is represented as windrose coordinates (N=0, E=90, S=189, W=270). The result can be used as the argument of [angles](https://github.com/rawify/Angles.js) `compass()` method: - -```javascript -const angles = require('angles'); -console.log(angles.compass(GPS.Heading(50, 10, 51, 9))); // will return x ∈ { N, S, E, W, NE, ... } -``` - - -## Using GPS.js with the browser - -The use cases should be rare to parse NMEA directly inside the browser, but it works too. - -```html - - -``` - -## Building the library - -After cloning the Git repository run: - -``` -npm install -npm run build -``` - -## Run a test - -Testing the source against the shipped test suite is as easy as - -``` -npm run test + + +[](https://npmjs.org/package/gps "View this project on npm") +[](http://opensource.org/licenses/MIT) + +GPS.js is an extensible parser for [NMEA](http://www.gpsinformation.org/dale/nmea.htm) sentences, given by any common GPS receiver. The output is tried to be as high-level as possible to make it more useful than simply splitting the information. The aim is, that you don't have to understand NMEA, just plug in your receiver and you're ready to go. + + +## Usage + + +The interface of GPS.js is as simple as the following few lines. You need to add an event-listener for the completion of the task and invoke the update method with a sentence you want to process. There are much more examples in the examples folder. + +```javascript +const gps = new GPS; + +// Add an event listener on all protocols +gps.on('data', parsed => { + console.log(parsed); +}); + +// Call the update routine directly with a NMEA sentence, which would +// come from the serial port or stream-reader normally +gps.update("$GPGGA,224900.000,4832.3762,N,00903.5393,E,1,04,7.8,498.6,M,48.0,M,,0000*5E"); +``` + +It's also possible to add event-listeners only on one of the following protocols, by stating `gps.on('GGA', ...)` for example. + +## State + + +The real advantage over other NMEA implementations is, that the GPS information is interpreted and normalized. The most high-level API is the state object, which changes with every new event. You can use this information with: + +```javascript +gps.on('data', () => { + console.log(gps.state); +}); +``` + +## Installation + +You can install `GPS.js` via npm: + +```bash +npm install gps +``` + +Or with yarn: + +```bash +yarn add gps +``` + +Alternatively, download or clone the repository: + +```bash +git clone https://github.com/rawify/GPS.js +``` + +## Usage + +Include the `gps.min.js` file in your project: + +```html + +``` + +Or in a Node.js project: + +```javascript +const GPS = require('gps'); +``` + +or + +```javascript +import GPS from 'gps'; +``` + +## Find the serial device + + +On Linux serial devices typically have names like `/dev/ttyS1`, on OSX `/dev/tty.usbmodem1411` after installing a USB to serial driver and on Windows, you're probably fine by using the highest COM device you can find in the device manager. Please note that if you have multiple USB ports on your computer and use them randomly, you have to lookup the path/device again. + +Register device on a BeagleBone +--- + +If you find yourself on a BeagleBone, the serial device must be registered manually. Luckily, this can be done within node quite easily using [octalbonescript](https://www.npmjs.com/package/octalbonescript): + +```javascript +const obs = require('octalbonescript'); +obs.serial.enable('/dev/ttyS1', () => { + console.log('serial device activated'); +}); +``` + +## Examples + + +GPS.js comes with some examples, like drawing the current latitude and longitude to Google Maps, displaying a persistent state and displaying the parsed raw data. In some cases you have to adjust the serial path to your own GPS receiver to make it work. + +Simple serial example +--- + +```javascript +const SerialPort = require('serialport'); +const GPS = require('gps'); + +const port = new SerialPort('/dev/tty.usbmodem11401', { // change path + baudRate: 9600, + parser: new SerialPort.parsers.Readline({ + delimiter: '\r\n' + }) +}); + +const gps = new GPS; + +gps.on('data', data => { + console.log(data, gps.state); +}) + +port.on('data', data => { + gps.updatePartial(data); +}) +``` + +Dashboard +--- +Go into the folder `examples/dashboard` and start the server with + +``` +node server +``` + +After that you can open the browser and go to http://localhost:3000. The result should look like the following, which in principle is just a visualization of the state object `gps.state` + + + +Google Maps +--- +Go into the folder `examples/maps` and start the server with + +``` +node server +``` + +After that you can open the browser and go to http://localhost:3000 The result should look like + + + +Confluence +--- +[Confluence](http://www.confluence.org/) is a project, which tries to travel to and document all integer GPS coordinates. GPS.js can assist on that goal. Go into the examples folder and run: + +``` +node confluence +``` + +You should see something like the following, updating as you move around + +``` +You are at (48.53, 9.05951), +The closest confluence point (49, 9) is in 51.36 km. +You have to go 355.2° N +``` + +Set Time +--- +On systems without a RTC - like Raspberry PI - you need to update the time yourself at runtime. If the device has an internet connection, it's quite easy to use an NTP server. An alternative for disconnected projects with access to a GPS receiver can be the high-precision time signal, sent by satellites. Go to the examples folder and run the following to update the time: + +``` +node set-date +``` + +## Available Methods + + +update(line) +--- +The update method is the most important function, it parses a NMEA sentence and forces the callbacks to trigger + +updatePartial(chunk) +--- +Will call `update()` when a full NMEA sentence has been arrived + +on(event, callback) +--- +Adds an event listener for a protocol to occur (see implemented protocols, simply use the name - upper case) or for all sentences with `data`. Because GPS.js should be more general, it doesn't inherit `EventEmitter`, but simply invokes the callback. + +off(event) +--- +Removes an event listener + +## Implemented Protocols + + +GGA - Fix information +--- +Gets the data, you're most probably looking for: *latitude and longitude* + +The parsed object will have the following attributes: + +- type: "GGA" +- time: The time given as a JavaScript Date object +- lat: The latitude +- lon: The longitude +- alt: The altitude +- quality: Fix quality (either invalid, fix or diff) +- satellites: Number of satellites being tracked +- hdop: Horizontal [dilution of precision](https://en.wikipedia.org/wiki/Dilution_of_precision_(GPS)) +- geoidal: Height of geoid in meters (mean sea level) +- age: time in seconds since last DGPS update +- stationID: DGPS station ID number +- valid: Indicates if the checksum is okay + +RMC - NMEAs own version of essential GPS data +--- +Similar to GGA but gives also delivers the velocity + +The parsed object will have the following attributes: + +- type: "RMC" +- time: The time given as a JavaScript Date object +- status: Status active or void +- lat: The latitude +- lon: The longitude +- speed: Speed over the ground in km/h +- track: Track angle in degrees +- variation: Magnetic Variation +- faa: The FAA mode, introduced with NMEA 2.3 +- valid: Indicates if the checksum is okay + + +GSA - Active satellites +--- +The parsed object will have the following attributes: + +- type: "GSA" +- mode: Auto selection of 2D or 3D fix (either auto or manual) +- fix: The selected fix mode (either 2D or 3D) +- satellites: Array of satellite IDs +- pdop: Position [dilution of precision](https://en.wikipedia.org/wiki/Dilution_of_precision_(GPS)) +- vdop: Vertical [dilution of precision](https://en.wikipedia.org/wiki/Dilution_of_precision_(GPS)) +- hdop: Horizontal [dilution of precision](https://en.wikipedia.org/wiki/Dilution_of_precision_(GPS)) +- valid: Indicates if the checksum is okay + +GLL - Geographic Position - Latitude/Longitude +--- +The parsed object will have the following attributes: + +- type: "GLL" +- lat: The latitude +- lon: The longitude +- status: Status active or void +- time: The time given as a JavaScript Date object +- valid: Indicates if the checksum is okay + +GSV - List of Satellites in view +--- +GSV messages are paginated. `msgNumber` indicates the current page and `msgsTotal` is the total number of pages. + +The parsed object will have the following attributes: + +- type: "GSV" +- msgNumber: Current page +- msgsTotal: Number of pages +- satellites: Array of satellite objects with the following attributes: + - prn: Satellite PRN number + - elevation: Elevation in degrees + - azimuth: Azimuth in degrees + - snr: Signal to Noise Ratio (higher is better) +- valid: Indicates if the checksum is okay + + +VTG - vector track and speed over ground +--- + +The parsed object will have the following attributes: + +- type: "VTG" +- track: Track in degrees +- speed: Speed over ground in km/h +- faa: The FAA mode, introduced with NMEA 2.3 +- valid: Indicates if the checksum is okay + +ZDA - UTC day, month, and year, and local time zone offset +--- + +The parsed object will have the following attributes: + +- type: "ZDA" +- time: The time given as a JavaScript Date object + +HDT - Heading +--- + +The parsed object will have the following attributes: + +- type: "HDT" +- heading: Heading in degrees +- trueNorth: Indicates heading relative to True North +- valid: Indicates if the checksum is okay + +GST - Position error statistics +--- + +The parsed object will have the following attributes: + +- type: "GST" +- time: The time given as a JavaScript Date object +- rms: RMS value of the pseudorange residuals; includes carrier phase residuals during periods of RTK (float) and RTK (fixed) +- ellipseMajor: Error ellipse semi-major axis 1 sigma error, in meters +- ellipseMinor: Error ellipse semi-minor axis 1 sigma error, in meters +- ellipseOrientation: Error ellipse orientation, degrees from true north +- latitudeError: Latitude 1 sigma error, in meters +- longitudeError: Longitude 1 sigma error, in meters +- heightError: Height 1 sigma error, in meters +- valid: Indicates if the checksum is okay + +## GPS State + +If the streaming API is not needed, but a solid state of the system, the `gps.state` object can be used. It has the following properties: + +- time: Current time +- lat: Latitude +- lon: Longitude +- alt: Altitude +- satsActive: Array of active satellites +- speed: Speed over ground in km/h +- track: Track in degrees +- satsVisible: Array of all visible satellites + +Adding new protocols is a matter of minutes. If you need a protocol which isn't implemented, I'm happy to see a pull request or a new ticket. + + +## Troubleshooting + +If you don't get valid position information after turning on the receiver, chances are high you simply have to wait as it takes some [time to first fix](https://en.wikipedia.org/wiki/Time_to_first_fix). + +## Functions + + +GPS.js comes with a few static functions, which helps working with geo-coordinates. + +GPS.Parse(line) +--- +Parses a single line and returns the resulting object, in case the callback system isn't needed/wanted + +GPS.Distance(latFrom, lonFrom, latTo, lonTo) +--- +Calculates the distance between two geo-coordinates using Haversine formula + +GPS.TotalDistance(points) +--- +Calculates the length of a traveled route, given as an array of {lat: x, lon: y} point objects + +GPS.Heading(latFrom, lonFrom, latTo, lonTo) +--- +Calculates the angle from one coordinate to another. Heading is represented as windrose coordinates (N=0, E=90, S=189, W=270). The result can be used as the argument of [angles](https://github.com/rawify/Angles.js) `compass()` method: + +```javascript +const angles = require('angles'); +console.log(angles.compass(GPS.Heading(50, 10, 51, 9))); // will return x ∈ { N, S, E, W, NE, ... } +``` + + +## Using GPS.js with the browser + +The use cases should be rare to parse NMEA directly inside the browser, but it works too. + +```html + + +``` + +## Building the library + +After cloning the Git repository run: + +``` +npm install +npm run build +``` + +## Run a test + +Testing the source against the shipped test suite is as easy as + +``` +npm run test ``` ## Copyright and Licensing diff --git a/dist/gps.js b/dist/gps.js index d82ff69..44b8264 100644 --- a/dist/gps.js +++ b/dist/gps.js @@ -1,1027 +1,1038 @@ 'use strict'; -const D2R = Math.PI / 180; - -function parseTime(time, date = null) { - // Accepts hhmmss(.sss)? and optional ddmmyy or ddmmyyyy (ZDA/GPRMC variants). - if (!time) return null; - - const ret = new Date(); - - if (date) { - const year = date.slice(4); - const month = date.slice(2, 4) - 1; - const day = date.slice(0, 2); - - if (year.length === 4) { - ret.setUTCFullYear(+year, +month, +day); - } else { - // If we need to parse older GPRMC data, we should hack something like - // year < 73 ? 2000+year : 1900+year - // Since GPS appeared in 1973 - ret.setUTCFullYear(Number('20' + year), +month, +day); - } - } - - ret.setUTCHours(+time.slice(0, 2)); - ret.setUTCMinutes(+time.slice(2, 4)); - ret.setUTCSeconds(+time.slice(4, 6)); - - // Milliseconds: allow no decimals, .ss, .sss, .ssss... and normalize to ms - const dot = time.indexOf('.'); - let ms = 0; - if (dot !== -1 && dot + 1 < time.length) { - const frac = time.slice(dot + 1); - // Take up to 3 digits; if fewer, scale; if more, truncate - if (frac.length >= 3) { - ms = +frac.slice(0, 3); - } else if (frac.length === 2) { - ms = +frac * 10; // .xx => xx0 ms - } else if (frac.length === 1) { - ms = +frac * 100; // .x => x00 ms - } - } - ret.setUTCMilliseconds(ms); - return ret; -} - -function parseCoord(coord, dir) { - // NMEA lat: DDMM.mmmm; lon: DDDMM.mmmm; dir in {N,S,E,W} - // Latitude can go from 0 to 90; longitude can go from -180 to 180. - if (coord === '') return null; - const sgn = (dir === 'S' || dir === 'W') ? -1 : 1; - const n = (dir === 'N' || dir === 'S') ? 2 : 3; - return sgn * (parseFloat(coord.slice(0, n)) + parseFloat(coord.slice(n)) / 60); -} - -function parseNumber(num) { - return num === '' ? null : parseFloat(num); -} - -function parseKnots(knots) { - return knots === '' ? null : parseFloat(knots) * 1.852; // km/h -} - -function parseSystemId(systemId) { - switch (systemId) { - case 0: return 'QZSS'; - case 1: return 'GPS'; - case 2: return 'GLONASS'; - case 3: return 'Galileo'; - case 4: return 'BeiDou'; - default: return 'unknown'; - } -} - -function parseSystem(str) { - const satellite = str.slice(1, 3); - switch (satellite) { - case 'GP': return 'GPS'; - case 'GQ': return 'QZSS'; - case 'GL': return 'GLONASS'; - case 'GA': return 'Galileo'; - case 'GB': return 'BeiDou'; - default: return satellite; - } -} - -function parseGSAMode(mode) { - switch (mode) { - case 'M': return 'manual'; - case 'A': return 'automatic'; - case '': return null; - } - throw new Error('INVALID GSA MODE: ' + mode); -} - -function parseGGAFix(fix) { - if (fix === '') return null; - switch (parseInt(fix, 10)) { - case 0: return null; - case 1: return 'fix'; // valid SPS fix - case 2: return 'dgps-fix'; // valid DGPS fix - case 3: return 'pps-fix'; // valid PPS fix - case 4: return 'rtk'; // RTK fixed - case 5: return 'rtk-float'; // RTK float - case 6: return 'estimated'; // dead reckoning - case 7: return 'manual'; - case 8: return 'simulated'; - } - throw new Error('INVALID GGA FIX: ' + fix); -} - -function parseGSAFix(fix) { - if (fix === '') return null; - switch (parseInt(fix, 10)) { - case 1: return null; - case 2: return '2D'; - case 3: return '3D'; - } - throw new Error('INVALID GSA FIX: ' + fix); -} - -function parseRMC_GLLStatus(status) { - switch (status) { - case '': return null; - case 'A': return 'active'; - case 'V': return 'void'; - } - throw new Error('INVALID RMC/GLL STATUS: ' + status); -} - -function parseFAA(faa) { - // Only A and D will correspond to an Active and reliable sentence - switch (faa) { - case '': return null; - case 'A': return 'autonomous'; - case 'D': return 'differential'; - case 'E': return 'estimated'; // dead reckoning - case 'M': return 'manual input'; - case 'S': return 'simulated'; - case 'N': return 'not valid'; - case 'P': return 'precise'; - case 'R': return 'rtk'; - case 'F': return 'rtk-float'; - } - throw new Error('INVALID FAA MODE: ' + faa); -} - -function parseRMCVariation(vari, dir) { - if (vari === '' || dir === '') return null; - return parseFloat(vari) * (dir === 'W' ? -1 : 1); -} - -function parseDist(num, unit) { - if (unit === 'M' || unit === '') return parseNumber(num); - throw new Error('Unknown unit: ' + unit); -} - -/** - * Decode TXT caret-escapes and reject invalid chars. - * Spec: NMEA0183-2 §5.1.3 (escapes) and §6.1 Table 1 (invalid chars) - * - * @param {string} str - * @returns {string} - */ -function escapeString(str) { - if (str == null) return ''; - - // invalid characters per spec (excluding '^' which introduces escapes) - var invalid = ["\r", "\n", "$", "*", ",", "!", "\\", "~", "\u007F" /* DEL */]; - for (var i = 0; i < invalid.length; i++) { - if (str.indexOf(invalid[i]) !== -1) { - throw new Error("Message may not contain invalid character '" + invalid[i] + "'"); - } - } - - // caret escapes: ^HH (hex byte) or ^^ (literal caret) - var out = ''; - for (var j = 0; j < str.length; j++) { - var ch = str.charCodeAt(j); - if (ch !== 94 /* '^' */) { out += str[j]; continue; } - var n1 = str[j + 1], n2 = str[j + 2]; - if (n1 === '^') { out += '^'; j += 1; continue; } - if (n1 && n2 && - ((n1 >= '0' && n1 <= '9') || (n1 >= 'A' && n1 <= 'F') || (n1 >= 'a' && n1 <= 'f')) && - ((n2 >= '0' && n2 <= '9') || (n2 >= 'A' && n2 <= 'F') || (n2 >= 'a' && n2 <= 'f'))) { - out += String.fromCharCode(parseInt(n1 + n2, 16)); - j += 2; - } else { - // unknown escape → keep caret literally - out += '^'; - } - } - return out; -} - -/** - * - * @constructor - */ -function GPS() { - if (!(this instanceof GPS)) return new GPS(); - - // Public fields - this['events'] = Object.create(null); - this['state'] = { 'errors': 0, 'processed': 0, 'txtBuffer': {} }; - - // Internal, per-instance collectors (avoid cross-stream state bleed) - this['_collectSats'] = Object.create(null); - this['_collectActiveSats'] = Object.create(null); - this['_lastSeenSat'] = Object.create(null); - - // Streaming buffer - this['partial'] = ''; -} - -/* Static fields (explicit for speed and minification) */ -GPS['parsers'] = { - // Global Positioning System Fix Data - 'GGA': function (str, gga) { - if (gga.length !== 16 && gga.length !== 14) { - throw new Error('Invalid GGA length: ' + str); - } - - /* - 11 - 1 2 3 4 5 6 7 8 9 10 | 12 13 14 15 - | | | | | | | | | | | | | | | - $--GGA,hhmmss.ss,llll.ll,a,yyyyy.yy,a,x,xx,x.x,x.x,M,x.x,M,x.x,xxxx*hh - - 1) Time (UTC) - 2) Latitude - 3) N or S (North or South) - 4) Longitude - 5) E or W (East or West) - 6) GPS Quality Indicator, - 0 = Invalid, 1 = Valid SPS, 2 = Valid DGPS, 3 = Valid PPS - 7) Number of satellites in view, 00 - 12 - 8) Horizontal Dilution of precision, lower is better - 9) Antenna Altitude above/below mean-sea-level (geoid) - 10) Units of antenna altitude, meters - 11) Geoidal separation, the difference between the WGS-84 earth - ellipsoid and mean-sea-level (geoid), '-' means mean-sea-level below ellipsoid - 12) Units of geoidal separation, meters - 13) Age of differential GPS data, time in seconds since last SC104 - type 1 or 9 update, null field when DGPS is not used - 14) Differential reference station ID, 0000-1023 - 15) Checksum - */ - - return { - 'time': parseTime(gga[1]), - 'lat': parseCoord(gga[2], gga[3]), - 'lon': parseCoord(gga[4], gga[5]), - 'alt': parseDist(gga[9], gga[10]), - 'quality': parseGGAFix(gga[6]), - 'satellites': parseNumber(gga[7]), - 'hdop': parseNumber(gga[8]), // dilution - 'geoidal': parseDist(gga[11], gga[12]), // above geoid - 'age': gga[13] === undefined ? null : parseNumber(gga[13]), // DGPS age - 'stationID': gga[14] === undefined ? null : parseNumber(gga[14]) // DGPS ref - }; - }, - - // GPS DOP and active satellites - 'GSA': function (str, gsa) { - - if (gsa.length !== 19 && gsa.length !== 20) { - throw new Error('Invalid GSA length: ' + str); - } - - /* - eg1. $GPGSA,A,3,,,,,,16,18,,22,24,,,3.6,2.1,2.2*3C - eg2. $GPGSA,A,3,19,28,14,18,27,22,31,39,,,,,1.7,1.0,1.3*35 - - - 1 = Mode: - M=Manual, forced to operate in 2D or 3D - A=Automatic, 3D/2D - 2 = Mode: - 1=Fix not available - 2=2D - 3=3D - 3-14 = PRNs of Satellite Vehicles (SVs) used in position fix (null for unused fields) - 15 = PDOP - 16 = HDOP - 17 = VDOP - (18) = systemID NMEA 4.10 - 18 = Checksum - */ - - const sats = []; - for (let i = 3; i < 15; i++) { - if (gsa[i] !== '') sats.push(parseInt(gsa[i], 10)); - } - const sid = gsa.length > 19 ? parseNumber(gsa[18]) : null; - return { - 'mode': parseGSAMode(gsa[1]), - 'fix': parseGSAFix(gsa[2]), - 'satellites': sats, - 'pdop': parseNumber(gsa[15]), - 'hdop': parseNumber(gsa[16]), - 'vdop': parseNumber(gsa[17]), - 'systemId': sid, - 'system': sid !== null ? parseSystemId(sid) : 'unknown' - }; - }, - - // Recommended Minimum data for GPS - 'RMC': function (str, rmc) { - if (rmc.length !== 13 && rmc.length !== 14 && rmc.length !== 15) { - throw new Error('Invalid RMC length: ' + str); - } - - /* - $GPRMC,hhmmss.ss,A,llll.ll,a,yyyyy.yy,a,x.x,x.x,ddmmyy,x.x,a*hh - - RMC = Recommended Minimum Specific GPS/TRANSIT Data - 1 = UTC of position fix - 2 = Data status (A-ok, V-invalid) - 3 = Latitude of fix - 4 = N or S - 5 = Longitude of fix - 6 = E or W - 7 = Speed over ground in knots - 8 = Track made good in degrees True - 9 = UT date - 10 = Magnetic variation degrees (Easterly var. subtracts from true course) - 11 = E or W - (12) = NMEA 2.3 introduced FAA mode indicator (A=Autonomous, D=Differential, E=Estimated, N=Data not valid) - (13) = NMEA 4.10 introduced nav status - 12 = Checksum - */ - - return { - 'time': parseTime(rmc[1], rmc[9]), - 'status': parseRMC_GLLStatus(rmc[2]), - 'lat': parseCoord(rmc[3], rmc[4]), - 'lon': parseCoord(rmc[5], rmc[6]), - 'speed': parseKnots(rmc[7]), - 'track': parseNumber(rmc[8]), // heading (true) - 'variation': parseRMCVariation(rmc[10], rmc[11]), - 'faa': rmc.length > 13 ? parseFAA(rmc[12]) : null, - 'navStatus': rmc.length > 14 ? rmc[13] : null - }; - }, - - // Track info - 'VTG': function (str, vtg) { - if (vtg.length !== 10 && vtg.length !== 11) { - throw new Error('Invalid VTG length: ' + str); - } - - /* - ------------------------------------------------------------------------------ - 1 2 3 4 5 6 7 8 9 10 - | | | | | | | | | | - $--VTG,x.x,T,x.x,M,x.x,N,x.x,K,m,*hh - ------------------------------------------------------------------------------ - - 1 = Track made good (degrees true) - 2 = Fixed text 'T' indicates that track made good is relative to true north - 3 = optional: Track made good (degrees magnetic) - 4 = optional: M: track made good is relative to magnetic north - 5 = Speed over ground in knots - 6 = Fixed text 'N' indicates that speed over ground in in knots - 7 = Speed over ground in kilometers/hour - 8 = Fixed text 'K' indicates that speed over ground is in kilometers/hour - (9) = FAA mode indicator (NMEA 2.3 and later) - 9/10 = Checksum - */ - - // Empty / all-null VTG (some receivers output this) - if (vtg[2] === '' && vtg[8] === '' && vtg[6] === '') { - return { - 'track': null, - 'trackMagnetic': null, - 'speed': null, - 'faa': null - }; - } - - if (vtg[2] !== 'T') { - throw new Error('Invalid VTG track mode: ' + str); - } - if (vtg[8] !== 'K' || vtg[6] !== 'N') { - throw new Error('Invalid VTG speed tag: ' + str); - } - - return { - 'track': parseNumber(vtg[1]), // true heading - 'trackMagnetic': vtg[3] === '' ? null : parseNumber(vtg[3]), // magnetic - 'speed': parseKnots(vtg[5]), - 'faa': vtg.length === 11 ? parseFAA(vtg[9]) : null - }; - }, - - // Satellites in view - 'GSV': function (str, gsv) { - // NMEA allows variable chunks of 4 fields per satellite + header/footer. - // Keep legacy guard but allow most common valid shapes. - if (gsv.length % 4 === 0) { - // = 1 -> normal package - // = 2 -> NMEA 4.10 extension - // = 3 -> BeiDou extension? - throw new Error('Invalid GSV length: ' + str); - } - - /* - $GPGSV,1,1,13,02,02,213,,03,-3,000,,11,00,121,,14,13,172,05*67 - - 1 = Total number of messages of this type in this cycle - 2 = Message number - 3 = Total number of SVs in view - repeat [ - 4 = SV PRN number - 5 = Elevation in degrees, 90 maximum - 6 = Azimuth, degrees from true north, 000 to 359 - 7 = SNR (signal to noise ratio), 00-99 dB (null when not tracking, higher is better) - ] - N+1 = signalID NMEA 4.10 - N+2 = Checksum - */ - - const sats = []; - const satellite = str.slice(1, 3); - // fields: [totMsgs, msgNum, satsInView, (prn,elev,az,snr)*, (signalId)?, checksum] - for (let i = 4; i < gsv.length - 3; i += 4) { - const prn = parseNumber(gsv[i]); - const snr = parseNumber(gsv[i + 3]); - /* - Plot satellites in Radar chart with north on top - by linear map elevation from 0° to 90° into r to 0 - - centerX + cos(azimuth - 90) * (1 - elevation / 90) * radius - centerY + sin(azimuth - 90) * (1 - elevation / 90) * radius - */ - sats.push({ - 'prn': prn, - 'elevation': parseNumber(gsv[i + 1]), - 'azimuth': parseNumber(gsv[i + 2]), - 'snr': snr, - 'status': prn !== null ? (snr !== null ? 'tracking' : 'in view') : null, - 'system': parseSystem(str), - 'key': satellite + prn - }); - } - - return { - 'msgNumber': parseNumber(gsv[2]), - 'msgsTotal': parseNumber(gsv[1]), - 'satsInView': parseNumber(gsv[3]), - 'satellites': sats, - 'signalId': gsv.length % 4 === 2 ? parseNumber(gsv[gsv.length - 2]) : null, // NMEA 4.10 - 'system': parseSystem(str) - }; - }, - - // Geographic Position - Latitude/Longitude - 'GLL': function (str, gll) { - if (gll.length !== 9 && gll.length !== 8) { - throw new Error('Invalid GLL length: ' + str); - } - - /* - ------------------------------------------------------------------------------ - 1 2 3 4 5 6 7 8 - | | | | | | | | - $--GLL,llll.ll,a,yyyyy.yy,a,hhmmss.ss,a,m,*hh - ------------------------------------------------------------------------------ - - 1. Latitude - 2. N or S (North or South) - 3. Longitude - 4. E or W (East or West) - 5. Universal Time Coordinated (UTC) - 6. Status A - Data Valid, V - Data Invalid - 7. FAA mode indicator (NMEA 2.3 and later) - 8. Checksum - */ - - return { - 'time': parseTime(gll[5]), - 'status': parseRMC_GLLStatus(gll[6]), - 'lat': parseCoord(gll[1], gll[2]), - 'lon': parseCoord(gll[3], gll[4]), - 'faa': gll.length === 9 ? parseFAA(gll[7]) : null - }; - }, - - // UTC Date / Time and Local Time Zone Offset - 'ZDA': function (str, zda) { - - /* - 1 = hhmmss.ss = UTC - 2 = xx = Day, 01 to 31 - 3 = xx = Month, 01 to 12 - 4 = xxxx = Year - 5 = xx = Local zone description, 00 to +/- 13 hours - 6 = xx = Local zone minutes description (same sign as hours) - */ - - // (No strict length guard; some receivers omit trailing fields) - return { - 'time': parseTime(zda[1], zda[2] + zda[3] + zda[4]), - // 'delta': can be derived by consumer: (Date.now() - time)/1000 - 'offsetMin': (zda[5] === '' || zda[6] === '') ? null - : (parseInt(zda[5], 10) * 60 + parseInt(zda[6], 10)) - }; - }, - - 'GST': function (str, gst) { - if (gst.length !== 10) { - throw new Error('Invalid GST length: ' + str); - } - - /* - 1 = Time (UTC) - 2 = RMS value of the pseudorange residuals; includes carrier phase residuals during periods of RTK (float) and RTK (fixed) processing - 3 = Error ellipse semi-major axis 1 sigma error, in meters - 4 = Error ellipse semi-minor axis 1 sigma error, in meters - 5 = Error ellipse orientation, degrees from true north - 6 = Latitude 1 sigma error, in meters - 7 = Longitude 1 sigma error, in meters - 8 = Height 1 sigma error, in meters - 9 = Checksum - */ - - return { - 'time': parseTime(gst[1]), - 'rms': parseNumber(gst[2]), - 'ellipseMajor': parseNumber(gst[3]), - 'ellipseMinor': parseNumber(gst[4]), - 'ellipseOrientation': parseNumber(gst[5]), - 'latitudeError': parseNumber(gst[6]), - 'longitudeError': parseNumber(gst[7]), - 'heightError': parseNumber(gst[8]) - }; - }, - - // Heading relative to True North - 'HDT': function (str, hdt) { - if (hdt.length !== 4) { - throw new Error('Invalid HDT length: ' + str); - } - - /* - ------------------------------------------------------------------------------ - 1 2 3 - | | | - $--HDT,hhh.hhh,T*XX - ------------------------------------------------------------------------------ - - 1. Heading in degrees - 2. T: indicates heading relative to True North - 3. Checksum - */ - - return { - 'heading': parseFloat(hdt[1]), - 'trueNorth': hdt[2] === 'T' - }; - }, - - 'GRS': function (str, grs) { - if (grs.length !== 18) { - throw new Error('Invalid GRS length: ' + str); - } - const res = []; - for (let i = 3; i <= 14; i++) { - const tmp = parseNumber(grs[i]); - if (tmp !== null) res.push(tmp); - } - return { - 'time': parseTime(grs[1]), - 'mode': parseNumber(grs[2]), - 'res': res - }; - }, - - 'GBS': function (str, gbs) { - if (gbs.length !== 10 && gbs.length !== 12) { - throw new Error('Invalid GBS length: ' + str); - } - - /* - 0 1 2 3 4 5 6 7 8 - | | | | | | | | | - $--GBS,hhmmss.ss,x.x,x.x,x.x,x.x,x.x,x.x,x.x*hh - - 1. UTC time of the GGA or GNS fix associated with this sentence - 2. Expected error in latitude (meters) - 3. Expected error in longitude (meters) - 4. Expected error in altitude (meters) - 5. PRN (id) of most likely failed satellite - 6. Probability of missed detection for most likely failed satellite - 7. Estimate of bias in meters on most likely failed satellite - 8. Standard deviation of bias estimate - -- - 9. systemID (NMEA 4.10) - 10. signalID (NMEA 4.10) - */ - - return { - 'time': parseTime(gbs[1]), - 'errLat': parseNumber(gbs[2]), - 'errLon': parseNumber(gbs[3]), - 'errAlt': parseNumber(gbs[4]), - 'failedSat': parseNumber(gbs[5]), - 'probFailedSat': parseNumber(gbs[6]), - 'biasFailedSat': parseNumber(gbs[7]), - 'stdFailedSat': parseNumber(gbs[8]), - 'systemId': gbs.length === 12 ? parseNumber(gbs[9]) : null, - 'signalId': gbs.length === 12 ? parseNumber(gbs[10]) : null - }; - }, - - 'GNS': function (str, gns) { - if (gns.length !== 14 && gns.length !== 15) { - throw new Error('Invalid GNS length: ' + str); - } - return { - 'time': parseTime(gns[1]), - 'lat': parseCoord(gns[2], gns[3]), - 'lon': parseCoord(gns[4], gns[5]), - 'mode': gns[6], - 'satsUsed': parseNumber(gns[7]), - 'hdop': parseNumber(gns[8]), - 'alt': parseNumber(gns[9]), - 'sep': parseNumber(gns[10]), - 'diffAge': parseNumber(gns[11]), - 'diffStation': parseNumber(gns[12]), - 'navStatus': gns.length === 15 ? gns[13] : null // NMEA 4.10 - }; - }, - - // Text Transmission (TXT) - // NMEA0183-2 §6.3 ($--TXT,xx,xx,xx,c...c*hh) - 'TXT': function (str, txt) { - - // After talker removal, txt expected: ['TXT', total, index, id, payload, checksum] - if (txt.length !== 6) { - throw new Error('Invalid TXT length: ' + str); - } - - var total = parseInt(txt[1], 10); - var index = parseInt(txt[2], 10); - var textId = parseInt(txt[3], 10); - var rawPart = txt[4] || ''; - - if (!(total >= 1 && total <= 99)) throw new Error('Invalid TXT total: ' + txt[1]); - if (!(index >= 1 && index <= total)) throw new Error('Invalid TXT index: ' + txt[2]); - if (!(textId >= 0 && textId <= 99)) throw new Error('Invalid TXT id: ' + txt[3]); - if (rawPart.length > 61) throw new Error('Invalid TXT message length: ' + rawPart.length); - - var part = escapeString(rawPart); - if (part === '') throw new Error('Invalid empty TXT message'); - - // For single-part messages, we can return a completed object right away. - // Multi-part completion is handled in instance _assembleTXT (see below). - return { - // assembly fields: - 'total': total, - 'index': index, - 'id': textId, - 'part': part, // decoded segment - 'message': (total === 1) ? part : null, - 'completed': (total === 1), - 'rawMessages': (total === 1) ? [part] : [], - 'system': parseSystem(str) // e.g. 'GPS', 'GLONASS', ... - }; - } -}; - -/* Static parse + geodesy helpers */ - -GPS['Parse'] = function (line) { - if (typeof line !== 'string' || line.length < 6) return false; - if (line.charCodeAt(0) !== 36 /* '$' */) return false; - - const star = line.indexOf('*', 1); - if (star === -1 || star + 2 >= line.length) return false; - - const nmea = []; - const firstComma = line.indexOf(',', 1); - if (firstComma === -1 || firstComma > star) return false; - - nmea.push('$' + line.slice(1, firstComma)); - - // checksum over everything between '$' and '*' - let checksum = 0; - for (let i = 1; i < star; i++) checksum ^= line.charCodeAt(i); - - // split fields after the first comma - let fieldStart = firstComma + 1; - for (let i = fieldStart; i < star; i++) { - if (line.charCodeAt(i) === 44 /* ',' */) { - nmea.push(line.slice(fieldStart, i)); - fieldStart = i + 1; - } - } - nmea.push(line.slice(fieldStart, star)); - - const crcStr = line.slice(star + 1).trim(); - const crc = parseInt(crcStr.slice(0, 2), 16); - if (!(crc >= 0 && crc <= 255)) return false; - - nmea[0] = nmea[0].slice(3); - const type = nmea[0]; - const mod = GPS['parsers'][type]; - if (mod === undefined) return false; - - nmea.push(crcStr.slice(0, 2)); - - const data = mod(line, nmea); - data['raw'] = line; - data['valid'] = (checksum === crc); - data['type'] = type; - - return data; -}; - -// Heading (N=0, E=90, S=180, W=270) from point 1 to point 2 -GPS['Heading'] = function (lat1, lon1, lat2, lon2) { - const dlon = (lon2 - lon1) * D2R; - lat1 *= D2R; lat2 *= D2R; - - const sdlon = Math.sin(dlon), cdlon = Math.cos(dlon); - const slat1 = Math.sin(lat1), clat1 = Math.cos(lat1); - const slat2 = Math.sin(lat2), clat2 = Math.cos(lat2); - - const y = sdlon * clat2; - const x = clat1 * slat2 - slat1 * clat2 * cdlon; - - const head = Math.atan2(y, x) * 180 / Math.PI; - return (head + 360) % 360; -}; - -GPS['Distance'] = function (lat1, lon1, lat2, lon2) { - // Haversine Formula - // R.W. Sinnott, "Virtues of the Haversine", Sky and Telescope, vol. 68, no. 2, 1984, p. 159 - - // Because Earth is no exact sphere, rounding errors may be up to 0.5%. - // var RADIUS = 6371; // Earth radius average - // var RADIUS = 6378.137; // Earth radius at equator - const RADIUS = 6372.8; // km - const hLat = (lat2 - lat1) * D2R * 0.5; - const hLon = (lon2 - lon1) * D2R * 0.5; - lat1 *= D2R; lat2 *= D2R; - - const shLat = Math.sin(hLat), shLon = Math.sin(hLon); - const clat1 = Math.cos(lat1), clat2 = Math.cos(lat2); - - const tmp = shLat * shLat + clat1 * clat2 * shLon * shLon; - //return RADIUS * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1.0 - a)); - return RADIUS * 2 * Math.asin(Math.sqrt(tmp)); -}; - -GPS['TotalDistance'] = function (path) { - - if (path.length < 2) return 0; - let len = 0; - for (let i = 0; i < path.length - 1; i++) { - const c = path[i]; - const n = path[i + 1]; - len += GPS['Distance'](c['lat'], c['lon'], n['lat'], n['lon']); - } - return len; -}; - -/* ---------- Instance methods (single prototype assignment) ---------- */ - -GPS.prototype = { - constructor: GPS, - - /* Internal: merge parsed packet into state, keep short-term sat caches fresh */ - '_updateState': function (data) { - const state = this['state']; - - // TODO: can we really use RMC time here or is it the time of fix? - if (data['type'] === 'RMC' || data['type'] === 'GGA' || data['type'] === 'GLL' || data['type'] === 'GNS') { - state['time'] = data['time']; - state['lat'] = data['lat']; - state['lon'] = data['lon']; - } - - if (data['type'] === 'HDT') { - state['heading'] = data['heading']; - state['trueNorth'] = data['trueNorth']; - } - - if (data['type'] === 'ZDA') { - state['time'] = data['time']; - } - - if (data['type'] === 'GGA') { - state['alt'] = data['alt']; - } - - if (data['type'] === 'RMC' || data['type'] === 'VTG') { - if (data['speed'] != null) state['speed'] = data['speed']; - if (data['track'] != null) state['track'] = data['track']; - } - - if (data['type'] === 'GSA') { - const systemId = data['systemId']; - if (systemId != null) this['_collectActiveSats'][systemId] = data['satellites']; - - const satsActive = []; - const collectActiveSats = this['_collectActiveSats']; - for (const s in collectActiveSats) { - if (Object.prototype.hasOwnProperty.call(collectActiveSats, s)) { - // Concatenate without allocating a new array for each system - const arr = collectActiveSats[s]; - for (let i = 0, L = arr.length; i < L; i++) satsActive.push(arr[i]); - } - } - - state['satsActive'] = satsActive; - state['fix'] = data['fix']; - state['hdop'] = data['hdop']; - state['pdop'] = data['pdop']; - state['vdop'] = data['vdop']; - } - - if (data['type'] === 'GSV') { - const now = Date.now(); - const sats = data['satellites']; - const collectSats = this['_collectSats']; - const lastSeenSat = this['_lastSeenSat']; - - for (let i = 0, L = sats.length; i < L; i++) { - const key = sats[i]['key']; - lastSeenSat[key] = now; - collectSats[key] = sats[i]; - } - - // Satellites are considered "visible" for 3 seconds after last seen - const ret = []; - for (const key in collectSats) { - if (Object.prototype.hasOwnProperty.call(collectSats, key)) { - if (now - lastSeenSat[key] < 3000) ret.push(collectSats[key]); - else { - // Optional: clean up stale entries - delete collectSats[key]; - delete lastSeenSat[key]; - } - } - } - state['satsVisible'] = ret; - } - }, - - '_assembleTXT': function (data) { - // Single-part already complete (parser set message) - if (data['total'] === 1) return data; - - const key = (data['system'] || '') + '#' + data['id']; - - let buf = this['state']['txtBuffer'][key]; - if (!buf) { - buf = this['state']['txtBuffer'][key] = { - 'total': data['total'], - 'parts': new Array(data['total']).fill(null), - 'received': 0, - 'timer': null - }; - // 10s timeout to avoid leaks - const self = this; - buf['timer'] = setTimeout(function () { - self['state']['errors']++; - delete self['state']['txtBuffer'][key]; - }, 10000); - } - - // store part (index is 1-based) - const idx = data['index'] - 1; - if (0 <= idx && idx < buf['total']) { - buf['parts'][idx] = data['part']; - buf['received']++; - } - - // check completion - if (buf['received'] === buf['total']) { - clearTimeout(buf['timer']); - delete this['state']['txtBuffer'][key]; - data['message'] = buf['parts'].join(''); - data['completed'] = true; - data['rawMessages'] = buf['parts']; - - } else { - data['message'] = null; - data['completed'] = false; - data['rawMessages'] = []; - } - return data; - }, - - /** - * Feed one full NMEA line (starting with '$', ending before CRLF). - * Emits both 'data' and '' events on success. - */ - 'update': function (line) { - const parsed = GPS['Parse'](line); - this['state']['processed']++; - - if (parsed === false) { - this['state']['errors']++; - return false; - } - - // Assemble TXT multi-part here - if (parsed['type'] === 'TXT') { - this['_assembleTXT'](parsed); - } - - this['_updateState'](parsed); - - this['emit']('data', parsed); - this['emit'](parsed['type'], parsed); - - return true; - }, - - /** - * Feed streaming data (chunks, possibly split arbitrarily). - * Accepts either "\r\n" or "\n" as line delimiters. - */ - 'updatePartial': function (chunk) { - if (chunk) this['partial'] += chunk; - - // Process all complete lines - for (; ;) { - const idxRN = this['partial'].indexOf('\r\n'); - const idxN = this['partial'].indexOf('\n'); - - let pos = -1; - if (idxRN !== -1) pos = idxRN; - else if (idxN !== -1) pos = idxN; - - if (pos === -1) break; - - const line = this['partial'].slice(0, pos); - // Advance buffer past delimiter (2 for CRLF, 1 for LF) - this['partial'] = this['partial'].slice(pos + (idxRN === pos ? 2 : 1)); - - if (line.charAt(0) !== '$') continue; - - try { - this['update'](line); - } catch (err) { - // Keep buffer (don’t drop subsequent lines), but count the error - this['state']['errors']++; - // Re-throw for caller visibility - throw err; - } - } - }, - - /** - * Subscribe to an event. Multiple listeners per event are supported. - * @param {string} ev - * @param {function()} cb - * @returns {GPS} this (chainable) - */ - 'on': function (ev, cb) { - const cur = this['events'][ev]; - if (cur === undefined) { - this['events'][ev] = [cb]; - } else if (typeof cur === 'function') { - // Backward compatibility with previous single-listener design - this['events'][ev] = [cur, cb]; - } else { - this['events'][ev].push(cb); - } - return this; - }, - - /** - * Remove listeners. If cb omitted, remove all for the event. - * @param {string} ev - * @param {function()} cb - * @returns {GPS} this - */ - 'off': function (ev, cb) { - const cur = this['events'][ev]; - if (cur === undefined) return this; - - if (!cb) { - delete this['events'][ev]; - return this; - } - - if (typeof cur === 'function') { - if (cur === cb) delete this['events'][ev]; - return this; - } - - // Array case - for (let i = cur.length - 1; i >= 0; i--) { - if (cur[i] === cb) cur.splice(i, 1); - } - if (cur.length === 0) delete this['events'][ev]; - return this; - }, - - /** - * Emit an event to all listeners. - * @param {string} ev - * @param {*} data - */ - 'emit': function (ev, data) { - const cur = this['events'][ev]; - if (cur === undefined) return; - - if (typeof cur === 'function') { - cur.call(this, data); - return; - } - // Array of listeners - for (let i = 0, L = cur.length; i < L; i++) { - cur[i].call(this, data); - } - } +const D2R = Math.PI / 180; + +function parseTime(time, date = null) { + // Accepts hhmmss(.sss)? and optional ddmmyy or ddmmyyyy (ZDA/GPRMC variants). + if (!time) return null; + + const ret = new Date(); + + if (date) { + const year = date.slice(4); + const month = date.slice(2, 4) - 1; + const day = date.slice(0, 2); + + if (year.length === 4) { + ret.setUTCFullYear(+year, +month, +day); + } else { + // If we need to parse older GPRMC data, we should hack something like + // year < 73 ? 2000+year : 1900+year + // Since GPS appeared in 1973 + ret.setUTCFullYear(Number('20' + year), +month, +day); + } + } + + ret.setUTCHours(+time.slice(0, 2)); + ret.setUTCMinutes(+time.slice(2, 4)); + ret.setUTCSeconds(+time.slice(4, 6)); + + // Milliseconds: allow no decimals, .ss, .sss, .ssss... and normalize to ms + const dot = time.indexOf('.'); + let ms = 0; + if (dot !== -1 && dot + 1 < time.length) { + const frac = time.slice(dot + 1); + // Take up to 3 digits; if fewer, scale; if more, truncate + if (frac.length >= 3) { + ms = +frac.slice(0, 3); + } else if (frac.length === 2) { + ms = +frac * 10; // .xx => xx0 ms + } else if (frac.length === 1) { + ms = +frac * 100; // .x => x00 ms + } + } + ret.setUTCMilliseconds(ms); + return ret; +} + +function parseCoord(coord, dir) { + // NMEA lat: DDMM.mmmm; lon: DDDMM.mmmm; dir in {N,S,E,W} + // Latitude can go from 0 to 90; longitude can go from -180 to 180. + if (coord === '') return null; + const sgn = (dir === 'S' || dir === 'W') ? -1 : 1; + const n = (dir === 'N' || dir === 'S') ? 2 : 3; + return sgn * (parseFloat(coord.slice(0, n)) + parseFloat(coord.slice(n)) / 60); +} + +function parseNumber(num) { + return num === '' ? null : parseFloat(num); +} + +function parseKnots(knots) { + return knots === '' ? null : parseFloat(knots) * 1.852; // km/h +} + +function parseSystemId(systemId) { + switch (systemId) { + case 0: return 'QZSS'; + case 1: return 'GPS'; + case 2: return 'GLONASS'; + case 3: return 'Galileo'; + case 4: return 'BeiDou'; + default: return 'unknown'; + } +} + +function parseSystem(str) { + const satellite = str.slice(1, 3); + switch (satellite) { + case 'GP': return 'GPS'; + case 'GQ': return 'QZSS'; + case 'GL': return 'GLONASS'; + case 'GA': return 'Galileo'; + case 'GB': return 'BeiDou'; + default: return satellite; + } +} + +function parseGSAMode(mode) { + switch (mode) { + case 'M': return 'manual'; + case 'A': return 'automatic'; + case '': return null; + } + //throw new Error('INVALID GSA MODE: ' + mode); + this.error(new Error('INVALID GSA MODE: ' + mode)) +} + +function parseGGAFix(fix) { + if (fix === '') return null; + switch (parseInt(fix, 10)) { + case 0: return null; + case 1: return 'fix'; // valid SPS fix + case 2: return 'dgps-fix'; // valid DGPS fix + case 3: return 'pps-fix'; // valid PPS fix + case 4: return 'rtk'; // RTK fixed + case 5: return 'rtk-float'; // RTK float + case 6: return 'estimated'; // dead reckoning + case 7: return 'manual'; + case 8: return 'simulated'; + } + this.error(new Error('INVALID GGA FIX: ' + fix)) +} + +function parseGSAFix(fix) { + if (fix === '') return null; + switch (parseInt(fix, 10)) { + case 1: return null; + case 2: return '2D'; + case 3: return '3D'; + } + this.error(new Error('INVALID GSA FIX: ' + fix)) +} + +function parseRMC_GLLStatus(status) { + switch (status) { + case '': return null; + case 'A': return 'active'; + case 'V': return 'void'; + } + this.error(new Error('INVALID RMC/GLL STATUS: ' + status)) +} + +function parseFAA(faa) { + // Only A and D will correspond to an Active and reliable sentence + switch (faa) { + case '': return null; + case 'A': return 'autonomous'; + case 'D': return 'differential'; + case 'E': return 'estimated'; // dead reckoning + case 'M': return 'manual input'; + case 'S': return 'simulated'; + case 'N': return 'not valid'; + case 'P': return 'precise'; + case 'R': return 'rtk'; + case 'F': return 'rtk-float'; + } + this.error(new Error('INVALID FAA MODE: ' + faa)) +} + +function parseRMCVariation(vari, dir) { + if (vari === '' || dir === '') return null; + return parseFloat(vari) * (dir === 'W' ? -1 : 1); +} + +function parseDist(num, unit) { + if (unit === 'M' || unit === '') return parseNumber(num); + this.error(new Error('Unknown unit: ' + unit)) +} + +/** + * Decode TXT caret-escapes and reject invalid chars. + * Spec: NMEA0183-2 §5.1.3 (escapes) and §6.1 Table 1 (invalid chars) + * + * @param {string} str + * @returns {string} + */ +function escapeString(str) { + if (str == null) return ''; + + // invalid characters per spec (excluding '^' which introduces escapes) + var invalid = ["\r", "\n", "$", "*", ",", "!", "\\", "~", "\u007F" /* DEL */]; + for (var i = 0; i < invalid.length; i++) { + if (str.indexOf(invalid[i]) !== -1) { + this.error(new Error("Message may not contain invalid character '" + invalid[i] + "'")) + } + } + + // caret escapes: ^HH (hex byte) or ^^ (literal caret) + var out = ''; + for (var j = 0; j < str.length; j++) { + var ch = str.charCodeAt(j); + if (ch !== 94 /* '^' */) { out += str[j]; continue; } + var n1 = str[j + 1], n2 = str[j + 2]; + if (n1 === '^') { out += '^'; j += 1; continue; } + if (n1 && n2 && + ((n1 >= '0' && n1 <= '9') || (n1 >= 'A' && n1 <= 'F') || (n1 >= 'a' && n1 <= 'f')) && + ((n2 >= '0' && n2 <= '9') || (n2 >= 'A' && n2 <= 'F') || (n2 >= 'a' && n2 <= 'f'))) { + out += String.fromCharCode(parseInt(n1 + n2, 16)); + j += 2; + } else { + // unknown escape → keep caret literally + out += '^'; + } + } + return out; +} + +/** + * + * @constructor + */ +function GPS() { + if (!(this instanceof GPS)) return new GPS(); + + // Public fields + this['events'] = Object.create(null); + this['state'] = { 'errors': 0, 'processed': 0, 'txtBuffer': {} }; + + // Internal, per-instance collectors (avoid cross-stream state bleed) + this['_collectSats'] = Object.create(null); + this['_collectActiveSats'] = Object.create(null); + this['_lastSeenSat'] = Object.create(null); + + // Streaming buffer + this['partial'] = ''; +} + +/* Static fields (explicit for speed and minification) */ +GPS['parsers'] = { + // Global Positioning System Fix Data + 'GGA': function (str, gga) { + if (gga.length !== 16 && gga.length !== 14) { + this.error(new Error('Invalid GGA length: ' + str)) + } + + /* + 11 + 1 2 3 4 5 6 7 8 9 10 | 12 13 14 15 + | | | | | | | | | | | | | | | + $--GGA,hhmmss.ss,llll.ll,a,yyyyy.yy,a,x,xx,x.x,x.x,M,x.x,M,x.x,xxxx*hh + + 1) Time (UTC) + 2) Latitude + 3) N or S (North or South) + 4) Longitude + 5) E or W (East or West) + 6) GPS Quality Indicator, + 0 = Invalid, 1 = Valid SPS, 2 = Valid DGPS, 3 = Valid PPS + 7) Number of satellites in view, 00 - 12 + 8) Horizontal Dilution of precision, lower is better + 9) Antenna Altitude above/below mean-sea-level (geoid) + 10) Units of antenna altitude, meters + 11) Geoidal separation, the difference between the WGS-84 earth + ellipsoid and mean-sea-level (geoid), '-' means mean-sea-level below ellipsoid + 12) Units of geoidal separation, meters + 13) Age of differential GPS data, time in seconds since last SC104 + type 1 or 9 update, null field when DGPS is not used + 14) Differential reference station ID, 0000-1023 + 15) Checksum + */ + + return { + 'time': parseTime(gga[1]), + 'lat': parseCoord(gga[2], gga[3]), + 'lon': parseCoord(gga[4], gga[5]), + 'alt': parseDist(gga[9], gga[10]), + 'quality': parseGGAFix(gga[6]), + 'satellites': parseNumber(gga[7]), + 'hdop': parseNumber(gga[8]), // dilution + 'geoidal': parseDist(gga[11], gga[12]), // above geoid + 'age': gga[13] === undefined ? null : parseNumber(gga[13]), // DGPS age + 'stationID': gga[14] === undefined ? null : parseNumber(gga[14]) // DGPS ref + }; + }, + + // GPS DOP and active satellites + 'GSA': function (str, gsa) { + + if (gsa.length !== 19 && gsa.length !== 20) { + this.error(new Error('Invalid GSA length: ' + str)) + } + + /* + eg1. $GPGSA,A,3,,,,,,16,18,,22,24,,,3.6,2.1,2.2*3C + eg2. $GPGSA,A,3,19,28,14,18,27,22,31,39,,,,,1.7,1.0,1.3*35 + + + 1 = Mode: + M=Manual, forced to operate in 2D or 3D + A=Automatic, 3D/2D + 2 = Mode: + 1=Fix not available + 2=2D + 3=3D + 3-14 = PRNs of Satellite Vehicles (SVs) used in position fix (null for unused fields) + 15 = PDOP + 16 = HDOP + 17 = VDOP + (18) = systemID NMEA 4.10 + 18 = Checksum + */ + + const sats = []; + for (let i = 3; i < 15; i++) { + if (gsa[i] !== '') sats.push(parseInt(gsa[i], 10)); + } + const sid = gsa.length > 19 ? parseNumber(gsa[18]) : null; + return { + 'mode': parseGSAMode(gsa[1]), + 'fix': parseGSAFix(gsa[2]), + 'satellites': sats, + 'pdop': parseNumber(gsa[15]), + 'hdop': parseNumber(gsa[16]), + 'vdop': parseNumber(gsa[17]), + 'systemId': sid, + 'system': sid !== null ? parseSystemId(sid) : 'unknown' + }; + }, + + // Recommended Minimum data for GPS + 'RMC': function (str, rmc) { + if (rmc.length !== 13 && rmc.length !== 14 && rmc.length !== 15) { + this.error(new Error('Invalid RMC length: ' + str)) + } + + /* + $GPRMC,hhmmss.ss,A,llll.ll,a,yyyyy.yy,a,x.x,x.x,ddmmyy,x.x,a*hh + + RMC = Recommended Minimum Specific GPS/TRANSIT Data + 1 = UTC of position fix + 2 = Data status (A-ok, V-invalid) + 3 = Latitude of fix + 4 = N or S + 5 = Longitude of fix + 6 = E or W + 7 = Speed over ground in knots + 8 = Track made good in degrees True + 9 = UT date + 10 = Magnetic variation degrees (Easterly var. subtracts from true course) + 11 = E or W + (12) = NMEA 2.3 introduced FAA mode indicator (A=Autonomous, D=Differential, E=Estimated, N=Data not valid) + (13) = NMEA 4.10 introduced nav status + 12 = Checksum + */ + + return { + 'time': parseTime(rmc[1], rmc[9]), + 'status': parseRMC_GLLStatus(rmc[2]), + 'lat': parseCoord(rmc[3], rmc[4]), + 'lon': parseCoord(rmc[5], rmc[6]), + 'speed': parseKnots(rmc[7]), + 'track': parseNumber(rmc[8]), // heading (true) + 'variation': parseRMCVariation(rmc[10], rmc[11]), + 'faa': rmc.length > 13 ? parseFAA(rmc[12]) : null, + 'navStatus': rmc.length > 14 ? rmc[13] : null + }; + }, + + // Track info + 'VTG': function (str, vtg) { + if (vtg.length !== 10 && vtg.length !== 11) { + this.error(new Error('Invalid VTG length: ' + str)) + } + + /* + ------------------------------------------------------------------------------ + 1 2 3 4 5 6 7 8 9 10 + | | | | | | | | | | + $--VTG,x.x,T,x.x,M,x.x,N,x.x,K,m,*hh + ------------------------------------------------------------------------------ + + 1 = Track made good (degrees true) + 2 = Fixed text 'T' indicates that track made good is relative to true north + 3 = optional: Track made good (degrees magnetic) + 4 = optional: M: track made good is relative to magnetic north + 5 = Speed over ground in knots + 6 = Fixed text 'N' indicates that speed over ground in in knots + 7 = Speed over ground in kilometers/hour + 8 = Fixed text 'K' indicates that speed over ground is in kilometers/hour + (9) = FAA mode indicator (NMEA 2.3 and later) + 9/10 = Checksum + */ + + // Empty / all-null VTG (some receivers output this) + if (vtg[2] === '' && vtg[8] === '' && vtg[6] === '') { + return { + 'track': null, + 'trackMagnetic': null, + 'speed': null, + 'faa': null + }; + } + + if (vtg[2] !== 'T') { + this.error(new Error('Invalid VTG track mode: ' + str)) + } + if (vtg[8] !== 'K' || vtg[6] !== 'N') { + this.error(new Error('Invalid VTG speed tag: ' + str)) + } + + return { + 'track': parseNumber(vtg[1]), // true heading + 'trackMagnetic': vtg[3] === '' ? null : parseNumber(vtg[3]), // magnetic + 'speed': parseKnots(vtg[5]), + 'faa': vtg.length === 11 ? parseFAA(vtg[9]) : null + }; + }, + + // Satellites in view + 'GSV': function (str, gsv) { + // NMEA allows variable chunks of 4 fields per satellite + header/footer. + // Keep legacy guard but allow most common valid shapes. + if (gsv.length % 4 === 0) { + // = 1 -> normal package + // = 2 -> NMEA 4.10 extension + // = 3 -> BeiDou extension? + this.error(new Error('Invalid GSV length: ' + str)) + } + + /* + $GPGSV,1,1,13,02,02,213,,03,-3,000,,11,00,121,,14,13,172,05*67 + + 1 = Total number of messages of this type in this cycle + 2 = Message number + 3 = Total number of SVs in view + repeat [ + 4 = SV PRN number + 5 = Elevation in degrees, 90 maximum + 6 = Azimuth, degrees from true north, 000 to 359 + 7 = SNR (signal to noise ratio), 00-99 dB (null when not tracking, higher is better) + ] + N+1 = signalID NMEA 4.10 + N+2 = Checksum + */ + + const sats = []; + const satellite = str.slice(1, 3); + // fields: [totMsgs, msgNum, satsInView, (prn,elev,az,snr)*, (signalId)?, checksum] + for (let i = 4; i < gsv.length - 3; i += 4) { + const prn = parseNumber(gsv[i]); + const snr = parseNumber(gsv[i + 3]); + /* + Plot satellites in Radar chart with north on top + by linear map elevation from 0° to 90° into r to 0 + + centerX + cos(azimuth - 90) * (1 - elevation / 90) * radius + centerY + sin(azimuth - 90) * (1 - elevation / 90) * radius + */ + sats.push({ + 'prn': prn, + 'elevation': parseNumber(gsv[i + 1]), + 'azimuth': parseNumber(gsv[i + 2]), + 'snr': snr, + 'status': prn !== null ? (snr !== null ? 'tracking' : 'in view') : null, + 'system': parseSystem(str), + 'key': satellite + prn + }); + } + + return { + 'msgNumber': parseNumber(gsv[2]), + 'msgsTotal': parseNumber(gsv[1]), + 'satsInView': parseNumber(gsv[3]), + 'satellites': sats, + 'signalId': gsv.length % 4 === 2 ? parseNumber(gsv[gsv.length - 2]) : null, // NMEA 4.10 + 'system': parseSystem(str) + }; + }, + + // Geographic Position - Latitude/Longitude + 'GLL': function (str, gll) { + if (gll.length !== 9 && gll.length !== 8) { + this.error(new Error('Invalid GLL length: ' + str)) + } + + /* + ------------------------------------------------------------------------------ + 1 2 3 4 5 6 7 8 + | | | | | | | | + $--GLL,llll.ll,a,yyyyy.yy,a,hhmmss.ss,a,m,*hh + ------------------------------------------------------------------------------ + + 1. Latitude + 2. N or S (North or South) + 3. Longitude + 4. E or W (East or West) + 5. Universal Time Coordinated (UTC) + 6. Status A - Data Valid, V - Data Invalid + 7. FAA mode indicator (NMEA 2.3 and later) + 8. Checksum + */ + + return { + 'time': parseTime(gll[5]), + 'status': parseRMC_GLLStatus(gll[6]), + 'lat': parseCoord(gll[1], gll[2]), + 'lon': parseCoord(gll[3], gll[4]), + 'faa': gll.length === 9 ? parseFAA(gll[7]) : null + }; + }, + + // UTC Date / Time and Local Time Zone Offset + 'ZDA': function (str, zda) { + + /* + 1 = hhmmss.ss = UTC + 2 = xx = Day, 01 to 31 + 3 = xx = Month, 01 to 12 + 4 = xxxx = Year + 5 = xx = Local zone description, 00 to +/- 13 hours + 6 = xx = Local zone minutes description (same sign as hours) + */ + + // (No strict length guard; some receivers omit trailing fields) + return { + 'time': parseTime(zda[1], zda[2] + zda[3] + zda[4]), + // 'delta': can be derived by consumer: (Date.now() - time)/1000 + 'offsetMin': (zda[5] === '' || zda[6] === '') ? null + : (parseInt(zda[5], 10) * 60 + parseInt(zda[6], 10)) + }; + }, + + 'GST': function (str, gst) { + if (gst.length !== 10) { + this.error(new Error('Invalid GST length: ' + str)) + } + + /* + 1 = Time (UTC) + 2 = RMS value of the pseudorange residuals; includes carrier phase residuals during periods of RTK (float) and RTK (fixed) processing + 3 = Error ellipse semi-major axis 1 sigma error, in meters + 4 = Error ellipse semi-minor axis 1 sigma error, in meters + 5 = Error ellipse orientation, degrees from true north + 6 = Latitude 1 sigma error, in meters + 7 = Longitude 1 sigma error, in meters + 8 = Height 1 sigma error, in meters + 9 = Checksum + */ + + return { + 'time': parseTime(gst[1]), + 'rms': parseNumber(gst[2]), + 'ellipseMajor': parseNumber(gst[3]), + 'ellipseMinor': parseNumber(gst[4]), + 'ellipseOrientation': parseNumber(gst[5]), + 'latitudeError': parseNumber(gst[6]), + 'longitudeError': parseNumber(gst[7]), + 'heightError': parseNumber(gst[8]) + }; + }, + + // Heading relative to True North + 'HDT': function (str, hdt) { + if (hdt.length !== 4) { + this.error(new Error('Invalid HDT length: ' + str)) + } + + /* + ------------------------------------------------------------------------------ + 1 2 3 + | | | + $--HDT,hhh.hhh,T*XX + ------------------------------------------------------------------------------ + + 1. Heading in degrees + 2. T: indicates heading relative to True North + 3. Checksum + */ + + return { + 'heading': parseFloat(hdt[1]), + 'trueNorth': hdt[2] === 'T' + }; + }, + + 'GRS': function (str, grs) { + if (grs.length !== 18) { + this.error(new Error('Invalid GRS length: ' + str)) + } + const res = []; + for (let i = 3; i <= 14; i++) { + const tmp = parseNumber(grs[i]); + if (tmp !== null) res.push(tmp); + } + return { + 'time': parseTime(grs[1]), + 'mode': parseNumber(grs[2]), + 'res': res + }; + }, + + 'GBS': function (str, gbs) { + if (gbs.length !== 10 && gbs.length !== 12) { + this.error(new Error('Invalid GBS length: ' + str)) + } + + /* + 0 1 2 3 4 5 6 7 8 + | | | | | | | | | + $--GBS,hhmmss.ss,x.x,x.x,x.x,x.x,x.x,x.x,x.x*hh + + 1. UTC time of the GGA or GNS fix associated with this sentence + 2. Expected error in latitude (meters) + 3. Expected error in longitude (meters) + 4. Expected error in altitude (meters) + 5. PRN (id) of most likely failed satellite + 6. Probability of missed detection for most likely failed satellite + 7. Estimate of bias in meters on most likely failed satellite + 8. Standard deviation of bias estimate + -- + 9. systemID (NMEA 4.10) + 10. signalID (NMEA 4.10) + */ + + return { + 'time': parseTime(gbs[1]), + 'errLat': parseNumber(gbs[2]), + 'errLon': parseNumber(gbs[3]), + 'errAlt': parseNumber(gbs[4]), + 'failedSat': parseNumber(gbs[5]), + 'probFailedSat': parseNumber(gbs[6]), + 'biasFailedSat': parseNumber(gbs[7]), + 'stdFailedSat': parseNumber(gbs[8]), + 'systemId': gbs.length === 12 ? parseNumber(gbs[9]) : null, + 'signalId': gbs.length === 12 ? parseNumber(gbs[10]) : null + }; + }, + + 'GNS': function (str, gns) { + if (gns.length !== 14 && gns.length !== 15) { + this.error(new Error('Invalid GNS length: ' + str)) + } + return { + 'time': parseTime(gns[1]), + 'lat': parseCoord(gns[2], gns[3]), + 'lon': parseCoord(gns[4], gns[5]), + 'mode': gns[6], + 'satsUsed': parseNumber(gns[7]), + 'hdop': parseNumber(gns[8]), + 'alt': parseNumber(gns[9]), + 'sep': parseNumber(gns[10]), + 'diffAge': parseNumber(gns[11]), + 'diffStation': parseNumber(gns[12]), + 'navStatus': gns.length === 15 ? gns[13] : null // NMEA 4.10 + }; + }, + + // Text Transmission (TXT) + // NMEA0183-2 §6.3 ($--TXT,xx,xx,xx,c...c*hh) + 'TXT': function (str, txt) { + + // After talker removal, txt expected: ['TXT', total, index, id, payload, checksum] + if (txt.length !== 6) { + this.error(new Error('Invalid TXT length: ' + str)) + } + + var total = parseInt(txt[1], 10); + var index = parseInt(txt[2], 10); + var textId = parseInt(txt[3], 10); + var rawPart = txt[4] || ''; + + if (!(total >= 1 && total <= 99)) this.error(new Error('Invalid TXT total: ' + txt[1])); + if (!(index >= 1 && index <= total)) this.error(new Error('Invalid TXT index: ' + txt[2])); + if (!(textId >= 0 && textId <= 99)) this.error(new Error('Invalid TXT id: ' + txt[3])); + if (rawPart.length > 61) this.error(new Error('Invalid TXT message length: ' + rawPart.length)); + + var part = escapeString(rawPart); + if (part === '') this.error(new Error('Invalid empty TXT message')); + + // For single-part messages, we can return a completed object right away. + // Multi-part completion is handled in instance _assembleTXT (see below). + return { + // assembly fields: + 'total': total, + 'index': index, + 'id': textId, + 'part': part, // decoded segment + 'message': (total === 1) ? part : null, + 'completed': (total === 1), + 'rawMessages': (total === 1) ? [part] : [], + 'system': parseSystem(str) // e.g. 'GPS', 'GLONASS', ... + }; + } +}; + +/* Static parse + geodesy helpers */ + +GPS['Parse'] = function (line) { + if (typeof line !== 'string' || line.length < 6) return false; + if (line.charCodeAt(0) !== 36 /* '$' */) return false; + + const star = line.indexOf('*', 1); + if (star === -1 || star + 2 >= line.length) return false; + + const nmea = []; + const firstComma = line.indexOf(',', 1); + if (firstComma === -1 || firstComma > star) return false; + + nmea.push('$' + line.slice(1, firstComma)); + + // checksum over everything between '$' and '*' + let checksum = 0; + for (let i = 1; i < star; i++) checksum ^= line.charCodeAt(i); + + // split fields after the first comma + let fieldStart = firstComma + 1; + for (let i = fieldStart; i < star; i++) { + if (line.charCodeAt(i) === 44 /* ',' */) { + nmea.push(line.slice(fieldStart, i)); + fieldStart = i + 1; + } + } + nmea.push(line.slice(fieldStart, star)); + + const crcStr = line.slice(star + 1).trim(); + const crc = parseInt(crcStr.slice(0, 2), 16); + if (!(crc >= 0 && crc <= 255)) return false; + + nmea[0] = nmea[0].slice(3); + const type = nmea[0]; + const mod = GPS['parsers'][type]; + if (mod === undefined) return false; + + nmea.push(crcStr.slice(0, 2)); + + const data = mod(line, nmea); + data['raw'] = line; + data['valid'] = (checksum === crc); + data['type'] = type; + + return data; +}; + +// Heading (N=0, E=90, S=180, W=270) from point 1 to point 2 +GPS['Heading'] = function (lat1, lon1, lat2, lon2) { + const dlon = (lon2 - lon1) * D2R; + lat1 *= D2R; lat2 *= D2R; + + const sdlon = Math.sin(dlon), cdlon = Math.cos(dlon); + const slat1 = Math.sin(lat1), clat1 = Math.cos(lat1); + const slat2 = Math.sin(lat2), clat2 = Math.cos(lat2); + + const y = sdlon * clat2; + const x = clat1 * slat2 - slat1 * clat2 * cdlon; + + const head = Math.atan2(y, x) * 180 / Math.PI; + return (head + 360) % 360; +}; + +GPS['Distance'] = function (lat1, lon1, lat2, lon2) { + // Haversine Formula + // R.W. Sinnott, "Virtues of the Haversine", Sky and Telescope, vol. 68, no. 2, 1984, p. 159 + + // Because Earth is no exact sphere, rounding errors may be up to 0.5%. + // var RADIUS = 6371; // Earth radius average + // var RADIUS = 6378.137; // Earth radius at equator + const RADIUS = 6372.8; // km + const hLat = (lat2 - lat1) * D2R * 0.5; + const hLon = (lon2 - lon1) * D2R * 0.5; + lat1 *= D2R; lat2 *= D2R; + + const shLat = Math.sin(hLat), shLon = Math.sin(hLon); + const clat1 = Math.cos(lat1), clat2 = Math.cos(lat2); + + const tmp = shLat * shLat + clat1 * clat2 * shLon * shLon; + //return RADIUS * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1.0 - a)); + return RADIUS * 2 * Math.asin(Math.sqrt(tmp)); +}; + +GPS['TotalDistance'] = function (path) { + + if (path.length < 2) return 0; + let len = 0; + for (let i = 0; i < path.length - 1; i++) { + const c = path[i]; + const n = path[i + 1]; + len += GPS['Distance'](c['lat'], c['lon'], n['lat'], n['lon']); + } + return len; +}; + +/* ---------- Instance methods (single prototype assignment) ---------- */ + +GPS.prototype = { + constructor: GPS, + + /* Internal: merge parsed packet into state, keep short-term sat caches fresh */ + '_updateState': function (data) { + const state = this['state']; + + // TODO: can we really use RMC time here or is it the time of fix? + if (data['type'] === 'RMC' || data['type'] === 'GGA' || data['type'] === 'GLL' || data['type'] === 'GNS') { + state['time'] = data['time']; + state['lat'] = data['lat']; + state['lon'] = data['lon']; + } + + if (data['type'] === 'HDT') { + state['heading'] = data['heading']; + state['trueNorth'] = data['trueNorth']; + } + + if (data['type'] === 'ZDA') { + state['time'] = data['time']; + } + + if (data['type'] === 'GGA') { + state['alt'] = data['alt']; + } + + if (data['type'] === 'RMC' || data['type'] === 'VTG') { + if (data['speed'] != null) state['speed'] = data['speed']; + if (data['track'] != null) state['track'] = data['track']; + } + + if (data['type'] === 'GSA') { + const systemId = data['systemId']; + if (systemId != null) this['_collectActiveSats'][systemId] = data['satellites']; + + const satsActive = []; + const collectActiveSats = this['_collectActiveSats']; + for (const s in collectActiveSats) { + if (Object.prototype.hasOwnProperty.call(collectActiveSats, s)) { + // Concatenate without allocating a new array for each system + const arr = collectActiveSats[s]; + for (let i = 0, L = arr.length; i < L; i++) satsActive.push(arr[i]); + } + } + + state['satsActive'] = satsActive; + state['fix'] = data['fix']; + state['hdop'] = data['hdop']; + state['pdop'] = data['pdop']; + state['vdop'] = data['vdop']; + } + + if (data['type'] === 'GSV') { + const now = Date.now(); + const sats = data['satellites']; + const collectSats = this['_collectSats']; + const lastSeenSat = this['_lastSeenSat']; + + for (let i = 0, L = sats.length; i < L; i++) { + const key = sats[i]['key']; + lastSeenSat[key] = now; + collectSats[key] = sats[i]; + } + + // Satellites are considered "visible" for 3 seconds after last seen + const ret = []; + for (const key in collectSats) { + if (Object.prototype.hasOwnProperty.call(collectSats, key)) { + if (now - lastSeenSat[key] < 3000) ret.push(collectSats[key]); + else { + // Optional: clean up stale entries + delete collectSats[key]; + delete lastSeenSat[key]; + } + } + } + state['satsVisible'] = ret; + } + }, + + '_assembleTXT': function (data) { + // Single-part already complete (parser set message) + if (data['total'] === 1) return data; + + const key = (data['system'] || '') + '#' + data['id']; + + let buf = this['state']['txtBuffer'][key]; + if (!buf) { + buf = this['state']['txtBuffer'][key] = { + 'total': data['total'], + 'parts': new Array(data['total']).fill(null), + 'received': 0, + 'timer': null + }; + // 10s timeout to avoid leaks + const self = this; + buf['timer'] = setTimeout(function () { + self['state']['errors']++; + delete self['state']['txtBuffer'][key]; + }, 10000); + } + + // store part (index is 1-based) + const idx = data['index'] - 1; + if (0 <= idx && idx < buf['total']) { + buf['parts'][idx] = data['part']; + buf['received']++; + } + + // check completion + if (buf['received'] === buf['total']) { + clearTimeout(buf['timer']); + delete this['state']['txtBuffer'][key]; + data['message'] = buf['parts'].join(''); + data['completed'] = true; + data['rawMessages'] = buf['parts']; + + } else { + data['message'] = null; + data['completed'] = false; + data['rawMessages'] = []; + } + return data; + }, + + /** + * Feed one full NMEA line (starting with '$', ending before CRLF). + * Emits both 'data' and '' events on success. + */ + 'update': function (line) { + const parsed = GPS['Parse'](line); + this['state']['processed']++; + + if (parsed === false) { + this['state']['errors']++; + return false; + } + + // Assemble TXT multi-part here + if (parsed['type'] === 'TXT') { + this['_assembleTXT'](parsed); + } + + this['_updateState'](parsed); + + this['emit']('data', parsed); + this['emit'](parsed['type'], parsed); + + return true; + }, + + /** + * Feed streaming data (chunks, possibly split arbitrarily). + * Accepts either "\r\n" or "\n" as line delimiters. + */ + 'updatePartial': function (chunk) { + if (chunk) this['partial'] += chunk; + + // Process all complete lines + for (; ;) { + const idxRN = this['partial'].indexOf('\r\n'); + const idxN = this['partial'].indexOf('\n'); + + let pos = -1; + if (idxRN !== -1) pos = idxRN; + else if (idxN !== -1) pos = idxN; + + if (pos === -1) break; + + const line = this['partial'].slice(0, pos); + // Advance buffer past delimiter (2 for CRLF, 1 for LF) + this['partial'] = this['partial'].slice(pos + (idxRN === pos ? 2 : 1)); + + if (line.charAt(0) !== '$') continue; + + try { + this['update'](line); + } catch (err) { + // Keep buffer (don’t drop subsequent lines), but count the error + this['state']['errors']++; + // Re-throw for caller visibility + this.error(err); + } + } + }, + + /** + * Subscribe to an event. Multiple listeners per event are supported. + * @param {string} ev + * @param {function()} cb + * @returns {GPS} this (chainable) + */ + 'on': function (ev, cb) { + const cur = this['events'][ev]; + if (cur === undefined) { + this['events'][ev] = [cb]; + } else if (typeof cur === 'function') { + // Backward compatibility with previous single-listener design + this['events'][ev] = [cur, cb]; + } else { + this['events'][ev].push(cb); + } + return this; + }, + + /** + * Remove listeners. If cb omitted, remove all for the event. + * @param {string} ev + * @param {function()} cb + * @returns {GPS} this + */ + 'off': function (ev, cb) { + const cur = this['events'][ev]; + if (cur === undefined) return this; + + if (!cb) { + delete this['events'][ev]; + return this; + } + + if (typeof cur === 'function') { + if (cur === cb) delete this['events'][ev]; + return this; + } + + // Array case + for (let i = cur.length - 1; i >= 0; i--) { + if (cur[i] === cb) cur.splice(i, 1); + } + if (cur.length === 0) delete this['events'][ev]; + return this; + }, + + /** + * Emit an event to all listeners. + * @param {string} ev + * @param {*} data + */ + 'emit': function (ev, data) { + const cur = this['events'][ev]; + if (cur === undefined) return; + + if (typeof cur === 'function') { + cur.call(this, data); + return; + } + // Array of listeners + for (let i = 0, L = cur.length; i < L; i++) { + cur[i].call(this, data); + } + }, + + 'error': function(error) { + const cur = this['events']['error']; + if(cur === undefined) { + throw error + } + else { + this['emit']('error', error); + } + } }; Object.defineProperty(GPS, "__esModule", { 'value': true }); diff --git a/dist/gps.min.js b/dist/gps.min.js index 4a800e8..630e5fa 100644 --- a/dist/gps.min.js +++ b/dist/gps.min.js @@ -1,28 +1,29 @@ /* -GPS.js v0.8.1 8/16/2025 +GPS.js v0.8.1 11/14/2025 https://raw.org/article/using-gps-with-node-js-and-javascript/ Copyright (c) 2025, Robert Eisele (https://raw.org/) Licensed under the MIT license. */ -'use strict';(function(x){function n(b,a=null){if(!b)return null;const c=new Date;if(a){var d=a.slice(4),g=a.slice(2,4)-1;a=a.slice(0,2);4===d.length?c.setUTCFullYear(+d,+g,+a):c.setUTCFullYear(Number("20"+d),+g,+a)}c.setUTCHours(+b.slice(0,2));c.setUTCMinutes(+b.slice(2,4));c.setUTCSeconds(+b.slice(4,6));g=b.indexOf(".");d=0;-1!==g&&g+1c;c++)""!==a[c]&&b.push(parseInt(a[c],10));c=19=c;c++){const d=e(a[c]);null!==d&& -b.push(d)}return{time:n(a[1]),mode:e(a[2]),res:b}},GBS:function(b,a){if(10!==a.length&&12!==a.length)throw Error("Invalid GBS length: "+b);return{time:n(a[1]),errLat:e(a[2]),errLon:e(a[3]),errAlt:e(a[4]),failedSat:e(a[5]),probFailedSat:e(a[6]),biasFailedSat:e(a[7]),stdFailedSat:e(a[8]),systemId:12===a.length?e(a[9]):null,signalId:12===a.length?e(a[10]):null}},GNS:function(b,a){if(14!==a.length&&15!==a.length)throw Error("Invalid GNS length: "+b);return{time:n(a[1]),lat:p(a[2],a[3]),lon:p(a[4],a[5]), -mode:a[6],satsUsed:e(a[7]),hdop:e(a[8]),alt:e(a[9]),sep:e(a[10]),diffAge:e(a[11]),diffStation:e(a[12]),navStatus:15===a.length?a[13]:null}},TXT:function(b,a){if(6!==a.length)throw Error("Invalid TXT length: "+b);var c=parseInt(a[1],10),d=parseInt(a[2],10),g=parseInt(a[3],10),f=a[4]||"";if(!(1<=c&&99>=c))throw Error("Invalid TXT total: "+a[1]);if(!(1<=d&&d<=c))throw Error("Invalid TXT index: "+a[2]);if(!(0<=g&&99>=g))throw Error("Invalid TXT id: "+a[3]);if(61=l||"A"<=l&&"F">=l||"a"<=l&&"f">=l)&&("0"<=m&&"9">=m||"A"<=m&&"F">=m||"a"<=m&&"f">=m)?(a+=String.fromCharCode(parseInt(l+m,16)),h+=2):a+="^"}f=a}if(""===f)throw Error("Invalid empty TXT message"); -return{total:c,index:d,id:g,part:f,message:1===c?f:null,completed:1===c,rawMessages:1===c?[f]:[],system:r(b)}}};k.Parse=function(b){if("string"!==typeof b||6>b.length||36!==b.charCodeAt(0))return!1;var a=b.indexOf("*",1);if(-1===a||a+2>=b.length)return!1;var c=[],d=b.indexOf(",",1);if(-1===d||d>a)return!1;c.push("$"+b.slice(1,d));let g=0;for(var f=1;f=a))return!1;c[0]=c[0].slice(3);d=c[0];const h=k.parsers[d];if(void 0===h)return!1;c.push(f.slice(0,2));c=h(b,c);c.raw=b;c.valid=g===a;c.type=d;return c};k.Heading=function(b,a,c,d){a=(d-a)*q;b*=q;c*=q;d=Math.cos(c);return(180*Math.atan2(Math.sin(a)*d,Math.cos(b)*Math.sin(c)-Math.sin(b)*d*Math.cos(a))/Math.PI+360)%360};k.Distance=function(b,a,c,d){var g=(c-b)*q*.5;a=(d-a)*q*.5;b*=q;c*=q;g=Math.sin(g);a=Math.sin(a);return 12745.6*Math.asin(Math.sqrt(g*g+Math.cos(b)*Math.cos(c)* -a*a))};k.TotalDistance=function(b){if(2>b.length)return 0;let a=0;for(let c=0;cg-c[h]?d.push(b[h]):(delete b[h],delete c[h]));a.satsVisible=d}},_assembleTXT:function(b){if(1===b.total)return b;const a=(b.system||"")+"#"+b.id;let c=this.state.txtBuffer[a];if(!c){c=this.state.txtBuffer[a]={total:b.total,parts:Array(b.total).fill(null),received:0,timer:null};const g=this;c.timer=setTimeout(function(){g.state.errors++;delete g.state.txtBuffer[a]},1E4)}const d=b.index-1;0<=d&&d=3?d=+b.slice(0,3):b.length===2?d=+b*10:b.length===1&&(d=+b*100));c.setUTCMilliseconds(d);return c}function m(b,a){if(b==="")return null; +const c=a==="N"||a==="S"?2:3;return(a==="S"||a==="W"?-1:1)*(parseFloat(b.slice(0,c))+parseFloat(b.slice(c))/60)}function f(b){return b===""?null:parseFloat(b)}function t(b){return b===""?null:parseFloat(b)*1.852}function y(b){switch(b){case 0:return"QZSS";case 1:return"GPS";case 2:return"GLONASS";case 3:return"Galileo";case 4:return"BeiDou";default:return"unknown"}}function q(b){b=b.slice(1,3);switch(b){case "GP":return"GPS";case "GQ":return"QZSS";case "GL":return"GLONASS";case "GA":return"Galileo"; +case "GB":return"BeiDou";default:return b}}function z(b){switch(b){case "M":return"manual";case "A":return"automatic";case "":return null}this.error(Error("INVALID GSA MODE: "+b))}function A(b){if(b==="")return null;switch(parseInt(b,10)){case 0:return null;case 1:return"fix";case 2:return"dgps-fix";case 3:return"pps-fix";case 4:return"rtk";case 5:return"rtk-float";case 6:return"estimated";case 7:return"manual";case 8:return"simulated"}this.error(Error("INVALID GGA FIX: "+b))}function B(b){if(b=== +"")return null;switch(parseInt(b,10)){case 1:return null;case 2:return"2D";case 3:return"3D"}this.error(Error("INVALID GSA FIX: "+b))}function u(b){switch(b){case "":return null;case "A":return"active";case "V":return"void"}this.error(Error("INVALID RMC/GLL STATUS: "+b))}function r(b){switch(b){case "":return null;case "A":return"autonomous";case "D":return"differential";case "E":return"estimated";case "M":return"manual input";case "S":return"simulated";case "N":return"not valid";case "P":return"precise"; +case "R":return"rtk";case "F":return"rtk-float"}this.error(Error("INVALID FAA MODE: "+b))}function v(b,a){if(a==="M"||a==="")return f(b);this.error(Error("Unknown unit: "+a))}function C(b){if(b==null)return"";for(var a="\r\n$*,!\\~\u007f".split(""),c=0;c="0"&&d<="9"||d>="A"&& +d<="F"||d>="a"&&d<="f")&&(e>="0"&&e<="9"||e>="A"&&e<="F"||e>="a"&&e<="f")?(a+=String.fromCharCode(parseInt(d+e,16)),c+=2):a+="^"}return a}function k(){if(!(this instanceof k))return new k;this.events=Object.create(null);this.state={errors:0,processed:0,txtBuffer:{}};this._collectSats=Object.create(null);this._collectActiveSats=Object.create(null);this._lastSeenSat=Object.create(null);this.partial=""}const n=Math.PI/180;k.parsers={GGA:function(b,a){a.length!==16&&a.length!==14&&this.error(Error("Invalid GGA length: "+ +b));return{time:l(a[1]),lat:m(a[2],a[3]),lon:m(a[4],a[5]),alt:v(a[9],a[10]),quality:A(a[6]),satellites:f(a[7]),hdop:f(a[8]),geoidal:v(a[11],a[12]),age:a[13]===void 0?null:f(a[13]),stationID:a[14]===void 0?null:f(a[14])}},GSA:function(b,a){a.length!==19&&a.length!==20&&this.error(Error("Invalid GSA length: "+b));b=[];for(var c=3;c<15;c++)a[c]!==""&&b.push(parseInt(a[c],10));c=a.length>19?f(a[18]):null;return{mode:z(a[1]),fix:B(a[2]),satellites:b,pdop:f(a[15]),hdop:f(a[16]),vdop:f(a[17]),systemId:c, +system:c!==null?y(c):"unknown"}},RMC:function(b,a){a.length!==13&&a.length!==14&&a.length!==15&&this.error(Error("Invalid RMC length: "+b));b=l(a[1],a[9]);var c=u(a[2]),d=m(a[3],a[4]),e=m(a[5],a[6]),g=t(a[7]),h=f(a[8]),p=a[10],w=a[11];return{time:b,status:c,lat:d,lon:e,speed:g,track:h,variation:p===""||w===""?null:parseFloat(p)*(w==="W"?-1:1),faa:a.length>13?r(a[12]):null,navStatus:a.length>14?a[13]:null}},VTG:function(b,a){a.length!==10&&a.length!==11&&this.error(Error("Invalid VTG length: "+b)); +if(a[2]===""&&a[8]===""&&a[6]==="")return{track:null,trackMagnetic:null,speed:null,faa:null};a[2]!=="T"&&this.error(Error("Invalid VTG track mode: "+b));a[8]==="K"&&a[6]==="N"||this.error(Error("Invalid VTG speed tag: "+b));return{track:f(a[1]),trackMagnetic:a[3]===""?null:f(a[3]),speed:t(a[5]),faa:a.length===11?r(a[9]):null}},GSV:function(b,a){a.length%4===0&&this.error(Error("Invalid GSV length: "+b));const c=[],d=b.slice(1,3);for(let e=4;e=1&&c<=99||this.error(Error("Invalid TXT total: "+a[1]));d>=1&&d<=c||this.error(Error("Invalid TXT index: "+a[2]));e>=0&&e<=99||this.error(Error("Invalid TXT id: "+ +a[3]));g.length>61&&this.error(Error("Invalid TXT message length: "+g.length));a=C(g);a===""&&this.error(Error("Invalid empty TXT message"));return{total:c,index:d,id:e,part:a,message:c===1?a:null,completed:c===1,rawMessages:c===1?[a]:[],system:q(b)}}};k.Parse=function(b){if(typeof b!=="string"||b.length<6||b.charCodeAt(0)!==36)return!1;var a=b.indexOf("*",1);if(a===-1||a+2>=b.length)return!1;var c=[],d=b.indexOf(",",1);if(d===-1||d>a)return!1;c.push("$"+b.slice(1,d));let e=0;for(var g=1;g=0&&a<=255))return!1;c[0]=c[0].slice(3);d=c[0];const h=k.parsers[d];if(h===void 0)return!1;c.push(g.slice(0,2));c=h(b,c);c.raw=b;c.valid=e===a;c.type=d;return c};k.Heading=function(b,a,c,d){a=(d-a)*n;b*=n;c*=n;d=Math.cos(c);return(Math.atan2(Math.sin(a)*d,Math.cos(b)*Math.sin(c)-Math.sin(b)*d*Math.cos(a))*180/Math.PI+360)%360};k.Distance= +function(b,a,c,d){var e=(c-b)*n*.5;a=(d-a)*n*.5;b*=n;c*=n;e=Math.sin(e);a=Math.sin(a);return 12745.6*Math.asin(Math.sqrt(e*e+Math.cos(b)*Math.cos(c)*a*a))};k.TotalDistance=function(b){if(b.length<2)return 0;let a=0;for(let c=0;c=0;d--)c[d]===a&&c.splice(d,1);c.length===0&&delete this.events[b];return this},emit:function(b,a){b=this.events[b];if(b!==void 0)if(typeof b==="function")b.call(this,a);else for(let c=0,d=b.length;c= 3) { - ms = +frac.slice(0, 3); - } else if (frac.length === 2) { - ms = +frac * 10; // .xx => xx0 ms - } else if (frac.length === 1) { - ms = +frac * 100; // .x => x00 ms - } - } - ret.setUTCMilliseconds(ms); - return ret; -} - -function parseCoord(coord, dir) { - // NMEA lat: DDMM.mmmm; lon: DDDMM.mmmm; dir in {N,S,E,W} - // Latitude can go from 0 to 90; longitude can go from -180 to 180. - if (coord === '') return null; - const sgn = (dir === 'S' || dir === 'W') ? -1 : 1; - const n = (dir === 'N' || dir === 'S') ? 2 : 3; - return sgn * (parseFloat(coord.slice(0, n)) + parseFloat(coord.slice(n)) / 60); -} - -function parseNumber(num) { - return num === '' ? null : parseFloat(num); -} - -function parseKnots(knots) { - return knots === '' ? null : parseFloat(knots) * 1.852; // km/h -} - -function parseSystemId(systemId) { - switch (systemId) { - case 0: return 'QZSS'; - case 1: return 'GPS'; - case 2: return 'GLONASS'; - case 3: return 'Galileo'; - case 4: return 'BeiDou'; - default: return 'unknown'; - } -} - -function parseSystem(str) { - const satellite = str.slice(1, 3); - switch (satellite) { - case 'GP': return 'GPS'; - case 'GQ': return 'QZSS'; - case 'GL': return 'GLONASS'; - case 'GA': return 'Galileo'; - case 'GB': return 'BeiDou'; - default: return satellite; - } -} - -function parseGSAMode(mode) { - switch (mode) { - case 'M': return 'manual'; - case 'A': return 'automatic'; - case '': return null; - } - throw new Error('INVALID GSA MODE: ' + mode); -} - -function parseGGAFix(fix) { - if (fix === '') return null; - switch (parseInt(fix, 10)) { - case 0: return null; - case 1: return 'fix'; // valid SPS fix - case 2: return 'dgps-fix'; // valid DGPS fix - case 3: return 'pps-fix'; // valid PPS fix - case 4: return 'rtk'; // RTK fixed - case 5: return 'rtk-float'; // RTK float - case 6: return 'estimated'; // dead reckoning - case 7: return 'manual'; - case 8: return 'simulated'; - } - throw new Error('INVALID GGA FIX: ' + fix); -} - -function parseGSAFix(fix) { - if (fix === '') return null; - switch (parseInt(fix, 10)) { - case 1: return null; - case 2: return '2D'; - case 3: return '3D'; - } - throw new Error('INVALID GSA FIX: ' + fix); -} - -function parseRMC_GLLStatus(status) { - switch (status) { - case '': return null; - case 'A': return 'active'; - case 'V': return 'void'; - } - throw new Error('INVALID RMC/GLL STATUS: ' + status); -} - -function parseFAA(faa) { - // Only A and D will correspond to an Active and reliable sentence - switch (faa) { - case '': return null; - case 'A': return 'autonomous'; - case 'D': return 'differential'; - case 'E': return 'estimated'; // dead reckoning - case 'M': return 'manual input'; - case 'S': return 'simulated'; - case 'N': return 'not valid'; - case 'P': return 'precise'; - case 'R': return 'rtk'; - case 'F': return 'rtk-float'; - } - throw new Error('INVALID FAA MODE: ' + faa); -} - -function parseRMCVariation(vari, dir) { - if (vari === '' || dir === '') return null; - return parseFloat(vari) * (dir === 'W' ? -1 : 1); -} - -function parseDist(num, unit) { - if (unit === 'M' || unit === '') return parseNumber(num); - throw new Error('Unknown unit: ' + unit); -} - -/** - * Decode TXT caret-escapes and reject invalid chars. - * Spec: NMEA0183-2 §5.1.3 (escapes) and §6.1 Table 1 (invalid chars) - * - * @param {string} str - * @returns {string} - */ -function escapeString(str) { - if (str == null) return ''; - - // invalid characters per spec (excluding '^' which introduces escapes) - var invalid = ["\r", "\n", "$", "*", ",", "!", "\\", "~", "\u007F" /* DEL */]; - for (var i = 0; i < invalid.length; i++) { - if (str.indexOf(invalid[i]) !== -1) { - throw new Error("Message may not contain invalid character '" + invalid[i] + "'"); - } - } - - // caret escapes: ^HH (hex byte) or ^^ (literal caret) - var out = ''; - for (var j = 0; j < str.length; j++) { - var ch = str.charCodeAt(j); - if (ch !== 94 /* '^' */) { out += str[j]; continue; } - var n1 = str[j + 1], n2 = str[j + 2]; - if (n1 === '^') { out += '^'; j += 1; continue; } - if (n1 && n2 && - ((n1 >= '0' && n1 <= '9') || (n1 >= 'A' && n1 <= 'F') || (n1 >= 'a' && n1 <= 'f')) && - ((n2 >= '0' && n2 <= '9') || (n2 >= 'A' && n2 <= 'F') || (n2 >= 'a' && n2 <= 'f'))) { - out += String.fromCharCode(parseInt(n1 + n2, 16)); - j += 2; - } else { - // unknown escape → keep caret literally - out += '^'; - } - } - return out; -} - -/** - * - * @constructor - */ -function GPS() { - if (!(this instanceof GPS)) return new GPS(); - - // Public fields - this['events'] = Object.create(null); - this['state'] = { 'errors': 0, 'processed': 0, 'txtBuffer': {} }; - - // Internal, per-instance collectors (avoid cross-stream state bleed) - this['_collectSats'] = Object.create(null); - this['_collectActiveSats'] = Object.create(null); - this['_lastSeenSat'] = Object.create(null); - - // Streaming buffer - this['partial'] = ''; -} - -/* Static fields (explicit for speed and minification) */ -GPS['parsers'] = { - // Global Positioning System Fix Data - 'GGA': function (str, gga) { - if (gga.length !== 16 && gga.length !== 14) { - throw new Error('Invalid GGA length: ' + str); - } - - /* - 11 - 1 2 3 4 5 6 7 8 9 10 | 12 13 14 15 - | | | | | | | | | | | | | | | - $--GGA,hhmmss.ss,llll.ll,a,yyyyy.yy,a,x,xx,x.x,x.x,M,x.x,M,x.x,xxxx*hh - - 1) Time (UTC) - 2) Latitude - 3) N or S (North or South) - 4) Longitude - 5) E or W (East or West) - 6) GPS Quality Indicator, - 0 = Invalid, 1 = Valid SPS, 2 = Valid DGPS, 3 = Valid PPS - 7) Number of satellites in view, 00 - 12 - 8) Horizontal Dilution of precision, lower is better - 9) Antenna Altitude above/below mean-sea-level (geoid) - 10) Units of antenna altitude, meters - 11) Geoidal separation, the difference between the WGS-84 earth - ellipsoid and mean-sea-level (geoid), '-' means mean-sea-level below ellipsoid - 12) Units of geoidal separation, meters - 13) Age of differential GPS data, time in seconds since last SC104 - type 1 or 9 update, null field when DGPS is not used - 14) Differential reference station ID, 0000-1023 - 15) Checksum - */ - - return { - 'time': parseTime(gga[1]), - 'lat': parseCoord(gga[2], gga[3]), - 'lon': parseCoord(gga[4], gga[5]), - 'alt': parseDist(gga[9], gga[10]), - 'quality': parseGGAFix(gga[6]), - 'satellites': parseNumber(gga[7]), - 'hdop': parseNumber(gga[8]), // dilution - 'geoidal': parseDist(gga[11], gga[12]), // above geoid - 'age': gga[13] === undefined ? null : parseNumber(gga[13]), // DGPS age - 'stationID': gga[14] === undefined ? null : parseNumber(gga[14]) // DGPS ref - }; - }, - - // GPS DOP and active satellites - 'GSA': function (str, gsa) { - - if (gsa.length !== 19 && gsa.length !== 20) { - throw new Error('Invalid GSA length: ' + str); - } - - /* - eg1. $GPGSA,A,3,,,,,,16,18,,22,24,,,3.6,2.1,2.2*3C - eg2. $GPGSA,A,3,19,28,14,18,27,22,31,39,,,,,1.7,1.0,1.3*35 - - - 1 = Mode: - M=Manual, forced to operate in 2D or 3D - A=Automatic, 3D/2D - 2 = Mode: - 1=Fix not available - 2=2D - 3=3D - 3-14 = PRNs of Satellite Vehicles (SVs) used in position fix (null for unused fields) - 15 = PDOP - 16 = HDOP - 17 = VDOP - (18) = systemID NMEA 4.10 - 18 = Checksum - */ - - const sats = []; - for (let i = 3; i < 15; i++) { - if (gsa[i] !== '') sats.push(parseInt(gsa[i], 10)); - } - const sid = gsa.length > 19 ? parseNumber(gsa[18]) : null; - return { - 'mode': parseGSAMode(gsa[1]), - 'fix': parseGSAFix(gsa[2]), - 'satellites': sats, - 'pdop': parseNumber(gsa[15]), - 'hdop': parseNumber(gsa[16]), - 'vdop': parseNumber(gsa[17]), - 'systemId': sid, - 'system': sid !== null ? parseSystemId(sid) : 'unknown' - }; - }, - - // Recommended Minimum data for GPS - 'RMC': function (str, rmc) { - if (rmc.length !== 13 && rmc.length !== 14 && rmc.length !== 15) { - throw new Error('Invalid RMC length: ' + str); - } - - /* - $GPRMC,hhmmss.ss,A,llll.ll,a,yyyyy.yy,a,x.x,x.x,ddmmyy,x.x,a*hh - - RMC = Recommended Minimum Specific GPS/TRANSIT Data - 1 = UTC of position fix - 2 = Data status (A-ok, V-invalid) - 3 = Latitude of fix - 4 = N or S - 5 = Longitude of fix - 6 = E or W - 7 = Speed over ground in knots - 8 = Track made good in degrees True - 9 = UT date - 10 = Magnetic variation degrees (Easterly var. subtracts from true course) - 11 = E or W - (12) = NMEA 2.3 introduced FAA mode indicator (A=Autonomous, D=Differential, E=Estimated, N=Data not valid) - (13) = NMEA 4.10 introduced nav status - 12 = Checksum - */ - - return { - 'time': parseTime(rmc[1], rmc[9]), - 'status': parseRMC_GLLStatus(rmc[2]), - 'lat': parseCoord(rmc[3], rmc[4]), - 'lon': parseCoord(rmc[5], rmc[6]), - 'speed': parseKnots(rmc[7]), - 'track': parseNumber(rmc[8]), // heading (true) - 'variation': parseRMCVariation(rmc[10], rmc[11]), - 'faa': rmc.length > 13 ? parseFAA(rmc[12]) : null, - 'navStatus': rmc.length > 14 ? rmc[13] : null - }; - }, - - // Track info - 'VTG': function (str, vtg) { - if (vtg.length !== 10 && vtg.length !== 11) { - throw new Error('Invalid VTG length: ' + str); - } - - /* - ------------------------------------------------------------------------------ - 1 2 3 4 5 6 7 8 9 10 - | | | | | | | | | | - $--VTG,x.x,T,x.x,M,x.x,N,x.x,K,m,*hh - ------------------------------------------------------------------------------ - - 1 = Track made good (degrees true) - 2 = Fixed text 'T' indicates that track made good is relative to true north - 3 = optional: Track made good (degrees magnetic) - 4 = optional: M: track made good is relative to magnetic north - 5 = Speed over ground in knots - 6 = Fixed text 'N' indicates that speed over ground in in knots - 7 = Speed over ground in kilometers/hour - 8 = Fixed text 'K' indicates that speed over ground is in kilometers/hour - (9) = FAA mode indicator (NMEA 2.3 and later) - 9/10 = Checksum - */ - - // Empty / all-null VTG (some receivers output this) - if (vtg[2] === '' && vtg[8] === '' && vtg[6] === '') { - return { - 'track': null, - 'trackMagnetic': null, - 'speed': null, - 'faa': null - }; - } - - if (vtg[2] !== 'T') { - throw new Error('Invalid VTG track mode: ' + str); - } - if (vtg[8] !== 'K' || vtg[6] !== 'N') { - throw new Error('Invalid VTG speed tag: ' + str); - } - - return { - 'track': parseNumber(vtg[1]), // true heading - 'trackMagnetic': vtg[3] === '' ? null : parseNumber(vtg[3]), // magnetic - 'speed': parseKnots(vtg[5]), - 'faa': vtg.length === 11 ? parseFAA(vtg[9]) : null - }; - }, - - // Satellites in view - 'GSV': function (str, gsv) { - // NMEA allows variable chunks of 4 fields per satellite + header/footer. - // Keep legacy guard but allow most common valid shapes. - if (gsv.length % 4 === 0) { - // = 1 -> normal package - // = 2 -> NMEA 4.10 extension - // = 3 -> BeiDou extension? - throw new Error('Invalid GSV length: ' + str); - } - - /* - $GPGSV,1,1,13,02,02,213,,03,-3,000,,11,00,121,,14,13,172,05*67 - - 1 = Total number of messages of this type in this cycle - 2 = Message number - 3 = Total number of SVs in view - repeat [ - 4 = SV PRN number - 5 = Elevation in degrees, 90 maximum - 6 = Azimuth, degrees from true north, 000 to 359 - 7 = SNR (signal to noise ratio), 00-99 dB (null when not tracking, higher is better) - ] - N+1 = signalID NMEA 4.10 - N+2 = Checksum - */ - - const sats = []; - const satellite = str.slice(1, 3); - // fields: [totMsgs, msgNum, satsInView, (prn,elev,az,snr)*, (signalId)?, checksum] - for (let i = 4; i < gsv.length - 3; i += 4) { - const prn = parseNumber(gsv[i]); - const snr = parseNumber(gsv[i + 3]); - /* - Plot satellites in Radar chart with north on top - by linear map elevation from 0° to 90° into r to 0 - - centerX + cos(azimuth - 90) * (1 - elevation / 90) * radius - centerY + sin(azimuth - 90) * (1 - elevation / 90) * radius - */ - sats.push({ - 'prn': prn, - 'elevation': parseNumber(gsv[i + 1]), - 'azimuth': parseNumber(gsv[i + 2]), - 'snr': snr, - 'status': prn !== null ? (snr !== null ? 'tracking' : 'in view') : null, - 'system': parseSystem(str), - 'key': satellite + prn - }); - } - - return { - 'msgNumber': parseNumber(gsv[2]), - 'msgsTotal': parseNumber(gsv[1]), - 'satsInView': parseNumber(gsv[3]), - 'satellites': sats, - 'signalId': gsv.length % 4 === 2 ? parseNumber(gsv[gsv.length - 2]) : null, // NMEA 4.10 - 'system': parseSystem(str) - }; - }, - - // Geographic Position - Latitude/Longitude - 'GLL': function (str, gll) { - if (gll.length !== 9 && gll.length !== 8) { - throw new Error('Invalid GLL length: ' + str); - } - - /* - ------------------------------------------------------------------------------ - 1 2 3 4 5 6 7 8 - | | | | | | | | - $--GLL,llll.ll,a,yyyyy.yy,a,hhmmss.ss,a,m,*hh - ------------------------------------------------------------------------------ - - 1. Latitude - 2. N or S (North or South) - 3. Longitude - 4. E or W (East or West) - 5. Universal Time Coordinated (UTC) - 6. Status A - Data Valid, V - Data Invalid - 7. FAA mode indicator (NMEA 2.3 and later) - 8. Checksum - */ - - return { - 'time': parseTime(gll[5]), - 'status': parseRMC_GLLStatus(gll[6]), - 'lat': parseCoord(gll[1], gll[2]), - 'lon': parseCoord(gll[3], gll[4]), - 'faa': gll.length === 9 ? parseFAA(gll[7]) : null - }; - }, - - // UTC Date / Time and Local Time Zone Offset - 'ZDA': function (str, zda) { - - /* - 1 = hhmmss.ss = UTC - 2 = xx = Day, 01 to 31 - 3 = xx = Month, 01 to 12 - 4 = xxxx = Year - 5 = xx = Local zone description, 00 to +/- 13 hours - 6 = xx = Local zone minutes description (same sign as hours) - */ - - // (No strict length guard; some receivers omit trailing fields) - return { - 'time': parseTime(zda[1], zda[2] + zda[3] + zda[4]), - // 'delta': can be derived by consumer: (Date.now() - time)/1000 - 'offsetMin': (zda[5] === '' || zda[6] === '') ? null - : (parseInt(zda[5], 10) * 60 + parseInt(zda[6], 10)) - }; - }, - - 'GST': function (str, gst) { - if (gst.length !== 10) { - throw new Error('Invalid GST length: ' + str); - } - - /* - 1 = Time (UTC) - 2 = RMS value of the pseudorange residuals; includes carrier phase residuals during periods of RTK (float) and RTK (fixed) processing - 3 = Error ellipse semi-major axis 1 sigma error, in meters - 4 = Error ellipse semi-minor axis 1 sigma error, in meters - 5 = Error ellipse orientation, degrees from true north - 6 = Latitude 1 sigma error, in meters - 7 = Longitude 1 sigma error, in meters - 8 = Height 1 sigma error, in meters - 9 = Checksum - */ - - return { - 'time': parseTime(gst[1]), - 'rms': parseNumber(gst[2]), - 'ellipseMajor': parseNumber(gst[3]), - 'ellipseMinor': parseNumber(gst[4]), - 'ellipseOrientation': parseNumber(gst[5]), - 'latitudeError': parseNumber(gst[6]), - 'longitudeError': parseNumber(gst[7]), - 'heightError': parseNumber(gst[8]) - }; - }, - - // Heading relative to True North - 'HDT': function (str, hdt) { - if (hdt.length !== 4) { - throw new Error('Invalid HDT length: ' + str); - } - - /* - ------------------------------------------------------------------------------ - 1 2 3 - | | | - $--HDT,hhh.hhh,T*XX - ------------------------------------------------------------------------------ - - 1. Heading in degrees - 2. T: indicates heading relative to True North - 3. Checksum - */ - - return { - 'heading': parseFloat(hdt[1]), - 'trueNorth': hdt[2] === 'T' - }; - }, - - 'GRS': function (str, grs) { - if (grs.length !== 18) { - throw new Error('Invalid GRS length: ' + str); - } - const res = []; - for (let i = 3; i <= 14; i++) { - const tmp = parseNumber(grs[i]); - if (tmp !== null) res.push(tmp); - } - return { - 'time': parseTime(grs[1]), - 'mode': parseNumber(grs[2]), - 'res': res - }; - }, - - 'GBS': function (str, gbs) { - if (gbs.length !== 10 && gbs.length !== 12) { - throw new Error('Invalid GBS length: ' + str); - } - - /* - 0 1 2 3 4 5 6 7 8 - | | | | | | | | | - $--GBS,hhmmss.ss,x.x,x.x,x.x,x.x,x.x,x.x,x.x*hh - - 1. UTC time of the GGA or GNS fix associated with this sentence - 2. Expected error in latitude (meters) - 3. Expected error in longitude (meters) - 4. Expected error in altitude (meters) - 5. PRN (id) of most likely failed satellite - 6. Probability of missed detection for most likely failed satellite - 7. Estimate of bias in meters on most likely failed satellite - 8. Standard deviation of bias estimate - -- - 9. systemID (NMEA 4.10) - 10. signalID (NMEA 4.10) - */ - - return { - 'time': parseTime(gbs[1]), - 'errLat': parseNumber(gbs[2]), - 'errLon': parseNumber(gbs[3]), - 'errAlt': parseNumber(gbs[4]), - 'failedSat': parseNumber(gbs[5]), - 'probFailedSat': parseNumber(gbs[6]), - 'biasFailedSat': parseNumber(gbs[7]), - 'stdFailedSat': parseNumber(gbs[8]), - 'systemId': gbs.length === 12 ? parseNumber(gbs[9]) : null, - 'signalId': gbs.length === 12 ? parseNumber(gbs[10]) : null - }; - }, - - 'GNS': function (str, gns) { - if (gns.length !== 14 && gns.length !== 15) { - throw new Error('Invalid GNS length: ' + str); - } - return { - 'time': parseTime(gns[1]), - 'lat': parseCoord(gns[2], gns[3]), - 'lon': parseCoord(gns[4], gns[5]), - 'mode': gns[6], - 'satsUsed': parseNumber(gns[7]), - 'hdop': parseNumber(gns[8]), - 'alt': parseNumber(gns[9]), - 'sep': parseNumber(gns[10]), - 'diffAge': parseNumber(gns[11]), - 'diffStation': parseNumber(gns[12]), - 'navStatus': gns.length === 15 ? gns[13] : null // NMEA 4.10 - }; - }, - - // Text Transmission (TXT) - // NMEA0183-2 §6.3 ($--TXT,xx,xx,xx,c...c*hh) - 'TXT': function (str, txt) { - - // After talker removal, txt expected: ['TXT', total, index, id, payload, checksum] - if (txt.length !== 6) { - throw new Error('Invalid TXT length: ' + str); - } - - var total = parseInt(txt[1], 10); - var index = parseInt(txt[2], 10); - var textId = parseInt(txt[3], 10); - var rawPart = txt[4] || ''; - - if (!(total >= 1 && total <= 99)) throw new Error('Invalid TXT total: ' + txt[1]); - if (!(index >= 1 && index <= total)) throw new Error('Invalid TXT index: ' + txt[2]); - if (!(textId >= 0 && textId <= 99)) throw new Error('Invalid TXT id: ' + txt[3]); - if (rawPart.length > 61) throw new Error('Invalid TXT message length: ' + rawPart.length); - - var part = escapeString(rawPart); - if (part === '') throw new Error('Invalid empty TXT message'); - - // For single-part messages, we can return a completed object right away. - // Multi-part completion is handled in instance _assembleTXT (see below). - return { - // assembly fields: - 'total': total, - 'index': index, - 'id': textId, - 'part': part, // decoded segment - 'message': (total === 1) ? part : null, - 'completed': (total === 1), - 'rawMessages': (total === 1) ? [part] : [], - 'system': parseSystem(str) // e.g. 'GPS', 'GLONASS', ... - }; - } -}; - -/* Static parse + geodesy helpers */ - -GPS['Parse'] = function (line) { - if (typeof line !== 'string' || line.length < 6) return false; - if (line.charCodeAt(0) !== 36 /* '$' */) return false; - - const star = line.indexOf('*', 1); - if (star === -1 || star + 2 >= line.length) return false; - - const nmea = []; - const firstComma = line.indexOf(',', 1); - if (firstComma === -1 || firstComma > star) return false; - - nmea.push('$' + line.slice(1, firstComma)); - - // checksum over everything between '$' and '*' - let checksum = 0; - for (let i = 1; i < star; i++) checksum ^= line.charCodeAt(i); - - // split fields after the first comma - let fieldStart = firstComma + 1; - for (let i = fieldStart; i < star; i++) { - if (line.charCodeAt(i) === 44 /* ',' */) { - nmea.push(line.slice(fieldStart, i)); - fieldStart = i + 1; - } - } - nmea.push(line.slice(fieldStart, star)); - - const crcStr = line.slice(star + 1).trim(); - const crc = parseInt(crcStr.slice(0, 2), 16); - if (!(crc >= 0 && crc <= 255)) return false; - - nmea[0] = nmea[0].slice(3); - const type = nmea[0]; - const mod = GPS['parsers'][type]; - if (mod === undefined) return false; - - nmea.push(crcStr.slice(0, 2)); - - const data = mod(line, nmea); - data['raw'] = line; - data['valid'] = (checksum === crc); - data['type'] = type; - - return data; -}; - -// Heading (N=0, E=90, S=180, W=270) from point 1 to point 2 -GPS['Heading'] = function (lat1, lon1, lat2, lon2) { - const dlon = (lon2 - lon1) * D2R; - lat1 *= D2R; lat2 *= D2R; - - const sdlon = Math.sin(dlon), cdlon = Math.cos(dlon); - const slat1 = Math.sin(lat1), clat1 = Math.cos(lat1); - const slat2 = Math.sin(lat2), clat2 = Math.cos(lat2); - - const y = sdlon * clat2; - const x = clat1 * slat2 - slat1 * clat2 * cdlon; - - const head = Math.atan2(y, x) * 180 / Math.PI; - return (head + 360) % 360; -}; - -GPS['Distance'] = function (lat1, lon1, lat2, lon2) { - // Haversine Formula - // R.W. Sinnott, "Virtues of the Haversine", Sky and Telescope, vol. 68, no. 2, 1984, p. 159 - - // Because Earth is no exact sphere, rounding errors may be up to 0.5%. - // var RADIUS = 6371; // Earth radius average - // var RADIUS = 6378.137; // Earth radius at equator - const RADIUS = 6372.8; // km - const hLat = (lat2 - lat1) * D2R * 0.5; - const hLon = (lon2 - lon1) * D2R * 0.5; - lat1 *= D2R; lat2 *= D2R; - - const shLat = Math.sin(hLat), shLon = Math.sin(hLon); - const clat1 = Math.cos(lat1), clat2 = Math.cos(lat2); - - const tmp = shLat * shLat + clat1 * clat2 * shLon * shLon; - //return RADIUS * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1.0 - a)); - return RADIUS * 2 * Math.asin(Math.sqrt(tmp)); -}; - -GPS['TotalDistance'] = function (path) { - - if (path.length < 2) return 0; - let len = 0; - for (let i = 0; i < path.length - 1; i++) { - const c = path[i]; - const n = path[i + 1]; - len += GPS['Distance'](c['lat'], c['lon'], n['lat'], n['lon']); - } - return len; -}; - -/* ---------- Instance methods (single prototype assignment) ---------- */ - -GPS.prototype = { - constructor: GPS, - - /* Internal: merge parsed packet into state, keep short-term sat caches fresh */ - '_updateState': function (data) { - const state = this['state']; - - // TODO: can we really use RMC time here or is it the time of fix? - if (data['type'] === 'RMC' || data['type'] === 'GGA' || data['type'] === 'GLL' || data['type'] === 'GNS') { - state['time'] = data['time']; - state['lat'] = data['lat']; - state['lon'] = data['lon']; - } - - if (data['type'] === 'HDT') { - state['heading'] = data['heading']; - state['trueNorth'] = data['trueNorth']; - } - - if (data['type'] === 'ZDA') { - state['time'] = data['time']; - } - - if (data['type'] === 'GGA') { - state['alt'] = data['alt']; - } - - if (data['type'] === 'RMC' || data['type'] === 'VTG') { - if (data['speed'] != null) state['speed'] = data['speed']; - if (data['track'] != null) state['track'] = data['track']; - } - - if (data['type'] === 'GSA') { - const systemId = data['systemId']; - if (systemId != null) this['_collectActiveSats'][systemId] = data['satellites']; - - const satsActive = []; - const collectActiveSats = this['_collectActiveSats']; - for (const s in collectActiveSats) { - if (Object.prototype.hasOwnProperty.call(collectActiveSats, s)) { - // Concatenate without allocating a new array for each system - const arr = collectActiveSats[s]; - for (let i = 0, L = arr.length; i < L; i++) satsActive.push(arr[i]); - } - } - - state['satsActive'] = satsActive; - state['fix'] = data['fix']; - state['hdop'] = data['hdop']; - state['pdop'] = data['pdop']; - state['vdop'] = data['vdop']; - } - - if (data['type'] === 'GSV') { - const now = Date.now(); - const sats = data['satellites']; - const collectSats = this['_collectSats']; - const lastSeenSat = this['_lastSeenSat']; - - for (let i = 0, L = sats.length; i < L; i++) { - const key = sats[i]['key']; - lastSeenSat[key] = now; - collectSats[key] = sats[i]; - } - - // Satellites are considered "visible" for 3 seconds after last seen - const ret = []; - for (const key in collectSats) { - if (Object.prototype.hasOwnProperty.call(collectSats, key)) { - if (now - lastSeenSat[key] < 3000) ret.push(collectSats[key]); - else { - // Optional: clean up stale entries - delete collectSats[key]; - delete lastSeenSat[key]; - } - } - } - state['satsVisible'] = ret; - } - }, - - '_assembleTXT': function (data) { - // Single-part already complete (parser set message) - if (data['total'] === 1) return data; - - const key = (data['system'] || '') + '#' + data['id']; - - let buf = this['state']['txtBuffer'][key]; - if (!buf) { - buf = this['state']['txtBuffer'][key] = { - 'total': data['total'], - 'parts': new Array(data['total']).fill(null), - 'received': 0, - 'timer': null - }; - // 10s timeout to avoid leaks - const self = this; - buf['timer'] = setTimeout(function () { - self['state']['errors']++; - delete self['state']['txtBuffer'][key]; - }, 10000); - } - - // store part (index is 1-based) - const idx = data['index'] - 1; - if (0 <= idx && idx < buf['total']) { - buf['parts'][idx] = data['part']; - buf['received']++; - } - - // check completion - if (buf['received'] === buf['total']) { - clearTimeout(buf['timer']); - delete this['state']['txtBuffer'][key]; - data['message'] = buf['parts'].join(''); - data['completed'] = true; - data['rawMessages'] = buf['parts']; - - } else { - data['message'] = null; - data['completed'] = false; - data['rawMessages'] = []; - } - return data; - }, - - /** - * Feed one full NMEA line (starting with '$', ending before CRLF). - * Emits both 'data' and '' events on success. - */ - 'update': function (line) { - const parsed = GPS['Parse'](line); - this['state']['processed']++; - - if (parsed === false) { - this['state']['errors']++; - return false; - } - - // Assemble TXT multi-part here - if (parsed['type'] === 'TXT') { - this['_assembleTXT'](parsed); - } - - this['_updateState'](parsed); - - this['emit']('data', parsed); - this['emit'](parsed['type'], parsed); - - return true; - }, - - /** - * Feed streaming data (chunks, possibly split arbitrarily). - * Accepts either "\r\n" or "\n" as line delimiters. - */ - 'updatePartial': function (chunk) { - if (chunk) this['partial'] += chunk; - - // Process all complete lines - for (; ;) { - const idxRN = this['partial'].indexOf('\r\n'); - const idxN = this['partial'].indexOf('\n'); - - let pos = -1; - if (idxRN !== -1) pos = idxRN; - else if (idxN !== -1) pos = idxN; - - if (pos === -1) break; - - const line = this['partial'].slice(0, pos); - // Advance buffer past delimiter (2 for CRLF, 1 for LF) - this['partial'] = this['partial'].slice(pos + (idxRN === pos ? 2 : 1)); - - if (line.charAt(0) !== '$') continue; - - try { - this['update'](line); - } catch (err) { - // Keep buffer (don’t drop subsequent lines), but count the error - this['state']['errors']++; - // Re-throw for caller visibility - throw err; - } - } - }, - - /** - * Subscribe to an event. Multiple listeners per event are supported. - * @param {string} ev - * @param {function()} cb - * @returns {GPS} this (chainable) - */ - 'on': function (ev, cb) { - const cur = this['events'][ev]; - if (cur === undefined) { - this['events'][ev] = [cb]; - } else if (typeof cur === 'function') { - // Backward compatibility with previous single-listener design - this['events'][ev] = [cur, cb]; - } else { - this['events'][ev].push(cb); - } - return this; - }, - - /** - * Remove listeners. If cb omitted, remove all for the event. - * @param {string} ev - * @param {function()} cb - * @returns {GPS} this - */ - 'off': function (ev, cb) { - const cur = this['events'][ev]; - if (cur === undefined) return this; - - if (!cb) { - delete this['events'][ev]; - return this; - } - - if (typeof cur === 'function') { - if (cur === cb) delete this['events'][ev]; - return this; - } - - // Array case - for (let i = cur.length - 1; i >= 0; i--) { - if (cur[i] === cb) cur.splice(i, 1); - } - if (cur.length === 0) delete this['events'][ev]; - return this; - }, - - /** - * Emit an event to all listeners. - * @param {string} ev - * @param {*} data - */ - 'emit': function (ev, data) { - const cur = this['events'][ev]; - if (cur === undefined) return; - - if (typeof cur === 'function') { - cur.call(this, data); - return; - } - // Array of listeners - for (let i = 0, L = cur.length; i < L; i++) { - cur[i].call(this, data); - } - } +const D2R = Math.PI / 180; + +function parseTime(time, date = null) { + // Accepts hhmmss(.sss)? and optional ddmmyy or ddmmyyyy (ZDA/GPRMC variants). + if (!time) return null; + + const ret = new Date(); + + if (date) { + const year = date.slice(4); + const month = date.slice(2, 4) - 1; + const day = date.slice(0, 2); + + if (year.length === 4) { + ret.setUTCFullYear(+year, +month, +day); + } else { + // If we need to parse older GPRMC data, we should hack something like + // year < 73 ? 2000+year : 1900+year + // Since GPS appeared in 1973 + ret.setUTCFullYear(Number('20' + year), +month, +day); + } + } + + ret.setUTCHours(+time.slice(0, 2)); + ret.setUTCMinutes(+time.slice(2, 4)); + ret.setUTCSeconds(+time.slice(4, 6)); + + // Milliseconds: allow no decimals, .ss, .sss, .ssss... and normalize to ms + const dot = time.indexOf('.'); + let ms = 0; + if (dot !== -1 && dot + 1 < time.length) { + const frac = time.slice(dot + 1); + // Take up to 3 digits; if fewer, scale; if more, truncate + if (frac.length >= 3) { + ms = +frac.slice(0, 3); + } else if (frac.length === 2) { + ms = +frac * 10; // .xx => xx0 ms + } else if (frac.length === 1) { + ms = +frac * 100; // .x => x00 ms + } + } + ret.setUTCMilliseconds(ms); + return ret; +} + +function parseCoord(coord, dir) { + // NMEA lat: DDMM.mmmm; lon: DDDMM.mmmm; dir in {N,S,E,W} + // Latitude can go from 0 to 90; longitude can go from -180 to 180. + if (coord === '') return null; + const sgn = (dir === 'S' || dir === 'W') ? -1 : 1; + const n = (dir === 'N' || dir === 'S') ? 2 : 3; + return sgn * (parseFloat(coord.slice(0, n)) + parseFloat(coord.slice(n)) / 60); +} + +function parseNumber(num) { + return num === '' ? null : parseFloat(num); +} + +function parseKnots(knots) { + return knots === '' ? null : parseFloat(knots) * 1.852; // km/h +} + +function parseSystemId(systemId) { + switch (systemId) { + case 0: return 'QZSS'; + case 1: return 'GPS'; + case 2: return 'GLONASS'; + case 3: return 'Galileo'; + case 4: return 'BeiDou'; + default: return 'unknown'; + } +} + +function parseSystem(str) { + const satellite = str.slice(1, 3); + switch (satellite) { + case 'GP': return 'GPS'; + case 'GQ': return 'QZSS'; + case 'GL': return 'GLONASS'; + case 'GA': return 'Galileo'; + case 'GB': return 'BeiDou'; + default: return satellite; + } +} + +function parseGSAMode(mode) { + switch (mode) { + case 'M': return 'manual'; + case 'A': return 'automatic'; + case '': return null; + } + //throw new Error('INVALID GSA MODE: ' + mode); + this.error(new Error('INVALID GSA MODE: ' + mode)) +} + +function parseGGAFix(fix) { + if (fix === '') return null; + switch (parseInt(fix, 10)) { + case 0: return null; + case 1: return 'fix'; // valid SPS fix + case 2: return 'dgps-fix'; // valid DGPS fix + case 3: return 'pps-fix'; // valid PPS fix + case 4: return 'rtk'; // RTK fixed + case 5: return 'rtk-float'; // RTK float + case 6: return 'estimated'; // dead reckoning + case 7: return 'manual'; + case 8: return 'simulated'; + } + this.error(new Error('INVALID GGA FIX: ' + fix)) +} + +function parseGSAFix(fix) { + if (fix === '') return null; + switch (parseInt(fix, 10)) { + case 1: return null; + case 2: return '2D'; + case 3: return '3D'; + } + this.error(new Error('INVALID GSA FIX: ' + fix)) +} + +function parseRMC_GLLStatus(status) { + switch (status) { + case '': return null; + case 'A': return 'active'; + case 'V': return 'void'; + } + this.error(new Error('INVALID RMC/GLL STATUS: ' + status)) +} + +function parseFAA(faa) { + // Only A and D will correspond to an Active and reliable sentence + switch (faa) { + case '': return null; + case 'A': return 'autonomous'; + case 'D': return 'differential'; + case 'E': return 'estimated'; // dead reckoning + case 'M': return 'manual input'; + case 'S': return 'simulated'; + case 'N': return 'not valid'; + case 'P': return 'precise'; + case 'R': return 'rtk'; + case 'F': return 'rtk-float'; + } + this.error(new Error('INVALID FAA MODE: ' + faa)) +} + +function parseRMCVariation(vari, dir) { + if (vari === '' || dir === '') return null; + return parseFloat(vari) * (dir === 'W' ? -1 : 1); +} + +function parseDist(num, unit) { + if (unit === 'M' || unit === '') return parseNumber(num); + this.error(new Error('Unknown unit: ' + unit)) +} + +/** + * Decode TXT caret-escapes and reject invalid chars. + * Spec: NMEA0183-2 §5.1.3 (escapes) and §6.1 Table 1 (invalid chars) + * + * @param {string} str + * @returns {string} + */ +function escapeString(str) { + if (str == null) return ''; + + // invalid characters per spec (excluding '^' which introduces escapes) + var invalid = ["\r", "\n", "$", "*", ",", "!", "\\", "~", "\u007F" /* DEL */]; + for (var i = 0; i < invalid.length; i++) { + if (str.indexOf(invalid[i]) !== -1) { + this.error(new Error("Message may not contain invalid character '" + invalid[i] + "'")) + } + } + + // caret escapes: ^HH (hex byte) or ^^ (literal caret) + var out = ''; + for (var j = 0; j < str.length; j++) { + var ch = str.charCodeAt(j); + if (ch !== 94 /* '^' */) { out += str[j]; continue; } + var n1 = str[j + 1], n2 = str[j + 2]; + if (n1 === '^') { out += '^'; j += 1; continue; } + if (n1 && n2 && + ((n1 >= '0' && n1 <= '9') || (n1 >= 'A' && n1 <= 'F') || (n1 >= 'a' && n1 <= 'f')) && + ((n2 >= '0' && n2 <= '9') || (n2 >= 'A' && n2 <= 'F') || (n2 >= 'a' && n2 <= 'f'))) { + out += String.fromCharCode(parseInt(n1 + n2, 16)); + j += 2; + } else { + // unknown escape → keep caret literally + out += '^'; + } + } + return out; +} + +/** + * + * @constructor + */ +function GPS() { + if (!(this instanceof GPS)) return new GPS(); + + // Public fields + this['events'] = Object.create(null); + this['state'] = { 'errors': 0, 'processed': 0, 'txtBuffer': {} }; + + // Internal, per-instance collectors (avoid cross-stream state bleed) + this['_collectSats'] = Object.create(null); + this['_collectActiveSats'] = Object.create(null); + this['_lastSeenSat'] = Object.create(null); + + // Streaming buffer + this['partial'] = ''; +} + +/* Static fields (explicit for speed and minification) */ +GPS['parsers'] = { + // Global Positioning System Fix Data + 'GGA': function (str, gga) { + if (gga.length !== 16 && gga.length !== 14) { + this.error(new Error('Invalid GGA length: ' + str)) + } + + /* + 11 + 1 2 3 4 5 6 7 8 9 10 | 12 13 14 15 + | | | | | | | | | | | | | | | + $--GGA,hhmmss.ss,llll.ll,a,yyyyy.yy,a,x,xx,x.x,x.x,M,x.x,M,x.x,xxxx*hh + + 1) Time (UTC) + 2) Latitude + 3) N or S (North or South) + 4) Longitude + 5) E or W (East or West) + 6) GPS Quality Indicator, + 0 = Invalid, 1 = Valid SPS, 2 = Valid DGPS, 3 = Valid PPS + 7) Number of satellites in view, 00 - 12 + 8) Horizontal Dilution of precision, lower is better + 9) Antenna Altitude above/below mean-sea-level (geoid) + 10) Units of antenna altitude, meters + 11) Geoidal separation, the difference between the WGS-84 earth + ellipsoid and mean-sea-level (geoid), '-' means mean-sea-level below ellipsoid + 12) Units of geoidal separation, meters + 13) Age of differential GPS data, time in seconds since last SC104 + type 1 or 9 update, null field when DGPS is not used + 14) Differential reference station ID, 0000-1023 + 15) Checksum + */ + + return { + 'time': parseTime(gga[1]), + 'lat': parseCoord(gga[2], gga[3]), + 'lon': parseCoord(gga[4], gga[5]), + 'alt': parseDist(gga[9], gga[10]), + 'quality': parseGGAFix(gga[6]), + 'satellites': parseNumber(gga[7]), + 'hdop': parseNumber(gga[8]), // dilution + 'geoidal': parseDist(gga[11], gga[12]), // above geoid + 'age': gga[13] === undefined ? null : parseNumber(gga[13]), // DGPS age + 'stationID': gga[14] === undefined ? null : parseNumber(gga[14]) // DGPS ref + }; + }, + + // GPS DOP and active satellites + 'GSA': function (str, gsa) { + + if (gsa.length !== 19 && gsa.length !== 20) { + this.error(new Error('Invalid GSA length: ' + str)) + } + + /* + eg1. $GPGSA,A,3,,,,,,16,18,,22,24,,,3.6,2.1,2.2*3C + eg2. $GPGSA,A,3,19,28,14,18,27,22,31,39,,,,,1.7,1.0,1.3*35 + + + 1 = Mode: + M=Manual, forced to operate in 2D or 3D + A=Automatic, 3D/2D + 2 = Mode: + 1=Fix not available + 2=2D + 3=3D + 3-14 = PRNs of Satellite Vehicles (SVs) used in position fix (null for unused fields) + 15 = PDOP + 16 = HDOP + 17 = VDOP + (18) = systemID NMEA 4.10 + 18 = Checksum + */ + + const sats = []; + for (let i = 3; i < 15; i++) { + if (gsa[i] !== '') sats.push(parseInt(gsa[i], 10)); + } + const sid = gsa.length > 19 ? parseNumber(gsa[18]) : null; + return { + 'mode': parseGSAMode(gsa[1]), + 'fix': parseGSAFix(gsa[2]), + 'satellites': sats, + 'pdop': parseNumber(gsa[15]), + 'hdop': parseNumber(gsa[16]), + 'vdop': parseNumber(gsa[17]), + 'systemId': sid, + 'system': sid !== null ? parseSystemId(sid) : 'unknown' + }; + }, + + // Recommended Minimum data for GPS + 'RMC': function (str, rmc) { + if (rmc.length !== 13 && rmc.length !== 14 && rmc.length !== 15) { + this.error(new Error('Invalid RMC length: ' + str)) + } + + /* + $GPRMC,hhmmss.ss,A,llll.ll,a,yyyyy.yy,a,x.x,x.x,ddmmyy,x.x,a*hh + + RMC = Recommended Minimum Specific GPS/TRANSIT Data + 1 = UTC of position fix + 2 = Data status (A-ok, V-invalid) + 3 = Latitude of fix + 4 = N or S + 5 = Longitude of fix + 6 = E or W + 7 = Speed over ground in knots + 8 = Track made good in degrees True + 9 = UT date + 10 = Magnetic variation degrees (Easterly var. subtracts from true course) + 11 = E or W + (12) = NMEA 2.3 introduced FAA mode indicator (A=Autonomous, D=Differential, E=Estimated, N=Data not valid) + (13) = NMEA 4.10 introduced nav status + 12 = Checksum + */ + + return { + 'time': parseTime(rmc[1], rmc[9]), + 'status': parseRMC_GLLStatus(rmc[2]), + 'lat': parseCoord(rmc[3], rmc[4]), + 'lon': parseCoord(rmc[5], rmc[6]), + 'speed': parseKnots(rmc[7]), + 'track': parseNumber(rmc[8]), // heading (true) + 'variation': parseRMCVariation(rmc[10], rmc[11]), + 'faa': rmc.length > 13 ? parseFAA(rmc[12]) : null, + 'navStatus': rmc.length > 14 ? rmc[13] : null + }; + }, + + // Track info + 'VTG': function (str, vtg) { + if (vtg.length !== 10 && vtg.length !== 11) { + this.error(new Error('Invalid VTG length: ' + str)) + } + + /* + ------------------------------------------------------------------------------ + 1 2 3 4 5 6 7 8 9 10 + | | | | | | | | | | + $--VTG,x.x,T,x.x,M,x.x,N,x.x,K,m,*hh + ------------------------------------------------------------------------------ + + 1 = Track made good (degrees true) + 2 = Fixed text 'T' indicates that track made good is relative to true north + 3 = optional: Track made good (degrees magnetic) + 4 = optional: M: track made good is relative to magnetic north + 5 = Speed over ground in knots + 6 = Fixed text 'N' indicates that speed over ground in in knots + 7 = Speed over ground in kilometers/hour + 8 = Fixed text 'K' indicates that speed over ground is in kilometers/hour + (9) = FAA mode indicator (NMEA 2.3 and later) + 9/10 = Checksum + */ + + // Empty / all-null VTG (some receivers output this) + if (vtg[2] === '' && vtg[8] === '' && vtg[6] === '') { + return { + 'track': null, + 'trackMagnetic': null, + 'speed': null, + 'faa': null + }; + } + + if (vtg[2] !== 'T') { + this.error(new Error('Invalid VTG track mode: ' + str)) + } + if (vtg[8] !== 'K' || vtg[6] !== 'N') { + this.error(new Error('Invalid VTG speed tag: ' + str)) + } + + return { + 'track': parseNumber(vtg[1]), // true heading + 'trackMagnetic': vtg[3] === '' ? null : parseNumber(vtg[3]), // magnetic + 'speed': parseKnots(vtg[5]), + 'faa': vtg.length === 11 ? parseFAA(vtg[9]) : null + }; + }, + + // Satellites in view + 'GSV': function (str, gsv) { + // NMEA allows variable chunks of 4 fields per satellite + header/footer. + // Keep legacy guard but allow most common valid shapes. + if (gsv.length % 4 === 0) { + // = 1 -> normal package + // = 2 -> NMEA 4.10 extension + // = 3 -> BeiDou extension? + this.error(new Error('Invalid GSV length: ' + str)) + } + + /* + $GPGSV,1,1,13,02,02,213,,03,-3,000,,11,00,121,,14,13,172,05*67 + + 1 = Total number of messages of this type in this cycle + 2 = Message number + 3 = Total number of SVs in view + repeat [ + 4 = SV PRN number + 5 = Elevation in degrees, 90 maximum + 6 = Azimuth, degrees from true north, 000 to 359 + 7 = SNR (signal to noise ratio), 00-99 dB (null when not tracking, higher is better) + ] + N+1 = signalID NMEA 4.10 + N+2 = Checksum + */ + + const sats = []; + const satellite = str.slice(1, 3); + // fields: [totMsgs, msgNum, satsInView, (prn,elev,az,snr)*, (signalId)?, checksum] + for (let i = 4; i < gsv.length - 3; i += 4) { + const prn = parseNumber(gsv[i]); + const snr = parseNumber(gsv[i + 3]); + /* + Plot satellites in Radar chart with north on top + by linear map elevation from 0° to 90° into r to 0 + + centerX + cos(azimuth - 90) * (1 - elevation / 90) * radius + centerY + sin(azimuth - 90) * (1 - elevation / 90) * radius + */ + sats.push({ + 'prn': prn, + 'elevation': parseNumber(gsv[i + 1]), + 'azimuth': parseNumber(gsv[i + 2]), + 'snr': snr, + 'status': prn !== null ? (snr !== null ? 'tracking' : 'in view') : null, + 'system': parseSystem(str), + 'key': satellite + prn + }); + } + + return { + 'msgNumber': parseNumber(gsv[2]), + 'msgsTotal': parseNumber(gsv[1]), + 'satsInView': parseNumber(gsv[3]), + 'satellites': sats, + 'signalId': gsv.length % 4 === 2 ? parseNumber(gsv[gsv.length - 2]) : null, // NMEA 4.10 + 'system': parseSystem(str) + }; + }, + + // Geographic Position - Latitude/Longitude + 'GLL': function (str, gll) { + if (gll.length !== 9 && gll.length !== 8) { + this.error(new Error('Invalid GLL length: ' + str)) + } + + /* + ------------------------------------------------------------------------------ + 1 2 3 4 5 6 7 8 + | | | | | | | | + $--GLL,llll.ll,a,yyyyy.yy,a,hhmmss.ss,a,m,*hh + ------------------------------------------------------------------------------ + + 1. Latitude + 2. N or S (North or South) + 3. Longitude + 4. E or W (East or West) + 5. Universal Time Coordinated (UTC) + 6. Status A - Data Valid, V - Data Invalid + 7. FAA mode indicator (NMEA 2.3 and later) + 8. Checksum + */ + + return { + 'time': parseTime(gll[5]), + 'status': parseRMC_GLLStatus(gll[6]), + 'lat': parseCoord(gll[1], gll[2]), + 'lon': parseCoord(gll[3], gll[4]), + 'faa': gll.length === 9 ? parseFAA(gll[7]) : null + }; + }, + + // UTC Date / Time and Local Time Zone Offset + 'ZDA': function (str, zda) { + + /* + 1 = hhmmss.ss = UTC + 2 = xx = Day, 01 to 31 + 3 = xx = Month, 01 to 12 + 4 = xxxx = Year + 5 = xx = Local zone description, 00 to +/- 13 hours + 6 = xx = Local zone minutes description (same sign as hours) + */ + + // (No strict length guard; some receivers omit trailing fields) + return { + 'time': parseTime(zda[1], zda[2] + zda[3] + zda[4]), + // 'delta': can be derived by consumer: (Date.now() - time)/1000 + 'offsetMin': (zda[5] === '' || zda[6] === '') ? null + : (parseInt(zda[5], 10) * 60 + parseInt(zda[6], 10)) + }; + }, + + 'GST': function (str, gst) { + if (gst.length !== 10) { + this.error(new Error('Invalid GST length: ' + str)) + } + + /* + 1 = Time (UTC) + 2 = RMS value of the pseudorange residuals; includes carrier phase residuals during periods of RTK (float) and RTK (fixed) processing + 3 = Error ellipse semi-major axis 1 sigma error, in meters + 4 = Error ellipse semi-minor axis 1 sigma error, in meters + 5 = Error ellipse orientation, degrees from true north + 6 = Latitude 1 sigma error, in meters + 7 = Longitude 1 sigma error, in meters + 8 = Height 1 sigma error, in meters + 9 = Checksum + */ + + return { + 'time': parseTime(gst[1]), + 'rms': parseNumber(gst[2]), + 'ellipseMajor': parseNumber(gst[3]), + 'ellipseMinor': parseNumber(gst[4]), + 'ellipseOrientation': parseNumber(gst[5]), + 'latitudeError': parseNumber(gst[6]), + 'longitudeError': parseNumber(gst[7]), + 'heightError': parseNumber(gst[8]) + }; + }, + + // Heading relative to True North + 'HDT': function (str, hdt) { + if (hdt.length !== 4) { + this.error(new Error('Invalid HDT length: ' + str)) + } + + /* + ------------------------------------------------------------------------------ + 1 2 3 + | | | + $--HDT,hhh.hhh,T*XX + ------------------------------------------------------------------------------ + + 1. Heading in degrees + 2. T: indicates heading relative to True North + 3. Checksum + */ + + return { + 'heading': parseFloat(hdt[1]), + 'trueNorth': hdt[2] === 'T' + }; + }, + + 'GRS': function (str, grs) { + if (grs.length !== 18) { + this.error(new Error('Invalid GRS length: ' + str)) + } + const res = []; + for (let i = 3; i <= 14; i++) { + const tmp = parseNumber(grs[i]); + if (tmp !== null) res.push(tmp); + } + return { + 'time': parseTime(grs[1]), + 'mode': parseNumber(grs[2]), + 'res': res + }; + }, + + 'GBS': function (str, gbs) { + if (gbs.length !== 10 && gbs.length !== 12) { + this.error(new Error('Invalid GBS length: ' + str)) + } + + /* + 0 1 2 3 4 5 6 7 8 + | | | | | | | | | + $--GBS,hhmmss.ss,x.x,x.x,x.x,x.x,x.x,x.x,x.x*hh + + 1. UTC time of the GGA or GNS fix associated with this sentence + 2. Expected error in latitude (meters) + 3. Expected error in longitude (meters) + 4. Expected error in altitude (meters) + 5. PRN (id) of most likely failed satellite + 6. Probability of missed detection for most likely failed satellite + 7. Estimate of bias in meters on most likely failed satellite + 8. Standard deviation of bias estimate + -- + 9. systemID (NMEA 4.10) + 10. signalID (NMEA 4.10) + */ + + return { + 'time': parseTime(gbs[1]), + 'errLat': parseNumber(gbs[2]), + 'errLon': parseNumber(gbs[3]), + 'errAlt': parseNumber(gbs[4]), + 'failedSat': parseNumber(gbs[5]), + 'probFailedSat': parseNumber(gbs[6]), + 'biasFailedSat': parseNumber(gbs[7]), + 'stdFailedSat': parseNumber(gbs[8]), + 'systemId': gbs.length === 12 ? parseNumber(gbs[9]) : null, + 'signalId': gbs.length === 12 ? parseNumber(gbs[10]) : null + }; + }, + + 'GNS': function (str, gns) { + if (gns.length !== 14 && gns.length !== 15) { + this.error(new Error('Invalid GNS length: ' + str)) + } + return { + 'time': parseTime(gns[1]), + 'lat': parseCoord(gns[2], gns[3]), + 'lon': parseCoord(gns[4], gns[5]), + 'mode': gns[6], + 'satsUsed': parseNumber(gns[7]), + 'hdop': parseNumber(gns[8]), + 'alt': parseNumber(gns[9]), + 'sep': parseNumber(gns[10]), + 'diffAge': parseNumber(gns[11]), + 'diffStation': parseNumber(gns[12]), + 'navStatus': gns.length === 15 ? gns[13] : null // NMEA 4.10 + }; + }, + + // Text Transmission (TXT) + // NMEA0183-2 §6.3 ($--TXT,xx,xx,xx,c...c*hh) + 'TXT': function (str, txt) { + + // After talker removal, txt expected: ['TXT', total, index, id, payload, checksum] + if (txt.length !== 6) { + this.error(new Error('Invalid TXT length: ' + str)) + } + + var total = parseInt(txt[1], 10); + var index = parseInt(txt[2], 10); + var textId = parseInt(txt[3], 10); + var rawPart = txt[4] || ''; + + if (!(total >= 1 && total <= 99)) this.error(new Error('Invalid TXT total: ' + txt[1])); + if (!(index >= 1 && index <= total)) this.error(new Error('Invalid TXT index: ' + txt[2])); + if (!(textId >= 0 && textId <= 99)) this.error(new Error('Invalid TXT id: ' + txt[3])); + if (rawPart.length > 61) this.error(new Error('Invalid TXT message length: ' + rawPart.length)); + + var part = escapeString(rawPart); + if (part === '') this.error(new Error('Invalid empty TXT message')); + + // For single-part messages, we can return a completed object right away. + // Multi-part completion is handled in instance _assembleTXT (see below). + return { + // assembly fields: + 'total': total, + 'index': index, + 'id': textId, + 'part': part, // decoded segment + 'message': (total === 1) ? part : null, + 'completed': (total === 1), + 'rawMessages': (total === 1) ? [part] : [], + 'system': parseSystem(str) // e.g. 'GPS', 'GLONASS', ... + }; + } +}; + +/* Static parse + geodesy helpers */ + +GPS['Parse'] = function (line) { + if (typeof line !== 'string' || line.length < 6) return false; + if (line.charCodeAt(0) !== 36 /* '$' */) return false; + + const star = line.indexOf('*', 1); + if (star === -1 || star + 2 >= line.length) return false; + + const nmea = []; + const firstComma = line.indexOf(',', 1); + if (firstComma === -1 || firstComma > star) return false; + + nmea.push('$' + line.slice(1, firstComma)); + + // checksum over everything between '$' and '*' + let checksum = 0; + for (let i = 1; i < star; i++) checksum ^= line.charCodeAt(i); + + // split fields after the first comma + let fieldStart = firstComma + 1; + for (let i = fieldStart; i < star; i++) { + if (line.charCodeAt(i) === 44 /* ',' */) { + nmea.push(line.slice(fieldStart, i)); + fieldStart = i + 1; + } + } + nmea.push(line.slice(fieldStart, star)); + + const crcStr = line.slice(star + 1).trim(); + const crc = parseInt(crcStr.slice(0, 2), 16); + if (!(crc >= 0 && crc <= 255)) return false; + + nmea[0] = nmea[0].slice(3); + const type = nmea[0]; + const mod = GPS['parsers'][type]; + if (mod === undefined) return false; + + nmea.push(crcStr.slice(0, 2)); + + const data = mod(line, nmea); + data['raw'] = line; + data['valid'] = (checksum === crc); + data['type'] = type; + + return data; +}; + +// Heading (N=0, E=90, S=180, W=270) from point 1 to point 2 +GPS['Heading'] = function (lat1, lon1, lat2, lon2) { + const dlon = (lon2 - lon1) * D2R; + lat1 *= D2R; lat2 *= D2R; + + const sdlon = Math.sin(dlon), cdlon = Math.cos(dlon); + const slat1 = Math.sin(lat1), clat1 = Math.cos(lat1); + const slat2 = Math.sin(lat2), clat2 = Math.cos(lat2); + + const y = sdlon * clat2; + const x = clat1 * slat2 - slat1 * clat2 * cdlon; + + const head = Math.atan2(y, x) * 180 / Math.PI; + return (head + 360) % 360; +}; + +GPS['Distance'] = function (lat1, lon1, lat2, lon2) { + // Haversine Formula + // R.W. Sinnott, "Virtues of the Haversine", Sky and Telescope, vol. 68, no. 2, 1984, p. 159 + + // Because Earth is no exact sphere, rounding errors may be up to 0.5%. + // var RADIUS = 6371; // Earth radius average + // var RADIUS = 6378.137; // Earth radius at equator + const RADIUS = 6372.8; // km + const hLat = (lat2 - lat1) * D2R * 0.5; + const hLon = (lon2 - lon1) * D2R * 0.5; + lat1 *= D2R; lat2 *= D2R; + + const shLat = Math.sin(hLat), shLon = Math.sin(hLon); + const clat1 = Math.cos(lat1), clat2 = Math.cos(lat2); + + const tmp = shLat * shLat + clat1 * clat2 * shLon * shLon; + //return RADIUS * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1.0 - a)); + return RADIUS * 2 * Math.asin(Math.sqrt(tmp)); +}; + +GPS['TotalDistance'] = function (path) { + + if (path.length < 2) return 0; + let len = 0; + for (let i = 0; i < path.length - 1; i++) { + const c = path[i]; + const n = path[i + 1]; + len += GPS['Distance'](c['lat'], c['lon'], n['lat'], n['lon']); + } + return len; +}; + +/* ---------- Instance methods (single prototype assignment) ---------- */ + +GPS.prototype = { + constructor: GPS, + + /* Internal: merge parsed packet into state, keep short-term sat caches fresh */ + '_updateState': function (data) { + const state = this['state']; + + // TODO: can we really use RMC time here or is it the time of fix? + if (data['type'] === 'RMC' || data['type'] === 'GGA' || data['type'] === 'GLL' || data['type'] === 'GNS') { + state['time'] = data['time']; + state['lat'] = data['lat']; + state['lon'] = data['lon']; + } + + if (data['type'] === 'HDT') { + state['heading'] = data['heading']; + state['trueNorth'] = data['trueNorth']; + } + + if (data['type'] === 'ZDA') { + state['time'] = data['time']; + } + + if (data['type'] === 'GGA') { + state['alt'] = data['alt']; + } + + if (data['type'] === 'RMC' || data['type'] === 'VTG') { + if (data['speed'] != null) state['speed'] = data['speed']; + if (data['track'] != null) state['track'] = data['track']; + } + + if (data['type'] === 'GSA') { + const systemId = data['systemId']; + if (systemId != null) this['_collectActiveSats'][systemId] = data['satellites']; + + const satsActive = []; + const collectActiveSats = this['_collectActiveSats']; + for (const s in collectActiveSats) { + if (Object.prototype.hasOwnProperty.call(collectActiveSats, s)) { + // Concatenate without allocating a new array for each system + const arr = collectActiveSats[s]; + for (let i = 0, L = arr.length; i < L; i++) satsActive.push(arr[i]); + } + } + + state['satsActive'] = satsActive; + state['fix'] = data['fix']; + state['hdop'] = data['hdop']; + state['pdop'] = data['pdop']; + state['vdop'] = data['vdop']; + } + + if (data['type'] === 'GSV') { + const now = Date.now(); + const sats = data['satellites']; + const collectSats = this['_collectSats']; + const lastSeenSat = this['_lastSeenSat']; + + for (let i = 0, L = sats.length; i < L; i++) { + const key = sats[i]['key']; + lastSeenSat[key] = now; + collectSats[key] = sats[i]; + } + + // Satellites are considered "visible" for 3 seconds after last seen + const ret = []; + for (const key in collectSats) { + if (Object.prototype.hasOwnProperty.call(collectSats, key)) { + if (now - lastSeenSat[key] < 3000) ret.push(collectSats[key]); + else { + // Optional: clean up stale entries + delete collectSats[key]; + delete lastSeenSat[key]; + } + } + } + state['satsVisible'] = ret; + } + }, + + '_assembleTXT': function (data) { + // Single-part already complete (parser set message) + if (data['total'] === 1) return data; + + const key = (data['system'] || '') + '#' + data['id']; + + let buf = this['state']['txtBuffer'][key]; + if (!buf) { + buf = this['state']['txtBuffer'][key] = { + 'total': data['total'], + 'parts': new Array(data['total']).fill(null), + 'received': 0, + 'timer': null + }; + // 10s timeout to avoid leaks + const self = this; + buf['timer'] = setTimeout(function () { + self['state']['errors']++; + delete self['state']['txtBuffer'][key]; + }, 10000); + } + + // store part (index is 1-based) + const idx = data['index'] - 1; + if (0 <= idx && idx < buf['total']) { + buf['parts'][idx] = data['part']; + buf['received']++; + } + + // check completion + if (buf['received'] === buf['total']) { + clearTimeout(buf['timer']); + delete this['state']['txtBuffer'][key]; + data['message'] = buf['parts'].join(''); + data['completed'] = true; + data['rawMessages'] = buf['parts']; + + } else { + data['message'] = null; + data['completed'] = false; + data['rawMessages'] = []; + } + return data; + }, + + /** + * Feed one full NMEA line (starting with '$', ending before CRLF). + * Emits both 'data' and '' events on success. + */ + 'update': function (line) { + const parsed = GPS['Parse'](line); + this['state']['processed']++; + + if (parsed === false) { + this['state']['errors']++; + return false; + } + + // Assemble TXT multi-part here + if (parsed['type'] === 'TXT') { + this['_assembleTXT'](parsed); + } + + this['_updateState'](parsed); + + this['emit']('data', parsed); + this['emit'](parsed['type'], parsed); + + return true; + }, + + /** + * Feed streaming data (chunks, possibly split arbitrarily). + * Accepts either "\r\n" or "\n" as line delimiters. + */ + 'updatePartial': function (chunk) { + if (chunk) this['partial'] += chunk; + + // Process all complete lines + for (; ;) { + const idxRN = this['partial'].indexOf('\r\n'); + const idxN = this['partial'].indexOf('\n'); + + let pos = -1; + if (idxRN !== -1) pos = idxRN; + else if (idxN !== -1) pos = idxN; + + if (pos === -1) break; + + const line = this['partial'].slice(0, pos); + // Advance buffer past delimiter (2 for CRLF, 1 for LF) + this['partial'] = this['partial'].slice(pos + (idxRN === pos ? 2 : 1)); + + if (line.charAt(0) !== '$') continue; + + try { + this['update'](line); + } catch (err) { + // Keep buffer (don’t drop subsequent lines), but count the error + this['state']['errors']++; + // Re-throw for caller visibility + this.error(err); + } + } + }, + + /** + * Subscribe to an event. Multiple listeners per event are supported. + * @param {string} ev + * @param {function()} cb + * @returns {GPS} this (chainable) + */ + 'on': function (ev, cb) { + const cur = this['events'][ev]; + if (cur === undefined) { + this['events'][ev] = [cb]; + } else if (typeof cur === 'function') { + // Backward compatibility with previous single-listener design + this['events'][ev] = [cur, cb]; + } else { + this['events'][ev].push(cb); + } + return this; + }, + + /** + * Remove listeners. If cb omitted, remove all for the event. + * @param {string} ev + * @param {function()} cb + * @returns {GPS} this + */ + 'off': function (ev, cb) { + const cur = this['events'][ev]; + if (cur === undefined) return this; + + if (!cb) { + delete this['events'][ev]; + return this; + } + + if (typeof cur === 'function') { + if (cur === cb) delete this['events'][ev]; + return this; + } + + // Array case + for (let i = cur.length - 1; i >= 0; i--) { + if (cur[i] === cb) cur.splice(i, 1); + } + if (cur.length === 0) delete this['events'][ev]; + return this; + }, + + /** + * Emit an event to all listeners. + * @param {string} ev + * @param {*} data + */ + 'emit': function (ev, data) { + const cur = this['events'][ev]; + if (cur === undefined) return; + + if (typeof cur === 'function') { + cur.call(this, data); + return; + } + // Array of listeners + for (let i = 0, L = cur.length; i < L; i++) { + cur[i].call(this, data); + } + }, + + 'error': function(error) { + const cur = this['events']['error']; + if(cur === undefined) { + throw error + } + else { + this['emit']('error', error); + } + } }; export { GPS as default, GPS diff --git a/examples/confluence.js b/examples/confluence.js index 28ab1b0..5edda9d 100644 --- a/examples/confluence.js +++ b/examples/confluence.js @@ -1,49 +1,49 @@ - - -// var file = '/dev/cu.usbserial'; -// var file = '/dev/ttyUSB0'; -//var file = '/dev/tty.usbserial'; -var file = '/dev/tty.usbmodem1411'; - -const { SerialPort } = require('serialport'); -const { ReadlineParser } = require('@serialport/parser-readline'); - -const parser = new ReadlineParser({ - delimiter: '\r\n' -}); - -const port = new SerialPort({ - path: file, - baudRate: 4800 -}); - -port.pipe(parser); - - -var Angles = require('angles'); -var GPS = require('gps'); -var gps = new GPS; - -gps.on('data', function (data) { - - var lat1 = gps.state.lat; - var lon1 = gps.state.lon; - - // Find closest confluence point as destination - var lat2 = Math.round(lat1); - var lon2 = Math.round(lon1); - - var dist = GPS.Distance(lat1, lon1, lat2, lon2); - var head = GPS.Heading(lat1, lon1, lat2, lon2); - var rose = Angles.compass(head); - - console.log("\033[2J\033[;H" + - "You are at (" + lat1 + ", " + lon1 + "),\n" + - "The closest confluence point (" + lat2 + ", " + lon2 + ") is in " + dist + " km.\n" + - "You have to go " + head + "° " + rose); - -}); - -parser.on('data', function (data) { - gps.update(data); -}); + + +// var file = '/dev/cu.usbserial'; +// var file = '/dev/ttyUSB0'; +//var file = '/dev/tty.usbserial'; +var file = '/dev/tty.usbmodem1411'; + +const { SerialPort } = require('serialport'); +const { ReadlineParser } = require('@serialport/parser-readline'); + +const parser = new ReadlineParser({ + delimiter: '\r\n' +}); + +const port = new SerialPort({ + path: file, + baudRate: 4800 +}); + +port.pipe(parser); + + +var Angles = require('angles'); +var GPS = require('gps'); +var gps = new GPS; + +gps.on('data', function (data) { + + var lat1 = gps.state.lat; + var lon1 = gps.state.lon; + + // Find closest confluence point as destination + var lat2 = Math.round(lat1); + var lon2 = Math.round(lon1); + + var dist = GPS.Distance(lat1, lon1, lat2, lon2); + var head = GPS.Heading(lat1, lon1, lat2, lon2); + var rose = Angles.compass(head); + + console.log("\033[2J\033[;H" + + "You are at (" + lat1 + ", " + lon1 + "),\n" + + "The closest confluence point (" + lat2 + ", " + lon2 + ") is in " + dist + " km.\n" + + "You have to go " + head + "° " + rose); + +}); + +parser.on('data', function (data) { + gps.update(data); +}); diff --git a/examples/dashboard/dashboard.html b/examples/dashboard/dashboard.html index 72e36d4..3a6dd1e 100644 --- a/examples/dashboard/dashboard.html +++ b/examples/dashboard/dashboard.html @@ -1,568 +1,568 @@ - - - - - - - gps.js — Satellites Dashboard - - - - - - - - - - Satellites - Disconnected - - — - - - - - Sky View - - - - Speed / Heading - - - - - - SNR (signal-to-noise) - - - - - - - - Information - - Time - — - Latitude - — - Longitude - — - Altitude - — - Speed - — - Track - — - Bearing - — - Fix - — - PDOP - — - VDOP - — - HDOP - — - - - - - Satellites in Use - — - - - Satellites in View - — - - - - - - - - + + + + + + + gps.js — Satellites Dashboard + + + + + + + + + + Satellites + Disconnected + + — + + + + + Sky View + + + + Speed / Heading + + + + + + SNR (signal-to-noise) + + + + + + + + Information + + Time + — + Latitude + — + Longitude + — + Altitude + — + Speed + — + Track + — + Bearing + — + Fix + — + PDOP + — + VDOP + — + HDOP + — + + + + + Satellites in Use + — + + + Satellites in View + — + + + + + + + + \ No newline at end of file diff --git a/examples/dashboard/server.js b/examples/dashboard/server.js index 5caa682..7376d58 100644 --- a/examples/dashboard/server.js +++ b/examples/dashboard/server.js @@ -1,64 +1,64 @@ -'use strict'; - -const path = require('path'); -const express = require('express'); -const http = require('http'); -const WebSocket = require('ws'); - -const { SerialPort } = require('serialport'); -const { ReadlineParser } = require('@serialport/parser-readline'); - -const GPS = require('gps'); - -const SERIAL_PATH = '/dev/tty.usbmodem2101'; -const SERIAL_BAUD = 4800; - -const app = express(); -const server = http.createServer(app); -const wss = new WebSocket.Server({ server, path: '/ws' }); - -app.use(express.static(__dirname)); - -app.get('/', (req, res) => { - res.sendFile(path.join(__dirname, 'dashboard.html')); // rename saved client file to dashboard.html -}); - -function broadcast(obj) { - const data = JSON.stringify(obj); - for (const client of wss.clients) { - if (client.readyState === WebSocket.OPEN) client.send(data); - } -} - -wss.on('connection', (ws) => { - try { ws.send(JSON.stringify({ type: 'hello', payload: { ts: Date.now() } })); } catch { } -}); - -const port = new SerialPort({ path: SERIAL_PATH, baudRate: SERIAL_BAUD }); -const parser = new ReadlineParser({ delimiter: '\r\n' }); -port.pipe(parser); - -const gps = new GPS(); -gps.state.bearing = 0; -let prev = { lat: null, lon: null }; - -gps.on('data', function () { - // compute bearing from previous fix to current fix (if available) - if (prev.lat != null && prev.lon != null && gps.state.lat != null && gps.state.lon != null) { - gps.state.bearing = GPS.Heading(prev.lat, prev.lon, gps.state.lat, gps.state.lon); - } - prev.lat = gps.state.lat; - prev.lon = gps.state.lon; - - broadcast({ type: 'state', payload: gps.state }); -}); - -parser.on('data', function (line) { - try { gps.update(line); } catch (e) { /* errors counted in gps.state.errors */ } -}); - -// ---------- Logs ---------- -port.on('open', () => console.log(`[serial] open ${SERIAL_PATH} @ ${SERIAL_BAUD}`)); -port.on('error', (err) => console.error('[serial] error', err)); -wss.on('listening', () => console.log('[ws] listening on /ws')); -server.listen(3000, () => console.log(`listening on http://localhost:3000`)); +'use strict'; + +const path = require('path'); +const express = require('express'); +const http = require('http'); +const WebSocket = require('ws'); + +const { SerialPort } = require('serialport'); +const { ReadlineParser } = require('@serialport/parser-readline'); + +const GPS = require('gps'); + +const SERIAL_PATH = '/dev/tty.usbmodem2101'; +const SERIAL_BAUD = 4800; + +const app = express(); +const server = http.createServer(app); +const wss = new WebSocket.Server({ server, path: '/ws' }); + +app.use(express.static(__dirname)); + +app.get('/', (req, res) => { + res.sendFile(path.join(__dirname, 'dashboard.html')); // rename saved client file to dashboard.html +}); + +function broadcast(obj) { + const data = JSON.stringify(obj); + for (const client of wss.clients) { + if (client.readyState === WebSocket.OPEN) client.send(data); + } +} + +wss.on('connection', (ws) => { + try { ws.send(JSON.stringify({ type: 'hello', payload: { ts: Date.now() } })); } catch { } +}); + +const port = new SerialPort({ path: SERIAL_PATH, baudRate: SERIAL_BAUD }); +const parser = new ReadlineParser({ delimiter: '\r\n' }); +port.pipe(parser); + +const gps = new GPS(); +gps.state.bearing = 0; +let prev = { lat: null, lon: null }; + +gps.on('data', function () { + // compute bearing from previous fix to current fix (if available) + if (prev.lat != null && prev.lon != null && gps.state.lat != null && gps.state.lon != null) { + gps.state.bearing = GPS.Heading(prev.lat, prev.lon, gps.state.lat, gps.state.lon); + } + prev.lat = gps.state.lat; + prev.lon = gps.state.lon; + + broadcast({ type: 'state', payload: gps.state }); +}); + +parser.on('data', function (line) { + try { gps.update(line); } catch (e) { /* errors counted in gps.state.errors */ } +}); + +// ---------- Logs ---------- +port.on('open', () => console.log(`[serial] open ${SERIAL_PATH} @ ${SERIAL_BAUD}`)); +port.on('error', (err) => console.error('[serial] error', err)); +wss.on('listening', () => console.log('[ws] listening on /ws')); +server.listen(3000, () => console.log(`listening on http://localhost:3000`)); diff --git a/examples/fileRead.js b/examples/fileRead.js index 909274e..c940d86 100644 --- a/examples/fileRead.js +++ b/examples/fileRead.js @@ -1,21 +1,21 @@ - -var fs = require('fs'); -var rs = fs.createReadStream('gps.dump'); - -var byline = require('byline'); -var GPS = require('gps'); -var gps = new GPS; - -var stream = byline(rs); - -// This filters all GGA packages from the dump -gps.on('GGA', function (gga) { - - console.log('Lat: ' + gga.lat); - console.log('Lon: ' + gga.lon); -}); - -stream.on('data', function (data) { - - gps.update(data.toString()); -}); + +var fs = require('fs'); +var rs = fs.createReadStream('gps.dump'); + +var byline = require('byline'); +var GPS = require('gps'); +var gps = new GPS; + +var stream = byline(rs); + +// This filters all GGA packages from the dump +gps.on('GGA', function (gga) { + + console.log('Lat: ' + gga.lat); + console.log('Lon: ' + gga.lon); +}); + +stream.on('data', function (data) { + + gps.update(data.toString()); +}); diff --git a/examples/fileWrite.js b/examples/fileWrite.js index 591e8fe..d3f2e49 100644 --- a/examples/fileWrite.js +++ b/examples/fileWrite.js @@ -1,33 +1,33 @@ - -// var file = '/dev/cu.usbserial'; -// var file = '/dev/ttyUSB0'; -//var file = '/dev/tty.usbserial'; -var file = '/dev/tty.usbmodem1411'; - -const { SerialPort } = require('serialport'); -const { ReadlineParser } = require('@serialport/parser-readline'); - -const parser = new ReadlineParser({ - delimiter: '\r\n' -}); - -const port = new SerialPort({ - path: file, - baudRate: 4800 -}); - -port.pipe(parser); - -var fs = require('fs'); -var ws = fs.createWriteStream('gps.dump'); - -var GPS = require('gps'); -var gps = new GPS; - -gps.on('data', function (data) { - ws.write(data.raw + '\n'); -}); - -parser.on('data', function (data) { - gps.update(data); -}); + +// var file = '/dev/cu.usbserial'; +// var file = '/dev/ttyUSB0'; +//var file = '/dev/tty.usbserial'; +var file = '/dev/tty.usbmodem1411'; + +const { SerialPort } = require('serialport'); +const { ReadlineParser } = require('@serialport/parser-readline'); + +const parser = new ReadlineParser({ + delimiter: '\r\n' +}); + +const port = new SerialPort({ + path: file, + baudRate: 4800 +}); + +port.pipe(parser); + +var fs = require('fs'); +var ws = fs.createWriteStream('gps.dump'); + +var GPS = require('gps'); +var gps = new GPS; + +gps.on('data', function (data) { + ws.write(data.raw + '\n'); +}); + +parser.on('data', function (data) { + gps.update(data); +}); diff --git a/examples/json-stream.js b/examples/json-stream.js index 269f1a8..e5b993d 100644 --- a/examples/json-stream.js +++ b/examples/json-stream.js @@ -1,57 +1,57 @@ - -// cat gps.out | node json-stream - -var Transform = require('stream').Transform; - -var GPS = require('gps'); - -process.stdin.resume(); -process.stdin.setEncoding('utf8'); - -function Process() { - Transform.call(this, { objectMode: true }); -} - -Process.prototype = { - _line: "", - _transform: function (chunk, encoding, done) { - - var data = this._line + chunk.toString(); - - var lines = data.split('\n'); - this._line = lines.splice(lines.length - 1, 1)[0]; - - var self = this; - - lines.forEach(function (x) { - - var tmp = GPS.parse(x); - if (tmp !== false) { - self.push(JSON.stringify(tmp) + '\n'); - } - }); - - done(); - }, - _flush: function (done) { - - if (this._line) { - var tmp = GPS.parse(this._line); - this.push(JSON.stringify(tmp) + '\n'); - } - this._line = ""; - done(); - } - -}; - -var origProto = Process.prototype; -Process.prototype = Object.create(Transform.prototype); -for (var key in origProto) { - Process.prototype[key] = origProto[key]; -} -Process.prototype.constructor = Process; - -process.stdin - .pipe(new Process) - .pipe(process.stdout); + +// cat gps.out | node json-stream + +var Transform = require('stream').Transform; + +var GPS = require('gps'); + +process.stdin.resume(); +process.stdin.setEncoding('utf8'); + +function Process() { + Transform.call(this, { objectMode: true }); +} + +Process.prototype = { + _line: "", + _transform: function (chunk, encoding, done) { + + var data = this._line + chunk.toString(); + + var lines = data.split('\n'); + this._line = lines.splice(lines.length - 1, 1)[0]; + + var self = this; + + lines.forEach(function (x) { + + var tmp = GPS.parse(x); + if (tmp !== false) { + self.push(JSON.stringify(tmp) + '\n'); + } + }); + + done(); + }, + _flush: function (done) { + + if (this._line) { + var tmp = GPS.parse(this._line); + this.push(JSON.stringify(tmp) + '\n'); + } + this._line = ""; + done(); + } + +}; + +var origProto = Process.prototype; +Process.prototype = Object.create(Transform.prototype); +for (var key in origProto) { + Process.prototype[key] = origProto[key]; +} +Process.prototype.constructor = Process; + +process.stdin + .pipe(new Process) + .pipe(process.stdout); diff --git a/examples/maps/maps.html b/examples/maps/maps.html index 2ccb333..6f4131a 100644 --- a/examples/maps/maps.html +++ b/examples/maps/maps.html @@ -1,315 +1,315 @@ - - - - - - - gps.js — Live Position - - - - - - - - gps.js — Live Position - - - Lat - – - Lon - – - Alt - – - Speed - – - Track - – - HDOP - – - Sats used - – - - - Follow: On - Disconnected - - - - - - - - - - - - + + + + + + + gps.js — Live Position + + + + + + + + gps.js — Live Position + + + Lat + – + Lon + – + Alt + – + Speed + – + Track + – + HDOP + – + Sats used + – + + + Follow: On + Disconnected + + + + + + + + + + + + \ No newline at end of file diff --git a/examples/maps/server.js b/examples/maps/server.js index 8fa6410..cd9424a 100644 --- a/examples/maps/server.js +++ b/examples/maps/server.js @@ -1,90 +1,90 @@ - -const path = require('path'); -const express = require('express'); -const http = require('http'); -const WebSocket = require('ws'); - -const { SerialPort } = require('serialport'); -const { ReadlineParser } = require('@serialport/parser-readline'); - -const GPS = require('gps'); -const Sylvester = require('sylvester'); -const Kalman = require('kalman').KF; - -// Serial device (adjust to your platform) -const SERIAL_PATH = '/dev/tty.usbmodem2101'; -const SERIAL_BAUD = 4800; - -const app = express(); -const server = http.createServer(app); -const wss = new WebSocket.Server({ server, path: '/ws' }); - -// Static assets -app.use(express.static(__dirname)); - -app.get('/', (req, res, next) => { - const file = path.join(__dirname, 'maps.html'); - return res.sendFile(file); -}); - -// WS helpers -function broadcast(obj) { - const msg = JSON.stringify(obj); - wss.clients.forEach((ws) => { - if (ws.readyState === WebSocket.OPEN) ws.send(msg); - }); -} - -wss.on('connection', (ws) => { - try { - ws.send(JSON.stringify({ type: 'hello', payload: { ts: Date.now() } })); - } catch { } -}); - -const port = new SerialPort({ path: SERIAL_PATH, baudRate: SERIAL_BAUD }); -const parser = new ReadlineParser({ delimiter: '\r\n' }); -port.pipe(parser); - -const gps = new GPS(); - -// Simple 2D Kalman (lat, lon) -const A = Sylvester.Matrix.I(2); -const B = Sylvester.Matrix.Zero(2, 2); -const H = Sylvester.Matrix.I(2); -const C = Sylvester.Matrix.I(2); - -// Tune Q/R for your receiver (these are conservative defaults) -const Q = Sylvester.Matrix.I(2).multiply(1e-11); -const R = Sylvester.Matrix.I(2).multiply(1e-5); - -const u = Sylvester.Vector.create([0, 0]); -const filter = new Kalman(Sylvester.Vector.create([0, 0]), Sylvester.Matrix.create([[1, 0], [0, 1]])); - -gps.on('data', function (data) { - if (data.lat != null && data.lon != null) { - filter.update({ - A, B, C, H, R, Q, u, - y: Sylvester.Vector.create([data.lat, data.lon]) - }); - - // Attach filtered position + covariance to state - gps.state.position = { - pos: filter.x.elements, // [lat, lon] - cov: filter.P.elements // [[..],[..]] - }; - } - - broadcast({ type: 'position', payload: gps.state }); -}); - -// Feed NMEA from serial into gps.js -parser.on('data', (line) => { - try { gps.update(line); } catch (e) { /* count is inside gps.state.errors */ } -}); - -// Basic lifecycle logs -port.on('open', () => console.log(`[serial] open ${SERIAL_PATH} @ ${SERIAL_BAUD}`)); -port.on('error', (err) => console.error('[serial] error', err)); -wss.on('listening', () => console.log('[ws] listening on /ws')); - -server.listen(3000, () => console.log(`listening on http://localhost:3000`)); + +const path = require('path'); +const express = require('express'); +const http = require('http'); +const WebSocket = require('ws'); + +const { SerialPort } = require('serialport'); +const { ReadlineParser } = require('@serialport/parser-readline'); + +const GPS = require('gps'); +const Sylvester = require('sylvester'); +const Kalman = require('kalman').KF; + +// Serial device (adjust to your platform) +const SERIAL_PATH = '/dev/tty.usbmodem2101'; +const SERIAL_BAUD = 4800; + +const app = express(); +const server = http.createServer(app); +const wss = new WebSocket.Server({ server, path: '/ws' }); + +// Static assets +app.use(express.static(__dirname)); + +app.get('/', (req, res, next) => { + const file = path.join(__dirname, 'maps.html'); + return res.sendFile(file); +}); + +// WS helpers +function broadcast(obj) { + const msg = JSON.stringify(obj); + wss.clients.forEach((ws) => { + if (ws.readyState === WebSocket.OPEN) ws.send(msg); + }); +} + +wss.on('connection', (ws) => { + try { + ws.send(JSON.stringify({ type: 'hello', payload: { ts: Date.now() } })); + } catch { } +}); + +const port = new SerialPort({ path: SERIAL_PATH, baudRate: SERIAL_BAUD }); +const parser = new ReadlineParser({ delimiter: '\r\n' }); +port.pipe(parser); + +const gps = new GPS(); + +// Simple 2D Kalman (lat, lon) +const A = Sylvester.Matrix.I(2); +const B = Sylvester.Matrix.Zero(2, 2); +const H = Sylvester.Matrix.I(2); +const C = Sylvester.Matrix.I(2); + +// Tune Q/R for your receiver (these are conservative defaults) +const Q = Sylvester.Matrix.I(2).multiply(1e-11); +const R = Sylvester.Matrix.I(2).multiply(1e-5); + +const u = Sylvester.Vector.create([0, 0]); +const filter = new Kalman(Sylvester.Vector.create([0, 0]), Sylvester.Matrix.create([[1, 0], [0, 1]])); + +gps.on('data', function (data) { + if (data.lat != null && data.lon != null) { + filter.update({ + A, B, C, H, R, Q, u, + y: Sylvester.Vector.create([data.lat, data.lon]) + }); + + // Attach filtered position + covariance to state + gps.state.position = { + pos: filter.x.elements, // [lat, lon] + cov: filter.P.elements // [[..],[..]] + }; + } + + broadcast({ type: 'position', payload: gps.state }); +}); + +// Feed NMEA from serial into gps.js +parser.on('data', (line) => { + try { gps.update(line); } catch (e) { /* count is inside gps.state.errors */ } +}); + +// Basic lifecycle logs +port.on('open', () => console.log(`[serial] open ${SERIAL_PATH} @ ${SERIAL_BAUD}`)); +port.on('error', (err) => console.error('[serial] error', err)); +wss.on('listening', () => console.log('[ws] listening on /ws')); + +server.listen(3000, () => console.log(`listening on http://localhost:3000`)); diff --git a/examples/serial.js b/examples/serial.js index 380b8ea..e1f80e7 100644 --- a/examples/serial.js +++ b/examples/serial.js @@ -1,31 +1,31 @@ - -// var file = '/dev/cu.usbserial'; -// var file = '/dev/ttyUSB0'; -//var file = '/dev/tty.usbserial'; -var file = '/dev/tty.usbmodem1411'; - -const { SerialPort } = require('serialport'); -const { ReadlineParser } = require('@serialport/parser-readline'); - -const parser = new ReadlineParser({ - delimiter: '\r\n' -}); - -const port = new SerialPort({ - path: file, - baudRate: 4800 -}); - -port.pipe(parser); - - -var GPS = require('gps'); -var gps = new GPS; - -gps.on('data', function (data) { - console.log(data); -}); - -parser.on('data', function (data) { - gps.update(data); -}); + +// var file = '/dev/cu.usbserial'; +// var file = '/dev/ttyUSB0'; +//var file = '/dev/tty.usbserial'; +var file = '/dev/tty.usbmodem1411'; + +const { SerialPort } = require('serialport'); +const { ReadlineParser } = require('@serialport/parser-readline'); + +const parser = new ReadlineParser({ + delimiter: '\r\n' +}); + +const port = new SerialPort({ + path: file, + baudRate: 4800 +}); + +port.pipe(parser); + + +var GPS = require('gps'); +var gps = new GPS; + +gps.on('data', function (data) { + console.log(data); +}); + +parser.on('data', function (data) { + gps.update(data); +}); diff --git a/examples/set-date.js b/examples/set-date.js index 76475f4..4e89dc0 100644 --- a/examples/set-date.js +++ b/examples/set-date.js @@ -1,43 +1,43 @@ - -// var file = '/dev/cu.usbserial'; -// var file = '/dev/ttyUSB0'; -//var file = '/dev/tty.usbserial'; -var file = '/dev/ttyACM0'; - -var exec = require('child_process').exec; - -const { SerialPort } = require('serialport'); -const { ReadlineParser } = require('@serialport/parser-readline'); - -const parser = new ReadlineParser({ - delimiter: '\r\n' -}); - -const port = new SerialPort({ - path: file, - baudRate: 4800 -}); - -port.pipe(parser); - -var GPS = require('gps'); -var gps = new GPS; - -gps.on('data', function (data) { - - if (!data.time) - return; - - exec('date -s "' + data.time.toString() + '"', function (error, stdout, stderr) { - if (error) throw error; - // Clock should be set now, exit - console.log("Set time to " + data.time.toString()); - process.exit(); - }); -}); - -parser.on('data', function (data) { - gps.update(data); -}); - - + +// var file = '/dev/cu.usbserial'; +// var file = '/dev/ttyUSB0'; +//var file = '/dev/tty.usbserial'; +var file = '/dev/ttyACM0'; + +var exec = require('child_process').exec; + +const { SerialPort } = require('serialport'); +const { ReadlineParser } = require('@serialport/parser-readline'); + +const parser = new ReadlineParser({ + delimiter: '\r\n' +}); + +const port = new SerialPort({ + path: file, + baudRate: 4800 +}); + +port.pipe(parser); + +var GPS = require('gps'); +var gps = new GPS; + +gps.on('data', function (data) { + + if (!data.time) + return; + + exec('date -s "' + data.time.toString() + '"', function (error, stdout, stderr) { + if (error) throw error; + // Clock should be set now, exit + console.log("Set time to " + data.time.toString()); + process.exit(); + }); +}); + +parser.on('data', function (data) { + gps.update(data); +}); + + diff --git a/examples/simple.js b/examples/simple.js index 7c4bc3d..7dfa6ee 100644 --- a/examples/simple.js +++ b/examples/simple.js @@ -1,13 +1,13 @@ - - -var GPS = require('gps'); -var gps = new GPS; - -var sentence = '$GPGGA,224900.000,4832.3762,N,00903.5393,E,1,04,7.8,498.6,M,48.0,M,,0000*5E'; - -gps.on('data', function (parsed) { - - console.log(parsed); -}); - + + +var GPS = require('gps'); +var gps = new GPS; + +var sentence = '$GPGGA,224900.000,4832.3762,N,00903.5393,E,1,04,7.8,498.6,M,48.0,M,,0000*5E'; + +gps.on('data', function (parsed) { + + console.log(parsed); +}); + gps.update(sentence); \ No newline at end of file diff --git a/examples/state.js b/examples/state.js index 5e9ef72..a05194f 100644 --- a/examples/state.js +++ b/examples/state.js @@ -1,31 +1,31 @@ - - -// var file = '/dev/cu.usbserial'; -// var file = '/dev/ttyUSB0'; -//var file = '/dev/tty.usbserial'; -var file = '/dev/tty.usbmodem1411'; - -const { SerialPort } = require('serialport'); -const { ReadlineParser } = require('@serialport/parser-readline'); - -const parser = new ReadlineParser({ - delimiter: '\r\n' -}); - -const port = new SerialPort({ - path: file, - baudRate: 4800 -}); - -port.pipe(parser); - -var GPS = require('gps'); -var gps = new GPS; - -gps.on('data', function (data) { - console.log(gps.state); -}); - -parser.on('data', function (data) { - gps.update(data); -}); + + +// var file = '/dev/cu.usbserial'; +// var file = '/dev/ttyUSB0'; +//var file = '/dev/tty.usbserial'; +var file = '/dev/tty.usbmodem1411'; + +const { SerialPort } = require('serialport'); +const { ReadlineParser } = require('@serialport/parser-readline'); + +const parser = new ReadlineParser({ + delimiter: '\r\n' +}); + +const port = new SerialPort({ + path: file, + baudRate: 4800 +}); + +port.pipe(parser); + +var GPS = require('gps'); +var gps = new GPS; + +gps.on('data', function (data) { + console.log(gps.state); +}); + +parser.on('data', function (data) { + gps.update(data); +}); diff --git a/gps.d.mts b/gps.d.mts index fb639ee..31e5afe 100644 --- a/gps.d.mts +++ b/gps.d.mts @@ -1,325 +1,325 @@ - -declare class GPS { - /** Mutable parser state aggregated from recent sentences */ - state: GPS.GPSState; - - /** - * Parse a full NMEA sentence and emit events. - * @param line NMEA string (must start with '$' and contain '*xx' checksum) - * @returns true if parsed (even if checksum invalid), false if sentence structure was rejected - */ - update(line: string): boolean; - - /** - * Feed streaming chunks; calls update() whenever a full line is assembled. - * Accepts both CRLF and LF as delimiters. - */ - updatePartial(chunk: string): void; - - /** - * Subscribe to an event ('data' for all sentences or a concrete type like 'GGA', 'RMC', ...). - * Multiple listeners per event are supported. - */ - on(event: string, callback: (data: any) => void): GPS; - - /** - * Remove listeners. If callback omitted, removes all listeners for the event. - */ - off(event: string, callback?: (data: any) => void): GPS; - - /** - * Parse a single line without using the event system. - * Returns the typed NMEA object (with `.raw`, `.valid`, `.type`) or false if unrecognized/invalid structure. - */ - static Parse(line: string): false | GPS.NMEA; - - /** - * Haversine distance in kilometers - */ - static Distance( - latFrom: number, - lonFrom: number, - latTo: number, - lonTo: number - ): number; - - /** - * Sum of pairwise distances along a path - */ - static TotalDistance(points: GPS.LatLon[]): number; - - /** - * Initial bearing (windrose: N=0, E=90, S=180, W=270) - */ - static Heading( - latFrom: number, - lonFrom: number, - latTo: number, - lonTo: number - ): number; -} - -declare namespace GPS { - - /* ---------- Shared ---------- */ - - export interface LatLon { - lat: number; - lon: number; - } - - /** Aggregated state built from recent sentences. All optional fields may be absent or null until observed. */ - export interface GPSState { - [key: string]: any; - processed: number; - errors: number; - - time?: Date | null; - lat?: number | null; - lon?: number | null; - alt?: number | null; - - speed?: number | null; - track?: number | null; - - heading?: number | null; // from HDT - trueNorth?: boolean | null; // from HDT - - // Fix quality from GSA - fix?: '2D' | '3D' | null; - hdop?: number | null; - pdop?: number | null; - vdop?: number | null; - - satsActive?: number[] | null; // PRNs used in fix (across systems) - satsVisible?: Satellite[] | null; // deduped & time-windowed set - - // Additional fields (not exhaustive): may appear depending on sentences seen - geoidal?: number | null; - } - - /* ---------- Sentence payloads (all include raw/valid/type) ---------- */ - - export interface GGA { - time: Date | null; - lat: number | null; - lon: number | null; - alt: number | null; - quality?: GGAQuality | null; - satellites: number | null; - hdop: number | null; - geoidal: number | null; - age: number | null; - stationID: number | null; - raw: string; - valid: boolean; - type: 'GGA'; - } - - export enum GGAQuality { - fix = 'fix', - 'dgps-fix' = 'dgps-fix', - 'pps-fix' = 'pps-fix', - rtk = 'rtk', - 'rtk-float' = 'rtk-float', - estimated = 'estimated', - manual = 'manual', - simulated = 'simulated' - } - - export interface GSA { - mode?: 'manual' | 'automatic' | null; - fix?: '2D' | '3D' | null; - satellites: number[]; // PRNs - pdop: number | null; - hdop: number | null; - vdop: number | null; - systemId?: number | null; // NMEA 4.10 - system?: string; // 'GPS' | 'GLONASS' | ... - raw: string; - valid: boolean; - type: 'GSA'; - } - - export interface RMC { - time: Date | null; - status?: 'active' | 'void' | null; - lat: number | null; - lon: number | null; - speed: number | null; // km/h - track: number | null; // degrees true - variation: number | null; // signed, E/W applied - faa?: FAAMode | null; - navStatus?: string | null; // NMEA 4.10 - raw: string; - valid: boolean; - type: 'RMC'; - } - - export interface VTG { - track: number | null; // degrees true - trackMagnetic: number | null; - speed: number | null; // km/h - faa: FAAMode | null; - raw: string; - valid: boolean; - type: 'VTG'; - } - - export interface TXT { - total: number; - index: number; - id: number; - part: string; // decoded segment - message: string | null; // full text when completed - completed: boolean; - rawMessages: string[]; // all parts when completed - system?: string; - raw: string; - valid: boolean; - type: 'TXT'; - } - - /** FAA mode (decoded human-readable strings) */ - export type FAAMode = - | 'autonomous' - | 'differential' - | 'estimated' - | 'manual input' - | 'simulated' - | 'not valid' - | 'precise' - | 'rtk' - | 'rtk-float'; - - export interface GSV { - msgNumber: number | null; - msgsTotal: number | null; - satsInView: number | null; - satellites: Satellite[]; - signalId?: number | null; // NMEA 4.10 - system?: string; // talker-derived ('GPS', 'GLONASS', ...) - raw: string; - valid: boolean; - type: 'GSV'; - } - - export interface Satellite { - prn: number | null; - elevation: number | null; - azimuth: number | null; - snr: number | null; - /** 'tracking' | 'in view' | null */ - status: string | null; - /** System derived from talker (e.g., 'GPS', 'GLONASS', 'Galileo', 'BeiDou', 'QZSS') */ - system: string; - /** Unique key like "GP12" used internally for visibility tracking */ - key: string; - } - - export interface GLL { - time: Date | null; - status?: 'active' | 'void' | null; - lat: number | null; - lon: number | null; - faa?: FAAMode | null; - raw: string; - valid: boolean; - type: 'GLL'; - } - - export interface ZDA { - time: Date | null; - offsetMin: number | null; - raw: string; - valid: boolean; - type: 'ZDA'; - } - - export interface GST { - time: Date | null; - rms: number | null; - ellipseMajor: number | null; - ellipseMinor: number | null; - ellipseOrientation: number | null; - latitudeError: number | null; - longitudeError: number | null; - heightError: number | null; - raw: string; - valid: boolean; - type: 'GST'; - } - - export interface HDT { - heading: number; // parsed as number; sentence requires a value - trueNorth: boolean; // 'T' - raw: string; - valid: boolean; - type: 'HDT'; - } - - export interface GRS { - time: Date | null; - mode: number | null; - /** residuals present in fields 3..14 (filtered to numeric) */ - res: number[]; - raw: string; - valid: boolean; - type: 'GRS'; - } - - export interface GBS { - time: Date | null; - errLat: number | null; - errLon: number | null; - errAlt: number | null; - failedSat: number | null; - probFailedSat: number | null; - biasFailedSat: number | null; - stdFailedSat: number | null; - systemId?: number | null; // NMEA 4.10 - signalId?: number | null; // NMEA 4.10 - raw: string; - valid: boolean; - type: 'GBS'; - } - - export interface GNS { - time: Date | null; - lat: number | null; - lon: number | null; - mode: string | null; // multi-constellation mode chars (as-is) - satsUsed: number | null; - hdop: number | null; - alt: number | null; - sep: number | null; - diffAge: number | null; - diffStation: number | null; - navStatus?: string | null; // NMEA 4.10 - raw: string; - valid: boolean; - type: 'GNS'; - } - - /** Union of all sentence payloads produced by GPS.Parse / events */ - export type NMEA = - | GGA - | GSA - | RMC - | VTG - | GSV - | GLL - | ZDA - | GST - | HDT - | GRS - | GBS - | GNS - | TXT; -} - -export default GPS; - -export type LatLon = GPS.LatLon; -export type GPSState = GPS.GPSState; -export type NMEA = GPS.NMEA; + +declare class GPS { + /** Mutable parser state aggregated from recent sentences */ + state: GPS.GPSState; + + /** + * Parse a full NMEA sentence and emit events. + * @param line NMEA string (must start with '$' and contain '*xx' checksum) + * @returns true if parsed (even if checksum invalid), false if sentence structure was rejected + */ + update(line: string): boolean; + + /** + * Feed streaming chunks; calls update() whenever a full line is assembled. + * Accepts both CRLF and LF as delimiters. + */ + updatePartial(chunk: string): void; + + /** + * Subscribe to an event ('data' for all sentences or a concrete type like 'GGA', 'RMC', ...). + * Multiple listeners per event are supported. + */ + on(event: string, callback: (data: any) => void): GPS; + + /** + * Remove listeners. If callback omitted, removes all listeners for the event. + */ + off(event: string, callback?: (data: any) => void): GPS; + + /** + * Parse a single line without using the event system. + * Returns the typed NMEA object (with `.raw`, `.valid`, `.type`) or false if unrecognized/invalid structure. + */ + static Parse(line: string): false | GPS.NMEA; + + /** + * Haversine distance in kilometers + */ + static Distance( + latFrom: number, + lonFrom: number, + latTo: number, + lonTo: number + ): number; + + /** + * Sum of pairwise distances along a path + */ + static TotalDistance(points: GPS.LatLon[]): number; + + /** + * Initial bearing (windrose: N=0, E=90, S=180, W=270) + */ + static Heading( + latFrom: number, + lonFrom: number, + latTo: number, + lonTo: number + ): number; +} + +declare namespace GPS { + + /* ---------- Shared ---------- */ + + export interface LatLon { + lat: number; + lon: number; + } + + /** Aggregated state built from recent sentences. All optional fields may be absent or null until observed. */ + export interface GPSState { + [key: string]: any; + processed: number; + errors: number; + + time?: Date | null; + lat?: number | null; + lon?: number | null; + alt?: number | null; + + speed?: number | null; + track?: number | null; + + heading?: number | null; // from HDT + trueNorth?: boolean | null; // from HDT + + // Fix quality from GSA + fix?: '2D' | '3D' | null; + hdop?: number | null; + pdop?: number | null; + vdop?: number | null; + + satsActive?: number[] | null; // PRNs used in fix (across systems) + satsVisible?: Satellite[] | null; // deduped & time-windowed set + + // Additional fields (not exhaustive): may appear depending on sentences seen + geoidal?: number | null; + } + + /* ---------- Sentence payloads (all include raw/valid/type) ---------- */ + + export interface GGA { + time: Date | null; + lat: number | null; + lon: number | null; + alt: number | null; + quality?: GGAQuality | null; + satellites: number | null; + hdop: number | null; + geoidal: number | null; + age: number | null; + stationID: number | null; + raw: string; + valid: boolean; + type: 'GGA'; + } + + export enum GGAQuality { + fix = 'fix', + 'dgps-fix' = 'dgps-fix', + 'pps-fix' = 'pps-fix', + rtk = 'rtk', + 'rtk-float' = 'rtk-float', + estimated = 'estimated', + manual = 'manual', + simulated = 'simulated' + } + + export interface GSA { + mode?: 'manual' | 'automatic' | null; + fix?: '2D' | '3D' | null; + satellites: number[]; // PRNs + pdop: number | null; + hdop: number | null; + vdop: number | null; + systemId?: number | null; // NMEA 4.10 + system?: string; // 'GPS' | 'GLONASS' | ... + raw: string; + valid: boolean; + type: 'GSA'; + } + + export interface RMC { + time: Date | null; + status?: 'active' | 'void' | null; + lat: number | null; + lon: number | null; + speed: number | null; // km/h + track: number | null; // degrees true + variation: number | null; // signed, E/W applied + faa?: FAAMode | null; + navStatus?: string | null; // NMEA 4.10 + raw: string; + valid: boolean; + type: 'RMC'; + } + + export interface VTG { + track: number | null; // degrees true + trackMagnetic: number | null; + speed: number | null; // km/h + faa: FAAMode | null; + raw: string; + valid: boolean; + type: 'VTG'; + } + + export interface TXT { + total: number; + index: number; + id: number; + part: string; // decoded segment + message: string | null; // full text when completed + completed: boolean; + rawMessages: string[]; // all parts when completed + system?: string; + raw: string; + valid: boolean; + type: 'TXT'; + } + + /** FAA mode (decoded human-readable strings) */ + export type FAAMode = + | 'autonomous' + | 'differential' + | 'estimated' + | 'manual input' + | 'simulated' + | 'not valid' + | 'precise' + | 'rtk' + | 'rtk-float'; + + export interface GSV { + msgNumber: number | null; + msgsTotal: number | null; + satsInView: number | null; + satellites: Satellite[]; + signalId?: number | null; // NMEA 4.10 + system?: string; // talker-derived ('GPS', 'GLONASS', ...) + raw: string; + valid: boolean; + type: 'GSV'; + } + + export interface Satellite { + prn: number | null; + elevation: number | null; + azimuth: number | null; + snr: number | null; + /** 'tracking' | 'in view' | null */ + status: string | null; + /** System derived from talker (e.g., 'GPS', 'GLONASS', 'Galileo', 'BeiDou', 'QZSS') */ + system: string; + /** Unique key like "GP12" used internally for visibility tracking */ + key: string; + } + + export interface GLL { + time: Date | null; + status?: 'active' | 'void' | null; + lat: number | null; + lon: number | null; + faa?: FAAMode | null; + raw: string; + valid: boolean; + type: 'GLL'; + } + + export interface ZDA { + time: Date | null; + offsetMin: number | null; + raw: string; + valid: boolean; + type: 'ZDA'; + } + + export interface GST { + time: Date | null; + rms: number | null; + ellipseMajor: number | null; + ellipseMinor: number | null; + ellipseOrientation: number | null; + latitudeError: number | null; + longitudeError: number | null; + heightError: number | null; + raw: string; + valid: boolean; + type: 'GST'; + } + + export interface HDT { + heading: number; // parsed as number; sentence requires a value + trueNorth: boolean; // 'T' + raw: string; + valid: boolean; + type: 'HDT'; + } + + export interface GRS { + time: Date | null; + mode: number | null; + /** residuals present in fields 3..14 (filtered to numeric) */ + res: number[]; + raw: string; + valid: boolean; + type: 'GRS'; + } + + export interface GBS { + time: Date | null; + errLat: number | null; + errLon: number | null; + errAlt: number | null; + failedSat: number | null; + probFailedSat: number | null; + biasFailedSat: number | null; + stdFailedSat: number | null; + systemId?: number | null; // NMEA 4.10 + signalId?: number | null; // NMEA 4.10 + raw: string; + valid: boolean; + type: 'GBS'; + } + + export interface GNS { + time: Date | null; + lat: number | null; + lon: number | null; + mode: string | null; // multi-constellation mode chars (as-is) + satsUsed: number | null; + hdop: number | null; + alt: number | null; + sep: number | null; + diffAge: number | null; + diffStation: number | null; + navStatus?: string | null; // NMEA 4.10 + raw: string; + valid: boolean; + type: 'GNS'; + } + + /** Union of all sentence payloads produced by GPS.Parse / events */ + export type NMEA = + | GGA + | GSA + | RMC + | VTG + | GSV + | GLL + | ZDA + | GST + | HDT + | GRS + | GBS + | GNS + | TXT; +} + +export default GPS; + +export type LatLon = GPS.LatLon; +export type GPSState = GPS.GPSState; +export type NMEA = GPS.NMEA; diff --git a/gps.d.ts b/gps.d.ts index b7b6583..93475c6 100644 --- a/gps.d.ts +++ b/gps.d.ts @@ -1,321 +1,321 @@ - -declare class GPS { - /** Mutable parser state aggregated from recent sentences */ - state: GPS.GPSState; - - /** - * Parse a full NMEA sentence and emit events. - * @param line NMEA string (must start with '$' and contain '*xx' checksum) - * @returns true if parsed (even if checksum invalid), false if sentence structure was rejected - */ - update(line: string): boolean; - - /** - * Feed streaming chunks; calls update() whenever a full line is assembled. - * Accepts both CRLF and LF as delimiters. - */ - updatePartial(chunk: string): void; - - /** - * Subscribe to an event ('data' for all sentences or a concrete type like 'GGA', 'RMC', ...). - * Multiple listeners per event are supported. - */ - on(event: string, callback: (data: any) => void): GPS; - - /** - * Remove listeners. If callback omitted, removes all listeners for the event. - */ - off(event: string, callback?: (data: any) => void): GPS; - - /** - * Parse a single line without using the event system. - * Returns the typed NMEA object (with `.raw`, `.valid`, `.type`) or false if unrecognized/invalid structure. - */ - static Parse(line: string): false | GPS.NMEA; - - /** - * Haversine distance in kilometers - */ - static Distance( - latFrom: number, - lonFrom: number, - latTo: number, - lonTo: number - ): number; - - /** - * Sum of pairwise distances along a path - */ - static TotalDistance(points: GPS.LatLon[]): number; - - /** - * Initial bearing (windrose: N=0, E=90, S=180, W=270) - */ - static Heading( - latFrom: number, - lonFrom: number, - latTo: number, - lonTo: number - ): number; -} - -declare namespace GPS { - - /* ---------- Shared ---------- */ - - export interface LatLon { - lat: number; - lon: number; - } - - /** Aggregated state built from recent sentences. All optional fields may be absent or null until observed. */ - export interface GPSState { - [key: string]: any; - processed: number; - errors: number; - - time?: Date | null; - lat?: number | null; - lon?: number | null; - alt?: number | null; - - speed?: number | null; - track?: number | null; - - heading?: number | null; // from HDT - trueNorth?: boolean | null; // from HDT - - // Fix quality from GSA - fix?: '2D' | '3D' | null; - hdop?: number | null; - pdop?: number | null; - vdop?: number | null; - - satsActive?: number[] | null; // PRNs used in fix (across systems) - satsVisible?: Satellite[] | null; // deduped & time-windowed set - - // Additional fields (not exhaustive): may appear depending on sentences seen - geoidal?: number | null; - } - - /* ---------- Sentence payloads (all include raw/valid/type) ---------- */ - - export interface GGA { - time: Date | null; - lat: number | null; - lon: number | null; - alt: number | null; - quality?: GGAQuality | null; - satellites: number | null; - hdop: number | null; - geoidal: number | null; - age: number | null; - stationID: number | null; - raw: string; - valid: boolean; - type: 'GGA'; - } - - export enum GGAQuality { - fix = 'fix', - 'dgps-fix' = 'dgps-fix', - 'pps-fix' = 'pps-fix', - rtk = 'rtk', - 'rtk-float' = 'rtk-float', - estimated = 'estimated', - manual = 'manual', - simulated = 'simulated' - } - - export interface GSA { - mode?: 'manual' | 'automatic' | null; - fix?: '2D' | '3D' | null; - satellites: number[]; // PRNs - pdop: number | null; - hdop: number | null; - vdop: number | null; - systemId?: number | null; // NMEA 4.10 - system?: string; // 'GPS' | 'GLONASS' | ... - raw: string; - valid: boolean; - type: 'GSA'; - } - - export interface RMC { - time: Date | null; - status?: 'active' | 'void' | null; - lat: number | null; - lon: number | null; - speed: number | null; // km/h - track: number | null; // degrees true - variation: number | null; // signed, E/W applied - faa?: FAAMode | null; - navStatus?: string | null; // NMEA 4.10 - raw: string; - valid: boolean; - type: 'RMC'; - } - - export interface VTG { - track: number | null; // degrees true - trackMagnetic: number | null; - speed: number | null; // km/h - faa: FAAMode | null; - raw: string; - valid: boolean; - type: 'VTG'; - } - - export interface TXT { - total: number; - index: number; - id: number; - part: string; // decoded segment - message: string | null; // full text when completed - completed: boolean; - rawMessages: string[]; // all parts when completed - system?: string; - raw: string; - valid: boolean; - type: 'TXT'; - } - - /** FAA mode (decoded human-readable strings) */ - export type FAAMode = - | 'autonomous' - | 'differential' - | 'estimated' - | 'manual input' - | 'simulated' - | 'not valid' - | 'precise' - | 'rtk' - | 'rtk-float'; - - export interface GSV { - msgNumber: number | null; - msgsTotal: number | null; - satsInView: number | null; - satellites: Satellite[]; - signalId?: number | null; // NMEA 4.10 - system?: string; // talker-derived ('GPS', 'GLONASS', ...) - raw: string; - valid: boolean; - type: 'GSV'; - } - - export interface Satellite { - prn: number | null; - elevation: number | null; - azimuth: number | null; - snr: number | null; - /** 'tracking' | 'in view' | null */ - status: string | null; - /** System derived from talker (e.g., 'GPS', 'GLONASS', 'Galileo', 'BeiDou', 'QZSS') */ - system: string; - /** Unique key like "GP12" used internally for visibility tracking */ - key: string; - } - - export interface GLL { - time: Date | null; - status?: 'active' | 'void' | null; - lat: number | null; - lon: number | null; - faa?: FAAMode | null; - raw: string; - valid: boolean; - type: 'GLL'; - } - - export interface ZDA { - time: Date | null; - offsetMin: number | null; - raw: string; - valid: boolean; - type: 'ZDA'; - } - - export interface GST { - time: Date | null; - rms: number | null; - ellipseMajor: number | null; - ellipseMinor: number | null; - ellipseOrientation: number | null; - latitudeError: number | null; - longitudeError: number | null; - heightError: number | null; - raw: string; - valid: boolean; - type: 'GST'; - } - - export interface HDT { - heading: number; // parsed as number; sentence requires a value - trueNorth: boolean; // 'T' - raw: string; - valid: boolean; - type: 'HDT'; - } - - export interface GRS { - time: Date | null; - mode: number | null; - /** residuals present in fields 3..14 (filtered to numeric) */ - res: number[]; - raw: string; - valid: boolean; - type: 'GRS'; - } - - export interface GBS { - time: Date | null; - errLat: number | null; - errLon: number | null; - errAlt: number | null; - failedSat: number | null; - probFailedSat: number | null; - biasFailedSat: number | null; - stdFailedSat: number | null; - systemId?: number | null; // NMEA 4.10 - signalId?: number | null; // NMEA 4.10 - raw: string; - valid: boolean; - type: 'GBS'; - } - - export interface GNS { - time: Date | null; - lat: number | null; - lon: number | null; - mode: string | null; // multi-constellation mode chars (as-is) - satsUsed: number | null; - hdop: number | null; - alt: number | null; - sep: number | null; - diffAge: number | null; - diffStation: number | null; - navStatus?: string | null; // NMEA 4.10 - raw: string; - valid: boolean; - type: 'GNS'; - } - - /** Union of all sentence payloads produced by GPS.Parse / events */ - export type NMEA = - | GGA - | GSA - | RMC - | VTG - | GSV - | GLL - | ZDA - | GST - | HDT - | GRS - | GBS - | GNS - | TXT; -} - + +declare class GPS { + /** Mutable parser state aggregated from recent sentences */ + state: GPS.GPSState; + + /** + * Parse a full NMEA sentence and emit events. + * @param line NMEA string (must start with '$' and contain '*xx' checksum) + * @returns true if parsed (even if checksum invalid), false if sentence structure was rejected + */ + update(line: string): boolean; + + /** + * Feed streaming chunks; calls update() whenever a full line is assembled. + * Accepts both CRLF and LF as delimiters. + */ + updatePartial(chunk: string): void; + + /** + * Subscribe to an event ('data' for all sentences or a concrete type like 'GGA', 'RMC', ...). + * Multiple listeners per event are supported. + */ + on(event: string, callback: (data: any) => void): GPS; + + /** + * Remove listeners. If callback omitted, removes all listeners for the event. + */ + off(event: string, callback?: (data: any) => void): GPS; + + /** + * Parse a single line without using the event system. + * Returns the typed NMEA object (with `.raw`, `.valid`, `.type`) or false if unrecognized/invalid structure. + */ + static Parse(line: string): false | GPS.NMEA; + + /** + * Haversine distance in kilometers + */ + static Distance( + latFrom: number, + lonFrom: number, + latTo: number, + lonTo: number + ): number; + + /** + * Sum of pairwise distances along a path + */ + static TotalDistance(points: GPS.LatLon[]): number; + + /** + * Initial bearing (windrose: N=0, E=90, S=180, W=270) + */ + static Heading( + latFrom: number, + lonFrom: number, + latTo: number, + lonTo: number + ): number; +} + +declare namespace GPS { + + /* ---------- Shared ---------- */ + + export interface LatLon { + lat: number; + lon: number; + } + + /** Aggregated state built from recent sentences. All optional fields may be absent or null until observed. */ + export interface GPSState { + [key: string]: any; + processed: number; + errors: number; + + time?: Date | null; + lat?: number | null; + lon?: number | null; + alt?: number | null; + + speed?: number | null; + track?: number | null; + + heading?: number | null; // from HDT + trueNorth?: boolean | null; // from HDT + + // Fix quality from GSA + fix?: '2D' | '3D' | null; + hdop?: number | null; + pdop?: number | null; + vdop?: number | null; + + satsActive?: number[] | null; // PRNs used in fix (across systems) + satsVisible?: Satellite[] | null; // deduped & time-windowed set + + // Additional fields (not exhaustive): may appear depending on sentences seen + geoidal?: number | null; + } + + /* ---------- Sentence payloads (all include raw/valid/type) ---------- */ + + export interface GGA { + time: Date | null; + lat: number | null; + lon: number | null; + alt: number | null; + quality?: GGAQuality | null; + satellites: number | null; + hdop: number | null; + geoidal: number | null; + age: number | null; + stationID: number | null; + raw: string; + valid: boolean; + type: 'GGA'; + } + + export enum GGAQuality { + fix = 'fix', + 'dgps-fix' = 'dgps-fix', + 'pps-fix' = 'pps-fix', + rtk = 'rtk', + 'rtk-float' = 'rtk-float', + estimated = 'estimated', + manual = 'manual', + simulated = 'simulated' + } + + export interface GSA { + mode?: 'manual' | 'automatic' | null; + fix?: '2D' | '3D' | null; + satellites: number[]; // PRNs + pdop: number | null; + hdop: number | null; + vdop: number | null; + systemId?: number | null; // NMEA 4.10 + system?: string; // 'GPS' | 'GLONASS' | ... + raw: string; + valid: boolean; + type: 'GSA'; + } + + export interface RMC { + time: Date | null; + status?: 'active' | 'void' | null; + lat: number | null; + lon: number | null; + speed: number | null; // km/h + track: number | null; // degrees true + variation: number | null; // signed, E/W applied + faa?: FAAMode | null; + navStatus?: string | null; // NMEA 4.10 + raw: string; + valid: boolean; + type: 'RMC'; + } + + export interface VTG { + track: number | null; // degrees true + trackMagnetic: number | null; + speed: number | null; // km/h + faa: FAAMode | null; + raw: string; + valid: boolean; + type: 'VTG'; + } + + export interface TXT { + total: number; + index: number; + id: number; + part: string; // decoded segment + message: string | null; // full text when completed + completed: boolean; + rawMessages: string[]; // all parts when completed + system?: string; + raw: string; + valid: boolean; + type: 'TXT'; + } + + /** FAA mode (decoded human-readable strings) */ + export type FAAMode = + | 'autonomous' + | 'differential' + | 'estimated' + | 'manual input' + | 'simulated' + | 'not valid' + | 'precise' + | 'rtk' + | 'rtk-float'; + + export interface GSV { + msgNumber: number | null; + msgsTotal: number | null; + satsInView: number | null; + satellites: Satellite[]; + signalId?: number | null; // NMEA 4.10 + system?: string; // talker-derived ('GPS', 'GLONASS', ...) + raw: string; + valid: boolean; + type: 'GSV'; + } + + export interface Satellite { + prn: number | null; + elevation: number | null; + azimuth: number | null; + snr: number | null; + /** 'tracking' | 'in view' | null */ + status: string | null; + /** System derived from talker (e.g., 'GPS', 'GLONASS', 'Galileo', 'BeiDou', 'QZSS') */ + system: string; + /** Unique key like "GP12" used internally for visibility tracking */ + key: string; + } + + export interface GLL { + time: Date | null; + status?: 'active' | 'void' | null; + lat: number | null; + lon: number | null; + faa?: FAAMode | null; + raw: string; + valid: boolean; + type: 'GLL'; + } + + export interface ZDA { + time: Date | null; + offsetMin: number | null; + raw: string; + valid: boolean; + type: 'ZDA'; + } + + export interface GST { + time: Date | null; + rms: number | null; + ellipseMajor: number | null; + ellipseMinor: number | null; + ellipseOrientation: number | null; + latitudeError: number | null; + longitudeError: number | null; + heightError: number | null; + raw: string; + valid: boolean; + type: 'GST'; + } + + export interface HDT { + heading: number; // parsed as number; sentence requires a value + trueNorth: boolean; // 'T' + raw: string; + valid: boolean; + type: 'HDT'; + } + + export interface GRS { + time: Date | null; + mode: number | null; + /** residuals present in fields 3..14 (filtered to numeric) */ + res: number[]; + raw: string; + valid: boolean; + type: 'GRS'; + } + + export interface GBS { + time: Date | null; + errLat: number | null; + errLon: number | null; + errAlt: number | null; + failedSat: number | null; + probFailedSat: number | null; + biasFailedSat: number | null; + stdFailedSat: number | null; + systemId?: number | null; // NMEA 4.10 + signalId?: number | null; // NMEA 4.10 + raw: string; + valid: boolean; + type: 'GBS'; + } + + export interface GNS { + time: Date | null; + lat: number | null; + lon: number | null; + mode: string | null; // multi-constellation mode chars (as-is) + satsUsed: number | null; + hdop: number | null; + alt: number | null; + sep: number | null; + diffAge: number | null; + diffStation: number | null; + navStatus?: string | null; // NMEA 4.10 + raw: string; + valid: boolean; + type: 'GNS'; + } + + /** Union of all sentence payloads produced by GPS.Parse / events */ + export type NMEA = + | GGA + | GSA + | RMC + | VTG + | GSV + | GLL + | ZDA + | GST + | HDT + | GRS + | GBS + | GNS + | TXT; +} + export = GPS; \ No newline at end of file diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..92bf682 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,2753 @@ +{ + "name": "gps", + "version": "0.8.1", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "gps", + "version": "0.8.1", + "license": "MIT", + "dependencies": { + "ws": "^8.18.3" + }, + "devDependencies": { + "angles": "^0.5.0", + "byline": "^5.0.0", + "crude-build": "^0.1.2", + "express": "^5.1.0", + "kalman": "0.0.2", + "mocha": "^11.7.1", + "serialport": "^13.0.0", + "sylvester": "0.0.21" + }, + "engines": { + "node": "*" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/rawify" + } + }, + "node_modules/@isaacs/cliui": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", + "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^5.1.2", + "string-width-cjs": "npm:string-width@^4.2.0", + "strip-ansi": "^7.0.1", + "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", + "wrap-ansi": "^8.1.0", + "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@pkgjs/parseargs": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", + "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=14" + } + }, + "node_modules/@serialport/binding-mock": { + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/@serialport/binding-mock/-/binding-mock-10.2.2.tgz", + "integrity": "sha512-HAFzGhk9OuFMpuor7aT5G1ChPgn5qSsklTFOTUX72Rl6p0xwcSVsRtG/xaGp6bxpN7fI9D/S8THLBWbBgS6ldw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@serialport/bindings-interface": "^1.2.1", + "debug": "^4.3.3" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/@serialport/bindings-cpp": { + "version": "13.0.0", + "resolved": "https://registry.npmjs.org/@serialport/bindings-cpp/-/bindings-cpp-13.0.0.tgz", + "integrity": "sha512-r25o4Bk/vaO1LyUfY/ulR6hCg/aWiN6Wo2ljVlb4Pj5bqWGcSRC4Vse4a9AcapuAu/FeBzHCbKMvRQeCuKjzIQ==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "@serialport/bindings-interface": "1.2.2", + "@serialport/parser-readline": "12.0.0", + "debug": "4.4.0", + "node-addon-api": "8.3.0", + "node-gyp-build": "4.8.4" + }, + "engines": { + "node": ">=18.0.0" + }, + "funding": { + "url": "https://opencollective.com/serialport/donate" + } + }, + "node_modules/@serialport/bindings-cpp/node_modules/@serialport/parser-delimiter": { + "version": "12.0.0", + "resolved": "https://registry.npmjs.org/@serialport/parser-delimiter/-/parser-delimiter-12.0.0.tgz", + "integrity": "sha512-gu26tVt5lQoybhorLTPsH2j2LnX3AOP2x/34+DUSTNaUTzu2fBXw+isVjQJpUBFWu6aeQRZw5bJol5X9Gxjblw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://opencollective.com/serialport/donate" + } + }, + "node_modules/@serialport/bindings-cpp/node_modules/@serialport/parser-readline": { + "version": "12.0.0", + "resolved": "https://registry.npmjs.org/@serialport/parser-readline/-/parser-readline-12.0.0.tgz", + "integrity": "sha512-O7cywCWC8PiOMvo/gglEBfAkLjp/SENEML46BXDykfKP5mTPM46XMaX1L0waWU6DXJpBgjaL7+yX6VriVPbN4w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@serialport/parser-delimiter": "12.0.0" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://opencollective.com/serialport/donate" + } + }, + "node_modules/@serialport/bindings-cpp/node_modules/debug": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.0.tgz", + "integrity": "sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/@serialport/bindings-interface": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@serialport/bindings-interface/-/bindings-interface-1.2.2.tgz", + "integrity": "sha512-CJaUd5bLvtM9c5dmO9rPBHPXTa9R2UwpkJ0wdh9JCYcbrPWsKz+ErvR0hBLeo7NPeiFdjFO4sonRljiw4d2XiA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.22 || ^14.13 || >=16" + } + }, + "node_modules/@serialport/parser-byte-length": { + "version": "13.0.0", + "resolved": "https://registry.npmjs.org/@serialport/parser-byte-length/-/parser-byte-length-13.0.0.tgz", + "integrity": "sha512-32yvqeTAqJzAEtX5zCrN1Mej56GJ5h/cVFsCDPbF9S1ZSC9FWjOqNAgtByseHfFTSTs/4ZBQZZcZBpolt8sUng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/serialport/donate" + } + }, + "node_modules/@serialport/parser-cctalk": { + "version": "13.0.0", + "resolved": "https://registry.npmjs.org/@serialport/parser-cctalk/-/parser-cctalk-13.0.0.tgz", + "integrity": "sha512-RErAe57g9gvnlieVYGIn1xymb1bzNXb2QtUQd14FpmbQQYlcrmuRnJwKa1BgTCujoCkhtaTtgHlbBWOxm8U2uA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/serialport/donate" + } + }, + "node_modules/@serialport/parser-delimiter": { + "version": "13.0.0", + "resolved": "https://registry.npmjs.org/@serialport/parser-delimiter/-/parser-delimiter-13.0.0.tgz", + "integrity": "sha512-Qqyb0FX1avs3XabQqNaZSivyVbl/yl0jywImp7ePvfZKLwx7jBZjvL+Hawt9wIG6tfq6zbFM24vzCCK7REMUig==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/serialport/donate" + } + }, + "node_modules/@serialport/parser-inter-byte-timeout": { + "version": "13.0.0", + "resolved": "https://registry.npmjs.org/@serialport/parser-inter-byte-timeout/-/parser-inter-byte-timeout-13.0.0.tgz", + "integrity": "sha512-a0w0WecTW7bD2YHWrpTz1uyiWA2fDNym0kjmPeNSwZ2XCP+JbirZt31l43m2ey6qXItTYVuQBthm75sPVeHnGA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/serialport/donate" + } + }, + "node_modules/@serialport/parser-packet-length": { + "version": "13.0.0", + "resolved": "https://registry.npmjs.org/@serialport/parser-packet-length/-/parser-packet-length-13.0.0.tgz", + "integrity": "sha512-60ZDDIqYRi0Xs2SPZUo4Jr5LLIjtb+rvzPKMJCohrO6tAqSDponcNpcB1O4W21mKTxYjqInSz+eMrtk0LLfZIg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/@serialport/parser-readline": { + "version": "13.0.0", + "resolved": "https://registry.npmjs.org/@serialport/parser-readline/-/parser-readline-13.0.0.tgz", + "integrity": "sha512-dov3zYoyf0dt1Sudd1q42VVYQ4WlliF0MYvAMA3MOyiU1IeG4hl0J6buBA2w4gl3DOCC05tGgLDN/3yIL81gsA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@serialport/parser-delimiter": "13.0.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/serialport/donate" + } + }, + "node_modules/@serialport/parser-ready": { + "version": "13.0.0", + "resolved": "https://registry.npmjs.org/@serialport/parser-ready/-/parser-ready-13.0.0.tgz", + "integrity": "sha512-JNUQA+y2Rfs4bU+cGYNqOPnNMAcayhhW+XJZihSLQXOHcZsFnOa2F9YtMg9VXRWIcnHldHYtisp62Etjlw24bw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/serialport/donate" + } + }, + "node_modules/@serialport/parser-regex": { + "version": "13.0.0", + "resolved": "https://registry.npmjs.org/@serialport/parser-regex/-/parser-regex-13.0.0.tgz", + "integrity": "sha512-m7HpIf56G5XcuDdA3DB34Z0pJiwxNRakThEHjSa4mG05OnWYv0IG8l2oUyYfuGMowQWaVnQ+8r+brlPxGVH+eA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/serialport/donate" + } + }, + "node_modules/@serialport/parser-slip-encoder": { + "version": "13.0.0", + "resolved": "https://registry.npmjs.org/@serialport/parser-slip-encoder/-/parser-slip-encoder-13.0.0.tgz", + "integrity": "sha512-fUHZEExm6izJ7rg0A1yjXwu4sOzeBkPAjDZPfb+XQoqgtKAk+s+HfICiYn7N2QU9gyaeCO8VKgWwi+b/DowYOg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/serialport/donate" + } + }, + "node_modules/@serialport/parser-spacepacket": { + "version": "13.0.0", + "resolved": "https://registry.npmjs.org/@serialport/parser-spacepacket/-/parser-spacepacket-13.0.0.tgz", + "integrity": "sha512-DoXJ3mFYmyD8X/8931agJvrBPxqTaYDsPoly9/cwQSeh/q4EjQND9ySXBxpWz5WcpyCU4jOuusqCSAPsbB30Eg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/serialport/donate" + } + }, + "node_modules/@serialport/stream": { + "version": "13.0.0", + "resolved": "https://registry.npmjs.org/@serialport/stream/-/stream-13.0.0.tgz", + "integrity": "sha512-F7xLJKsjGo2WuEWMSEO1SimRcOA+WtWICsY13r0ahx8s2SecPQH06338g28OT7cW7uRXI7oEQAk62qh5gHJW3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@serialport/bindings-interface": "1.2.2", + "debug": "4.4.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/serialport/donate" + } + }, + "node_modules/@serialport/stream/node_modules/debug": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.0.tgz", + "integrity": "sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/accepts": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", + "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", + "dev": true, + "license": "MIT", + "dependencies": { + "mime-types": "^3.0.0", + "negotiator": "^1.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/angles": { + "version": "0.5.1", + "resolved": "https://registry.npmjs.org/angles/-/angles-0.5.1.tgz", + "integrity": "sha512-ei/UuKrfOTYBWF/WbjnPlEP12Rf/4gJQnyniFLM/tnc2Y6ZG+Ql/WnsLfor4IeawD81QE5UrrSHG3sS4N0rXaw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/rawify" + } + }, + "node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" + }, + "node_modules/b4a": { + "version": "1.7.3", + "resolved": "https://registry.npmjs.org/b4a/-/b4a-1.7.3.tgz", + "integrity": "sha512-5Q2mfq2WfGuFp3uS//0s6baOJLMoVduPYVeNmDYxu5OUA1/cBfvr2RIS7vi62LdNj/urk1hfmj867I3qt6uZ7Q==", + "dev": true, + "license": "Apache-2.0", + "peerDependencies": { + "react-native-b4a": "*" + }, + "peerDependenciesMeta": { + "react-native-b4a": { + "optional": true + } + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/bare-events": { + "version": "2.8.2", + "resolved": "https://registry.npmjs.org/bare-events/-/bare-events-2.8.2.tgz", + "integrity": "sha512-riJjyv1/mHLIPX4RwiK+oW9/4c3TEUeORHKefKAKnZ5kyslbN+HXowtbaVEqt4IMUB7OXlfixcs6gsFeo/jhiQ==", + "dev": true, + "license": "Apache-2.0", + "peerDependencies": { + "bare-abort-controller": "*" + }, + "peerDependenciesMeta": { + "bare-abort-controller": { + "optional": true + } + } + }, + "node_modules/body-parser": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.0.tgz", + "integrity": "sha512-02qvAaxv8tp7fBa/mw1ga98OGm+eCbqzJOKoRt70sLmfEEi+jyBYVTDGfCL/k06/4EMk/z01gCe7HoCH/f2LTg==", + "dev": true, + "license": "MIT", + "dependencies": { + "bytes": "^3.1.2", + "content-type": "^1.0.5", + "debug": "^4.4.0", + "http-errors": "^2.0.0", + "iconv-lite": "^0.6.3", + "on-finished": "^2.4.1", + "qs": "^6.14.0", + "raw-body": "^3.0.0", + "type-is": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/brace-expansion": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/browser-stdout": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/browser-stdout/-/browser-stdout-1.3.1.tgz", + "integrity": "sha512-qhAVI1+Av2X7qelOfAIYwXONood6XlZE/fXaBSmW/T5SzLAmCgzi+eiWE7fUvbHaeNBQH13UftjpXxsfLkMpgw==", + "dev": true, + "license": "ISC" + }, + "node_modules/byline": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/byline/-/byline-5.0.0.tgz", + "integrity": "sha512-s6webAy+R4SR8XVuJWt2V2rGvhnrhxN+9S15GNuTK3wKPOXFF6RNc+8ug2XhH+2s4f+uudG4kUVYmYOQWL2g0Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/camelcase": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-6.3.0.tgz", + "integrity": "sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/chalk": { + "version": "5.6.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", + "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.17.0 || ^14.13 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/chokidar": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", + "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "readdirp": "^4.0.1" + }, + "engines": { + "node": ">= 14.16.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/cliui/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/cliui/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/cliui/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/cliui/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/cliui/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/cliui/node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/clone": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/clone/-/clone-2.1.2.tgz", + "integrity": "sha512-3Pe/CF1Nn94hyhIYpjtiLhdCoEoz0DqQ+988E9gmeEdQZlojxnOb74wctFyuwWQHzqyf9X7C7MG8juUpqBJT8w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/content-disposition": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.0.tgz", + "integrity": "sha512-Au9nRL8VNUut/XSzbQA38+M78dzP4D+eqg3gfJHMIHHYa3bg067xj1KxMUWj+VULbiZMowKngFFbKczUrNJ1mg==", + "dev": true, + "license": "MIT", + "dependencies": { + "safe-buffer": "5.2.1" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.6.0" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/crude-build": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/crude-build/-/crude-build-0.1.3.tgz", + "integrity": "sha512-pM2nOqcpJ6LyqKj+IwUoxSLxd3pgAhy31dH6WHOvQWJGkojhcWfRfZFqeTTehngdTqQDp7Cp3SlG+u5ycqyl+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "google-closure-compiler": "^20250903.0.0" + }, + "bin": { + "crude-build": "bin/build.js" + }, + "engines": { + "node": "*" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/rawify" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decamelize": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-4.0.0.tgz", + "integrity": "sha512-9iE1PgSik9HeIIw2JO94IidnE3eBoQrFJ3w7sFuzSX4DpmZ3v5sZpUiV5Swcf6mQEF+Y0ru8Neo+p+nyh2J+hQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/diff": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/diff/-/diff-7.0.0.tgz", + "integrity": "sha512-PJWHUb1RFevKCwaFA9RlG5tCd+FO5iRh9A8HEtkmBH2Li03iJriB6m6JIN4rGz3K3JLawI7/veA1xzRKP6ISBw==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.3.1" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/eastasianwidth": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", + "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", + "dev": true, + "license": "MIT" + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "dev": true, + "license": "MIT" + }, + "node_modules/emoji-regex": { + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", + "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", + "dev": true, + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "dev": true, + "license": "MIT" + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/events-universal": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/events-universal/-/events-universal-1.0.1.tgz", + "integrity": "sha512-LUd5euvbMLpwOF8m6ivPCbhQeSiYVNb8Vs0fQ8QjXo0JTkEHpz8pxdQf0gStltaPpw0Cca8b39KxvK9cfKRiAw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "bare-events": "^2.7.0" + } + }, + "node_modules/express": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/express/-/express-5.1.0.tgz", + "integrity": "sha512-DT9ck5YIRU+8GYzzU5kT3eHGA5iL+1Zd0EutOmTE9Dtk+Tvuzd23VBU+ec7HPNSTxXYO55gPV/hq4pSBJDjFpA==", + "dev": true, + "license": "MIT", + "dependencies": { + "accepts": "^2.0.0", + "body-parser": "^2.2.0", + "content-disposition": "^1.0.0", + "content-type": "^1.0.5", + "cookie": "^0.7.1", + "cookie-signature": "^1.2.1", + "debug": "^4.4.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "finalhandler": "^2.1.0", + "fresh": "^2.0.0", + "http-errors": "^2.0.0", + "merge-descriptors": "^2.0.0", + "mime-types": "^3.0.0", + "on-finished": "^2.4.1", + "once": "^1.4.0", + "parseurl": "^1.3.3", + "proxy-addr": "^2.0.7", + "qs": "^6.14.0", + "range-parser": "^1.2.1", + "router": "^2.2.0", + "send": "^1.1.0", + "serve-static": "^2.2.0", + "statuses": "^2.0.1", + "type-is": "^2.0.1", + "vary": "^1.1.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/fast-fifo": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/fast-fifo/-/fast-fifo-1.3.2.tgz", + "integrity": "sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/finalhandler": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.0.tgz", + "integrity": "sha512-/t88Ty3d5JWQbWYgaOGCCYfXRwV1+be02WqYYlL6h0lEiUAMPM8o8qKGO01YIkOHzka2up08wvgYD0mDiI+q3Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "on-finished": "^2.4.1", + "parseurl": "^1.3.3", + "statuses": "^2.0.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/flat/-/flat-5.0.2.tgz", + "integrity": "sha512-b6suED+5/3rTpUBdG1gupIl8MPFCAMA0QXwmljLhvCUKcUvdE4gWky9zpuGCcXHOsz4J9wPGNWq6OKpmIzz3hQ==", + "dev": true, + "license": "BSD-3-Clause", + "bin": { + "flat": "cli.js" + } + }, + "node_modules/foreground-child": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", + "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", + "dev": true, + "license": "ISC", + "dependencies": { + "cross-spawn": "^7.0.6", + "signal-exit": "^4.0.1" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", + "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "dev": true, + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/glob": { + "version": "10.4.5", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.4.5.tgz", + "integrity": "sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==", + "dev": true, + "license": "ISC", + "dependencies": { + "foreground-child": "^3.1.0", + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^1.11.1" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/google-closure-compiler": { + "version": "20250903.0.0", + "resolved": "https://registry.npmjs.org/google-closure-compiler/-/google-closure-compiler-20250903.0.0.tgz", + "integrity": "sha512-+oXBmv/9X5XKT5x4pesMlX+yIxmmklMhkLzduxyV9IiEEIbh8v/xxoYKYcYM2vGzXCIjq7o6uh9wHhtiZCxPdQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "chalk": "5.x", + "google-closure-compiler-java": "^20250903.0.0", + "minimist": "1.x", + "vinyl": "3.x", + "vinyl-sourcemaps-apply": "^0.2.0" + }, + "bin": { + "google-closure-compiler": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "google-closure-compiler-linux": "^20250903.0.0", + "google-closure-compiler-linux-arm64": "^20250903.0.0", + "google-closure-compiler-macos": "^20250903.0.0", + "google-closure-compiler-windows": "^20250903.0.0" + } + }, + "node_modules/google-closure-compiler-java": { + "version": "20250903.0.0", + "resolved": "https://registry.npmjs.org/google-closure-compiler-java/-/google-closure-compiler-java-20250903.0.0.tgz", + "integrity": "sha512-hA3vFNwBbTFrCejGyRyMoXy1KWQNiQdxYEQqdaF3rN2WAARZkMzIcfetKMktkgCS5SDS/JDs59B05YLYlV9JjA==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/google-closure-compiler-linux": { + "version": "20250903.0.0", + "resolved": "https://registry.npmjs.org/google-closure-compiler-linux/-/google-closure-compiler-linux-20250903.0.0.tgz", + "integrity": "sha512-aryNIReNulYdH1hC0ATttNtp75Umr+MWDHkjiEPlGEMGO/OoXSoBtBJU+gqaGMGkh78GtEhQpXL+gPj6G5k9eA==", + "cpu": [ + "x32", + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/google-closure-compiler-linux-arm64": { + "version": "20250903.0.0", + "resolved": "https://registry.npmjs.org/google-closure-compiler-linux-arm64/-/google-closure-compiler-linux-arm64-20250903.0.0.tgz", + "integrity": "sha512-lyzQFgRRJxUJZN7YgHZIJHEIXakxWSnQ8ib4zRVfAGKXO34FHv2C08b1sZiQ+XkABtTNQypT/u6bkMRdZIEPmA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/google-closure-compiler-macos": { + "version": "20250903.0.0", + "resolved": "https://registry.npmjs.org/google-closure-compiler-macos/-/google-closure-compiler-macos-20250903.0.0.tgz", + "integrity": "sha512-jrERk9Apdy+Yky2RonqQQR9dAaHipzwM4oNCn9UzLWaRo/58FvzFIbPknVagN/Fo0n4odJZ2tgYkPzvrQAHCPA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/google-closure-compiler-windows": { + "version": "20250903.0.0", + "resolved": "https://registry.npmjs.org/google-closure-compiler-windows/-/google-closure-compiler-windows-20250903.0.0.tgz", + "integrity": "sha512-0/TvA1ukxQTNgMFu2yvo0AuNlxdlhG2WWdHk2+dTB9HdIPdK5v4WLPjJPbfU19zB03Qbgs/aa5qJ+AM6YF+sNw==", + "cpu": [ + "x32", + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/he": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/he/-/he-1.2.0.tgz", + "integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==", + "dev": true, + "license": "MIT", + "bin": { + "he": "bin/he" + } + }, + "node_modules/http-errors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz", + "integrity": "sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "depd": "2.0.0", + "inherits": "2.0.4", + "setprototypeof": "1.2.0", + "statuses": "2.0.1", + "toidentifier": "1.0.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/http-errors/node_modules/statuses": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz", + "integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-path-inside": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/is-path-inside/-/is-path-inside-3.0.3.tgz", + "integrity": "sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-plain-obj": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-2.1.0.tgz", + "integrity": "sha512-YWnfyRwxL/+SsrWYfOpUtz5b3YD+nyfkHvjbcanzk8zgyO4ASD67uVMRt8k5bM4lLMDnXfriRhOpemw+NfT1eA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-promise": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", + "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/is-unicode-supported": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-0.1.0.tgz", + "integrity": "sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/jackspeak": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", + "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/cliui": "^8.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + }, + "optionalDependencies": { + "@pkgjs/parseargs": "^0.11.0" + } + }, + "node_modules/js-yaml": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", + "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "dev": true, + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/kalman": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/kalman/-/kalman-0.0.2.tgz", + "integrity": "sha512-UhgPDyVirAlEFlr9GOcdcG1ViWaGGomGfbqQ0BJEnPMB44cFuSO8wRMuPQut+7SD1LsnvPJjFCVsoBPv1I12QQ==", + "dev": true, + "license": "MIT OR GPL-2.0", + "dependencies": { + "sylvester": "*" + }, + "engines": { + "node": "*" + } + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/log-symbols": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-4.1.0.tgz", + "integrity": "sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^4.1.0", + "is-unicode-supported": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/log-symbols/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/log-symbols/node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/log-symbols/node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", + "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/merge-descriptors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", + "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.1.tgz", + "integrity": "sha512-xRc4oEhT6eaBpU1XF7AjpOFD+xQmXNB5OVKwp4tqCuBpHLS/ZbBDrc07mYTDqVMg6PfxUjjNp85O6Cd2Z/5HWA==", + "dev": true, + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/minimatch": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", + "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/minipass": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz", + "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/mocha": { + "version": "11.7.5", + "resolved": "https://registry.npmjs.org/mocha/-/mocha-11.7.5.tgz", + "integrity": "sha512-mTT6RgopEYABzXWFx+GcJ+ZQ32kp4fMf0xvpZIIfSq9Z8lC/++MtcCnQ9t5FP2veYEP95FIYSvW+U9fV4xrlig==", + "dev": true, + "license": "MIT", + "dependencies": { + "browser-stdout": "^1.3.1", + "chokidar": "^4.0.1", + "debug": "^4.3.5", + "diff": "^7.0.0", + "escape-string-regexp": "^4.0.0", + "find-up": "^5.0.0", + "glob": "^10.4.5", + "he": "^1.2.0", + "is-path-inside": "^3.0.3", + "js-yaml": "^4.1.0", + "log-symbols": "^4.1.0", + "minimatch": "^9.0.5", + "ms": "^2.1.3", + "picocolors": "^1.1.1", + "serialize-javascript": "^6.0.2", + "strip-json-comments": "^3.1.1", + "supports-color": "^8.1.1", + "workerpool": "^9.2.0", + "yargs": "^17.7.2", + "yargs-parser": "^21.1.1", + "yargs-unparser": "^2.0.0" + }, + "bin": { + "_mocha": "bin/_mocha", + "mocha": "bin/mocha.js" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/negotiator": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", + "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/node-addon-api": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-8.3.0.tgz", + "integrity": "sha512-8VOpLHFrOQlAH+qA0ZzuGRlALRA6/LVh8QJldbrC4DY0hXoMP0l4Acq8TzFC018HztWiRqyCEj2aTWY2UvnJUg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18 || ^20 || >= 21" + } + }, + "node_modules/node-gyp-build": { + "version": "4.8.4", + "resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.8.4.tgz", + "integrity": "sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==", + "dev": true, + "license": "MIT", + "bin": { + "node-gyp-build": "bin.js", + "node-gyp-build-optional": "optional.js", + "node-gyp-build-test": "build-test.js" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "dev": true, + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "dev": true, + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/package-json-from-dist": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", + "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", + "dev": true, + "license": "BlueOak-1.0.0" + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-scurry": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", + "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^10.2.0", + "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" + }, + "engines": { + "node": ">=16 || 14 >=14.18" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/path-to-regexp": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.3.0.tgz", + "integrity": "sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA==", + "dev": true, + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/qs": { + "version": "6.14.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.0.tgz", + "integrity": "sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/randombytes": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz", + "integrity": "sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "safe-buffer": "^5.1.0" + } + }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.1.tgz", + "integrity": "sha512-9G8cA+tuMS75+6G/TzW8OtLzmBDMo8p1JRxN5AZ+LAp8uxGA8V8GZm4GQ4/N5QNQEnLmg6SS7wyuSmbKepiKqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "bytes": "3.1.2", + "http-errors": "2.0.0", + "iconv-lite": "0.7.0", + "unpipe": "1.0.0" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/raw-body/node_modules/iconv-lite": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.0.tgz", + "integrity": "sha512-cf6L2Ds3h57VVmkZe+Pn+5APsT7FpqJtEhhieDCvrE2MK5Qk9MyffgQyuxQTm6BChfeZNtcOLHp9IcWRVcIcBQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/readdirp": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", + "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.18.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/remove-trailing-separator": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/remove-trailing-separator/-/remove-trailing-separator-1.1.0.tgz", + "integrity": "sha512-/hS+Y0u3aOfIETiaiirUFwDBDzmXPvO+jAfKTitUngIPzdKc6Z0LoFjM/CK5PL4C+eKwHohlHAb6H0VFfmmUsw==", + "dev": true, + "license": "ISC" + }, + "node_modules/replace-ext": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/replace-ext/-/replace-ext-2.0.0.tgz", + "integrity": "sha512-UszKE5KVK6JvyD92nzMn9cDapSk6w/CaFZ96CnmDMUqH9oowfxF/ZjRITD25H4DnOQClLA4/j7jLGXXLVKxAug==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10" + } + }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/router": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", + "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "depd": "^2.0.0", + "is-promise": "^4.0.0", + "parseurl": "^1.3.3", + "path-to-regexp": "^8.0.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "dev": true, + "license": "MIT" + }, + "node_modules/send": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/send/-/send-1.2.0.tgz", + "integrity": "sha512-uaW0WwXKpL9blXE2o0bRhoL2EGXIrZxQ2ZQ4mgcfoBxdFmQold+qWsD2jLrfZ0trjKL6vOw0j//eAwcALFjKSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.3.5", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "fresh": "^2.0.0", + "http-errors": "^2.0.0", + "mime-types": "^3.0.1", + "ms": "^2.1.3", + "on-finished": "^2.4.1", + "range-parser": "^1.2.1", + "statuses": "^2.0.1" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/serialize-javascript": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-6.0.2.tgz", + "integrity": "sha512-Saa1xPByTTq2gdeFZYLLo+RFE35NHZkAbqZeWNd3BpzppeVisAqpDjcp8dyf6uIvEqJRd46jemmyA4iFIeVk8g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "randombytes": "^2.1.0" + } + }, + "node_modules/serialport": { + "version": "13.0.0", + "resolved": "https://registry.npmjs.org/serialport/-/serialport-13.0.0.tgz", + "integrity": "sha512-PHpnTd8isMGPfFTZNCzOZp9m4mAJSNWle9Jxu6BPTcWq7YXl5qN7tp8Sgn0h+WIGcD6JFz5QDgixC2s4VW7vzg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@serialport/binding-mock": "10.2.2", + "@serialport/bindings-cpp": "13.0.0", + "@serialport/parser-byte-length": "13.0.0", + "@serialport/parser-cctalk": "13.0.0", + "@serialport/parser-delimiter": "13.0.0", + "@serialport/parser-inter-byte-timeout": "13.0.0", + "@serialport/parser-packet-length": "13.0.0", + "@serialport/parser-readline": "13.0.0", + "@serialport/parser-ready": "13.0.0", + "@serialport/parser-regex": "13.0.0", + "@serialport/parser-slip-encoder": "13.0.0", + "@serialport/parser-spacepacket": "13.0.0", + "@serialport/stream": "13.0.0", + "debug": "4.4.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/serialport/donate" + } + }, + "node_modules/serialport/node_modules/debug": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.0.tgz", + "integrity": "sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/serve-static": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.0.tgz", + "integrity": "sha512-61g9pCh0Vnh7IutZjtLGGpTA355+OPn2TyDv/6ivP2h/AdAVX9azsoxmg2/M6nZeQZNYBEwIcsne1mJd9oQItQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "parseurl": "^1.3.3", + "send": "^1.2.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "dev": true, + "license": "ISC" + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/side-channel": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", + "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/source-map": { + "version": "0.5.7", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz", + "integrity": "sha512-LbrmJOMUSdEVxIKvdcJzQC+nQhe8FUZQTXQy6+I75skNgn3OoQ0DZA8YnFa7gp8tqtL3KPf1kmo0R5DoApeSGQ==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/streamx": { + "version": "2.23.0", + "resolved": "https://registry.npmjs.org/streamx/-/streamx-2.23.0.tgz", + "integrity": "sha512-kn+e44esVfn2Fa/O0CPFcex27fjIL6MkVae0Mm6q+E6f0hWv578YCERbv+4m02cjxvDsPKLnmxral/rR6lBMAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "events-universal": "^1.0.0", + "fast-fifo": "^1.3.2", + "text-decoder": "^1.1.0" + } + }, + "node_modules/string-width": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", + "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "eastasianwidth": "^0.2.0", + "emoji-regex": "^9.2.2", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/string-width-cjs": { + "name": "string-width", + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width-cjs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/string-width-cjs/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz", + "integrity": "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/strip-ansi-cjs": { + "name": "strip-ansi", + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/supports-color": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", + "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, + "node_modules/sylvester": { + "version": "0.0.21", + "resolved": "https://registry.npmjs.org/sylvester/-/sylvester-0.0.21.tgz", + "integrity": "sha512-yUT0ukFkFEt4nb+NY+n2ag51aS/u9UHXoZw+A4jgD77/jzZsBoSDHuqysrVCBC4CYR4TYvUJq54ONpXgDBH8tA==", + "dev": true, + "engines": { + "node": ">=0.2.6" + } + }, + "node_modules/teex": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/teex/-/teex-1.0.1.tgz", + "integrity": "sha512-eYE6iEI62Ni1H8oIa7KlDU6uQBtqr4Eajni3wX7rpfXD8ysFx8z0+dri+KWEPWpBsxXfxu58x/0jvTVT1ekOSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "streamx": "^2.12.5" + } + }, + "node_modules/text-decoder": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/text-decoder/-/text-decoder-1.2.3.tgz", + "integrity": "sha512-3/o9z3X0X0fTupwsYvR03pJ/DjWuqqrfwBgTQzdWDiQSm9KitAyz/9WqsT2JQW7KV2m+bC2ol/zqpW37NHxLaA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "b4a": "^1.6.4" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/type-is": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz", + "integrity": "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==", + "dev": true, + "license": "MIT", + "dependencies": { + "content-type": "^1.0.5", + "media-typer": "^1.1.0", + "mime-types": "^3.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/vinyl": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/vinyl/-/vinyl-3.0.1.tgz", + "integrity": "sha512-0QwqXteBNXgnLCdWdvPQBX6FXRHtIH3VhJPTd5Lwn28tJXc34YqSCWUmkOvtJHBmB3gGoPtrOKk3Ts8/kEZ9aA==", + "dev": true, + "license": "MIT", + "dependencies": { + "clone": "^2.1.2", + "remove-trailing-separator": "^1.1.0", + "replace-ext": "^2.0.0", + "teex": "^1.0.1" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/vinyl-sourcemaps-apply": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/vinyl-sourcemaps-apply/-/vinyl-sourcemaps-apply-0.2.1.tgz", + "integrity": "sha512-+oDh3KYZBoZC8hfocrbrxbLUeaYtQK7J5WU5Br9VqWqmCll3tFJqKp97GC9GmMsVIL0qnx2DgEDVxdo5EZ5sSw==", + "dev": true, + "license": "ISC", + "dependencies": { + "source-map": "^0.5.1" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/workerpool": { + "version": "9.3.4", + "resolved": "https://registry.npmjs.org/workerpool/-/workerpool-9.3.4.tgz", + "integrity": "sha512-TmPRQYYSAnnDiEB0P/Ytip7bFGvqnSU6I2BcuSw7Hx+JSg/DsUi5ebYfc8GYaSdpuvOcEs6dXxPurOYpe9QFwg==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/wrap-ansi": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", + "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.1.0", + "string-width": "^5.0.1", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs": { + "name": "wrap-ansi", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/wrap-ansi-cjs/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/ws": { + "version": "8.18.3", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz", + "integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/yargs": { + "version": "17.7.2", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", + "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs-unparser": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/yargs-unparser/-/yargs-unparser-2.0.0.tgz", + "integrity": "sha512-7pRTIA9Qc1caZ0bZ6RYRGbHJthJWuakf+WmHK0rVeLkNrrGhfoabBNdue6kdINI6r4if7ocq9aD/n7xwKOdzOA==", + "dev": true, + "license": "MIT", + "dependencies": { + "camelcase": "^6.0.0", + "decamelize": "^4.0.0", + "flat": "^5.0.2", + "is-plain-obj": "^2.1.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/yargs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/yargs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/yargs/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/yargs/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + } + } +} diff --git a/package.json b/package.json index bfc578c..bba6fa0 100644 --- a/package.json +++ b/package.json @@ -1,99 +1,99 @@ -{ - "name": "gps", - "title": "GPS.js", - "version": "0.8.1", - "description": "The RAW GPS NMEA parser library", - "homepage": "https://raw.org/article/using-gps-with-node-js-and-javascript/", - "bugs": "https://github.com/rawify/GPS.js/issues", - "keywords": [ - "nmea", - "gps", - "glonass", - "serial", - "parser", - "distance", - "geo", - "stream", - "location", - "rmc", - "gga", - "gll", - "gsa", - "vtg", - "gva", - "hdt" - ], - "private": false, - "main": "./dist/gps.js", - "module": "./dist/gps.mjs", - "browser": "./dist/gps.min.js", - "unpkg": "./dist/gps.min.js", - "types": "./gps.d.ts", - "exports": { - ".": { - "import": { - "types": "./gps.d.mts", - "default": "./dist/gps.mjs" - }, - "require": { - "types": "./gps.d.ts", - "default": "./dist/gps.js" - }, - "browser": { - "types": "./gps.d.ts", - "default": "./dist/gps.min.js" - }, - "default": { - "types": "./gps.d.ts", - "default": "./dist/gps.js" - } - }, - "./package.json": "./package.json" - }, - "typesVersions": { - "<4.7": { - "*": [ - "gps.d.ts" - ] - } - }, - "sideEffects": false, - "repository": { - "type": "git", - "url": "git+ssh://git@github.com/rawify/GPS.js.git" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/rawify" - }, - "author": { - "name": "Robert Eisele", - "email": "robert@raw.org", - "url": "https://raw.org/" - }, - "license": "MIT", - "engines": { - "node": "*" - }, - "directories": { - "example": "examples", - "test": "tests" - }, - "scripts": { - "build": "crude-build GPS", - "test": "mocha tests/*.js" - }, - "devDependencies": { - "angles": "^0.5.0", - "byline": "^5.0.0", - "crude-build": "^0.1.2", - "express": "^5.1.0", - "kalman": "0.0.2", - "mocha": "^11.7.1", - "serialport": "^13.0.0", - "sylvester": "0.0.21" - }, - "dependencies": { - "ws": "^8.18.3" - } -} +{ + "name": "gps", + "title": "GPS.js", + "version": "0.8.1", + "description": "The RAW GPS NMEA parser library", + "homepage": "https://raw.org/article/using-gps-with-node-js-and-javascript/", + "bugs": "https://github.com/rawify/GPS.js/issues", + "keywords": [ + "nmea", + "gps", + "glonass", + "serial", + "parser", + "distance", + "geo", + "stream", + "location", + "rmc", + "gga", + "gll", + "gsa", + "vtg", + "gva", + "hdt" + ], + "private": false, + "main": "./dist/gps.js", + "module": "./dist/gps.mjs", + "browser": "./dist/gps.min.js", + "unpkg": "./dist/gps.min.js", + "types": "./gps.d.ts", + "exports": { + ".": { + "import": { + "types": "./gps.d.mts", + "default": "./dist/gps.mjs" + }, + "require": { + "types": "./gps.d.ts", + "default": "./dist/gps.js" + }, + "browser": { + "types": "./gps.d.ts", + "default": "./dist/gps.min.js" + }, + "default": { + "types": "./gps.d.ts", + "default": "./dist/gps.js" + } + }, + "./package.json": "./package.json" + }, + "typesVersions": { + "<4.7": { + "*": [ + "gps.d.ts" + ] + } + }, + "sideEffects": false, + "repository": { + "type": "git", + "url": "git+ssh://git@github.com/rawify/GPS.js.git" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/rawify" + }, + "author": { + "name": "Robert Eisele", + "email": "robert@raw.org", + "url": "https://raw.org/" + }, + "license": "MIT", + "engines": { + "node": "*" + }, + "directories": { + "example": "examples", + "test": "tests" + }, + "scripts": { + "build": "crude-build GPS", + "test": "mocha tests/*.js" + }, + "devDependencies": { + "angles": "^0.5.0", + "byline": "^5.0.0", + "crude-build": "^0.1.2", + "express": "^5.1.0", + "kalman": "0.0.2", + "mocha": "^11.7.1", + "serialport": "^13.0.0", + "sylvester": "0.0.21" + }, + "dependencies": { + "ws": "^8.18.3" + } +} diff --git a/src/gps.js b/src/gps.js index 1b4d58f..4f4dea8 100644 --- a/src/gps.js +++ b/src/gps.js @@ -1,1031 +1,1042 @@ /** - * @license GPS.js v0.8.1 8/16/2025 + * @license GPS.js v0.8.1 11/14/2025 * https://raw.org/article/using-gps-with-node-js-and-javascript/ * * Copyright (c) 2025, Robert Eisele (https://raw.org/) * Licensed under the MIT license. **/ -const D2R = Math.PI / 180; - -function parseTime(time, date = null) { - // Accepts hhmmss(.sss)? and optional ddmmyy or ddmmyyyy (ZDA/GPRMC variants). - if (!time) return null; - - const ret = new Date(); - - if (date) { - const year = date.slice(4); - const month = date.slice(2, 4) - 1; - const day = date.slice(0, 2); - - if (year.length === 4) { - ret.setUTCFullYear(+year, +month, +day); - } else { - // If we need to parse older GPRMC data, we should hack something like - // year < 73 ? 2000+year : 1900+year - // Since GPS appeared in 1973 - ret.setUTCFullYear(Number('20' + year), +month, +day); - } - } - - ret.setUTCHours(+time.slice(0, 2)); - ret.setUTCMinutes(+time.slice(2, 4)); - ret.setUTCSeconds(+time.slice(4, 6)); - - // Milliseconds: allow no decimals, .ss, .sss, .ssss... and normalize to ms - const dot = time.indexOf('.'); - let ms = 0; - if (dot !== -1 && dot + 1 < time.length) { - const frac = time.slice(dot + 1); - // Take up to 3 digits; if fewer, scale; if more, truncate - if (frac.length >= 3) { - ms = +frac.slice(0, 3); - } else if (frac.length === 2) { - ms = +frac * 10; // .xx => xx0 ms - } else if (frac.length === 1) { - ms = +frac * 100; // .x => x00 ms - } - } - ret.setUTCMilliseconds(ms); - return ret; -} - -function parseCoord(coord, dir) { - // NMEA lat: DDMM.mmmm; lon: DDDMM.mmmm; dir in {N,S,E,W} - // Latitude can go from 0 to 90; longitude can go from -180 to 180. - if (coord === '') return null; - const sgn = (dir === 'S' || dir === 'W') ? -1 : 1; - const n = (dir === 'N' || dir === 'S') ? 2 : 3; - return sgn * (parseFloat(coord.slice(0, n)) + parseFloat(coord.slice(n)) / 60); -} - -function parseNumber(num) { - return num === '' ? null : parseFloat(num); -} - -function parseKnots(knots) { - return knots === '' ? null : parseFloat(knots) * 1.852; // km/h -} - -function parseSystemId(systemId) { - switch (systemId) { - case 0: return 'QZSS'; - case 1: return 'GPS'; - case 2: return 'GLONASS'; - case 3: return 'Galileo'; - case 4: return 'BeiDou'; - default: return 'unknown'; - } -} - -function parseSystem(str) { - const satellite = str.slice(1, 3); - switch (satellite) { - case 'GP': return 'GPS'; - case 'GQ': return 'QZSS'; - case 'GL': return 'GLONASS'; - case 'GA': return 'Galileo'; - case 'GB': return 'BeiDou'; - default: return satellite; - } -} - -function parseGSAMode(mode) { - switch (mode) { - case 'M': return 'manual'; - case 'A': return 'automatic'; - case '': return null; - } - throw new Error('INVALID GSA MODE: ' + mode); -} - -function parseGGAFix(fix) { - if (fix === '') return null; - switch (parseInt(fix, 10)) { - case 0: return null; - case 1: return 'fix'; // valid SPS fix - case 2: return 'dgps-fix'; // valid DGPS fix - case 3: return 'pps-fix'; // valid PPS fix - case 4: return 'rtk'; // RTK fixed - case 5: return 'rtk-float'; // RTK float - case 6: return 'estimated'; // dead reckoning - case 7: return 'manual'; - case 8: return 'simulated'; - } - throw new Error('INVALID GGA FIX: ' + fix); -} - -function parseGSAFix(fix) { - if (fix === '') return null; - switch (parseInt(fix, 10)) { - case 1: return null; - case 2: return '2D'; - case 3: return '3D'; - } - throw new Error('INVALID GSA FIX: ' + fix); -} - -function parseRMC_GLLStatus(status) { - switch (status) { - case '': return null; - case 'A': return 'active'; - case 'V': return 'void'; - } - throw new Error('INVALID RMC/GLL STATUS: ' + status); -} - -function parseFAA(faa) { - // Only A and D will correspond to an Active and reliable sentence - switch (faa) { - case '': return null; - case 'A': return 'autonomous'; - case 'D': return 'differential'; - case 'E': return 'estimated'; // dead reckoning - case 'M': return 'manual input'; - case 'S': return 'simulated'; - case 'N': return 'not valid'; - case 'P': return 'precise'; - case 'R': return 'rtk'; - case 'F': return 'rtk-float'; - } - throw new Error('INVALID FAA MODE: ' + faa); -} - -function parseRMCVariation(vari, dir) { - if (vari === '' || dir === '') return null; - return parseFloat(vari) * (dir === 'W' ? -1 : 1); -} - -function parseDist(num, unit) { - if (unit === 'M' || unit === '') return parseNumber(num); - throw new Error('Unknown unit: ' + unit); -} - -/** - * Decode TXT caret-escapes and reject invalid chars. - * Spec: NMEA0183-2 §5.1.3 (escapes) and §6.1 Table 1 (invalid chars) - * - * @param {string} str - * @returns {string} - */ -function escapeString(str) { - if (str == null) return ''; - - // invalid characters per spec (excluding '^' which introduces escapes) - var invalid = ["\r", "\n", "$", "*", ",", "!", "\\", "~", "\u007F" /* DEL */]; - for (var i = 0; i < invalid.length; i++) { - if (str.indexOf(invalid[i]) !== -1) { - throw new Error("Message may not contain invalid character '" + invalid[i] + "'"); - } - } - - // caret escapes: ^HH (hex byte) or ^^ (literal caret) - var out = ''; - for (var j = 0; j < str.length; j++) { - var ch = str.charCodeAt(j); - if (ch !== 94 /* '^' */) { out += str[j]; continue; } - var n1 = str[j + 1], n2 = str[j + 2]; - if (n1 === '^') { out += '^'; j += 1; continue; } - if (n1 && n2 && - ((n1 >= '0' && n1 <= '9') || (n1 >= 'A' && n1 <= 'F') || (n1 >= 'a' && n1 <= 'f')) && - ((n2 >= '0' && n2 <= '9') || (n2 >= 'A' && n2 <= 'F') || (n2 >= 'a' && n2 <= 'f'))) { - out += String.fromCharCode(parseInt(n1 + n2, 16)); - j += 2; - } else { - // unknown escape → keep caret literally - out += '^'; - } - } - return out; -} - -/** - * - * @constructor - */ -function GPS() { - if (!(this instanceof GPS)) return new GPS(); - - // Public fields - this['events'] = Object.create(null); - this['state'] = { 'errors': 0, 'processed': 0, 'txtBuffer': {} }; - - // Internal, per-instance collectors (avoid cross-stream state bleed) - this['_collectSats'] = Object.create(null); - this['_collectActiveSats'] = Object.create(null); - this['_lastSeenSat'] = Object.create(null); - - // Streaming buffer - this['partial'] = ''; -} - -/* Static fields (explicit for speed and minification) */ -GPS['parsers'] = { - // Global Positioning System Fix Data - 'GGA': function (str, gga) { - if (gga.length !== 16 && gga.length !== 14) { - throw new Error('Invalid GGA length: ' + str); - } - - /* - 11 - 1 2 3 4 5 6 7 8 9 10 | 12 13 14 15 - | | | | | | | | | | | | | | | - $--GGA,hhmmss.ss,llll.ll,a,yyyyy.yy,a,x,xx,x.x,x.x,M,x.x,M,x.x,xxxx*hh - - 1) Time (UTC) - 2) Latitude - 3) N or S (North or South) - 4) Longitude - 5) E or W (East or West) - 6) GPS Quality Indicator, - 0 = Invalid, 1 = Valid SPS, 2 = Valid DGPS, 3 = Valid PPS - 7) Number of satellites in view, 00 - 12 - 8) Horizontal Dilution of precision, lower is better - 9) Antenna Altitude above/below mean-sea-level (geoid) - 10) Units of antenna altitude, meters - 11) Geoidal separation, the difference between the WGS-84 earth - ellipsoid and mean-sea-level (geoid), '-' means mean-sea-level below ellipsoid - 12) Units of geoidal separation, meters - 13) Age of differential GPS data, time in seconds since last SC104 - type 1 or 9 update, null field when DGPS is not used - 14) Differential reference station ID, 0000-1023 - 15) Checksum - */ - - return { - 'time': parseTime(gga[1]), - 'lat': parseCoord(gga[2], gga[3]), - 'lon': parseCoord(gga[4], gga[5]), - 'alt': parseDist(gga[9], gga[10]), - 'quality': parseGGAFix(gga[6]), - 'satellites': parseNumber(gga[7]), - 'hdop': parseNumber(gga[8]), // dilution - 'geoidal': parseDist(gga[11], gga[12]), // above geoid - 'age': gga[13] === undefined ? null : parseNumber(gga[13]), // DGPS age - 'stationID': gga[14] === undefined ? null : parseNumber(gga[14]) // DGPS ref - }; - }, - - // GPS DOP and active satellites - 'GSA': function (str, gsa) { - - if (gsa.length !== 19 && gsa.length !== 20) { - throw new Error('Invalid GSA length: ' + str); - } - - /* - eg1. $GPGSA,A,3,,,,,,16,18,,22,24,,,3.6,2.1,2.2*3C - eg2. $GPGSA,A,3,19,28,14,18,27,22,31,39,,,,,1.7,1.0,1.3*35 - - - 1 = Mode: - M=Manual, forced to operate in 2D or 3D - A=Automatic, 3D/2D - 2 = Mode: - 1=Fix not available - 2=2D - 3=3D - 3-14 = PRNs of Satellite Vehicles (SVs) used in position fix (null for unused fields) - 15 = PDOP - 16 = HDOP - 17 = VDOP - (18) = systemID NMEA 4.10 - 18 = Checksum - */ - - const sats = []; - for (let i = 3; i < 15; i++) { - if (gsa[i] !== '') sats.push(parseInt(gsa[i], 10)); - } - const sid = gsa.length > 19 ? parseNumber(gsa[18]) : null; - return { - 'mode': parseGSAMode(gsa[1]), - 'fix': parseGSAFix(gsa[2]), - 'satellites': sats, - 'pdop': parseNumber(gsa[15]), - 'hdop': parseNumber(gsa[16]), - 'vdop': parseNumber(gsa[17]), - 'systemId': sid, - 'system': sid !== null ? parseSystemId(sid) : 'unknown' - }; - }, - - // Recommended Minimum data for GPS - 'RMC': function (str, rmc) { - if (rmc.length !== 13 && rmc.length !== 14 && rmc.length !== 15) { - throw new Error('Invalid RMC length: ' + str); - } - - /* - $GPRMC,hhmmss.ss,A,llll.ll,a,yyyyy.yy,a,x.x,x.x,ddmmyy,x.x,a*hh - - RMC = Recommended Minimum Specific GPS/TRANSIT Data - 1 = UTC of position fix - 2 = Data status (A-ok, V-invalid) - 3 = Latitude of fix - 4 = N or S - 5 = Longitude of fix - 6 = E or W - 7 = Speed over ground in knots - 8 = Track made good in degrees True - 9 = UT date - 10 = Magnetic variation degrees (Easterly var. subtracts from true course) - 11 = E or W - (12) = NMEA 2.3 introduced FAA mode indicator (A=Autonomous, D=Differential, E=Estimated, N=Data not valid) - (13) = NMEA 4.10 introduced nav status - 12 = Checksum - */ - - return { - 'time': parseTime(rmc[1], rmc[9]), - 'status': parseRMC_GLLStatus(rmc[2]), - 'lat': parseCoord(rmc[3], rmc[4]), - 'lon': parseCoord(rmc[5], rmc[6]), - 'speed': parseKnots(rmc[7]), - 'track': parseNumber(rmc[8]), // heading (true) - 'variation': parseRMCVariation(rmc[10], rmc[11]), - 'faa': rmc.length > 13 ? parseFAA(rmc[12]) : null, - 'navStatus': rmc.length > 14 ? rmc[13] : null - }; - }, - - // Track info - 'VTG': function (str, vtg) { - if (vtg.length !== 10 && vtg.length !== 11) { - throw new Error('Invalid VTG length: ' + str); - } - - /* - ------------------------------------------------------------------------------ - 1 2 3 4 5 6 7 8 9 10 - | | | | | | | | | | - $--VTG,x.x,T,x.x,M,x.x,N,x.x,K,m,*hh - ------------------------------------------------------------------------------ - - 1 = Track made good (degrees true) - 2 = Fixed text 'T' indicates that track made good is relative to true north - 3 = optional: Track made good (degrees magnetic) - 4 = optional: M: track made good is relative to magnetic north - 5 = Speed over ground in knots - 6 = Fixed text 'N' indicates that speed over ground in in knots - 7 = Speed over ground in kilometers/hour - 8 = Fixed text 'K' indicates that speed over ground is in kilometers/hour - (9) = FAA mode indicator (NMEA 2.3 and later) - 9/10 = Checksum - */ - - // Empty / all-null VTG (some receivers output this) - if (vtg[2] === '' && vtg[8] === '' && vtg[6] === '') { - return { - 'track': null, - 'trackMagnetic': null, - 'speed': null, - 'faa': null - }; - } - - if (vtg[2] !== 'T') { - throw new Error('Invalid VTG track mode: ' + str); - } - if (vtg[8] !== 'K' || vtg[6] !== 'N') { - throw new Error('Invalid VTG speed tag: ' + str); - } - - return { - 'track': parseNumber(vtg[1]), // true heading - 'trackMagnetic': vtg[3] === '' ? null : parseNumber(vtg[3]), // magnetic - 'speed': parseKnots(vtg[5]), - 'faa': vtg.length === 11 ? parseFAA(vtg[9]) : null - }; - }, - - // Satellites in view - 'GSV': function (str, gsv) { - // NMEA allows variable chunks of 4 fields per satellite + header/footer. - // Keep legacy guard but allow most common valid shapes. - if (gsv.length % 4 === 0) { - // = 1 -> normal package - // = 2 -> NMEA 4.10 extension - // = 3 -> BeiDou extension? - throw new Error('Invalid GSV length: ' + str); - } - - /* - $GPGSV,1,1,13,02,02,213,,03,-3,000,,11,00,121,,14,13,172,05*67 - - 1 = Total number of messages of this type in this cycle - 2 = Message number - 3 = Total number of SVs in view - repeat [ - 4 = SV PRN number - 5 = Elevation in degrees, 90 maximum - 6 = Azimuth, degrees from true north, 000 to 359 - 7 = SNR (signal to noise ratio), 00-99 dB (null when not tracking, higher is better) - ] - N+1 = signalID NMEA 4.10 - N+2 = Checksum - */ - - const sats = []; - const satellite = str.slice(1, 3); - // fields: [totMsgs, msgNum, satsInView, (prn,elev,az,snr)*, (signalId)?, checksum] - for (let i = 4; i < gsv.length - 3; i += 4) { - const prn = parseNumber(gsv[i]); - const snr = parseNumber(gsv[i + 3]); - /* - Plot satellites in Radar chart with north on top - by linear map elevation from 0° to 90° into r to 0 - - centerX + cos(azimuth - 90) * (1 - elevation / 90) * radius - centerY + sin(azimuth - 90) * (1 - elevation / 90) * radius - */ - sats.push({ - 'prn': prn, - 'elevation': parseNumber(gsv[i + 1]), - 'azimuth': parseNumber(gsv[i + 2]), - 'snr': snr, - 'status': prn !== null ? (snr !== null ? 'tracking' : 'in view') : null, - 'system': parseSystem(str), - 'key': satellite + prn - }); - } - - return { - 'msgNumber': parseNumber(gsv[2]), - 'msgsTotal': parseNumber(gsv[1]), - 'satsInView': parseNumber(gsv[3]), - 'satellites': sats, - 'signalId': gsv.length % 4 === 2 ? parseNumber(gsv[gsv.length - 2]) : null, // NMEA 4.10 - 'system': parseSystem(str) - }; - }, - - // Geographic Position - Latitude/Longitude - 'GLL': function (str, gll) { - if (gll.length !== 9 && gll.length !== 8) { - throw new Error('Invalid GLL length: ' + str); - } - - /* - ------------------------------------------------------------------------------ - 1 2 3 4 5 6 7 8 - | | | | | | | | - $--GLL,llll.ll,a,yyyyy.yy,a,hhmmss.ss,a,m,*hh - ------------------------------------------------------------------------------ - - 1. Latitude - 2. N or S (North or South) - 3. Longitude - 4. E or W (East or West) - 5. Universal Time Coordinated (UTC) - 6. Status A - Data Valid, V - Data Invalid - 7. FAA mode indicator (NMEA 2.3 and later) - 8. Checksum - */ - - return { - 'time': parseTime(gll[5]), - 'status': parseRMC_GLLStatus(gll[6]), - 'lat': parseCoord(gll[1], gll[2]), - 'lon': parseCoord(gll[3], gll[4]), - 'faa': gll.length === 9 ? parseFAA(gll[7]) : null - }; - }, - - // UTC Date / Time and Local Time Zone Offset - 'ZDA': function (str, zda) { - - /* - 1 = hhmmss.ss = UTC - 2 = xx = Day, 01 to 31 - 3 = xx = Month, 01 to 12 - 4 = xxxx = Year - 5 = xx = Local zone description, 00 to +/- 13 hours - 6 = xx = Local zone minutes description (same sign as hours) - */ - - // (No strict length guard; some receivers omit trailing fields) - return { - 'time': parseTime(zda[1], zda[2] + zda[3] + zda[4]), - // 'delta': can be derived by consumer: (Date.now() - time)/1000 - 'offsetMin': (zda[5] === '' || zda[6] === '') ? null - : (parseInt(zda[5], 10) * 60 + parseInt(zda[6], 10)) - }; - }, - - 'GST': function (str, gst) { - if (gst.length !== 10) { - throw new Error('Invalid GST length: ' + str); - } - - /* - 1 = Time (UTC) - 2 = RMS value of the pseudorange residuals; includes carrier phase residuals during periods of RTK (float) and RTK (fixed) processing - 3 = Error ellipse semi-major axis 1 sigma error, in meters - 4 = Error ellipse semi-minor axis 1 sigma error, in meters - 5 = Error ellipse orientation, degrees from true north - 6 = Latitude 1 sigma error, in meters - 7 = Longitude 1 sigma error, in meters - 8 = Height 1 sigma error, in meters - 9 = Checksum - */ - - return { - 'time': parseTime(gst[1]), - 'rms': parseNumber(gst[2]), - 'ellipseMajor': parseNumber(gst[3]), - 'ellipseMinor': parseNumber(gst[4]), - 'ellipseOrientation': parseNumber(gst[5]), - 'latitudeError': parseNumber(gst[6]), - 'longitudeError': parseNumber(gst[7]), - 'heightError': parseNumber(gst[8]) - }; - }, - - // Heading relative to True North - 'HDT': function (str, hdt) { - if (hdt.length !== 4) { - throw new Error('Invalid HDT length: ' + str); - } - - /* - ------------------------------------------------------------------------------ - 1 2 3 - | | | - $--HDT,hhh.hhh,T*XX - ------------------------------------------------------------------------------ - - 1. Heading in degrees - 2. T: indicates heading relative to True North - 3. Checksum - */ - - return { - 'heading': parseFloat(hdt[1]), - 'trueNorth': hdt[2] === 'T' - }; - }, - - 'GRS': function (str, grs) { - if (grs.length !== 18) { - throw new Error('Invalid GRS length: ' + str); - } - const res = []; - for (let i = 3; i <= 14; i++) { - const tmp = parseNumber(grs[i]); - if (tmp !== null) res.push(tmp); - } - return { - 'time': parseTime(grs[1]), - 'mode': parseNumber(grs[2]), - 'res': res - }; - }, - - 'GBS': function (str, gbs) { - if (gbs.length !== 10 && gbs.length !== 12) { - throw new Error('Invalid GBS length: ' + str); - } - - /* - 0 1 2 3 4 5 6 7 8 - | | | | | | | | | - $--GBS,hhmmss.ss,x.x,x.x,x.x,x.x,x.x,x.x,x.x*hh - - 1. UTC time of the GGA or GNS fix associated with this sentence - 2. Expected error in latitude (meters) - 3. Expected error in longitude (meters) - 4. Expected error in altitude (meters) - 5. PRN (id) of most likely failed satellite - 6. Probability of missed detection for most likely failed satellite - 7. Estimate of bias in meters on most likely failed satellite - 8. Standard deviation of bias estimate - -- - 9. systemID (NMEA 4.10) - 10. signalID (NMEA 4.10) - */ - - return { - 'time': parseTime(gbs[1]), - 'errLat': parseNumber(gbs[2]), - 'errLon': parseNumber(gbs[3]), - 'errAlt': parseNumber(gbs[4]), - 'failedSat': parseNumber(gbs[5]), - 'probFailedSat': parseNumber(gbs[6]), - 'biasFailedSat': parseNumber(gbs[7]), - 'stdFailedSat': parseNumber(gbs[8]), - 'systemId': gbs.length === 12 ? parseNumber(gbs[9]) : null, - 'signalId': gbs.length === 12 ? parseNumber(gbs[10]) : null - }; - }, - - 'GNS': function (str, gns) { - if (gns.length !== 14 && gns.length !== 15) { - throw new Error('Invalid GNS length: ' + str); - } - return { - 'time': parseTime(gns[1]), - 'lat': parseCoord(gns[2], gns[3]), - 'lon': parseCoord(gns[4], gns[5]), - 'mode': gns[6], - 'satsUsed': parseNumber(gns[7]), - 'hdop': parseNumber(gns[8]), - 'alt': parseNumber(gns[9]), - 'sep': parseNumber(gns[10]), - 'diffAge': parseNumber(gns[11]), - 'diffStation': parseNumber(gns[12]), - 'navStatus': gns.length === 15 ? gns[13] : null // NMEA 4.10 - }; - }, - - // Text Transmission (TXT) - // NMEA0183-2 §6.3 ($--TXT,xx,xx,xx,c...c*hh) - 'TXT': function (str, txt) { - - // After talker removal, txt expected: ['TXT', total, index, id, payload, checksum] - if (txt.length !== 6) { - throw new Error('Invalid TXT length: ' + str); - } - - var total = parseInt(txt[1], 10); - var index = parseInt(txt[2], 10); - var textId = parseInt(txt[3], 10); - var rawPart = txt[4] || ''; - - if (!(total >= 1 && total <= 99)) throw new Error('Invalid TXT total: ' + txt[1]); - if (!(index >= 1 && index <= total)) throw new Error('Invalid TXT index: ' + txt[2]); - if (!(textId >= 0 && textId <= 99)) throw new Error('Invalid TXT id: ' + txt[3]); - if (rawPart.length > 61) throw new Error('Invalid TXT message length: ' + rawPart.length); - - var part = escapeString(rawPart); - if (part === '') throw new Error('Invalid empty TXT message'); - - // For single-part messages, we can return a completed object right away. - // Multi-part completion is handled in instance _assembleTXT (see below). - return { - // assembly fields: - 'total': total, - 'index': index, - 'id': textId, - 'part': part, // decoded segment - 'message': (total === 1) ? part : null, - 'completed': (total === 1), - 'rawMessages': (total === 1) ? [part] : [], - 'system': parseSystem(str) // e.g. 'GPS', 'GLONASS', ... - }; - } -}; - -/* Static parse + geodesy helpers */ - -GPS['Parse'] = function (line) { - if (typeof line !== 'string' || line.length < 6) return false; - if (line.charCodeAt(0) !== 36 /* '$' */) return false; - - const star = line.indexOf('*', 1); - if (star === -1 || star + 2 >= line.length) return false; - - const nmea = []; - const firstComma = line.indexOf(',', 1); - if (firstComma === -1 || firstComma > star) return false; - - nmea.push('$' + line.slice(1, firstComma)); - - // checksum over everything between '$' and '*' - let checksum = 0; - for (let i = 1; i < star; i++) checksum ^= line.charCodeAt(i); - - // split fields after the first comma - let fieldStart = firstComma + 1; - for (let i = fieldStart; i < star; i++) { - if (line.charCodeAt(i) === 44 /* ',' */) { - nmea.push(line.slice(fieldStart, i)); - fieldStart = i + 1; - } - } - nmea.push(line.slice(fieldStart, star)); - - const crcStr = line.slice(star + 1).trim(); - const crc = parseInt(crcStr.slice(0, 2), 16); - if (!(crc >= 0 && crc <= 255)) return false; - - nmea[0] = nmea[0].slice(3); - const type = nmea[0]; - const mod = GPS['parsers'][type]; - if (mod === undefined) return false; - - nmea.push(crcStr.slice(0, 2)); - - const data = mod(line, nmea); - data['raw'] = line; - data['valid'] = (checksum === crc); - data['type'] = type; - - return data; -}; - -// Heading (N=0, E=90, S=180, W=270) from point 1 to point 2 -GPS['Heading'] = function (lat1, lon1, lat2, lon2) { - const dlon = (lon2 - lon1) * D2R; - lat1 *= D2R; lat2 *= D2R; - - const sdlon = Math.sin(dlon), cdlon = Math.cos(dlon); - const slat1 = Math.sin(lat1), clat1 = Math.cos(lat1); - const slat2 = Math.sin(lat2), clat2 = Math.cos(lat2); - - const y = sdlon * clat2; - const x = clat1 * slat2 - slat1 * clat2 * cdlon; - - const head = Math.atan2(y, x) * 180 / Math.PI; - return (head + 360) % 360; -}; - -GPS['Distance'] = function (lat1, lon1, lat2, lon2) { - // Haversine Formula - // R.W. Sinnott, "Virtues of the Haversine", Sky and Telescope, vol. 68, no. 2, 1984, p. 159 - - // Because Earth is no exact sphere, rounding errors may be up to 0.5%. - // var RADIUS = 6371; // Earth radius average - // var RADIUS = 6378.137; // Earth radius at equator - const RADIUS = 6372.8; // km - const hLat = (lat2 - lat1) * D2R * 0.5; - const hLon = (lon2 - lon1) * D2R * 0.5; - lat1 *= D2R; lat2 *= D2R; - - const shLat = Math.sin(hLat), shLon = Math.sin(hLon); - const clat1 = Math.cos(lat1), clat2 = Math.cos(lat2); - - const tmp = shLat * shLat + clat1 * clat2 * shLon * shLon; - //return RADIUS * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1.0 - a)); - return RADIUS * 2 * Math.asin(Math.sqrt(tmp)); -}; - -GPS['TotalDistance'] = function (path) { - - if (path.length < 2) return 0; - let len = 0; - for (let i = 0; i < path.length - 1; i++) { - const c = path[i]; - const n = path[i + 1]; - len += GPS['Distance'](c['lat'], c['lon'], n['lat'], n['lon']); - } - return len; -}; - -/* ---------- Instance methods (single prototype assignment) ---------- */ - -GPS.prototype = { - constructor: GPS, - - /* Internal: merge parsed packet into state, keep short-term sat caches fresh */ - '_updateState': function (data) { - const state = this['state']; - - // TODO: can we really use RMC time here or is it the time of fix? - if (data['type'] === 'RMC' || data['type'] === 'GGA' || data['type'] === 'GLL' || data['type'] === 'GNS') { - state['time'] = data['time']; - state['lat'] = data['lat']; - state['lon'] = data['lon']; - } - - if (data['type'] === 'HDT') { - state['heading'] = data['heading']; - state['trueNorth'] = data['trueNorth']; - } - - if (data['type'] === 'ZDA') { - state['time'] = data['time']; - } - - if (data['type'] === 'GGA') { - state['alt'] = data['alt']; - } - - if (data['type'] === 'RMC' || data['type'] === 'VTG') { - if (data['speed'] != null) state['speed'] = data['speed']; - if (data['track'] != null) state['track'] = data['track']; - } - - if (data['type'] === 'GSA') { - const systemId = data['systemId']; - if (systemId != null) this['_collectActiveSats'][systemId] = data['satellites']; - - const satsActive = []; - const collectActiveSats = this['_collectActiveSats']; - for (const s in collectActiveSats) { - if (Object.prototype.hasOwnProperty.call(collectActiveSats, s)) { - // Concatenate without allocating a new array for each system - const arr = collectActiveSats[s]; - for (let i = 0, L = arr.length; i < L; i++) satsActive.push(arr[i]); - } - } - - state['satsActive'] = satsActive; - state['fix'] = data['fix']; - state['hdop'] = data['hdop']; - state['pdop'] = data['pdop']; - state['vdop'] = data['vdop']; - } - - if (data['type'] === 'GSV') { - const now = Date.now(); - const sats = data['satellites']; - const collectSats = this['_collectSats']; - const lastSeenSat = this['_lastSeenSat']; - - for (let i = 0, L = sats.length; i < L; i++) { - const key = sats[i]['key']; - lastSeenSat[key] = now; - collectSats[key] = sats[i]; - } - - // Satellites are considered "visible" for 3 seconds after last seen - const ret = []; - for (const key in collectSats) { - if (Object.prototype.hasOwnProperty.call(collectSats, key)) { - if (now - lastSeenSat[key] < 3000) ret.push(collectSats[key]); - else { - // Optional: clean up stale entries - delete collectSats[key]; - delete lastSeenSat[key]; - } - } - } - state['satsVisible'] = ret; - } - }, - - '_assembleTXT': function (data) { - // Single-part already complete (parser set message) - if (data['total'] === 1) return data; - - const key = (data['system'] || '') + '#' + data['id']; - - let buf = this['state']['txtBuffer'][key]; - if (!buf) { - buf = this['state']['txtBuffer'][key] = { - 'total': data['total'], - 'parts': new Array(data['total']).fill(null), - 'received': 0, - 'timer': null - }; - // 10s timeout to avoid leaks - const self = this; - buf['timer'] = setTimeout(function () { - self['state']['errors']++; - delete self['state']['txtBuffer'][key]; - }, 10000); - } - - // store part (index is 1-based) - const idx = data['index'] - 1; - if (0 <= idx && idx < buf['total']) { - buf['parts'][idx] = data['part']; - buf['received']++; - } - - // check completion - if (buf['received'] === buf['total']) { - clearTimeout(buf['timer']); - delete this['state']['txtBuffer'][key]; - data['message'] = buf['parts'].join(''); - data['completed'] = true; - data['rawMessages'] = buf['parts']; - - } else { - data['message'] = null; - data['completed'] = false; - data['rawMessages'] = []; - } - return data; - }, - - /** - * Feed one full NMEA line (starting with '$', ending before CRLF). - * Emits both 'data' and '' events on success. - */ - 'update': function (line) { - const parsed = GPS['Parse'](line); - this['state']['processed']++; - - if (parsed === false) { - this['state']['errors']++; - return false; - } - - // Assemble TXT multi-part here - if (parsed['type'] === 'TXT') { - this['_assembleTXT'](parsed); - } - - this['_updateState'](parsed); - - this['emit']('data', parsed); - this['emit'](parsed['type'], parsed); - - return true; - }, - - /** - * Feed streaming data (chunks, possibly split arbitrarily). - * Accepts either "\r\n" or "\n" as line delimiters. - */ - 'updatePartial': function (chunk) { - if (chunk) this['partial'] += chunk; - - // Process all complete lines - for (; ;) { - const idxRN = this['partial'].indexOf('\r\n'); - const idxN = this['partial'].indexOf('\n'); - - let pos = -1; - if (idxRN !== -1) pos = idxRN; - else if (idxN !== -1) pos = idxN; - - if (pos === -1) break; - - const line = this['partial'].slice(0, pos); - // Advance buffer past delimiter (2 for CRLF, 1 for LF) - this['partial'] = this['partial'].slice(pos + (idxRN === pos ? 2 : 1)); - - if (line.charAt(0) !== '$') continue; - - try { - this['update'](line); - } catch (err) { - // Keep buffer (don’t drop subsequent lines), but count the error - this['state']['errors']++; - // Re-throw for caller visibility - throw err; - } - } - }, - - /** - * Subscribe to an event. Multiple listeners per event are supported. - * @param {string} ev - * @param {function()} cb - * @returns {GPS} this (chainable) - */ - 'on': function (ev, cb) { - const cur = this['events'][ev]; - if (cur === undefined) { - this['events'][ev] = [cb]; - } else if (typeof cur === 'function') { - // Backward compatibility with previous single-listener design - this['events'][ev] = [cur, cb]; - } else { - this['events'][ev].push(cb); - } - return this; - }, - - /** - * Remove listeners. If cb omitted, remove all for the event. - * @param {string} ev - * @param {function()} cb - * @returns {GPS} this - */ - 'off': function (ev, cb) { - const cur = this['events'][ev]; - if (cur === undefined) return this; - - if (!cb) { - delete this['events'][ev]; - return this; - } - - if (typeof cur === 'function') { - if (cur === cb) delete this['events'][ev]; - return this; - } - - // Array case - for (let i = cur.length - 1; i >= 0; i--) { - if (cur[i] === cb) cur.splice(i, 1); - } - if (cur.length === 0) delete this['events'][ev]; - return this; - }, - - /** - * Emit an event to all listeners. - * @param {string} ev - * @param {*} data - */ - 'emit': function (ev, data) { - const cur = this['events'][ev]; - if (cur === undefined) return; - - if (typeof cur === 'function') { - cur.call(this, data); - return; - } - // Array of listeners - for (let i = 0, L = cur.length; i < L; i++) { - cur[i].call(this, data); - } - } +const D2R = Math.PI / 180; + +function parseTime(time, date = null) { + // Accepts hhmmss(.sss)? and optional ddmmyy or ddmmyyyy (ZDA/GPRMC variants). + if (!time) return null; + + const ret = new Date(); + + if (date) { + const year = date.slice(4); + const month = date.slice(2, 4) - 1; + const day = date.slice(0, 2); + + if (year.length === 4) { + ret.setUTCFullYear(+year, +month, +day); + } else { + // If we need to parse older GPRMC data, we should hack something like + // year < 73 ? 2000+year : 1900+year + // Since GPS appeared in 1973 + ret.setUTCFullYear(Number('20' + year), +month, +day); + } + } + + ret.setUTCHours(+time.slice(0, 2)); + ret.setUTCMinutes(+time.slice(2, 4)); + ret.setUTCSeconds(+time.slice(4, 6)); + + // Milliseconds: allow no decimals, .ss, .sss, .ssss... and normalize to ms + const dot = time.indexOf('.'); + let ms = 0; + if (dot !== -1 && dot + 1 < time.length) { + const frac = time.slice(dot + 1); + // Take up to 3 digits; if fewer, scale; if more, truncate + if (frac.length >= 3) { + ms = +frac.slice(0, 3); + } else if (frac.length === 2) { + ms = +frac * 10; // .xx => xx0 ms + } else if (frac.length === 1) { + ms = +frac * 100; // .x => x00 ms + } + } + ret.setUTCMilliseconds(ms); + return ret; +} + +function parseCoord(coord, dir) { + // NMEA lat: DDMM.mmmm; lon: DDDMM.mmmm; dir in {N,S,E,W} + // Latitude can go from 0 to 90; longitude can go from -180 to 180. + if (coord === '') return null; + const sgn = (dir === 'S' || dir === 'W') ? -1 : 1; + const n = (dir === 'N' || dir === 'S') ? 2 : 3; + return sgn * (parseFloat(coord.slice(0, n)) + parseFloat(coord.slice(n)) / 60); +} + +function parseNumber(num) { + return num === '' ? null : parseFloat(num); +} + +function parseKnots(knots) { + return knots === '' ? null : parseFloat(knots) * 1.852; // km/h +} + +function parseSystemId(systemId) { + switch (systemId) { + case 0: return 'QZSS'; + case 1: return 'GPS'; + case 2: return 'GLONASS'; + case 3: return 'Galileo'; + case 4: return 'BeiDou'; + default: return 'unknown'; + } +} + +function parseSystem(str) { + const satellite = str.slice(1, 3); + switch (satellite) { + case 'GP': return 'GPS'; + case 'GQ': return 'QZSS'; + case 'GL': return 'GLONASS'; + case 'GA': return 'Galileo'; + case 'GB': return 'BeiDou'; + default: return satellite; + } +} + +function parseGSAMode(mode) { + switch (mode) { + case 'M': return 'manual'; + case 'A': return 'automatic'; + case '': return null; + } + //throw new Error('INVALID GSA MODE: ' + mode); + this.error(new Error('INVALID GSA MODE: ' + mode)) +} + +function parseGGAFix(fix) { + if (fix === '') return null; + switch (parseInt(fix, 10)) { + case 0: return null; + case 1: return 'fix'; // valid SPS fix + case 2: return 'dgps-fix'; // valid DGPS fix + case 3: return 'pps-fix'; // valid PPS fix + case 4: return 'rtk'; // RTK fixed + case 5: return 'rtk-float'; // RTK float + case 6: return 'estimated'; // dead reckoning + case 7: return 'manual'; + case 8: return 'simulated'; + } + this.error(new Error('INVALID GGA FIX: ' + fix)) +} + +function parseGSAFix(fix) { + if (fix === '') return null; + switch (parseInt(fix, 10)) { + case 1: return null; + case 2: return '2D'; + case 3: return '3D'; + } + this.error(new Error('INVALID GSA FIX: ' + fix)) +} + +function parseRMC_GLLStatus(status) { + switch (status) { + case '': return null; + case 'A': return 'active'; + case 'V': return 'void'; + } + this.error(new Error('INVALID RMC/GLL STATUS: ' + status)) +} + +function parseFAA(faa) { + // Only A and D will correspond to an Active and reliable sentence + switch (faa) { + case '': return null; + case 'A': return 'autonomous'; + case 'D': return 'differential'; + case 'E': return 'estimated'; // dead reckoning + case 'M': return 'manual input'; + case 'S': return 'simulated'; + case 'N': return 'not valid'; + case 'P': return 'precise'; + case 'R': return 'rtk'; + case 'F': return 'rtk-float'; + } + this.error(new Error('INVALID FAA MODE: ' + faa)) +} + +function parseRMCVariation(vari, dir) { + if (vari === '' || dir === '') return null; + return parseFloat(vari) * (dir === 'W' ? -1 : 1); +} + +function parseDist(num, unit) { + if (unit === 'M' || unit === '') return parseNumber(num); + this.error(new Error('Unknown unit: ' + unit)) +} + +/** + * Decode TXT caret-escapes and reject invalid chars. + * Spec: NMEA0183-2 §5.1.3 (escapes) and §6.1 Table 1 (invalid chars) + * + * @param {string} str + * @returns {string} + */ +function escapeString(str) { + if (str == null) return ''; + + // invalid characters per spec (excluding '^' which introduces escapes) + var invalid = ["\r", "\n", "$", "*", ",", "!", "\\", "~", "\u007F" /* DEL */]; + for (var i = 0; i < invalid.length; i++) { + if (str.indexOf(invalid[i]) !== -1) { + this.error(new Error("Message may not contain invalid character '" + invalid[i] + "'")) + } + } + + // caret escapes: ^HH (hex byte) or ^^ (literal caret) + var out = ''; + for (var j = 0; j < str.length; j++) { + var ch = str.charCodeAt(j); + if (ch !== 94 /* '^' */) { out += str[j]; continue; } + var n1 = str[j + 1], n2 = str[j + 2]; + if (n1 === '^') { out += '^'; j += 1; continue; } + if (n1 && n2 && + ((n1 >= '0' && n1 <= '9') || (n1 >= 'A' && n1 <= 'F') || (n1 >= 'a' && n1 <= 'f')) && + ((n2 >= '0' && n2 <= '9') || (n2 >= 'A' && n2 <= 'F') || (n2 >= 'a' && n2 <= 'f'))) { + out += String.fromCharCode(parseInt(n1 + n2, 16)); + j += 2; + } else { + // unknown escape → keep caret literally + out += '^'; + } + } + return out; +} + +/** + * + * @constructor + */ +function GPS() { + if (!(this instanceof GPS)) return new GPS(); + + // Public fields + this['events'] = Object.create(null); + this['state'] = { 'errors': 0, 'processed': 0, 'txtBuffer': {} }; + + // Internal, per-instance collectors (avoid cross-stream state bleed) + this['_collectSats'] = Object.create(null); + this['_collectActiveSats'] = Object.create(null); + this['_lastSeenSat'] = Object.create(null); + + // Streaming buffer + this['partial'] = ''; +} + +/* Static fields (explicit for speed and minification) */ +GPS['parsers'] = { + // Global Positioning System Fix Data + 'GGA': function (str, gga) { + if (gga.length !== 16 && gga.length !== 14) { + this.error(new Error('Invalid GGA length: ' + str)) + } + + /* + 11 + 1 2 3 4 5 6 7 8 9 10 | 12 13 14 15 + | | | | | | | | | | | | | | | + $--GGA,hhmmss.ss,llll.ll,a,yyyyy.yy,a,x,xx,x.x,x.x,M,x.x,M,x.x,xxxx*hh + + 1) Time (UTC) + 2) Latitude + 3) N or S (North or South) + 4) Longitude + 5) E or W (East or West) + 6) GPS Quality Indicator, + 0 = Invalid, 1 = Valid SPS, 2 = Valid DGPS, 3 = Valid PPS + 7) Number of satellites in view, 00 - 12 + 8) Horizontal Dilution of precision, lower is better + 9) Antenna Altitude above/below mean-sea-level (geoid) + 10) Units of antenna altitude, meters + 11) Geoidal separation, the difference between the WGS-84 earth + ellipsoid and mean-sea-level (geoid), '-' means mean-sea-level below ellipsoid + 12) Units of geoidal separation, meters + 13) Age of differential GPS data, time in seconds since last SC104 + type 1 or 9 update, null field when DGPS is not used + 14) Differential reference station ID, 0000-1023 + 15) Checksum + */ + + return { + 'time': parseTime(gga[1]), + 'lat': parseCoord(gga[2], gga[3]), + 'lon': parseCoord(gga[4], gga[5]), + 'alt': parseDist(gga[9], gga[10]), + 'quality': parseGGAFix(gga[6]), + 'satellites': parseNumber(gga[7]), + 'hdop': parseNumber(gga[8]), // dilution + 'geoidal': parseDist(gga[11], gga[12]), // above geoid + 'age': gga[13] === undefined ? null : parseNumber(gga[13]), // DGPS age + 'stationID': gga[14] === undefined ? null : parseNumber(gga[14]) // DGPS ref + }; + }, + + // GPS DOP and active satellites + 'GSA': function (str, gsa) { + + if (gsa.length !== 19 && gsa.length !== 20) { + this.error(new Error('Invalid GSA length: ' + str)) + } + + /* + eg1. $GPGSA,A,3,,,,,,16,18,,22,24,,,3.6,2.1,2.2*3C + eg2. $GPGSA,A,3,19,28,14,18,27,22,31,39,,,,,1.7,1.0,1.3*35 + + + 1 = Mode: + M=Manual, forced to operate in 2D or 3D + A=Automatic, 3D/2D + 2 = Mode: + 1=Fix not available + 2=2D + 3=3D + 3-14 = PRNs of Satellite Vehicles (SVs) used in position fix (null for unused fields) + 15 = PDOP + 16 = HDOP + 17 = VDOP + (18) = systemID NMEA 4.10 + 18 = Checksum + */ + + const sats = []; + for (let i = 3; i < 15; i++) { + if (gsa[i] !== '') sats.push(parseInt(gsa[i], 10)); + } + const sid = gsa.length > 19 ? parseNumber(gsa[18]) : null; + return { + 'mode': parseGSAMode(gsa[1]), + 'fix': parseGSAFix(gsa[2]), + 'satellites': sats, + 'pdop': parseNumber(gsa[15]), + 'hdop': parseNumber(gsa[16]), + 'vdop': parseNumber(gsa[17]), + 'systemId': sid, + 'system': sid !== null ? parseSystemId(sid) : 'unknown' + }; + }, + + // Recommended Minimum data for GPS + 'RMC': function (str, rmc) { + if (rmc.length !== 13 && rmc.length !== 14 && rmc.length !== 15) { + this.error(new Error('Invalid RMC length: ' + str)) + } + + /* + $GPRMC,hhmmss.ss,A,llll.ll,a,yyyyy.yy,a,x.x,x.x,ddmmyy,x.x,a*hh + + RMC = Recommended Minimum Specific GPS/TRANSIT Data + 1 = UTC of position fix + 2 = Data status (A-ok, V-invalid) + 3 = Latitude of fix + 4 = N or S + 5 = Longitude of fix + 6 = E or W + 7 = Speed over ground in knots + 8 = Track made good in degrees True + 9 = UT date + 10 = Magnetic variation degrees (Easterly var. subtracts from true course) + 11 = E or W + (12) = NMEA 2.3 introduced FAA mode indicator (A=Autonomous, D=Differential, E=Estimated, N=Data not valid) + (13) = NMEA 4.10 introduced nav status + 12 = Checksum + */ + + return { + 'time': parseTime(rmc[1], rmc[9]), + 'status': parseRMC_GLLStatus(rmc[2]), + 'lat': parseCoord(rmc[3], rmc[4]), + 'lon': parseCoord(rmc[5], rmc[6]), + 'speed': parseKnots(rmc[7]), + 'track': parseNumber(rmc[8]), // heading (true) + 'variation': parseRMCVariation(rmc[10], rmc[11]), + 'faa': rmc.length > 13 ? parseFAA(rmc[12]) : null, + 'navStatus': rmc.length > 14 ? rmc[13] : null + }; + }, + + // Track info + 'VTG': function (str, vtg) { + if (vtg.length !== 10 && vtg.length !== 11) { + this.error(new Error('Invalid VTG length: ' + str)) + } + + /* + ------------------------------------------------------------------------------ + 1 2 3 4 5 6 7 8 9 10 + | | | | | | | | | | + $--VTG,x.x,T,x.x,M,x.x,N,x.x,K,m,*hh + ------------------------------------------------------------------------------ + + 1 = Track made good (degrees true) + 2 = Fixed text 'T' indicates that track made good is relative to true north + 3 = optional: Track made good (degrees magnetic) + 4 = optional: M: track made good is relative to magnetic north + 5 = Speed over ground in knots + 6 = Fixed text 'N' indicates that speed over ground in in knots + 7 = Speed over ground in kilometers/hour + 8 = Fixed text 'K' indicates that speed over ground is in kilometers/hour + (9) = FAA mode indicator (NMEA 2.3 and later) + 9/10 = Checksum + */ + + // Empty / all-null VTG (some receivers output this) + if (vtg[2] === '' && vtg[8] === '' && vtg[6] === '') { + return { + 'track': null, + 'trackMagnetic': null, + 'speed': null, + 'faa': null + }; + } + + if (vtg[2] !== 'T') { + this.error(new Error('Invalid VTG track mode: ' + str)) + } + if (vtg[8] !== 'K' || vtg[6] !== 'N') { + this.error(new Error('Invalid VTG speed tag: ' + str)) + } + + return { + 'track': parseNumber(vtg[1]), // true heading + 'trackMagnetic': vtg[3] === '' ? null : parseNumber(vtg[3]), // magnetic + 'speed': parseKnots(vtg[5]), + 'faa': vtg.length === 11 ? parseFAA(vtg[9]) : null + }; + }, + + // Satellites in view + 'GSV': function (str, gsv) { + // NMEA allows variable chunks of 4 fields per satellite + header/footer. + // Keep legacy guard but allow most common valid shapes. + if (gsv.length % 4 === 0) { + // = 1 -> normal package + // = 2 -> NMEA 4.10 extension + // = 3 -> BeiDou extension? + this.error(new Error('Invalid GSV length: ' + str)) + } + + /* + $GPGSV,1,1,13,02,02,213,,03,-3,000,,11,00,121,,14,13,172,05*67 + + 1 = Total number of messages of this type in this cycle + 2 = Message number + 3 = Total number of SVs in view + repeat [ + 4 = SV PRN number + 5 = Elevation in degrees, 90 maximum + 6 = Azimuth, degrees from true north, 000 to 359 + 7 = SNR (signal to noise ratio), 00-99 dB (null when not tracking, higher is better) + ] + N+1 = signalID NMEA 4.10 + N+2 = Checksum + */ + + const sats = []; + const satellite = str.slice(1, 3); + // fields: [totMsgs, msgNum, satsInView, (prn,elev,az,snr)*, (signalId)?, checksum] + for (let i = 4; i < gsv.length - 3; i += 4) { + const prn = parseNumber(gsv[i]); + const snr = parseNumber(gsv[i + 3]); + /* + Plot satellites in Radar chart with north on top + by linear map elevation from 0° to 90° into r to 0 + + centerX + cos(azimuth - 90) * (1 - elevation / 90) * radius + centerY + sin(azimuth - 90) * (1 - elevation / 90) * radius + */ + sats.push({ + 'prn': prn, + 'elevation': parseNumber(gsv[i + 1]), + 'azimuth': parseNumber(gsv[i + 2]), + 'snr': snr, + 'status': prn !== null ? (snr !== null ? 'tracking' : 'in view') : null, + 'system': parseSystem(str), + 'key': satellite + prn + }); + } + + return { + 'msgNumber': parseNumber(gsv[2]), + 'msgsTotal': parseNumber(gsv[1]), + 'satsInView': parseNumber(gsv[3]), + 'satellites': sats, + 'signalId': gsv.length % 4 === 2 ? parseNumber(gsv[gsv.length - 2]) : null, // NMEA 4.10 + 'system': parseSystem(str) + }; + }, + + // Geographic Position - Latitude/Longitude + 'GLL': function (str, gll) { + if (gll.length !== 9 && gll.length !== 8) { + this.error(new Error('Invalid GLL length: ' + str)) + } + + /* + ------------------------------------------------------------------------------ + 1 2 3 4 5 6 7 8 + | | | | | | | | + $--GLL,llll.ll,a,yyyyy.yy,a,hhmmss.ss,a,m,*hh + ------------------------------------------------------------------------------ + + 1. Latitude + 2. N or S (North or South) + 3. Longitude + 4. E or W (East or West) + 5. Universal Time Coordinated (UTC) + 6. Status A - Data Valid, V - Data Invalid + 7. FAA mode indicator (NMEA 2.3 and later) + 8. Checksum + */ + + return { + 'time': parseTime(gll[5]), + 'status': parseRMC_GLLStatus(gll[6]), + 'lat': parseCoord(gll[1], gll[2]), + 'lon': parseCoord(gll[3], gll[4]), + 'faa': gll.length === 9 ? parseFAA(gll[7]) : null + }; + }, + + // UTC Date / Time and Local Time Zone Offset + 'ZDA': function (str, zda) { + + /* + 1 = hhmmss.ss = UTC + 2 = xx = Day, 01 to 31 + 3 = xx = Month, 01 to 12 + 4 = xxxx = Year + 5 = xx = Local zone description, 00 to +/- 13 hours + 6 = xx = Local zone minutes description (same sign as hours) + */ + + // (No strict length guard; some receivers omit trailing fields) + return { + 'time': parseTime(zda[1], zda[2] + zda[3] + zda[4]), + // 'delta': can be derived by consumer: (Date.now() - time)/1000 + 'offsetMin': (zda[5] === '' || zda[6] === '') ? null + : (parseInt(zda[5], 10) * 60 + parseInt(zda[6], 10)) + }; + }, + + 'GST': function (str, gst) { + if (gst.length !== 10) { + this.error(new Error('Invalid GST length: ' + str)) + } + + /* + 1 = Time (UTC) + 2 = RMS value of the pseudorange residuals; includes carrier phase residuals during periods of RTK (float) and RTK (fixed) processing + 3 = Error ellipse semi-major axis 1 sigma error, in meters + 4 = Error ellipse semi-minor axis 1 sigma error, in meters + 5 = Error ellipse orientation, degrees from true north + 6 = Latitude 1 sigma error, in meters + 7 = Longitude 1 sigma error, in meters + 8 = Height 1 sigma error, in meters + 9 = Checksum + */ + + return { + 'time': parseTime(gst[1]), + 'rms': parseNumber(gst[2]), + 'ellipseMajor': parseNumber(gst[3]), + 'ellipseMinor': parseNumber(gst[4]), + 'ellipseOrientation': parseNumber(gst[5]), + 'latitudeError': parseNumber(gst[6]), + 'longitudeError': parseNumber(gst[7]), + 'heightError': parseNumber(gst[8]) + }; + }, + + // Heading relative to True North + 'HDT': function (str, hdt) { + if (hdt.length !== 4) { + this.error(new Error('Invalid HDT length: ' + str)) + } + + /* + ------------------------------------------------------------------------------ + 1 2 3 + | | | + $--HDT,hhh.hhh,T*XX + ------------------------------------------------------------------------------ + + 1. Heading in degrees + 2. T: indicates heading relative to True North + 3. Checksum + */ + + return { + 'heading': parseFloat(hdt[1]), + 'trueNorth': hdt[2] === 'T' + }; + }, + + 'GRS': function (str, grs) { + if (grs.length !== 18) { + this.error(new Error('Invalid GRS length: ' + str)) + } + const res = []; + for (let i = 3; i <= 14; i++) { + const tmp = parseNumber(grs[i]); + if (tmp !== null) res.push(tmp); + } + return { + 'time': parseTime(grs[1]), + 'mode': parseNumber(grs[2]), + 'res': res + }; + }, + + 'GBS': function (str, gbs) { + if (gbs.length !== 10 && gbs.length !== 12) { + this.error(new Error('Invalid GBS length: ' + str)) + } + + /* + 0 1 2 3 4 5 6 7 8 + | | | | | | | | | + $--GBS,hhmmss.ss,x.x,x.x,x.x,x.x,x.x,x.x,x.x*hh + + 1. UTC time of the GGA or GNS fix associated with this sentence + 2. Expected error in latitude (meters) + 3. Expected error in longitude (meters) + 4. Expected error in altitude (meters) + 5. PRN (id) of most likely failed satellite + 6. Probability of missed detection for most likely failed satellite + 7. Estimate of bias in meters on most likely failed satellite + 8. Standard deviation of bias estimate + -- + 9. systemID (NMEA 4.10) + 10. signalID (NMEA 4.10) + */ + + return { + 'time': parseTime(gbs[1]), + 'errLat': parseNumber(gbs[2]), + 'errLon': parseNumber(gbs[3]), + 'errAlt': parseNumber(gbs[4]), + 'failedSat': parseNumber(gbs[5]), + 'probFailedSat': parseNumber(gbs[6]), + 'biasFailedSat': parseNumber(gbs[7]), + 'stdFailedSat': parseNumber(gbs[8]), + 'systemId': gbs.length === 12 ? parseNumber(gbs[9]) : null, + 'signalId': gbs.length === 12 ? parseNumber(gbs[10]) : null + }; + }, + + 'GNS': function (str, gns) { + if (gns.length !== 14 && gns.length !== 15) { + this.error(new Error('Invalid GNS length: ' + str)) + } + return { + 'time': parseTime(gns[1]), + 'lat': parseCoord(gns[2], gns[3]), + 'lon': parseCoord(gns[4], gns[5]), + 'mode': gns[6], + 'satsUsed': parseNumber(gns[7]), + 'hdop': parseNumber(gns[8]), + 'alt': parseNumber(gns[9]), + 'sep': parseNumber(gns[10]), + 'diffAge': parseNumber(gns[11]), + 'diffStation': parseNumber(gns[12]), + 'navStatus': gns.length === 15 ? gns[13] : null // NMEA 4.10 + }; + }, + + // Text Transmission (TXT) + // NMEA0183-2 §6.3 ($--TXT,xx,xx,xx,c...c*hh) + 'TXT': function (str, txt) { + + // After talker removal, txt expected: ['TXT', total, index, id, payload, checksum] + if (txt.length !== 6) { + this.error(new Error('Invalid TXT length: ' + str)) + } + + var total = parseInt(txt[1], 10); + var index = parseInt(txt[2], 10); + var textId = parseInt(txt[3], 10); + var rawPart = txt[4] || ''; + + if (!(total >= 1 && total <= 99)) this.error(new Error('Invalid TXT total: ' + txt[1])); + if (!(index >= 1 && index <= total)) this.error(new Error('Invalid TXT index: ' + txt[2])); + if (!(textId >= 0 && textId <= 99)) this.error(new Error('Invalid TXT id: ' + txt[3])); + if (rawPart.length > 61) this.error(new Error('Invalid TXT message length: ' + rawPart.length)); + + var part = escapeString(rawPart); + if (part === '') this.error(new Error('Invalid empty TXT message')); + + // For single-part messages, we can return a completed object right away. + // Multi-part completion is handled in instance _assembleTXT (see below). + return { + // assembly fields: + 'total': total, + 'index': index, + 'id': textId, + 'part': part, // decoded segment + 'message': (total === 1) ? part : null, + 'completed': (total === 1), + 'rawMessages': (total === 1) ? [part] : [], + 'system': parseSystem(str) // e.g. 'GPS', 'GLONASS', ... + }; + } +}; + +/* Static parse + geodesy helpers */ + +GPS['Parse'] = function (line) { + if (typeof line !== 'string' || line.length < 6) return false; + if (line.charCodeAt(0) !== 36 /* '$' */) return false; + + const star = line.indexOf('*', 1); + if (star === -1 || star + 2 >= line.length) return false; + + const nmea = []; + const firstComma = line.indexOf(',', 1); + if (firstComma === -1 || firstComma > star) return false; + + nmea.push('$' + line.slice(1, firstComma)); + + // checksum over everything between '$' and '*' + let checksum = 0; + for (let i = 1; i < star; i++) checksum ^= line.charCodeAt(i); + + // split fields after the first comma + let fieldStart = firstComma + 1; + for (let i = fieldStart; i < star; i++) { + if (line.charCodeAt(i) === 44 /* ',' */) { + nmea.push(line.slice(fieldStart, i)); + fieldStart = i + 1; + } + } + nmea.push(line.slice(fieldStart, star)); + + const crcStr = line.slice(star + 1).trim(); + const crc = parseInt(crcStr.slice(0, 2), 16); + if (!(crc >= 0 && crc <= 255)) return false; + + nmea[0] = nmea[0].slice(3); + const type = nmea[0]; + const mod = GPS['parsers'][type]; + if (mod === undefined) return false; + + nmea.push(crcStr.slice(0, 2)); + + const data = mod(line, nmea); + data['raw'] = line; + data['valid'] = (checksum === crc); + data['type'] = type; + + return data; +}; + +// Heading (N=0, E=90, S=180, W=270) from point 1 to point 2 +GPS['Heading'] = function (lat1, lon1, lat2, lon2) { + const dlon = (lon2 - lon1) * D2R; + lat1 *= D2R; lat2 *= D2R; + + const sdlon = Math.sin(dlon), cdlon = Math.cos(dlon); + const slat1 = Math.sin(lat1), clat1 = Math.cos(lat1); + const slat2 = Math.sin(lat2), clat2 = Math.cos(lat2); + + const y = sdlon * clat2; + const x = clat1 * slat2 - slat1 * clat2 * cdlon; + + const head = Math.atan2(y, x) * 180 / Math.PI; + return (head + 360) % 360; +}; + +GPS['Distance'] = function (lat1, lon1, lat2, lon2) { + // Haversine Formula + // R.W. Sinnott, "Virtues of the Haversine", Sky and Telescope, vol. 68, no. 2, 1984, p. 159 + + // Because Earth is no exact sphere, rounding errors may be up to 0.5%. + // var RADIUS = 6371; // Earth radius average + // var RADIUS = 6378.137; // Earth radius at equator + const RADIUS = 6372.8; // km + const hLat = (lat2 - lat1) * D2R * 0.5; + const hLon = (lon2 - lon1) * D2R * 0.5; + lat1 *= D2R; lat2 *= D2R; + + const shLat = Math.sin(hLat), shLon = Math.sin(hLon); + const clat1 = Math.cos(lat1), clat2 = Math.cos(lat2); + + const tmp = shLat * shLat + clat1 * clat2 * shLon * shLon; + //return RADIUS * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1.0 - a)); + return RADIUS * 2 * Math.asin(Math.sqrt(tmp)); +}; + +GPS['TotalDistance'] = function (path) { + + if (path.length < 2) return 0; + let len = 0; + for (let i = 0; i < path.length - 1; i++) { + const c = path[i]; + const n = path[i + 1]; + len += GPS['Distance'](c['lat'], c['lon'], n['lat'], n['lon']); + } + return len; +}; + +/* ---------- Instance methods (single prototype assignment) ---------- */ + +GPS.prototype = { + constructor: GPS, + + /* Internal: merge parsed packet into state, keep short-term sat caches fresh */ + '_updateState': function (data) { + const state = this['state']; + + // TODO: can we really use RMC time here or is it the time of fix? + if (data['type'] === 'RMC' || data['type'] === 'GGA' || data['type'] === 'GLL' || data['type'] === 'GNS') { + state['time'] = data['time']; + state['lat'] = data['lat']; + state['lon'] = data['lon']; + } + + if (data['type'] === 'HDT') { + state['heading'] = data['heading']; + state['trueNorth'] = data['trueNorth']; + } + + if (data['type'] === 'ZDA') { + state['time'] = data['time']; + } + + if (data['type'] === 'GGA') { + state['alt'] = data['alt']; + } + + if (data['type'] === 'RMC' || data['type'] === 'VTG') { + if (data['speed'] != null) state['speed'] = data['speed']; + if (data['track'] != null) state['track'] = data['track']; + } + + if (data['type'] === 'GSA') { + const systemId = data['systemId']; + if (systemId != null) this['_collectActiveSats'][systemId] = data['satellites']; + + const satsActive = []; + const collectActiveSats = this['_collectActiveSats']; + for (const s in collectActiveSats) { + if (Object.prototype.hasOwnProperty.call(collectActiveSats, s)) { + // Concatenate without allocating a new array for each system + const arr = collectActiveSats[s]; + for (let i = 0, L = arr.length; i < L; i++) satsActive.push(arr[i]); + } + } + + state['satsActive'] = satsActive; + state['fix'] = data['fix']; + state['hdop'] = data['hdop']; + state['pdop'] = data['pdop']; + state['vdop'] = data['vdop']; + } + + if (data['type'] === 'GSV') { + const now = Date.now(); + const sats = data['satellites']; + const collectSats = this['_collectSats']; + const lastSeenSat = this['_lastSeenSat']; + + for (let i = 0, L = sats.length; i < L; i++) { + const key = sats[i]['key']; + lastSeenSat[key] = now; + collectSats[key] = sats[i]; + } + + // Satellites are considered "visible" for 3 seconds after last seen + const ret = []; + for (const key in collectSats) { + if (Object.prototype.hasOwnProperty.call(collectSats, key)) { + if (now - lastSeenSat[key] < 3000) ret.push(collectSats[key]); + else { + // Optional: clean up stale entries + delete collectSats[key]; + delete lastSeenSat[key]; + } + } + } + state['satsVisible'] = ret; + } + }, + + '_assembleTXT': function (data) { + // Single-part already complete (parser set message) + if (data['total'] === 1) return data; + + const key = (data['system'] || '') + '#' + data['id']; + + let buf = this['state']['txtBuffer'][key]; + if (!buf) { + buf = this['state']['txtBuffer'][key] = { + 'total': data['total'], + 'parts': new Array(data['total']).fill(null), + 'received': 0, + 'timer': null + }; + // 10s timeout to avoid leaks + const self = this; + buf['timer'] = setTimeout(function () { + self['state']['errors']++; + delete self['state']['txtBuffer'][key]; + }, 10000); + } + + // store part (index is 1-based) + const idx = data['index'] - 1; + if (0 <= idx && idx < buf['total']) { + buf['parts'][idx] = data['part']; + buf['received']++; + } + + // check completion + if (buf['received'] === buf['total']) { + clearTimeout(buf['timer']); + delete this['state']['txtBuffer'][key]; + data['message'] = buf['parts'].join(''); + data['completed'] = true; + data['rawMessages'] = buf['parts']; + + } else { + data['message'] = null; + data['completed'] = false; + data['rawMessages'] = []; + } + return data; + }, + + /** + * Feed one full NMEA line (starting with '$', ending before CRLF). + * Emits both 'data' and '' events on success. + */ + 'update': function (line) { + const parsed = GPS['Parse'](line); + this['state']['processed']++; + + if (parsed === false) { + this['state']['errors']++; + return false; + } + + // Assemble TXT multi-part here + if (parsed['type'] === 'TXT') { + this['_assembleTXT'](parsed); + } + + this['_updateState'](parsed); + + this['emit']('data', parsed); + this['emit'](parsed['type'], parsed); + + return true; + }, + + /** + * Feed streaming data (chunks, possibly split arbitrarily). + * Accepts either "\r\n" or "\n" as line delimiters. + */ + 'updatePartial': function (chunk) { + if (chunk) this['partial'] += chunk; + + // Process all complete lines + for (; ;) { + const idxRN = this['partial'].indexOf('\r\n'); + const idxN = this['partial'].indexOf('\n'); + + let pos = -1; + if (idxRN !== -1) pos = idxRN; + else if (idxN !== -1) pos = idxN; + + if (pos === -1) break; + + const line = this['partial'].slice(0, pos); + // Advance buffer past delimiter (2 for CRLF, 1 for LF) + this['partial'] = this['partial'].slice(pos + (idxRN === pos ? 2 : 1)); + + if (line.charAt(0) !== '$') continue; + + try { + this['update'](line); + } catch (err) { + // Keep buffer (don’t drop subsequent lines), but count the error + this['state']['errors']++; + // Re-throw for caller visibility + this.error(err); + } + } + }, + + /** + * Subscribe to an event. Multiple listeners per event are supported. + * @param {string} ev + * @param {function()} cb + * @returns {GPS} this (chainable) + */ + 'on': function (ev, cb) { + const cur = this['events'][ev]; + if (cur === undefined) { + this['events'][ev] = [cb]; + } else if (typeof cur === 'function') { + // Backward compatibility with previous single-listener design + this['events'][ev] = [cur, cb]; + } else { + this['events'][ev].push(cb); + } + return this; + }, + + /** + * Remove listeners. If cb omitted, remove all for the event. + * @param {string} ev + * @param {function()} cb + * @returns {GPS} this + */ + 'off': function (ev, cb) { + const cur = this['events'][ev]; + if (cur === undefined) return this; + + if (!cb) { + delete this['events'][ev]; + return this; + } + + if (typeof cur === 'function') { + if (cur === cb) delete this['events'][ev]; + return this; + } + + // Array case + for (let i = cur.length - 1; i >= 0; i--) { + if (cur[i] === cb) cur.splice(i, 1); + } + if (cur.length === 0) delete this['events'][ev]; + return this; + }, + + /** + * Emit an event to all listeners. + * @param {string} ev + * @param {*} data + */ + 'emit': function (ev, data) { + const cur = this['events'][ev]; + if (cur === undefined) return; + + if (typeof cur === 'function') { + cur.call(this, data); + return; + } + // Array of listeners + for (let i = 0, L = cur.length; i < L; i++) { + cur[i].call(this, data); + } + }, + + 'error': function(error) { + const cur = this['events']['error']; + if(cur === undefined) { + throw error + } + else { + this['emit']('error', error); + } + } }; diff --git a/tests/functions.js b/tests/functions.js index bd25e64..82d8ca6 100644 --- a/tests/functions.js +++ b/tests/functions.js @@ -1,21 +1,21 @@ - -const GPS = require('gps'); -const assert = require('assert'); - -describe('GPS functions', function () { - - it('should measure distance', function () { - - var result = GPS.Distance(45.527517, -122.718766, 45.373373, -121.693604); - - assert.deepEqual(result, 81.80760861833895) - }); - - it('should measure heading', function () { - - var result = GPS.Heading(45.527517, -122.718766, 45.373373, -121.693604); - - assert.deepEqual(result, 101.73177498132071) - }); - + +const GPS = require('gps'); +const assert = require('assert'); + +describe('GPS functions', function () { + + it('should measure distance', function () { + + var result = GPS.Distance(45.527517, -122.718766, 45.373373, -121.693604); + + assert.deepEqual(result, 81.80760861833895) + }); + + it('should measure heading', function () { + + var result = GPS.Heading(45.527517, -122.718766, 45.373373, -121.693604); + + assert.deepEqual(result, 101.73177498132071) + }); + }); \ No newline at end of file diff --git a/tests/parser.js b/tests/parser.js index 77a874b..acc6c59 100644 --- a/tests/parser.js +++ b/tests/parser.js @@ -1,1289 +1,1289 @@ - -function _(x) { - return x < 10 ? "0" + x : x; -} - -let today = new Date(); -today = today.getUTCFullYear() + '-' + _(today.getUTCMonth() + 1) + '-' + _(today.getUTCDate()); - -const GPS = require('gps'); -const assert = require('assert'); -const gps = new GPS; -const tests = { - 'foo': 'invalid', - '$GPGSA,A,3,29,26,31,21,,,,,,,,,2.0,1.7,1.0*39': { - 'fix': '3D', - 'hdop': 1.7, - 'mode': 'automatic', - 'pdop': 2, - 'raw': '$GPGSA,A,3,29,26,31,21,,,,,,,,,2.0,1.7,1.0*39', - 'satellites': [ - 29, - 26, - 31, - 21 - ], - 'type': 'GSA', - "system": "unknown", - "systemId": null, - 'valid': true, - 'vdop': 1 - }, - '$GPRMC,234919.000,A,4832.3914,N,00903.5500,E,2.28,2.93,260116,,*0D': { - 'lat': 48.539856666666665, - 'lon': 9.059166666666666, - 'speed': 4.22256, - 'status': 'active', - 'time': new Date('2016-01-26T23:49:19.000Z'), - 'track': 2.93, - "navStatus": null, - 'raw': '$GPRMC,234919.000,A,4832.3914,N,00903.5500,E,2.28,2.93,260116,,*0D', - 'type': 'RMC', - 'faa': null, - 'valid': true, - 'variation': null - }, - '$GPVTG,2.93,T,,M,2.28,N,4.2,K*66': { - 'speed': 4.22256, - 'track': 2.93, - 'trackMagnetic': null, - 'raw': '$GPVTG,2.93,T,,M,2.28,N,4.2,K*66', - 'type': 'VTG', - 'faa': null, - 'valid': true - }, - '$GPGGA,234920.000,4832.3918,N,00903.5488,E,1,05,1.7,437.9,M,48.0,M,,0000*51': { - 'age': null, - 'alt': 437.9, - 'geoidal': 48, - 'hdop': 1.7, - 'lat': 48.53986333333334, - 'lon': 9.059146666666667, - 'quality': 'fix', - 'raw': '$GPGGA,234920.000,4832.3918,N,00903.5488,E,1,05,1.7,437.9,M,48.0,M,,0000*51', - 'satellites': 5, - 'stationID': 0, - 'time': new Date(today + 'T23:49:20.000Z'), - 'type': 'GGA', - 'valid': true - }, - '$GPGGA,123519,4807.038,N,01131.324,E,1,08,0.9,545.4,M,46.9,M, , *42': { - 'age': NaN, - 'alt': 545.4, - 'geoidal': 46.9, - 'hdop': 0.9, - 'lat': 48.1173, - 'raw': '$GPGGA,123519,4807.038,N,01131.324,E,1,08,0.9,545.4,M,46.9,M, , *42', - 'lon': 11.522066666666667, - 'quality': 'fix', - 'satellites': 8, - 'stationID': NaN, - 'time': new Date(today + 'T12:35:19.000Z'), - 'type': 'GGA', - 'valid': true - }, - '$GPGGA,123519,4807.038,N,01131.324,E,1,08,0.9,545.4,M,46.9,M,,': 'invalid', - '$GPRMC,081836,A,3751.65,S,14507.36,E,000.0,360.0,130998,011.3,E*62': { - 'lat': -37.86083333333333, - 'lon': 145.12266666666667, - 'speed': 0, - 'status': 'active', - 'raw': '$GPRMC,081836,A,3751.65,S,14507.36,E,000.0,360.0,130998,011.3,E*62', - 'time': new Date('2098-09-13T08:18:36.000Z'), - 'track': 360, - 'type': 'RMC', - "navStatus": null, - 'faa': null, - 'valid': true, - 'variation': 11.3 - }, - '$GPGSV,3,2,12,16,17,148,46,20,61,307,51,23,36,283,47,25,06,034,00*78': { - 'msgNumber': 2, - 'raw': '$GPGSV,3,2,12,16,17,148,46,20,61,307,51,23,36,283,47,25,06,034,00*78', - 'msgsTotal': 3, - "satsInView": 12, - 'signalId': null, - "system": "GPS", - 'satellites': [ - { - 'azimuth': 148, - 'elevation': 17, - "key": "GP16", - 'prn': 16, - 'snr': 46, - 'status': 'tracking', - "system": "GPS" - }, { - 'azimuth': 307, - 'elevation': 61, - "key": "GP20", - 'prn': 20, - 'snr': 51, - 'status': 'tracking', - "system": "GPS" - }, { - 'azimuth': 283, - 'elevation': 36, - "key": "GP23", - 'prn': 23, - 'snr': 47, - 'status': 'tracking', - "system": "GPS" - }, { - 'azimuth': 34, - 'elevation': 6, - "key": "GP25", - 'prn': 25, - 'snr': 0, - 'status': 'tracking', - "system": "GPS" - } - ], - 'type': 'GSV', - 'valid': true - }, - '$GPGGA,092750.000,5321.6802,N,00630.3372,W,1,8,1.03,61.7,M,55.2,M,,*76': { - 'age': null, - 'alt': 61.7, - 'geoidal': 55.2, - 'hdop': 1.03, - 'lat': 53.361336666666666, - 'lon': -6.50562, - 'quality': 'fix', - 'raw': '$GPGGA,092750.000,5321.6802,N,00630.3372,W,1,8,1.03,61.7,M,55.2,M,,*76', - 'satellites': 8, - 'stationID': null, - 'time': new Date(today + 'T09:27:50.000Z'), - 'type': 'GGA', - 'valid': true - }, - '$GPZDA,201530.00,04,07,2002,00,00*60': { - 'raw': '$GPZDA,201530.00,04,07,2002,00,00*60', - 'time': new Date('2002-07-04T20:15:30.000Z'), - 'offsetMin': 0, - 'type': 'ZDA', - 'valid': true - }, - '$GPGSA,A,3,10,07,05,02,29,04,08,13,,,,,1.72,1.03,1.38*0A': { - 'fix': '3D', - 'hdop': 1.03, - 'mode': 'automatic', - 'pdop': 1.72, - "systemId": null, - "system": "unknown", - 'raw': '$GPGSA,A,3,10,07,05,02,29,04,08,13,,,,,1.72,1.03,1.38*0A', - 'satellites': [ - 10, - 7, - 5, - 2, - 29, - 4, - 8, - 13 - ], - 'type': 'GSA', - 'valid': true, - 'vdop': 1.38 - }, - '$GPGSV,3,1,11,10,63,137,17,07,61,098,15,05,59,290,20,08,54,157,30*70': { - 'msgNumber': 1, - 'msgsTotal': 3, - "satsInView": 11, - 'signalId': null, - "system": "GPS", - 'raw': '$GPGSV,3,1,11,10,63,137,17,07,61,098,15,05,59,290,20,08,54,157,30*70', - 'satellites': [ - { - 'azimuth': 137, - 'elevation': 63, - "key": "GP10", - 'prn': 10, - 'snr': 17, - 'status': 'tracking', - "system": "GPS" - }, { - 'azimuth': 98, - 'elevation': 61, - "key": "GP7", - 'prn': 7, - 'snr': 15, - 'status': 'tracking', - "system": "GPS" - }, { - 'azimuth': 290, - 'elevation': 59, - "key": "GP5", - 'prn': 5, - 'snr': 20, - 'status': 'tracking', - "system": "GPS" - }, { - 'azimuth': 157, - 'elevation': 54, - "key": "GP8", - 'prn': 8, - 'snr': 30, - 'status': 'tracking', - "system": "GPS" - } - ], - 'type': 'GSV', - 'valid': true - }, - '$GPRMC,123519,A,4807.038,N,01131.000,E,022.4,084.4,230394,003.1,W*6A': { - 'faa': null, - 'lat': 48.1173, - 'lon': 11.516666666666667, - 'raw': '$GPRMC,123519,A,4807.038,N,01131.000,E,022.4,084.4,230394,003.1,W*6A', - 'speed': 41.4848, - 'status': 'active', - 'time': new Date('2094-03-23T12:35:19.000Z'), - 'track': 84.4, - 'type': 'RMC', - "navStatus": null, - 'valid': true, - 'variation': -3.1 - }, - //'$GPVTG,210.43,T,210.43,M,5.65,N,,,A*67': {}, - '$GPGGA,123519,4807.04,N,1131.00,E,1,8,0.9,545.9,M,46.9,M,,*45': { - 'age': null, - 'alt': 545.9, - 'geoidal': 46.9, - 'hdop': 0.9, - 'lat': 48.117333333333335, - 'lon': 113.01666666666667, - 'quality': 'fix', - 'raw': '$GPGGA,123519,4807.04,N,1131.00,E,1,8,0.9,545.9,M,46.9,M,,*45', - 'satellites': 8, - 'stationID': null, - 'time': new Date(today + 'T12:35:19.000Z'), - 'type': 'GGA', - 'valid': true - }, - '$GPGSA,A,3,12,05,25,29,,,,,,,,,9.4,7.6,5.6*37': { - 'fix': '3D', - 'hdop': 7.6, - 'mode': 'automatic', - 'pdop': 9.4, - 'raw': '$GPGSA,A,3,12,05,25,29,,,,,,,,,9.4,7.6,5.6*37', - "systemId": null, - 'satellites': [ - 12, - 5, - 25, - 29 - ], - "system": "unknown", - 'type': 'GSA', - 'valid': true, - 'vdop': 5.6 - }, - '$GPGSV,3,1,11,03,03,111,00,04,15,270,00,06,01,010,00,13,06,292,00*74': { - 'msgNumber': 1, - 'msgsTotal': 3, - "satsInView": 11, - 'signalId': null, - 'raw': '$GPGSV,3,1,11,03,03,111,00,04,15,270,00,06,01,010,00,13,06,292,00*74', - 'satellites': [ - { - 'azimuth': 111, - 'elevation': 3, - "key": "GP3", - 'prn': 3, - 'snr': 0, - 'status': 'tracking', - "system": "GPS" - }, { - 'azimuth': 270, - 'elevation': 15, - "key": "GP4", - 'prn': 4, - 'snr': 0, - 'status': 'tracking', - "system": "GPS" - }, { - 'azimuth': 10, - 'elevation': 1, - "key": "GP6", - 'prn': 6, - 'snr': 0, - 'status': 'tracking', - "system": "GPS" - }, { - 'azimuth': 292, - 'elevation': 6, - "key": "GP13", - 'prn': 13, - 'snr': 0, - 'status': 'tracking', - "system": "GPS" - } - ], - "system": "GPS", - 'type': 'GSV', - 'valid': true - }, - '$GPGSV,3,2,11,14,25,170,00,16,57,208,39,18,67,296,40,19,40,246,00*2D': { - 'msgNumber': 2, - 'msgsTotal': 3, - "satsInView": 11, - 'signalId': null, - 'raw': '$GPGSV,3,2,11,14,25,170,00,16,57,208,39,18,67,296,40,19,40,246,00*2D', - 'satellites': [ - { - 'azimuth': 170, - 'elevation': 25, - "key": "GP14", - 'prn': 14, - 'snr': 0, - 'status': 'tracking', - "system": "GPS" - }, { - 'azimuth': 208, - 'elevation': 57, - "key": "GP16", - 'prn': 16, - 'snr': 39, - 'status': 'tracking', - "system": "GPS" - }, { - 'azimuth': 296, - 'elevation': 67, - "key": "GP18", - 'prn': 18, - 'snr': 40, - 'status': 'tracking', - "system": "GPS" - }, { - 'azimuth': 246, - 'elevation': 40, - "key": "GP19", - 'prn': 19, - 'snr': 0, - 'status': 'tracking', - "system": "GPS" - } - ], - 'type': 'GSV', - "system": "GPS", - 'valid': false - }, - '$GPGSV,3,2,11,02,39,223,16,13,28,070,17,26,23,252,,04,14,186,15*77': { - 'msgNumber': 2, - 'msgsTotal': 3, - "satsInView": 11, - 'signalId': null, - 'raw': '$GPGSV,3,2,11,02,39,223,16,13,28,070,17,26,23,252,,04,14,186,15*77', - 'satellites': [ - { - 'azimuth': 223, - 'elevation': 39, - "key": "GP2", - 'prn': 2, - 'snr': 16, - 'status': 'tracking', - "system": "GPS" - }, { - 'azimuth': 70, - 'elevation': 28, - "key": "GP13", - 'prn': 13, - 'snr': 17, - 'status': 'tracking', - "system": "GPS" - }, { - 'azimuth': 252, - 'elevation': 23, - "key": "GP26", - 'prn': 26, - 'snr': null, - 'status': 'in view', - "system": "GPS" - }, { - 'azimuth': 186, - 'elevation': 14, - "key": "GP4", - 'prn': 4, - 'snr': 15, - 'status': 'tracking', - "system": "GPS" - } - ], - 'type': 'GSV', - "system": "GPS", - 'valid': true - }, - '$GPGSV,3,3,11,29,09,301,24,16,09,020,,36,,,*76': { - 'msgNumber': 3, - 'msgsTotal': 3, - "satsInView": 11, - 'signalId': null, - 'raw': '$GPGSV,3,3,11,29,09,301,24,16,09,020,,36,,,*76', - 'satellites': [ - { - 'azimuth': 301, - 'elevation': 9, - "key": "GP29", - 'prn': 29, - 'snr': 24, - 'status': 'tracking', - "system": "GPS" - }, { - 'azimuth': 20, - 'elevation': 9, - "key": "GP16", - 'prn': 16, - 'snr': null, - 'status': 'in view', - "system": "GPS" - }, { - 'azimuth': null, - 'elevation': null, - "key": "GP36", - 'prn': 36, - 'snr': null, - 'status': 'in view', - "system": "GPS" - } - ], - 'type': 'GSV', - "system": "GPS", - 'valid': true - }, - '$GPRMC,092750.000,A,5321.6802,N,00630.3372,W,0.02,31.66,280511,,,A*43': { - 'faa': 'autonomous', - 'lat': 53.361336666666666, - 'lon': -6.50562, - 'raw': '$GPRMC,092750.000,A,5321.6802,N,00630.3372,W,0.02,31.66,280511,,,A*43', - 'speed': 0.037040000000000003, - 'status': 'active', - 'time': new Date('2011-05-28T09:27:50.000Z'), - 'track': 31.66, - 'type': 'RMC', - "navStatus": null, - 'valid': true, - 'variation': null - }, - '$GPGGA,092751.000,5321.6802,N,00630.3371,W,1,8,1.03,61.7,M,55.3,M,,*75': { - 'age': null, - 'alt': 61.7, - 'geoidal': 55.3, - 'hdop': 1.03, - 'lat': 53.361336666666666, - 'lon': -6.5056183333333335, - 'quality': 'fix', - 'raw': '$GPGGA,092751.000,5321.6802,N,00630.3371,W,1,8,1.03,61.7,M,55.3,M,,*75', - 'satellites': 8, - 'stationID': null, - 'time': new Date(today + 'T09:27:51.000Z'), - 'type': 'GGA', - 'valid': true - }, - '$GPRMC,092751.000,A,5321.6802,N,00630.3371,W,0.06,31.66,280511,,,A*45': { - 'faa': 'autonomous', - 'lat': 53.361336666666666, - 'lon': -6.5056183333333335, - 'raw': '$GPRMC,092751.000,A,5321.6802,N,00630.3371,W,0.06,31.66,280511,,,A*45', - 'speed': 0.11112, - 'status': 'active', - 'time': new Date('2011-05-28T09:27:51.000Z'), - 'track': 31.66, - 'type': 'RMC', - "navStatus": null, - 'valid': true, - 'variation': null - }, - '$GPGLL,6005.068,N,02332.341,E,095601,A,D*42': { - 'lat': 60.084466666666664, - 'lon': 23.539016666666665, - 'raw': '$GPGLL,6005.068,N,02332.341,E,095601,A,D*42', - 'status': 'active', - 'time': new Date(today + 'T09:56:01.000Z'), - 'type': 'GLL', - 'valid': true, - "faa": "differential" - }, - '$GPGLL,4916.45,N,12311.12,W,225444,A,*1D': { - 'lat': 49.274166666666666, - 'lon': -123.18533333333333, - 'raw': '$GPGLL,4916.45,N,12311.12,W,225444,A,*1D', - 'status': 'active', - 'time': new Date(today + 'T22:54:44.000Z'), - 'type': 'GLL', - 'valid': true, - 'faa': null - }, - '$GPGGA,174815.40,4141.46474,N,00849.77225,W,1,08,1.24,11.8,M,50.5,M,,*76': { - 'age': null, - 'alt': 11.8, - 'geoidal': 50.5, - 'hdop': 1.24, - 'quality': 'fix', - 'satellites': 8, - 'stationID': null, - 'lat': 41.691079, - 'lon': -8.8295375, - 'time': new Date(today + 'T17:48:15.400Z'), - 'raw': '$GPGGA,174815.40,4141.46474,N,00849.77225,W,1,08,1.24,11.8,M,50.5,M,,*76', - 'type': 'GGA', - 'valid': true, - }, - // test with two digits on quality - '$GPGGA,174815.40,4141.46474,N,00849.77225,W,05,08,1.24,11.8,M,50.5,M,,*42': { - 'age': null, - 'alt': 11.8, - 'geoidal': 50.5, - 'hdop': 1.24, - 'quality': 'rtk-float', - 'satellites': 8, - 'stationID': null, - 'lat': 41.691079, - 'lon': -8.8295375, - 'time': new Date(today + 'T17:48:15.400Z'), - 'raw': '$GPGGA,174815.40,4141.46474,N,00849.77225,W,05,08,1.24,11.8,M,50.5,M,,*42', - 'type': 'GGA', - 'valid': true, - }, - '$GPGST,172814.0,0.006,0.023,0.020,273.6,0.023,0.020,0.031*6A': { - 'time': new Date(today + 'T17:28:14.000Z'), - 'rms': 0.006, - 'ellipseMajor': 0.023, - 'ellipseMinor': 0.020, - 'ellipseOrientation': 273.6, - 'latitudeError': 0.023, - 'longitudeError': 0.020, - 'heightError': 0.031, - 'raw': '$GPGST,172814.0,0.006,0.023,0.020,273.6,0.023,0.020,0.031*6A', - 'type': 'GST', - 'valid': true - }, - '$GLGST,172814.0,0.006,0.023,0.020,273.6,0.023,0.020,0.031*76': { - 'time': new Date(today + 'T17:28:14.000Z'), - 'rms': 0.006, - 'ellipseMajor': 0.023, - 'ellipseMinor': 0.020, - 'ellipseOrientation': 273.6, - 'latitudeError': 0.023, - 'longitudeError': 0.020, - 'heightError': 0.031, - 'raw': '$GLGST,172814.0,0.006,0.023,0.020,273.6,0.023,0.020,0.031*76', - 'type': 'GST', - 'valid': true - }, - '$GNGST,172814.0,0.006,0.023,0.020,273.6,0.023,0.020,0.031*74': { - 'time': new Date(today + 'T17:28:14.000Z'), - 'rms': 0.006, - 'ellipseMajor': 0.023, - 'ellipseMinor': 0.020, - 'ellipseOrientation': 273.6, - 'latitudeError': 0.023, - 'longitudeError': 0.020, - 'heightError': 0.031, - 'raw': '$GNGST,172814.0,0.006,0.023,0.020,273.6,0.023,0.020,0.031*74', - 'type': 'GST', - 'valid': true - }, - // add hdt test - '$HEHDT,066.2,T*2D': { - 'heading': 66.2, - 'raw': '$HEHDT,066.2,T*2D', - 'trueNorth': true, - 'type': 'HDT', - 'valid': true - }, - '$GPGGA,023920.476,5230.942,N,01323.025,E,0,12,1.0,0.0,M,0.0,M,,*6E': { - "age": null, - "alt": 0, - "geoidal": 0, - "hdop": 1, - "lat": 52.5157, - "lon": 13.38375, - "quality": null, - "raw": "$GPGGA,023920.476,5230.942,N,01323.025,E,0,12,1.0,0.0,M,0.0,M,,*6E", - "satellites": 12, - "stationID": null, - "time": new Date(today + 'T02:39:20.476Z'), - "type": "GGA", - "valid": false // false because we manually changed fix to 0 - }, - "$GPGSV,3,1,11,02,20,106,26,06,20,072,18,12,77,040,37,14,30,309,25,1*65": { - "msgNumber": 1, - "msgsTotal": 3, - "raw": "$GPGSV,3,1,11,02,20,106,26,06,20,072,18,12,77,040,37,14,30,309,25,1*65", - "satellites": [ - { - "azimuth": 106, - "elevation": 20, - "key": "GP2", - "prn": 2, - "snr": 26, - "status": "tracking", - "system": "GPS" - }, { - "azimuth": 72, - "elevation": 20, - "key": "GP6", - "prn": 6, - "snr": 18, - "status": "tracking", - "system": "GPS" - }, { - "azimuth": 40, - "elevation": 77, - "key": "GP12", - "prn": 12, - "snr": 37, - "status": "tracking", - "system": "GPS" - }, { - "azimuth": 309, - "elevation": 30, - "key": "GP14", - "prn": 14, - "snr": 25, - "status": "tracking", - "system": "GPS" - } - ], - "satsInView": 11, - "signalId": 1, - "system": "GPS", - "type": "GSV", - "valid": true - }, - "$GAGSV,3,3,09,33,11,027,,7*4F": { - "msgNumber": 3, - "msgsTotal": 3, - "raw": "$GAGSV,3,3,09,33,11,027,,7*4F", - "satellites": [ - { - "azimuth": 27, - "elevation": 11, - "key": "GA33", - "prn": 33, - "snr": null, - "status": "in view", - "system": "Galileo" - } - ], - "satsInView": 9, - "signalId": 7, - "system": "Galileo", - "type": "GSV", - "valid": true - }, - "$GPGSV,3,1,12,02,22,103,,03,00,357,,06,21,068,18,12,73,046,32,6*66": { - "msgNumber": 1, - "msgsTotal": 3, - "raw": "$GPGSV,3,1,12,02,22,103,,03,00,357,,06,21,068,18,12,73,046,32,6*66", - "satellites": [ - { - "azimuth": 103, - "elevation": 22, - "key": "GP2", - "prn": 2, - "snr": null, - "status": "in view", - "system": "GPS" - }, { - "azimuth": 357, - "elevation": 0, - "key": "GP3", - "prn": 3, - "snr": null, - "status": "in view", - "system": "GPS" - }, { - "azimuth": 68, - "elevation": 21, - "key": "GP6", - "prn": 6, - "snr": 18, - "status": "tracking", - "system": "GPS" - }, { - "azimuth": 46, - "elevation": 73, - "key": "GP12", - "prn": 12, - "snr": 32, - "status": "tracking", - "system": "GPS" - } - ], - "satsInView": 12, - "signalId": 6, - "system": "GPS", - "type": "GSV", - "valid": true - }, - "$GAGSV,3,1,11,02,49,285,30,03,22,221,29,07,12,328,,08,32,278,35,7*74": { - "msgNumber": 1, - "msgsTotal": 3, - "raw": "$GAGSV,3,1,11,02,49,285,30,03,22,221,29,07,12,328,,08,32,278,35,7*74", - "satellites": [ - { - "azimuth": 285, - "elevation": 49, - "key": "GA2", - "prn": 2, - "snr": 30, - "status": "tracking", - "system": "Galileo" - }, { - "azimuth": 221, - "elevation": 22, - "key": "GA3", - "prn": 3, - "snr": 29, - "status": "tracking", - "system": "Galileo" - }, { - "azimuth": 328, - "elevation": 12, - "key": "GA7", - "prn": 7, - "snr": null, - "status": "in view", - "system": "Galileo" - }, { - "azimuth": 278, - "elevation": 32, - "key": "GA8", - "prn": 8, - "snr": 35, - "status": "tracking", - "system": "Galileo" - } - ], - "satsInView": 11, - "signalId": 7, - "type": "GSV", - "system": "Galileo", - "valid": true - }, - "$GBGSV,1,1,04,13,31,064,,21,12,255,,26,18,293,27,29,46,155,31,1*78": { - "msgNumber": 1, - "msgsTotal": 1, - "raw": "$GBGSV,1,1,04,13,31,064,,21,12,255,,26,18,293,27,29,46,155,31,1*78", - "satellites": [ - { - "azimuth": 64, - "elevation": 31, - "key": "GB13", - "prn": 13, - "snr": null, - "status": "in view", - "system": "BeiDou" - }, { - "azimuth": 255, - "elevation": 12, - "key": "GB21", - "prn": 21, - "snr": null, - "status": "in view", - "system": "BeiDou" - }, { - "azimuth": 293, - "elevation": 18, - "key": "GB26", - "prn": 26, - "snr": 27, - "status": "tracking", - "system": "BeiDou" - }, { - "azimuth": 155, - "elevation": 46, - "key": "GB29", - "prn": 29, - "snr": 31, - "status": "tracking", - "system": "BeiDou" - } - ], - "satsInView": 4, - "system": "BeiDou", - "signalId": 1, - "type": "GSV", - "valid": true - }, - "$GNRMC,191029.00,A,4843.01033,N,00227.78756,E,0.024,,010319,,,A,V*1C": { - "faa": "autonomous", - "lat": 48.716838833333334, - "lon": 2.463126, - "navStatus": "V", - "raw": "$GNRMC,191029.00,A,4843.01033,N,00227.78756,E,0.024,,010319,,,A,V*1C", - "speed": 0.044448, - "status": "active", - "time": new Date("2019-03-01T19:10:29.000Z"), - "track": null, - "type": "RMC", - "valid": true, - "variation": null - }, - "$GNGSA,A,3,25,29,31,26,16,21,,,,,,,1.55,0.84,1.30,1*00": { - "fix": "3D", - "hdop": 0.84, - "mode": "automatic", - "system": "GPS", - "systemId": 1, - "pdop": 1.55, - "raw": "$GNGSA,A,3,25,29,31,26,16,21,,,,,,,1.55,0.84,1.30,1*00", - "satellites": [ - 25, - 29, - 31, - 26, - 16, - 21 - ], - "type": "GSA", - "valid": true, - "vdop": 1.3 - }, - '$GPRMC,085542.023,V,,,,,,,041211,,,N*45': { - "faa": "not valid", - "lat": null, - "lon": null, - "navStatus": null, - "raw": "$GPRMC,085542.023,V,,,,,,,041211,,,N*45", - "speed": null, - "status": "void", - "time": new Date('2011-12-04T08:55:42.023Z'), - "track": null, - "type": "RMC", - "valid": true, - "variation": null - }, - '$GPGGA,100313.99,3344.459045,N,09639.616711,W,1,05,0.0,220.9,M,0.0,M,0.0,0000*66': { - "age": 0, - "alt": 220.9, - "geoidal": 0, - "hdop": 0, - "lat": 33.74098408333333, - "lon": -96.66027851666666, - "quality": "fix", - "raw": "$GPGGA,100313.99,3344.459045,N,09639.616711,W,1,05,0.0,220.9,M,0.0,M,0.0,0000*66", - "satellites": 5, - "stationID": 0, - "time": new Date(today + 'T10:03:13.990Z'), - "type": "GGA", - "valid": true - }, - '$GNGRS,112423.00,1,-0.1,-0.4,5.6,-4.3,1.4,-0.2,,,,,,,1,1*51': { - "mode": 1, - "raw": "$GNGRS,112423.00,1,-0.1,-0.4,5.6,-4.3,1.4,-0.2,,,,,,,1,1*51", - "res": [ - -0.1, - -0.4, - 5.6, - -4.3, - 1.4, - -0.2 - ], - "time": new Date(today + 'T11:24:23.000Z'), - "type": "GRS", - "valid": true - }, - '$GNGRS,112423.00,1,0.0,0.0,0.0,-6.4,-1.2,,,,,,,,1,6*7F': { - "mode": 1, - "raw": "$GNGRS,112423.00,1,0.0,0.0,0.0,-6.4,-1.2,,,,,,,,1,6*7F", - "res": [ - 0, - 0, - 0, - -6.4, - -1.2 - ], - "time": new Date(today + 'T11:24:23.000Z'), - "type": "GRS", - "valid": true - }, - '$GNGRS,112423.00,1,-2.5,0.8,0.2,7.2,6.2,,,,,,,,2,1*5B': { - "mode": 1, - "raw": "$GNGRS,112423.00,1,-2.5,0.8,0.2,7.2,6.2,,,,,,,,2,1*5B", - "res": [ - -2.5, - 0.8, - 0.2, - 7.2, - 6.2 - ], - "time": new Date(today + 'T11:24:23.000Z'), - "type": "GRS", - "valid": true - }, - '$GNGBS,112424.00,2.5,1.5,5.4,,,,,,*5D': { - "raw": "$GNGBS,112424.00,2.5,1.5,5.4,,,,,,*5D", - "type": "GBS", - "time": new Date(today + 'T11:24:24.000Z'), - "errLat": 2.5, - "errLon": 1.5, - "errAlt": 5.4, - "failedSat": null, - "probFailedSat": null, - "biasFailedSat": null, - "stdFailedSat": null, - "valid": true, - "systemId": null, - "signalId": null - }, - '$GPGBS,015509.00,-0.031,-0.186,0.219,19,0.000,-0.354,6.972*4D': { - "raw": "$GPGBS,015509.00,-0.031,-0.186,0.219,19,0.000,-0.354,6.972*4D", - "type": "GBS", - "time": new Date(today + 'T01:55:09.000Z'), - "errLat": -0.031, - "errLon": -0.186, - "errAlt": 0.219, - "failedSat": 19, - "probFailedSat": 0, - "biasFailedSat": -0.354, - "stdFailedSat": 6.972, - "valid": true, - "systemId": null, - "signalId": null - }, - '$GNGSA,A,3,24,12,19,15,,,,,,,,,5.27,3.57,3.87,1*05': { - "fix": "3D", - "hdop": 3.57, - "mode": "automatic", - "pdop": 5.27, - "raw": "$GNGSA,A,3,24,12,19,15,,,,,,,,,5.27,3.57,3.87,1*05", - "satellites": [ - 24, - 12, - 19, - 15 - ], - "systemId": 1, - "system": "GPS", - "type": "GSA", - "valid": true, - "vdop": 3.87 - }, - '$GNGNS,133216.00,4843.01093,N,00227.78866,E,ANNN,04,3.57,55.4,46.3,,,V*29': { - "raw": "$GNGNS,133216.00,4843.01093,N,00227.78866,E,ANNN,04,3.57,55.4,46.3,,,V*29", - "type": "GNS", - "time": new Date(today + 'T13:32:16.000Z'), - "valid": true, - "alt": 55.4, - "diffAge": null, - "diffStation": null, - "hdop": 3.57, - "lat": 48.71684883333333, - "lon": 2.463144333333333, - "mode": "ANNN", - "navStatus": "V", - "satsUsed": 4, - "sep": 46.3 - }, - '$GPGLL,5000.05254,N,04500.02356,E,090037.059,A*35': { - 'faa': null, - "lat": 50.000875666666666, - "lon": 45.00039266666667, - "raw": "$GPGLL,5000.05254,N,04500.02356,E,090037.059,A*35", - "status": "active", - "type": "GLL", - "valid": true, - "time": new Date(today + 'T09:00:37.059Z'), - }, - '$GPGGA,033016,1227.2470,S,13050.8514,E,2,6,0.9,11.8,M,,M*4A': { - "age": 4, - "alt": 11.8, - "geoidal": null, - "hdop": 0.9, - "lat": -12.454116666666666, - "lon": 130.84752333333333, - "quality": "dgps-fix", - "raw": "$GPGGA,033016,1227.2470,S,13050.8514,E,2,6,0.9,11.8,M,,M*4A", - "satellites": 6, - "stationID": null, - "time": new Date(today + 'T03:30:16.000Z'), - "type": "GGA", - "valid": true - }, - '$GPGGA,033631,1227.2473,S,13050.8504,E,2,6,0.9,7.4,M,,M*70': { - "age": 70, - "alt": 7.4, - "geoidal": null, - "hdop": 0.9, - "lat": -12.454121666666667, - "lon": 130.84750666666667, - "quality": "dgps-fix", - "raw": "$GPGGA,033631,1227.2473,S,13050.8504,E,2,6,0.9,7.4,M,,M*70", - "satellites": 6, - "stationID": null, - "time": new Date(today + 'T03:36:31.000Z'), - "type": "GGA", - "valid": true - }, - '$GPGGA,034030,1227.2475,S,13050.8528,E,2,6,0.9,8.1,M,,M*72': { - "age": 72, - "alt": 8.1, - "geoidal": null, - "hdop": 0.9, - "lat": -12.454125, - "lon": 130.84754666666666, - "quality": "dgps-fix", - "raw": "$GPGGA,034030,1227.2475,S,13050.8528,E,2,6,0.9,8.1,M,,M*72", - "satellites": 6, - "stationID": null, - "time": new Date(today + 'T03:40:30.000Z'), - "type": "GGA", - "valid": true - }, - '$BDGSV,4,1,16,01,,,37,02,,,38,03,,,39,05,,,37,0,4*6A': { - "msgNumber": 1, - "msgsTotal": 4, - "raw": "$BDGSV,4,1,16,01,,,37,02,,,38,03,,,39,05,,,37,0,4*6A", - "satellites": [{ - "azimuth": null, - "elevation": null, - "key": "BD1", - "prn": 1, - "snr": 37, - "status": "tracking", - "system": "BD" - }, { - "azimuth": null, - "elevation": null, - "key": "BD2", - "prn": 2, - "snr": 38, - "status": "tracking", - "system": "BD" - }, { - "azimuth": null, - "elevation": null, - "key": "BD3", - "prn": 3, - "snr": 39, - "status": "tracking", - "system": "BD" - }, { - "azimuth": null, - "elevation": null, - "key": "BD5", - "prn": 5, - "snr": 37, - "status": "tracking", - "system": "BD" - }], - "satsInView": 16, - "system": "BD", - "signalId": null, - "type": "GSV", - "valid": true - }, - '$BDGSV,1,1,03,10,46,329,31,08,43,161,,09,40,217,*52': { - "msgNumber": 1, - "msgsTotal": 1, - "raw": "$BDGSV,1,1,03,10,46,329,31,08,43,161,,09,40,217,*52", - "satellites": [{ - "azimuth": 329, - "elevation": 46, - "key": "BD10", - "prn": 10, - "snr": 31, - "status": "tracking", - "system": "BD" - }, { - "azimuth": 161, - "elevation": 43, - "key": "BD8", - "prn": 8, - "snr": null, - "status": "in view", - "system": "BD" - }, { - "azimuth": 217, - "elevation": 40, - "key": "BD9", - "prn": 9, - "snr": null, - "status": "in view", - "system": "BD" - }], - "satsInView": 3, - "signalId": null, - "system": "BD", - "type": "GSV", - "valid": true - }, - '$BDGSV,2,1,06,211,18,305,36,205,07,113,,206,04,029,,209,30,046,*67': { - "msgNumber": 1, - "msgsTotal": 2, - "raw": "$BDGSV,2,1,06,211,18,305,36,205,07,113,,206,04,029,,209,30,046,*67", - "satellites": [{ - "azimuth": 305, - "elevation": 18, - "key": "BD211", - "prn": 211, - "snr": 36, - "status": "tracking", - "system": "BD" - }, { - "azimuth": 113, - "elevation": 7, - "key": "BD205", - "prn": 205, - "snr": null, - "status": "in view", - "system": "BD" - }, { - "azimuth": 29, - "elevation": 4, - "key": "BD206", - "prn": 206, - "snr": null, - "status": "in view", - "system": "BD" - }, { - "azimuth": 46, - "elevation": 30, - "key": "BD209", - "prn": 209, - "snr": null, - "status": "in view", - "system": "BD" - }], - "satsInView": 6, - "system": "BD", - "signalId": null, - "type": "GSV", - "valid": true - }, - '$GNTXT,01,01,02,PF=3FF*4B': { - "completed": true, - "id": 2, - "index": 1, - "message": "PF=3FF", - "part": 'PF=3FF', - "raw": "$GNTXT,01,01,02,PF=3FF*4B", - "rawMessages": [ - "PF=3FF", - ], - system: 'GN', - "total": 1, - "type": "TXT", - "valid": true - }, - '$GNTXT,01,01,02,ANTSTATUS=OK*25': { - "completed": true, - "id": 2, - "index": 1, - "message": "ANTSTATUS=OK", - "part": 'ANTSTATUS=OK', - "raw": "$GNTXT,01,01,02,ANTSTATUS=OK*25", - "rawMessages": [ - "ANTSTATUS=OK", - ], - "system": 'GN', - "total": 1, - "type": "TXT", - "valid": true - }, - '$GNTXT,01,01,02,LLC=FFFFFFFF-FFFFFFFF-FFFFFFFF-FFFFFFFF-FFFFFFFD*2F': { - "completed": true, - "id": 2, - "index": 1, - "message": "LLC=FFFFFFFF-FFFFFFFF-FFFFFFFF-FFFFFFFF-FFFFFFFD", - "part": '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", - ], - "system": 'GN', - "total": 1, - "type": "TXT", - "valid": true - }, - '$GNTXT,01,01,02,some escape chars: ^21*2F': { - "completed": true, - "id": 2, - "index": 1, - "message": "some escape chars: !", - "part": "some escape chars: !", - "raw": "$GNTXT,01,01,02,some escape chars: ^21*2F", - "rawMessages": [ - "some escape chars: !", - ], - "system": 'GN', - "total": 1, - "type": "TXT", - "valid": false - }, - '$GNTXT,02,01,02,a multipart message^2C this is part 1^0D^0A*34': { - "completed": false, - "id": 2, - "index": 1, - "message": null, - "part": "a multipart message, this is part 1\r\n", - "raw": "$GNTXT,02,01,02,a multipart message^2C this is part 1^0D^0A*34", - "rawMessages": [], - "system": 'GN', - "total": 2, - "type": "TXT", - "valid": true - }, - '$GNTXT,02,02,02,a multipart message^2C this is part 2^0D^0A*34': { - "completed": true, - "id": 2, - "index": 2, - "message": "a multipart message, this is part 1\r\na multipart message, this is part 2\r\n", - "part": 'a 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", - ], - "system": 'GN', - "total": 2, - "type": "TXT", - "valid": true - } -}; -var collect = {}; -gps.on('data', function (data) { - - collect[data.raw] = data; -}); -for (var i in tests) { - - if (!gps.update(i)) { - collect[i] = 'invalid'; - } -} - -describe('NMEA syntax', function () { - - for (var i in collect) { - - (function (i) { - - it('Should pass ' + i, function () { - assert.deepEqual(collect[i], tests[i]); - }); - })(i); - } -}); -/* - $IIDBT,036.41,f,011.10,M,005.99,F*25 - $IIMWV,017,R,02.91,N,A*2F - $XXMWV,017.00,R,2.91,N,A*31 - $IIVTG,210.43,T,210.43,M,5.65,N,,,A*67 - $XXVTG,210.43,T,209.43,M,2.91,N,,,A*63 - $GPGGA,123519,4807.038,N,01131.000,E,1,08,0.9,545.4,M,46.9,M,,*47 - '$GPGSA,A,1,,,,,,,,,,,,,,,*1E', - '$GPGSV,3,1,12,29,75,266,39,05,48,047,,26,43,108,,15,35,157,*78', - '$GPGSV,3,2,12,21,30,292,,18,21,234,,02,18,093,,25,13,215,*7F', - '$GPGSV,3,3,12,30,11,308,,16,,333,,12,,191,,07,-4,033,*62', - '$GPGGA,085543.023,,,,,0,00,,,M,0.0,M,,0000*58', - '$IIBWC,160947,6008.160,N,02454.290,E,162.4,T,154.3,M,001.050,N,DEST*1C', - '$IIAPB,A,A,0.001,L,N,V,V,154.3,M,DEST,154.3,M,154.2,M*19' - $GPGGA,092750.000,5321.6802,N,00630.3372,W,1,8,1.03,61.7,M,55.2,M,,*76 - $GPGGA,181650.692,7204.589,N,01915.106,W,0,00,,,M,,M,,*59 - $GPGGA,092751.000,5321.6802,N,00630.3371,W,1,8,1.03,61.7,M,55.3,M,,*75 - $GPGGA,181514.692,4951.923,S,03050.357,W,0,00,,,M,,M,,*4F - */ + +function _(x) { + return x < 10 ? "0" + x : x; +} + +let today = new Date(); +today = today.getUTCFullYear() + '-' + _(today.getUTCMonth() + 1) + '-' + _(today.getUTCDate()); + +const GPS = require('gps'); +const assert = require('assert'); +const gps = new GPS; +const tests = { + 'foo': 'invalid', + '$GPGSA,A,3,29,26,31,21,,,,,,,,,2.0,1.7,1.0*39': { + 'fix': '3D', + 'hdop': 1.7, + 'mode': 'automatic', + 'pdop': 2, + 'raw': '$GPGSA,A,3,29,26,31,21,,,,,,,,,2.0,1.7,1.0*39', + 'satellites': [ + 29, + 26, + 31, + 21 + ], + 'type': 'GSA', + "system": "unknown", + "systemId": null, + 'valid': true, + 'vdop': 1 + }, + '$GPRMC,234919.000,A,4832.3914,N,00903.5500,E,2.28,2.93,260116,,*0D': { + 'lat': 48.539856666666665, + 'lon': 9.059166666666666, + 'speed': 4.22256, + 'status': 'active', + 'time': new Date('2016-01-26T23:49:19.000Z'), + 'track': 2.93, + "navStatus": null, + 'raw': '$GPRMC,234919.000,A,4832.3914,N,00903.5500,E,2.28,2.93,260116,,*0D', + 'type': 'RMC', + 'faa': null, + 'valid': true, + 'variation': null + }, + '$GPVTG,2.93,T,,M,2.28,N,4.2,K*66': { + 'speed': 4.22256, + 'track': 2.93, + 'trackMagnetic': null, + 'raw': '$GPVTG,2.93,T,,M,2.28,N,4.2,K*66', + 'type': 'VTG', + 'faa': null, + 'valid': true + }, + '$GPGGA,234920.000,4832.3918,N,00903.5488,E,1,05,1.7,437.9,M,48.0,M,,0000*51': { + 'age': null, + 'alt': 437.9, + 'geoidal': 48, + 'hdop': 1.7, + 'lat': 48.53986333333334, + 'lon': 9.059146666666667, + 'quality': 'fix', + 'raw': '$GPGGA,234920.000,4832.3918,N,00903.5488,E,1,05,1.7,437.9,M,48.0,M,,0000*51', + 'satellites': 5, + 'stationID': 0, + 'time': new Date(today + 'T23:49:20.000Z'), + 'type': 'GGA', + 'valid': true + }, + '$GPGGA,123519,4807.038,N,01131.324,E,1,08,0.9,545.4,M,46.9,M, , *42': { + 'age': NaN, + 'alt': 545.4, + 'geoidal': 46.9, + 'hdop': 0.9, + 'lat': 48.1173, + 'raw': '$GPGGA,123519,4807.038,N,01131.324,E,1,08,0.9,545.4,M,46.9,M, , *42', + 'lon': 11.522066666666667, + 'quality': 'fix', + 'satellites': 8, + 'stationID': NaN, + 'time': new Date(today + 'T12:35:19.000Z'), + 'type': 'GGA', + 'valid': true + }, + '$GPGGA,123519,4807.038,N,01131.324,E,1,08,0.9,545.4,M,46.9,M,,': 'invalid', + '$GPRMC,081836,A,3751.65,S,14507.36,E,000.0,360.0,130998,011.3,E*62': { + 'lat': -37.86083333333333, + 'lon': 145.12266666666667, + 'speed': 0, + 'status': 'active', + 'raw': '$GPRMC,081836,A,3751.65,S,14507.36,E,000.0,360.0,130998,011.3,E*62', + 'time': new Date('2098-09-13T08:18:36.000Z'), + 'track': 360, + 'type': 'RMC', + "navStatus": null, + 'faa': null, + 'valid': true, + 'variation': 11.3 + }, + '$GPGSV,3,2,12,16,17,148,46,20,61,307,51,23,36,283,47,25,06,034,00*78': { + 'msgNumber': 2, + 'raw': '$GPGSV,3,2,12,16,17,148,46,20,61,307,51,23,36,283,47,25,06,034,00*78', + 'msgsTotal': 3, + "satsInView": 12, + 'signalId': null, + "system": "GPS", + 'satellites': [ + { + 'azimuth': 148, + 'elevation': 17, + "key": "GP16", + 'prn': 16, + 'snr': 46, + 'status': 'tracking', + "system": "GPS" + }, { + 'azimuth': 307, + 'elevation': 61, + "key": "GP20", + 'prn': 20, + 'snr': 51, + 'status': 'tracking', + "system": "GPS" + }, { + 'azimuth': 283, + 'elevation': 36, + "key": "GP23", + 'prn': 23, + 'snr': 47, + 'status': 'tracking', + "system": "GPS" + }, { + 'azimuth': 34, + 'elevation': 6, + "key": "GP25", + 'prn': 25, + 'snr': 0, + 'status': 'tracking', + "system": "GPS" + } + ], + 'type': 'GSV', + 'valid': true + }, + '$GPGGA,092750.000,5321.6802,N,00630.3372,W,1,8,1.03,61.7,M,55.2,M,,*76': { + 'age': null, + 'alt': 61.7, + 'geoidal': 55.2, + 'hdop': 1.03, + 'lat': 53.361336666666666, + 'lon': -6.50562, + 'quality': 'fix', + 'raw': '$GPGGA,092750.000,5321.6802,N,00630.3372,W,1,8,1.03,61.7,M,55.2,M,,*76', + 'satellites': 8, + 'stationID': null, + 'time': new Date(today + 'T09:27:50.000Z'), + 'type': 'GGA', + 'valid': true + }, + '$GPZDA,201530.00,04,07,2002,00,00*60': { + 'raw': '$GPZDA,201530.00,04,07,2002,00,00*60', + 'time': new Date('2002-07-04T20:15:30.000Z'), + 'offsetMin': 0, + 'type': 'ZDA', + 'valid': true + }, + '$GPGSA,A,3,10,07,05,02,29,04,08,13,,,,,1.72,1.03,1.38*0A': { + 'fix': '3D', + 'hdop': 1.03, + 'mode': 'automatic', + 'pdop': 1.72, + "systemId": null, + "system": "unknown", + 'raw': '$GPGSA,A,3,10,07,05,02,29,04,08,13,,,,,1.72,1.03,1.38*0A', + 'satellites': [ + 10, + 7, + 5, + 2, + 29, + 4, + 8, + 13 + ], + 'type': 'GSA', + 'valid': true, + 'vdop': 1.38 + }, + '$GPGSV,3,1,11,10,63,137,17,07,61,098,15,05,59,290,20,08,54,157,30*70': { + 'msgNumber': 1, + 'msgsTotal': 3, + "satsInView": 11, + 'signalId': null, + "system": "GPS", + 'raw': '$GPGSV,3,1,11,10,63,137,17,07,61,098,15,05,59,290,20,08,54,157,30*70', + 'satellites': [ + { + 'azimuth': 137, + 'elevation': 63, + "key": "GP10", + 'prn': 10, + 'snr': 17, + 'status': 'tracking', + "system": "GPS" + }, { + 'azimuth': 98, + 'elevation': 61, + "key": "GP7", + 'prn': 7, + 'snr': 15, + 'status': 'tracking', + "system": "GPS" + }, { + 'azimuth': 290, + 'elevation': 59, + "key": "GP5", + 'prn': 5, + 'snr': 20, + 'status': 'tracking', + "system": "GPS" + }, { + 'azimuth': 157, + 'elevation': 54, + "key": "GP8", + 'prn': 8, + 'snr': 30, + 'status': 'tracking', + "system": "GPS" + } + ], + 'type': 'GSV', + 'valid': true + }, + '$GPRMC,123519,A,4807.038,N,01131.000,E,022.4,084.4,230394,003.1,W*6A': { + 'faa': null, + 'lat': 48.1173, + 'lon': 11.516666666666667, + 'raw': '$GPRMC,123519,A,4807.038,N,01131.000,E,022.4,084.4,230394,003.1,W*6A', + 'speed': 41.4848, + 'status': 'active', + 'time': new Date('2094-03-23T12:35:19.000Z'), + 'track': 84.4, + 'type': 'RMC', + "navStatus": null, + 'valid': true, + 'variation': -3.1 + }, + //'$GPVTG,210.43,T,210.43,M,5.65,N,,,A*67': {}, + '$GPGGA,123519,4807.04,N,1131.00,E,1,8,0.9,545.9,M,46.9,M,,*45': { + 'age': null, + 'alt': 545.9, + 'geoidal': 46.9, + 'hdop': 0.9, + 'lat': 48.117333333333335, + 'lon': 113.01666666666667, + 'quality': 'fix', + 'raw': '$GPGGA,123519,4807.04,N,1131.00,E,1,8,0.9,545.9,M,46.9,M,,*45', + 'satellites': 8, + 'stationID': null, + 'time': new Date(today + 'T12:35:19.000Z'), + 'type': 'GGA', + 'valid': true + }, + '$GPGSA,A,3,12,05,25,29,,,,,,,,,9.4,7.6,5.6*37': { + 'fix': '3D', + 'hdop': 7.6, + 'mode': 'automatic', + 'pdop': 9.4, + 'raw': '$GPGSA,A,3,12,05,25,29,,,,,,,,,9.4,7.6,5.6*37', + "systemId": null, + 'satellites': [ + 12, + 5, + 25, + 29 + ], + "system": "unknown", + 'type': 'GSA', + 'valid': true, + 'vdop': 5.6 + }, + '$GPGSV,3,1,11,03,03,111,00,04,15,270,00,06,01,010,00,13,06,292,00*74': { + 'msgNumber': 1, + 'msgsTotal': 3, + "satsInView": 11, + 'signalId': null, + 'raw': '$GPGSV,3,1,11,03,03,111,00,04,15,270,00,06,01,010,00,13,06,292,00*74', + 'satellites': [ + { + 'azimuth': 111, + 'elevation': 3, + "key": "GP3", + 'prn': 3, + 'snr': 0, + 'status': 'tracking', + "system": "GPS" + }, { + 'azimuth': 270, + 'elevation': 15, + "key": "GP4", + 'prn': 4, + 'snr': 0, + 'status': 'tracking', + "system": "GPS" + }, { + 'azimuth': 10, + 'elevation': 1, + "key": "GP6", + 'prn': 6, + 'snr': 0, + 'status': 'tracking', + "system": "GPS" + }, { + 'azimuth': 292, + 'elevation': 6, + "key": "GP13", + 'prn': 13, + 'snr': 0, + 'status': 'tracking', + "system": "GPS" + } + ], + "system": "GPS", + 'type': 'GSV', + 'valid': true + }, + '$GPGSV,3,2,11,14,25,170,00,16,57,208,39,18,67,296,40,19,40,246,00*2D': { + 'msgNumber': 2, + 'msgsTotal': 3, + "satsInView": 11, + 'signalId': null, + 'raw': '$GPGSV,3,2,11,14,25,170,00,16,57,208,39,18,67,296,40,19,40,246,00*2D', + 'satellites': [ + { + 'azimuth': 170, + 'elevation': 25, + "key": "GP14", + 'prn': 14, + 'snr': 0, + 'status': 'tracking', + "system": "GPS" + }, { + 'azimuth': 208, + 'elevation': 57, + "key": "GP16", + 'prn': 16, + 'snr': 39, + 'status': 'tracking', + "system": "GPS" + }, { + 'azimuth': 296, + 'elevation': 67, + "key": "GP18", + 'prn': 18, + 'snr': 40, + 'status': 'tracking', + "system": "GPS" + }, { + 'azimuth': 246, + 'elevation': 40, + "key": "GP19", + 'prn': 19, + 'snr': 0, + 'status': 'tracking', + "system": "GPS" + } + ], + 'type': 'GSV', + "system": "GPS", + 'valid': false + }, + '$GPGSV,3,2,11,02,39,223,16,13,28,070,17,26,23,252,,04,14,186,15*77': { + 'msgNumber': 2, + 'msgsTotal': 3, + "satsInView": 11, + 'signalId': null, + 'raw': '$GPGSV,3,2,11,02,39,223,16,13,28,070,17,26,23,252,,04,14,186,15*77', + 'satellites': [ + { + 'azimuth': 223, + 'elevation': 39, + "key": "GP2", + 'prn': 2, + 'snr': 16, + 'status': 'tracking', + "system": "GPS" + }, { + 'azimuth': 70, + 'elevation': 28, + "key": "GP13", + 'prn': 13, + 'snr': 17, + 'status': 'tracking', + "system": "GPS" + }, { + 'azimuth': 252, + 'elevation': 23, + "key": "GP26", + 'prn': 26, + 'snr': null, + 'status': 'in view', + "system": "GPS" + }, { + 'azimuth': 186, + 'elevation': 14, + "key": "GP4", + 'prn': 4, + 'snr': 15, + 'status': 'tracking', + "system": "GPS" + } + ], + 'type': 'GSV', + "system": "GPS", + 'valid': true + }, + '$GPGSV,3,3,11,29,09,301,24,16,09,020,,36,,,*76': { + 'msgNumber': 3, + 'msgsTotal': 3, + "satsInView": 11, + 'signalId': null, + 'raw': '$GPGSV,3,3,11,29,09,301,24,16,09,020,,36,,,*76', + 'satellites': [ + { + 'azimuth': 301, + 'elevation': 9, + "key": "GP29", + 'prn': 29, + 'snr': 24, + 'status': 'tracking', + "system": "GPS" + }, { + 'azimuth': 20, + 'elevation': 9, + "key": "GP16", + 'prn': 16, + 'snr': null, + 'status': 'in view', + "system": "GPS" + }, { + 'azimuth': null, + 'elevation': null, + "key": "GP36", + 'prn': 36, + 'snr': null, + 'status': 'in view', + "system": "GPS" + } + ], + 'type': 'GSV', + "system": "GPS", + 'valid': true + }, + '$GPRMC,092750.000,A,5321.6802,N,00630.3372,W,0.02,31.66,280511,,,A*43': { + 'faa': 'autonomous', + 'lat': 53.361336666666666, + 'lon': -6.50562, + 'raw': '$GPRMC,092750.000,A,5321.6802,N,00630.3372,W,0.02,31.66,280511,,,A*43', + 'speed': 0.037040000000000003, + 'status': 'active', + 'time': new Date('2011-05-28T09:27:50.000Z'), + 'track': 31.66, + 'type': 'RMC', + "navStatus": null, + 'valid': true, + 'variation': null + }, + '$GPGGA,092751.000,5321.6802,N,00630.3371,W,1,8,1.03,61.7,M,55.3,M,,*75': { + 'age': null, + 'alt': 61.7, + 'geoidal': 55.3, + 'hdop': 1.03, + 'lat': 53.361336666666666, + 'lon': -6.5056183333333335, + 'quality': 'fix', + 'raw': '$GPGGA,092751.000,5321.6802,N,00630.3371,W,1,8,1.03,61.7,M,55.3,M,,*75', + 'satellites': 8, + 'stationID': null, + 'time': new Date(today + 'T09:27:51.000Z'), + 'type': 'GGA', + 'valid': true + }, + '$GPRMC,092751.000,A,5321.6802,N,00630.3371,W,0.06,31.66,280511,,,A*45': { + 'faa': 'autonomous', + 'lat': 53.361336666666666, + 'lon': -6.5056183333333335, + 'raw': '$GPRMC,092751.000,A,5321.6802,N,00630.3371,W,0.06,31.66,280511,,,A*45', + 'speed': 0.11112, + 'status': 'active', + 'time': new Date('2011-05-28T09:27:51.000Z'), + 'track': 31.66, + 'type': 'RMC', + "navStatus": null, + 'valid': true, + 'variation': null + }, + '$GPGLL,6005.068,N,02332.341,E,095601,A,D*42': { + 'lat': 60.084466666666664, + 'lon': 23.539016666666665, + 'raw': '$GPGLL,6005.068,N,02332.341,E,095601,A,D*42', + 'status': 'active', + 'time': new Date(today + 'T09:56:01.000Z'), + 'type': 'GLL', + 'valid': true, + "faa": "differential" + }, + '$GPGLL,4916.45,N,12311.12,W,225444,A,*1D': { + 'lat': 49.274166666666666, + 'lon': -123.18533333333333, + 'raw': '$GPGLL,4916.45,N,12311.12,W,225444,A,*1D', + 'status': 'active', + 'time': new Date(today + 'T22:54:44.000Z'), + 'type': 'GLL', + 'valid': true, + 'faa': null + }, + '$GPGGA,174815.40,4141.46474,N,00849.77225,W,1,08,1.24,11.8,M,50.5,M,,*76': { + 'age': null, + 'alt': 11.8, + 'geoidal': 50.5, + 'hdop': 1.24, + 'quality': 'fix', + 'satellites': 8, + 'stationID': null, + 'lat': 41.691079, + 'lon': -8.8295375, + 'time': new Date(today + 'T17:48:15.400Z'), + 'raw': '$GPGGA,174815.40,4141.46474,N,00849.77225,W,1,08,1.24,11.8,M,50.5,M,,*76', + 'type': 'GGA', + 'valid': true, + }, + // test with two digits on quality + '$GPGGA,174815.40,4141.46474,N,00849.77225,W,05,08,1.24,11.8,M,50.5,M,,*42': { + 'age': null, + 'alt': 11.8, + 'geoidal': 50.5, + 'hdop': 1.24, + 'quality': 'rtk-float', + 'satellites': 8, + 'stationID': null, + 'lat': 41.691079, + 'lon': -8.8295375, + 'time': new Date(today + 'T17:48:15.400Z'), + 'raw': '$GPGGA,174815.40,4141.46474,N,00849.77225,W,05,08,1.24,11.8,M,50.5,M,,*42', + 'type': 'GGA', + 'valid': true, + }, + '$GPGST,172814.0,0.006,0.023,0.020,273.6,0.023,0.020,0.031*6A': { + 'time': new Date(today + 'T17:28:14.000Z'), + 'rms': 0.006, + 'ellipseMajor': 0.023, + 'ellipseMinor': 0.020, + 'ellipseOrientation': 273.6, + 'latitudeError': 0.023, + 'longitudeError': 0.020, + 'heightError': 0.031, + 'raw': '$GPGST,172814.0,0.006,0.023,0.020,273.6,0.023,0.020,0.031*6A', + 'type': 'GST', + 'valid': true + }, + '$GLGST,172814.0,0.006,0.023,0.020,273.6,0.023,0.020,0.031*76': { + 'time': new Date(today + 'T17:28:14.000Z'), + 'rms': 0.006, + 'ellipseMajor': 0.023, + 'ellipseMinor': 0.020, + 'ellipseOrientation': 273.6, + 'latitudeError': 0.023, + 'longitudeError': 0.020, + 'heightError': 0.031, + 'raw': '$GLGST,172814.0,0.006,0.023,0.020,273.6,0.023,0.020,0.031*76', + 'type': 'GST', + 'valid': true + }, + '$GNGST,172814.0,0.006,0.023,0.020,273.6,0.023,0.020,0.031*74': { + 'time': new Date(today + 'T17:28:14.000Z'), + 'rms': 0.006, + 'ellipseMajor': 0.023, + 'ellipseMinor': 0.020, + 'ellipseOrientation': 273.6, + 'latitudeError': 0.023, + 'longitudeError': 0.020, + 'heightError': 0.031, + 'raw': '$GNGST,172814.0,0.006,0.023,0.020,273.6,0.023,0.020,0.031*74', + 'type': 'GST', + 'valid': true + }, + // add hdt test + '$HEHDT,066.2,T*2D': { + 'heading': 66.2, + 'raw': '$HEHDT,066.2,T*2D', + 'trueNorth': true, + 'type': 'HDT', + 'valid': true + }, + '$GPGGA,023920.476,5230.942,N,01323.025,E,0,12,1.0,0.0,M,0.0,M,,*6E': { + "age": null, + "alt": 0, + "geoidal": 0, + "hdop": 1, + "lat": 52.5157, + "lon": 13.38375, + "quality": null, + "raw": "$GPGGA,023920.476,5230.942,N,01323.025,E,0,12,1.0,0.0,M,0.0,M,,*6E", + "satellites": 12, + "stationID": null, + "time": new Date(today + 'T02:39:20.476Z'), + "type": "GGA", + "valid": false // false because we manually changed fix to 0 + }, + "$GPGSV,3,1,11,02,20,106,26,06,20,072,18,12,77,040,37,14,30,309,25,1*65": { + "msgNumber": 1, + "msgsTotal": 3, + "raw": "$GPGSV,3,1,11,02,20,106,26,06,20,072,18,12,77,040,37,14,30,309,25,1*65", + "satellites": [ + { + "azimuth": 106, + "elevation": 20, + "key": "GP2", + "prn": 2, + "snr": 26, + "status": "tracking", + "system": "GPS" + }, { + "azimuth": 72, + "elevation": 20, + "key": "GP6", + "prn": 6, + "snr": 18, + "status": "tracking", + "system": "GPS" + }, { + "azimuth": 40, + "elevation": 77, + "key": "GP12", + "prn": 12, + "snr": 37, + "status": "tracking", + "system": "GPS" + }, { + "azimuth": 309, + "elevation": 30, + "key": "GP14", + "prn": 14, + "snr": 25, + "status": "tracking", + "system": "GPS" + } + ], + "satsInView": 11, + "signalId": 1, + "system": "GPS", + "type": "GSV", + "valid": true + }, + "$GAGSV,3,3,09,33,11,027,,7*4F": { + "msgNumber": 3, + "msgsTotal": 3, + "raw": "$GAGSV,3,3,09,33,11,027,,7*4F", + "satellites": [ + { + "azimuth": 27, + "elevation": 11, + "key": "GA33", + "prn": 33, + "snr": null, + "status": "in view", + "system": "Galileo" + } + ], + "satsInView": 9, + "signalId": 7, + "system": "Galileo", + "type": "GSV", + "valid": true + }, + "$GPGSV,3,1,12,02,22,103,,03,00,357,,06,21,068,18,12,73,046,32,6*66": { + "msgNumber": 1, + "msgsTotal": 3, + "raw": "$GPGSV,3,1,12,02,22,103,,03,00,357,,06,21,068,18,12,73,046,32,6*66", + "satellites": [ + { + "azimuth": 103, + "elevation": 22, + "key": "GP2", + "prn": 2, + "snr": null, + "status": "in view", + "system": "GPS" + }, { + "azimuth": 357, + "elevation": 0, + "key": "GP3", + "prn": 3, + "snr": null, + "status": "in view", + "system": "GPS" + }, { + "azimuth": 68, + "elevation": 21, + "key": "GP6", + "prn": 6, + "snr": 18, + "status": "tracking", + "system": "GPS" + }, { + "azimuth": 46, + "elevation": 73, + "key": "GP12", + "prn": 12, + "snr": 32, + "status": "tracking", + "system": "GPS" + } + ], + "satsInView": 12, + "signalId": 6, + "system": "GPS", + "type": "GSV", + "valid": true + }, + "$GAGSV,3,1,11,02,49,285,30,03,22,221,29,07,12,328,,08,32,278,35,7*74": { + "msgNumber": 1, + "msgsTotal": 3, + "raw": "$GAGSV,3,1,11,02,49,285,30,03,22,221,29,07,12,328,,08,32,278,35,7*74", + "satellites": [ + { + "azimuth": 285, + "elevation": 49, + "key": "GA2", + "prn": 2, + "snr": 30, + "status": "tracking", + "system": "Galileo" + }, { + "azimuth": 221, + "elevation": 22, + "key": "GA3", + "prn": 3, + "snr": 29, + "status": "tracking", + "system": "Galileo" + }, { + "azimuth": 328, + "elevation": 12, + "key": "GA7", + "prn": 7, + "snr": null, + "status": "in view", + "system": "Galileo" + }, { + "azimuth": 278, + "elevation": 32, + "key": "GA8", + "prn": 8, + "snr": 35, + "status": "tracking", + "system": "Galileo" + } + ], + "satsInView": 11, + "signalId": 7, + "type": "GSV", + "system": "Galileo", + "valid": true + }, + "$GBGSV,1,1,04,13,31,064,,21,12,255,,26,18,293,27,29,46,155,31,1*78": { + "msgNumber": 1, + "msgsTotal": 1, + "raw": "$GBGSV,1,1,04,13,31,064,,21,12,255,,26,18,293,27,29,46,155,31,1*78", + "satellites": [ + { + "azimuth": 64, + "elevation": 31, + "key": "GB13", + "prn": 13, + "snr": null, + "status": "in view", + "system": "BeiDou" + }, { + "azimuth": 255, + "elevation": 12, + "key": "GB21", + "prn": 21, + "snr": null, + "status": "in view", + "system": "BeiDou" + }, { + "azimuth": 293, + "elevation": 18, + "key": "GB26", + "prn": 26, + "snr": 27, + "status": "tracking", + "system": "BeiDou" + }, { + "azimuth": 155, + "elevation": 46, + "key": "GB29", + "prn": 29, + "snr": 31, + "status": "tracking", + "system": "BeiDou" + } + ], + "satsInView": 4, + "system": "BeiDou", + "signalId": 1, + "type": "GSV", + "valid": true + }, + "$GNRMC,191029.00,A,4843.01033,N,00227.78756,E,0.024,,010319,,,A,V*1C": { + "faa": "autonomous", + "lat": 48.716838833333334, + "lon": 2.463126, + "navStatus": "V", + "raw": "$GNRMC,191029.00,A,4843.01033,N,00227.78756,E,0.024,,010319,,,A,V*1C", + "speed": 0.044448, + "status": "active", + "time": new Date("2019-03-01T19:10:29.000Z"), + "track": null, + "type": "RMC", + "valid": true, + "variation": null + }, + "$GNGSA,A,3,25,29,31,26,16,21,,,,,,,1.55,0.84,1.30,1*00": { + "fix": "3D", + "hdop": 0.84, + "mode": "automatic", + "system": "GPS", + "systemId": 1, + "pdop": 1.55, + "raw": "$GNGSA,A,3,25,29,31,26,16,21,,,,,,,1.55,0.84,1.30,1*00", + "satellites": [ + 25, + 29, + 31, + 26, + 16, + 21 + ], + "type": "GSA", + "valid": true, + "vdop": 1.3 + }, + '$GPRMC,085542.023,V,,,,,,,041211,,,N*45': { + "faa": "not valid", + "lat": null, + "lon": null, + "navStatus": null, + "raw": "$GPRMC,085542.023,V,,,,,,,041211,,,N*45", + "speed": null, + "status": "void", + "time": new Date('2011-12-04T08:55:42.023Z'), + "track": null, + "type": "RMC", + "valid": true, + "variation": null + }, + '$GPGGA,100313.99,3344.459045,N,09639.616711,W,1,05,0.0,220.9,M,0.0,M,0.0,0000*66': { + "age": 0, + "alt": 220.9, + "geoidal": 0, + "hdop": 0, + "lat": 33.74098408333333, + "lon": -96.66027851666666, + "quality": "fix", + "raw": "$GPGGA,100313.99,3344.459045,N,09639.616711,W,1,05,0.0,220.9,M,0.0,M,0.0,0000*66", + "satellites": 5, + "stationID": 0, + "time": new Date(today + 'T10:03:13.990Z'), + "type": "GGA", + "valid": true + }, + '$GNGRS,112423.00,1,-0.1,-0.4,5.6,-4.3,1.4,-0.2,,,,,,,1,1*51': { + "mode": 1, + "raw": "$GNGRS,112423.00,1,-0.1,-0.4,5.6,-4.3,1.4,-0.2,,,,,,,1,1*51", + "res": [ + -0.1, + -0.4, + 5.6, + -4.3, + 1.4, + -0.2 + ], + "time": new Date(today + 'T11:24:23.000Z'), + "type": "GRS", + "valid": true + }, + '$GNGRS,112423.00,1,0.0,0.0,0.0,-6.4,-1.2,,,,,,,,1,6*7F': { + "mode": 1, + "raw": "$GNGRS,112423.00,1,0.0,0.0,0.0,-6.4,-1.2,,,,,,,,1,6*7F", + "res": [ + 0, + 0, + 0, + -6.4, + -1.2 + ], + "time": new Date(today + 'T11:24:23.000Z'), + "type": "GRS", + "valid": true + }, + '$GNGRS,112423.00,1,-2.5,0.8,0.2,7.2,6.2,,,,,,,,2,1*5B': { + "mode": 1, + "raw": "$GNGRS,112423.00,1,-2.5,0.8,0.2,7.2,6.2,,,,,,,,2,1*5B", + "res": [ + -2.5, + 0.8, + 0.2, + 7.2, + 6.2 + ], + "time": new Date(today + 'T11:24:23.000Z'), + "type": "GRS", + "valid": true + }, + '$GNGBS,112424.00,2.5,1.5,5.4,,,,,,*5D': { + "raw": "$GNGBS,112424.00,2.5,1.5,5.4,,,,,,*5D", + "type": "GBS", + "time": new Date(today + 'T11:24:24.000Z'), + "errLat": 2.5, + "errLon": 1.5, + "errAlt": 5.4, + "failedSat": null, + "probFailedSat": null, + "biasFailedSat": null, + "stdFailedSat": null, + "valid": true, + "systemId": null, + "signalId": null + }, + '$GPGBS,015509.00,-0.031,-0.186,0.219,19,0.000,-0.354,6.972*4D': { + "raw": "$GPGBS,015509.00,-0.031,-0.186,0.219,19,0.000,-0.354,6.972*4D", + "type": "GBS", + "time": new Date(today + 'T01:55:09.000Z'), + "errLat": -0.031, + "errLon": -0.186, + "errAlt": 0.219, + "failedSat": 19, + "probFailedSat": 0, + "biasFailedSat": -0.354, + "stdFailedSat": 6.972, + "valid": true, + "systemId": null, + "signalId": null + }, + '$GNGSA,A,3,24,12,19,15,,,,,,,,,5.27,3.57,3.87,1*05': { + "fix": "3D", + "hdop": 3.57, + "mode": "automatic", + "pdop": 5.27, + "raw": "$GNGSA,A,3,24,12,19,15,,,,,,,,,5.27,3.57,3.87,1*05", + "satellites": [ + 24, + 12, + 19, + 15 + ], + "systemId": 1, + "system": "GPS", + "type": "GSA", + "valid": true, + "vdop": 3.87 + }, + '$GNGNS,133216.00,4843.01093,N,00227.78866,E,ANNN,04,3.57,55.4,46.3,,,V*29': { + "raw": "$GNGNS,133216.00,4843.01093,N,00227.78866,E,ANNN,04,3.57,55.4,46.3,,,V*29", + "type": "GNS", + "time": new Date(today + 'T13:32:16.000Z'), + "valid": true, + "alt": 55.4, + "diffAge": null, + "diffStation": null, + "hdop": 3.57, + "lat": 48.71684883333333, + "lon": 2.463144333333333, + "mode": "ANNN", + "navStatus": "V", + "satsUsed": 4, + "sep": 46.3 + }, + '$GPGLL,5000.05254,N,04500.02356,E,090037.059,A*35': { + 'faa': null, + "lat": 50.000875666666666, + "lon": 45.00039266666667, + "raw": "$GPGLL,5000.05254,N,04500.02356,E,090037.059,A*35", + "status": "active", + "type": "GLL", + "valid": true, + "time": new Date(today + 'T09:00:37.059Z'), + }, + '$GPGGA,033016,1227.2470,S,13050.8514,E,2,6,0.9,11.8,M,,M*4A': { + "age": 4, + "alt": 11.8, + "geoidal": null, + "hdop": 0.9, + "lat": -12.454116666666666, + "lon": 130.84752333333333, + "quality": "dgps-fix", + "raw": "$GPGGA,033016,1227.2470,S,13050.8514,E,2,6,0.9,11.8,M,,M*4A", + "satellites": 6, + "stationID": null, + "time": new Date(today + 'T03:30:16.000Z'), + "type": "GGA", + "valid": true + }, + '$GPGGA,033631,1227.2473,S,13050.8504,E,2,6,0.9,7.4,M,,M*70': { + "age": 70, + "alt": 7.4, + "geoidal": null, + "hdop": 0.9, + "lat": -12.454121666666667, + "lon": 130.84750666666667, + "quality": "dgps-fix", + "raw": "$GPGGA,033631,1227.2473,S,13050.8504,E,2,6,0.9,7.4,M,,M*70", + "satellites": 6, + "stationID": null, + "time": new Date(today + 'T03:36:31.000Z'), + "type": "GGA", + "valid": true + }, + '$GPGGA,034030,1227.2475,S,13050.8528,E,2,6,0.9,8.1,M,,M*72': { + "age": 72, + "alt": 8.1, + "geoidal": null, + "hdop": 0.9, + "lat": -12.454125, + "lon": 130.84754666666666, + "quality": "dgps-fix", + "raw": "$GPGGA,034030,1227.2475,S,13050.8528,E,2,6,0.9,8.1,M,,M*72", + "satellites": 6, + "stationID": null, + "time": new Date(today + 'T03:40:30.000Z'), + "type": "GGA", + "valid": true + }, + '$BDGSV,4,1,16,01,,,37,02,,,38,03,,,39,05,,,37,0,4*6A': { + "msgNumber": 1, + "msgsTotal": 4, + "raw": "$BDGSV,4,1,16,01,,,37,02,,,38,03,,,39,05,,,37,0,4*6A", + "satellites": [{ + "azimuth": null, + "elevation": null, + "key": "BD1", + "prn": 1, + "snr": 37, + "status": "tracking", + "system": "BD" + }, { + "azimuth": null, + "elevation": null, + "key": "BD2", + "prn": 2, + "snr": 38, + "status": "tracking", + "system": "BD" + }, { + "azimuth": null, + "elevation": null, + "key": "BD3", + "prn": 3, + "snr": 39, + "status": "tracking", + "system": "BD" + }, { + "azimuth": null, + "elevation": null, + "key": "BD5", + "prn": 5, + "snr": 37, + "status": "tracking", + "system": "BD" + }], + "satsInView": 16, + "system": "BD", + "signalId": null, + "type": "GSV", + "valid": true + }, + '$BDGSV,1,1,03,10,46,329,31,08,43,161,,09,40,217,*52': { + "msgNumber": 1, + "msgsTotal": 1, + "raw": "$BDGSV,1,1,03,10,46,329,31,08,43,161,,09,40,217,*52", + "satellites": [{ + "azimuth": 329, + "elevation": 46, + "key": "BD10", + "prn": 10, + "snr": 31, + "status": "tracking", + "system": "BD" + }, { + "azimuth": 161, + "elevation": 43, + "key": "BD8", + "prn": 8, + "snr": null, + "status": "in view", + "system": "BD" + }, { + "azimuth": 217, + "elevation": 40, + "key": "BD9", + "prn": 9, + "snr": null, + "status": "in view", + "system": "BD" + }], + "satsInView": 3, + "signalId": null, + "system": "BD", + "type": "GSV", + "valid": true + }, + '$BDGSV,2,1,06,211,18,305,36,205,07,113,,206,04,029,,209,30,046,*67': { + "msgNumber": 1, + "msgsTotal": 2, + "raw": "$BDGSV,2,1,06,211,18,305,36,205,07,113,,206,04,029,,209,30,046,*67", + "satellites": [{ + "azimuth": 305, + "elevation": 18, + "key": "BD211", + "prn": 211, + "snr": 36, + "status": "tracking", + "system": "BD" + }, { + "azimuth": 113, + "elevation": 7, + "key": "BD205", + "prn": 205, + "snr": null, + "status": "in view", + "system": "BD" + }, { + "azimuth": 29, + "elevation": 4, + "key": "BD206", + "prn": 206, + "snr": null, + "status": "in view", + "system": "BD" + }, { + "azimuth": 46, + "elevation": 30, + "key": "BD209", + "prn": 209, + "snr": null, + "status": "in view", + "system": "BD" + }], + "satsInView": 6, + "system": "BD", + "signalId": null, + "type": "GSV", + "valid": true + }, + '$GNTXT,01,01,02,PF=3FF*4B': { + "completed": true, + "id": 2, + "index": 1, + "message": "PF=3FF", + "part": 'PF=3FF', + "raw": "$GNTXT,01,01,02,PF=3FF*4B", + "rawMessages": [ + "PF=3FF", + ], + system: 'GN', + "total": 1, + "type": "TXT", + "valid": true + }, + '$GNTXT,01,01,02,ANTSTATUS=OK*25': { + "completed": true, + "id": 2, + "index": 1, + "message": "ANTSTATUS=OK", + "part": 'ANTSTATUS=OK', + "raw": "$GNTXT,01,01,02,ANTSTATUS=OK*25", + "rawMessages": [ + "ANTSTATUS=OK", + ], + "system": 'GN', + "total": 1, + "type": "TXT", + "valid": true + }, + '$GNTXT,01,01,02,LLC=FFFFFFFF-FFFFFFFF-FFFFFFFF-FFFFFFFF-FFFFFFFD*2F': { + "completed": true, + "id": 2, + "index": 1, + "message": "LLC=FFFFFFFF-FFFFFFFF-FFFFFFFF-FFFFFFFF-FFFFFFFD", + "part": '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", + ], + "system": 'GN', + "total": 1, + "type": "TXT", + "valid": true + }, + '$GNTXT,01,01,02,some escape chars: ^21*2F': { + "completed": true, + "id": 2, + "index": 1, + "message": "some escape chars: !", + "part": "some escape chars: !", + "raw": "$GNTXT,01,01,02,some escape chars: ^21*2F", + "rawMessages": [ + "some escape chars: !", + ], + "system": 'GN', + "total": 1, + "type": "TXT", + "valid": false + }, + '$GNTXT,02,01,02,a multipart message^2C this is part 1^0D^0A*34': { + "completed": false, + "id": 2, + "index": 1, + "message": null, + "part": "a multipart message, this is part 1\r\n", + "raw": "$GNTXT,02,01,02,a multipart message^2C this is part 1^0D^0A*34", + "rawMessages": [], + "system": 'GN', + "total": 2, + "type": "TXT", + "valid": true + }, + '$GNTXT,02,02,02,a multipart message^2C this is part 2^0D^0A*34': { + "completed": true, + "id": 2, + "index": 2, + "message": "a multipart message, this is part 1\r\na multipart message, this is part 2\r\n", + "part": 'a 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", + ], + "system": 'GN', + "total": 2, + "type": "TXT", + "valid": true + } +}; +var collect = {}; +gps.on('data', function (data) { + + collect[data.raw] = data; +}); +for (var i in tests) { + + if (!gps.update(i)) { + collect[i] = 'invalid'; + } +} + +describe('NMEA syntax', function () { + + for (var i in collect) { + + (function (i) { + + it('Should pass ' + i, function () { + assert.deepEqual(collect[i], tests[i]); + }); + })(i); + } +}); +/* + $IIDBT,036.41,f,011.10,M,005.99,F*25 + $IIMWV,017,R,02.91,N,A*2F + $XXMWV,017.00,R,2.91,N,A*31 + $IIVTG,210.43,T,210.43,M,5.65,N,,,A*67 + $XXVTG,210.43,T,209.43,M,2.91,N,,,A*63 + $GPGGA,123519,4807.038,N,01131.000,E,1,08,0.9,545.4,M,46.9,M,,*47 + '$GPGSA,A,1,,,,,,,,,,,,,,,*1E', + '$GPGSV,3,1,12,29,75,266,39,05,48,047,,26,43,108,,15,35,157,*78', + '$GPGSV,3,2,12,21,30,292,,18,21,234,,02,18,093,,25,13,215,*7F', + '$GPGSV,3,3,12,30,11,308,,16,,333,,12,,191,,07,-4,033,*62', + '$GPGGA,085543.023,,,,,0,00,,,M,0.0,M,,0000*58', + '$IIBWC,160947,6008.160,N,02454.290,E,162.4,T,154.3,M,001.050,N,DEST*1C', + '$IIAPB,A,A,0.001,L,N,V,V,154.3,M,DEST,154.3,M,154.2,M*19' + $GPGGA,092750.000,5321.6802,N,00630.3372,W,1,8,1.03,61.7,M,55.2,M,,*76 + $GPGGA,181650.692,7204.589,N,01915.106,W,0,00,,,M,,M,,*59 + $GPGGA,092751.000,5321.6802,N,00630.3371,W,1,8,1.03,61.7,M,55.3,M,,*75 + $GPGGA,181514.692,4951.923,S,03050.357,W,0,00,,,M,,M,,*4F + */ diff --git a/tests/partial.js b/tests/partial.js index 02a23ad..476d896 100644 --- a/tests/partial.js +++ b/tests/partial.js @@ -1,57 +1,57 @@ - -const GPS = require('gps'); -const assert = require('assert'); -const gps = new GPS; - -const res = [{ - 'lat': 48.539856666666665, - 'lon': 9.059166666666666, - 'speed': 4.22256, - 'status': 'active', - 'time': new Date('2016-01-26T23:49:19.000Z'), - 'track': 2.93, - 'raw': '$GPRMC,234919.000,A,4832.3914,N,00903.5500,E,2.28,2.93,260116,,*0D', - 'type': 'RMC', - 'faa': null, - "navStatus": null, - 'valid': true, - 'variation': null -}, { - 'speed': 4.22256, - 'track': 2.93, - 'trackMagnetic': null, - 'raw': '$GPVTG,2.93,T,,M,2.28,N,4.2,K*66', - 'type': 'VTG', - 'faa': null, - 'valid': true -} -]; - -describe('partial updates', function () { - - it('should work async with partial updates', function (done) { - - var K = 0; - - gps.on('data', function (data) { - - try { - assert.deepEqual(data, res[K++]); - } catch (e) { - done(e); - return; - } - - if (K === res.length) { - done(); - return; - } - }); - - gps.updatePartial("6,,*0D\r\n$GPRMC,234919.000"); - gps.updatePartial(",A,4832.3914,N,00903.5500"); - gps.updatePartial(",E,2.28,2.93,260116,,*0D\r\n$GPVTG,2."); - gps.updatePartial("93,T,,M,2.28,N,4.2,K*66\r\nfoo"); - - }); -}); + +const GPS = require('gps'); +const assert = require('assert'); +const gps = new GPS; + +const res = [{ + 'lat': 48.539856666666665, + 'lon': 9.059166666666666, + 'speed': 4.22256, + 'status': 'active', + 'time': new Date('2016-01-26T23:49:19.000Z'), + 'track': 2.93, + 'raw': '$GPRMC,234919.000,A,4832.3914,N,00903.5500,E,2.28,2.93,260116,,*0D', + 'type': 'RMC', + 'faa': null, + "navStatus": null, + 'valid': true, + 'variation': null +}, { + 'speed': 4.22256, + 'track': 2.93, + 'trackMagnetic': null, + 'raw': '$GPVTG,2.93,T,,M,2.28,N,4.2,K*66', + 'type': 'VTG', + 'faa': null, + 'valid': true +} +]; + +describe('partial updates', function () { + + it('should work async with partial updates', function (done) { + + var K = 0; + + gps.on('data', function (data) { + + try { + assert.deepEqual(data, res[K++]); + } catch (e) { + done(e); + return; + } + + if (K === res.length) { + done(); + return; + } + }); + + gps.updatePartial("6,,*0D\r\n$GPRMC,234919.000"); + gps.updatePartial(",A,4832.3914,N,00903.5500"); + gps.updatePartial(",E,2.28,2.93,260116,,*0D\r\n$GPVTG,2."); + gps.updatePartial("93,T,,M,2.28,N,4.2,K*66\r\nfoo"); + + }); +});