/**
* NmeaParser - Parses NMEA sentences from the GNSS receiver
*/
export class NmeaParser {
constructor(options = {}) {
this.lastPosition = null;
this.lastSatellites = [];
this.satellitesById = {};
this.buffer = '';
this.sentenceStats = {
GGA: 0,
GSA: 0,
GSV: 0,
RMC: 0,
GST: 0,
VTG: 0,
UNKNOWN: 0
};
this.lastSentenceTime = Date.now();
// Store the event emitter if provided
this.events = options.events || null;
// Debug settings
this.debug = options.debug || {
info: false,
debug: false,
errors: true,
parsedSentences: false,
rtcmMessages: false
};
// Set up logger functions
this.logger = {
info: (...args) => {
if (this.debug.info) {
console.info('[NMEA-INFO]', ...args);
}
},
debug: (...args) => {
if (this.debug.debug) {
console.debug('[NMEA-DEBUG]', ...args);
}
},
error: (...args) => {
if (this.debug.errors) {
console.error('[NMEA-ERROR]', ...args);
}
},
warn: (...args) => {
if (this.debug.errors) {
console.warn('[NMEA-WARN]', ...args);
}
},
parsedSentence: (...args) => {
if (this.debug.parsedSentences) {
console.log('[NMEA-PARSED]', ...args);
}
}
};
}
/**
* Parse data received from the device
* @param {string|ArrayBuffer} data - Raw data from receiver
* @returns {Object} Parsed NMEA data
*/
parseData(data) {
// Convert ArrayBuffer to string if needed
let stringData = '';
if (data instanceof ArrayBuffer) {
stringData = new TextDecoder().decode(data);
} else {
stringData = data;
}
// Add to buffer and process any complete sentences
this.buffer += stringData;
return this.processBuffer();
}
/**
* Alias for parseData for backward compatibility
* @param {string|ArrayBuffer} data - Raw data from receiver
* @returns {Object} Parsed NMEA data
*/
parse(data) {
return this.parseData(data);
}
/**
* Process the current buffer for complete NMEA sentences
* @returns {Object[]} Array of parsed NMEA objects
*/
processBuffer() {
// Handle different line endings (CRLF, LF, CR)
const sentences = this.buffer.split(/\r\n|\n|\r/);
// Keep the last potentially incomplete sentence in the buffer
this.buffer = sentences.pop() || '';
const results = [];
let positionUpdated = false;
let satellitesUpdated = false;
for (const sentence of sentences) {
if (sentence.trim() === '') continue;
try {
const parsed = this.parseSentence(sentence);
if (parsed) {
results.push(parsed);
// Check if position data has been updated
if ((parsed.type === 'GGA' || parsed.type === 'RMC') && this.lastPosition) {
positionUpdated = true;
}
// Check if satellite data has been updated
if (parsed.type === 'GSV' && parsed.messageNumber === parsed.totalMessages) {
satellitesUpdated = true;
}
}
} catch (error) {
this.logger.error('Error parsing NMEA sentence:', error, sentence);
}
}
// Emit position event if we have new position data and an event emitter
if (positionUpdated && this.events) {
const position = this.getPosition();
if (position) {
// Add a timestamp for convenience
const positionWithTimestamp = {
...position,
timestamp: new Date()
};
this.events.emit('nmea:position', positionWithTimestamp);
}
}
// Emit satellites event if we have new satellite data and an event emitter
if (satellitesUpdated && this.events) {
const satellites = this.getSatellites();
if (satellites && satellites.length > 0) {
this.events.emit('nmea:satellites', satellites);
}
}
return results;
}
/**
* Parse a single NMEA sentence
* @param {string} sentence - NMEA sentence
* @returns {Object|null} Parsed data or null if invalid
*/
parseSentence(sentence) {
try {
// Basic validation
if (!sentence || typeof sentence !== 'string') {
this.logger.debug('Invalid NMEA sentence (not a string):', sentence);
return null;
}
sentence = sentence.trim();
if (!sentence.startsWith('$') || sentence.length < 9) {
this.logger.debug('Invalid NMEA sentence format:', sentence);
return null;
}
// Check for checksum
const asteriskIndex = sentence.indexOf('*');
if (asteriskIndex === -1) {
this.logger.debug('Missing checksum in NMEA sentence:', sentence);
return null;
}
// Check checksum
if (!this.validateChecksum(sentence)) {
this.logger.debug('Invalid NMEA checksum:', sentence);
return null;
}
// Log raw sentence for debugging
this.logger.parsedSentence('Valid NMEA sentence:', sentence);
// Split the sentence by commas, removing the '$' and checksum
let parts = sentence.substring(1, asteriskIndex).split(',');
if (parts.length < 1) {
this.logger.debug('Invalid NMEA sentence structure:', sentence);
return null;
}
const sentenceType = parts[0];
if (!sentenceType || sentenceType.length < 3) {
this.logger.debug('Invalid NMEA sentence type:', sentenceType);
return null;
}
// Extract type without prefix (e.g., GPGGA -> GGA)
const typeWithoutPrefix = sentenceType.substring(2);
this.logger.parsedSentence(`Parsing NMEA sentence type: ${sentenceType} (${typeWithoutPrefix})`);
// Parse different sentence types
let result;
switch (sentenceType) {
case 'GPGGA':
case 'GNGGA':
case 'BDGGA':
case 'GLGGA':
result = this.parseGGA(parts);
break;
case 'GPGSA':
case 'GNGSA':
case 'BDGSA':
case 'GLGSA':
result = this.parseGSA(parts);
break;
case 'GPGSV':
case 'GNGSV':
case 'BDGSV':
case 'GLGSV':
result = this.parseGSV(parts);
break;
case 'GPRMC':
case 'GNRMC':
case 'BDRMC':
case 'GLRMC':
result = this.parseRMC(parts);
break;
case 'GPGST':
case 'GNGST':
case 'BDGST':
case 'GLGST':
result = this.parseGST(parts);
break;
case 'GPVTG':
case 'GNVTG':
case 'BDVTG':
case 'GLVTG':
result = this.parseVTG(parts);
break;
default: {
// For unknown sentence types, extract the last part of the type
// Common prefixes: GP = GPS, GN = GNSS, BD = BeiDou, GL = GLONASS, GA = Galileo
const match = sentenceType.match(/^(GP|GN|BD|GL|GA)(.+)$/);
const type = match ? match[2] : typeWithoutPrefix;
result = {
type,
raw: sentence
};
break;
}
}
// Add raw data for reference
if (result) {
result.raw = sentence;
// Update sentence statistics
if (result.type) {
if (this.sentenceStats.hasOwnProperty(result.type)) {
this.sentenceStats[result.type]++;
} else {
this.sentenceStats.UNKNOWN++;
}
}
// Calculate data rate
const now = Date.now();
const elapsed = now - this.lastSentenceTime;
if (elapsed > 0) {
result.dataRate = parseFloat((1000 / elapsed).toFixed(2)); // sentences per second
}
this.lastSentenceTime = now;
}
return result;
} catch (error) {
this.logger.error('Unexpected error parsing NMEA sentence:', error, sentence);
return null;
}
}
/**
* Validate NMEA checksum
* @param {string} sentence - NMEA sentence
* @returns {boolean} Whether checksum is valid
*/
validateChecksum(sentence) {
// Extract checksum from the sentence
const asteriskIndex = sentence.indexOf('*');
if (asteriskIndex === -1 || asteriskIndex === sentence.length - 1) {
return false;
}
const checksumString = sentence.substring(asteriskIndex + 1);
const expectedChecksum = parseInt(checksumString, 16);
// Calculate checksum by XORing all bytes between $ and *
let calculatedChecksum = 0;
for (let i = 1; i < asteriskIndex; i++) {
calculatedChecksum ^= sentence.charCodeAt(i);
}
return calculatedChecksum === expectedChecksum;
}
/**
* Parse GGA sentence (Global Positioning System Fix Data)
* @param {string[]} parts - Sentence parts
* @returns {Object} Parsed GGA data
*/
parseGGA(parts) {
const latitude = this.parseLatitude(parts[2], parts[3]);
const longitude = this.parseLongitude(parts[4], parts[5]);
const fixQuality = parseInt(parts[6] || '0');
const satellites = parseInt(parts[7] || '0');
const hdop = parseFloat(parts[8] || '0');
const altitude = parts[9] ? parseFloat(parts[9]) : null;
// Update the last position if coordinates are valid
if (latitude !== null && longitude !== null) {
this.lastPosition = {
latitude,
longitude,
fixQuality,
satellites,
hdop,
altitude,
// Add other position details
altitudeUnits: parts[10],
geoidHeight: parts[11] ? parseFloat(parts[11]) : null,
geoidHeightUnits: parts[12],
dgpsAge: parts[13] ? parseFloat(parts[13]) : null,
dgpsStation: parts[14]
};
}
return {
type: 'GGA',
time: parts[1],
latitude,
longitude,
fixQuality,
satellites,
hdop,
altitude,
altitudeUnits: parts[10],
geoidHeight: parts[11] ? parseFloat(parts[11]) : null,
geoidHeightUnits: parts[12],
dgpsAge: parts[13] ? parseFloat(parts[13]) : null,
dgpsStation: parts[14]
};
}
/**
* Parse GSA sentence (GPS DOP and active satellites)
* @param {string[]} parts - Sentence parts
* @returns {Object} Parsed GSA data
*/
parseGSA(parts) {
const satellites = [];
// Extract satellite IDs (parts 3-14)
for (let i = 3; i <= 14; i++) {
if (parts[i] && parts[i].trim() !== '') {
satellites.push(parseInt(parts[i]));
}
}
return {
type: 'GSA',
mode: parts[1],
fixType: parseInt(parts[2] || '1'),
satellites,
pdop: parseFloat(parts[15] || '0'),
hdop: parseFloat(parts[16] || '0'),
vdop: parseFloat(parts[17] || '0')
};
}
/**
* Parse GSV sentence (GPS Satellites in view)
* @param {string[]} parts - Sentence parts
* @returns {Object} Parsed GSV data
*/
parseGSV(parts) {
const currentMessageSatellites = [];
// Total number of messages, message number, total satellites in view
const totalMessages = parseInt(parts[1] || '1');
const messageNumber = parseInt(parts[2] || '1');
const satellitesInView = parseInt(parts[3] || '0');
// Handle first message in set
if (messageNumber === 1) {
// Clear existing satellites if this is a new set of messages
this.satellitesById = {};
}
// Each satellite block is 4 parts: PRN, elevation, azimuth, SNR
const numSatellitesInMessage = Math.min(4, Math.floor((parts.length - 4) / 4));
for (let i = 0; i < numSatellitesInMessage; i++) {
const baseIndex = 4 + (i * 4);
// Some receivers may not send all 4 values for each satellite
if (baseIndex + 3 < parts.length) {
const prn = parseInt(parts[baseIndex] || '0');
if (prn === 0) continue; // Skip invalid PRNs
const satellite = {
prn,
elevation: parseInt(parts[baseIndex + 1] || '0'),
azimuth: parseInt(parts[baseIndex + 2] || '0'),
snr: parts[baseIndex + 3] ? parseInt(parts[baseIndex + 3]) : null
};
// Store satellite by PRN in our tracking object
this.satellitesById[prn] = satellite;
// Add to current message list
currentMessageSatellites.push(satellite);
}
}
// Rebuild full satellite list after processing all messages
if (messageNumber === totalMessages) {
this.lastSatellites = Object.values(this.satellitesById);
}
return {
type: 'GSV',
totalMessages,
messageNumber,
satellitesInView,
satellites: currentMessageSatellites
};
}
/**
* Parse RMC sentence (Recommended Minimum Navigation Information)
* @param {string[]} parts - Sentence parts
* @returns {Object} Parsed RMC data
*/
parseRMC(parts) {
const latitude = this.parseLatitude(parts[3], parts[4]);
const longitude = this.parseLongitude(parts[5], parts[6]);
const speed = parts[7] ? parseFloat(parts[7]) : null; // Speed over ground in knots
const course = parts[8] ? parseFloat(parts[8]) : null; // Course in degrees
// Extract date components
let date = null;
if (parts[9] && parts[9].length === 6) {
const day = parts[9].substring(0, 2);
const month = parts[9].substring(2, 4);
const year = '20' + parts[9].substring(4, 6); // Assuming 20xx years
date = `${year}-${month}-${day}`;
}
// Extract time components
let time = null;
if (parts[1] && parts[1].length >= 6) {
const hours = parts[1].substring(0, 2);
const minutes = parts[1].substring(2, 4);
const seconds = parts[1].substring(4);
time = `${hours}:${minutes}:${seconds}`;
}
// Update the last position if coordinates are valid and status is active
if (latitude !== null && longitude !== null && parts[2] === 'A') {
// Preserve the existing data like altitude and fix quality
// that might have come from GGA sentences
const currentPosition = this.lastPosition || {};
this.lastPosition = {
...currentPosition,
latitude,
longitude,
// Add RMC-specific data
status: parts[2],
speed,
course,
date,
time,
// The RMC sentence has a mode indicator too
mode: parts[12]
};
}
return {
type: 'RMC',
time,
status: parts[2], // A=active, V=void
latitude,
longitude,
speed, // Speed over ground in knots
course, // Course in degrees
date,
magneticVariation: parts[10] ? parseFloat(parts[10]) : null,
magneticVariationDirection: parts[11],
mode: parts[12] // A=autonomous, D=differential, E=estimated
};
}
/**
* Parse GST sentence (GPS Pseudorange Noise Statistics)
* @param {string[]} parts - Sentence parts
* @returns {Object} Parsed GST data
*/
parseGST(parts) {
return {
type: 'GST',
time: parts[1],
rms: parseFloat(parts[2] || '0'), // RMS value of the standard deviation of the range inputs
semiMajorError: parseFloat(parts[3] || '0'), // Standard deviation of semi-major axis
semiMinorError: parseFloat(parts[4] || '0'), // Standard deviation of semi-minor axis
orientationError: parseFloat(parts[5] || '0'), // Orientation of semi-major axis
latitudeError: parseFloat(parts[6] || '0'), // Standard deviation of latitude error
longitudeError: parseFloat(parts[7] || '0'), // Standard deviation of longitude error
heightError: parseFloat(parts[8] || '0') // Standard deviation of height error
};
}
/**
* Parse VTG sentence (Course Over Ground and Ground Speed)
* @param {string[]} parts - Sentence parts
* @returns {Object} Parsed VTG data
*/
parseVTG(parts) {
return {
type: 'VTG',
courseTrue: parts[1] ? parseFloat(parts[1]) : null, // Course over ground (true)
trueCourseRef: parts[2], // T = True
courseMagnetic: parts[3] ? parseFloat(parts[3]) : null, // Course over ground (magnetic)
magneticCourseRef: parts[4], // M = Magnetic
speedKnots: parts[5] ? parseFloat(parts[5]) : null, // Speed over ground in knots
knotsRef: parts[6], // N = Knots
speedKmh: parts[7] ? parseFloat(parts[7]) : null, // Speed over ground in km/h
kmhRef: parts[8], // K = km/h
mode: parts[9] // Mode indicator: A=Autonomous, D=Differential, E=Estimated
};
}
/**
* Parse latitude from NMEA format
* @param {string} value - Latitude value
* @param {string} direction - N/S
* @returns {number|null} Decimal latitude
*/
parseLatitude(value, direction) {
if (!value || value === '') {
return null;
}
try {
// NMEA format: DDMM.MMMM
const degrees = parseInt(value.substring(0, 2));
const minutes = parseFloat(value.substring(2));
let latitude = degrees + (minutes / 60);
// Apply direction
if (direction === 'S') {
latitude = -latitude;
}
return parseFloat(latitude.toFixed(6));
} catch (error) {
this.logger.error('Error parsing latitude:', error, value, direction);
return null;
}
}
/**
* Parse longitude from NMEA format
* @param {string} value - Longitude value
* @param {string} direction - E/W
* @returns {number|null} Decimal longitude
*/
parseLongitude(value, direction) {
if (!value || value === '') {
return null;
}
try {
// NMEA format: DDDMM.MMMM
const degrees = parseInt(value.substring(0, 3));
const minutes = parseFloat(value.substring(3));
let longitude = degrees + (minutes / 60);
// Apply direction
if (direction === 'W') {
longitude = -longitude;
}
return parseFloat(longitude.toFixed(6));
} catch (error) {
this.logger.error('Error parsing longitude:', error, value, direction);
return null;
}
}
/**
* Get the current position
* @returns {Object|null} Current position with latitude, longitude, altitude, quality, etc.
*/
getPosition() {
if (!this.lastPosition) {
return null;
}
// Create a complete position object
return {
latitude: this.lastPosition.latitude,
longitude: this.lastPosition.longitude,
altitude: this.lastPosition.altitude || null,
// Include fix quality from GGA if available
quality: this.lastPosition.fixQuality || 0,
// Include satellite count
satellites: this.lastPosition.satellites || 0,
// Include HDOP if available
hdop: this.lastPosition.hdop || null,
// Include speed if available (from RMC)
speed: this.lastPosition.speed || null,
// Include course if available (from RMC)
course: this.lastPosition.course || null,
};
}
/**
* Get current satellite information
* @returns {Object[]|null} Satellite information
*/
getSatellites() {
if (!this.lastSatellites || this.lastSatellites.length === 0) {
return [];
}
// Return a clone of the satellites array so external code cannot modify our internal state
return [...this.lastSatellites];
}
/**
* Get sentence statistics
* @returns {Object} Sentence type counts and rates
*/
getSentenceStats() {
return {
...this.sentenceStats,
lastUpdate: this.lastSentenceTime
};
}
/**
* Clear parsed data
*/
reset() {
this.lastPosition = null;
this.lastSatellites = [];
this.satellitesById = {};
this.buffer = '';
// Reset sentence stats
Object.keys(this.sentenceStats).forEach(key => {
this.sentenceStats[key] = 0;
});
}
}
export default NmeaParser;