@signalk/nmea0183-utilities
Version:
Various utilities for transforming NMEA0183 units into SI units for use in SK.
370 lines • 13 kB
JavaScript
;
/**
* NMEA 0183 utilities: checksum, unit conversion, coordinate parsing,
* timestamp assembly, and numeric parsing helpers.
*
* All transforms output SI units so downstream Signal K consumers do not
* need to carry unit metadata.
*/
Object.defineProperty(exports, "__esModule", { value: true });
exports.RATIOS = void 0;
exports.valid = valid;
exports.appendChecksum = appendChecksum;
exports.source = source;
exports.transform = transform;
exports.magneticVariation = magneticVariation;
exports.timestamp = timestamp;
exports.coordinate = coordinate;
exports.isValidPosition = isValidPosition;
exports.zero = zero;
exports.int = int;
exports.float = float;
exports.intOrNull = intOrNull;
exports.floatOrNull = floatOrNull;
exports.transformOrNull = transformOrNull;
exports.magneticVariationOrNull = magneticVariationOrNull;
// Conversion ratios. Kept exported because downstream parsers reference
// specific ratios directly when formatting output.
exports.RATIOS = {
// DISTANCE
NM_IN_KM: 1.852,
KM_IN_NM: 0.539956803,
// SPEED
// Knots
KNOTS_IN_MS: 0.514444,
KNOTS_IN_MPH: 1.150779,
KNOTS_IN_KPH: 1.852,
// MPH
MPH_IN_MS: 0.44704,
MPH_IN_KPH: 1.609344,
MPH_IN_KNOTS: 0.868976,
// KPH
KPH_IN_MS: 0.277778,
KPH_IN_MPH: 0.621371,
KPH_IN_KNOTS: 0.539957,
// MS
MS_IN_KPH: 3.6,
MS_IN_MPH: 2.236936,
MS_IN_KNOTS: 1.943844,
// DEGREE
DEG_IN_RAD: 0.0174532925,
RAD_IN_DEG: 57.2957795,
// TEMPERATURES
// Celsius
CELSIUS_IN_KELVIN: 273.15,
// Length
METER_IN_FEET: 3.2808,
METER_IN_FATHOM: 0.5468
};
function checksum(sentencePart) {
// NMEA 0183 XOR checksum over every byte after the leading `$` or `!`.
// `for...of` avoids an explicit numeric bound so the loop can't be
// mutated into an off-by-one that reads NaN (which XORs as 0 and leaves
// the checksum unchanged — equivalent to the original, unkillable by a
// test). NMEA is ASCII, so iterating code points ≡ iterating code units.
let check = 0;
for (const char of sentencePart.slice(1)) {
check ^= char.charCodeAt(0);
}
return check;
}
function valid(sentence, validateChecksum) {
// An empty string has charAt(0) === '', which neither equals '$' nor
// '!', so the prefix check below rejects it without a dedicated
// early return.
const s = String(sentence).trim();
const shouldValidate = typeof validateChecksum === 'undefined' || validateChecksum;
if ((s.charAt(0) === '$' || s.charAt(0) === '!') &&
(shouldValidate === false || s.charAt(s.length - 3) === '*')) {
if (shouldValidate) {
const split = s.split('*');
return parseInt(split[1], 16) === checksum(split[0]);
}
return true;
}
return false;
}
function appendChecksum(sentence) {
const split = String(sentence).trim().split('*');
if (split.length === 1) {
const part = split[0];
if (part.charAt(0) === '$' || part.charAt(0) === '!') {
return part
.concat('*')
.concat(checksum(part).toString(16).padStart(2, '0').toUpperCase());
}
}
return sentence;
}
function source(sentence) {
return {
type: 'NMEA0183',
label: 'signalk-parser-nmea0183',
sentence: sentence || ''
};
}
// Dispatch table: key = 'from:to', value = converter fn.
// Kept as a single source of truth so adding a new unit pair is
// one line here and a new test.
const CONVERSIONS = {
// Distance
'km:nm': (v) => v / exports.RATIOS.NM_IN_KM,
'km:m': (v) => v * 1000,
'nm:km': (v) => v / exports.RATIOS.KM_IN_NM,
'nm:m': (v) => (v * 1000) / exports.RATIOS.KM_IN_NM,
'm:km': (v) => v / 1000,
'm:nm': (v) => (v / 1000) * exports.RATIOS.KM_IN_NM,
'm:ft': (v) => v * exports.RATIOS.METER_IN_FEET,
'm:fa': (v) => v * exports.RATIOS.METER_IN_FATHOM,
'ft:m': (v) => v / exports.RATIOS.METER_IN_FEET,
'fa:m': (v) => v / exports.RATIOS.METER_IN_FATHOM,
// Speed
'knots:kph': (v) => v / exports.RATIOS.KPH_IN_KNOTS,
'knots:ms': (v) => v / exports.RATIOS.MS_IN_KNOTS,
'knots:mph': (v) => v / exports.RATIOS.MPH_IN_KNOTS,
'kph:knots': (v) => v / exports.RATIOS.KNOTS_IN_KPH,
'kph:ms': (v) => v / exports.RATIOS.MS_IN_KPH,
'kph:mph': (v) => v / exports.RATIOS.MPH_IN_KPH,
'mph:knots': (v) => v / exports.RATIOS.KNOTS_IN_MPH,
'mph:ms': (v) => v / exports.RATIOS.MS_IN_MPH,
'mph:kph': (v) => v / exports.RATIOS.KPH_IN_MPH,
'ms:knots': (v) => v / exports.RATIOS.KNOTS_IN_MS,
'ms:mph': (v) => v / exports.RATIOS.MPH_IN_MS,
'ms:kph': (v) => v / exports.RATIOS.KPH_IN_MS,
// Angle
'deg:rad': (v) => v / exports.RATIOS.RAD_IN_DEG,
'rad:deg': (v) => v / exports.RATIOS.DEG_IN_RAD,
// Temperature
'c:k': (v) => v + exports.RATIOS.CELSIUS_IN_KELVIN,
'c:f': (v) => v * 1.8 + 32,
'k:c': (v) => v - exports.RATIOS.CELSIUS_IN_KELVIN,
'k:f': (v) => (v - exports.RATIOS.CELSIUS_IN_KELVIN) * 1.8 + 32,
'f:c': (v) => (v - 32) / 1.8,
'f:k': (v) => (v - 32) / 1.8 + exports.RATIOS.CELSIUS_IN_KELVIN
};
function transform(value, inputFormat, outputFormat) {
const numeric = float(value);
if (inputFormat === outputFormat) {
return numeric;
}
const converter = CONVERSIONS[inputFormat + ':' + outputFormat];
if (!converter) {
throw new Error('unsupported conversion: ' + inputFormat + ' -> ' + outputFormat);
}
return converter(numeric);
}
function magneticVariation(degrees, pole) {
const deg = float(degrees);
// Exhaustive switch: the `default` branch is unreachable under the
// `Pole` type, but exists as a runtime safety net for JS callers
// passing a bogus string (which would otherwise silently no-op and
// return the wrong-sign result). Unary `-deg` avoids Stryker's
// `*= → /=` equivalent mutant.
switch (pole) {
case 'S':
case 'W':
return -deg;
case 'N':
case 'E':
return deg;
default: {
const exhaustive = pole;
throw new Error(`unsupported pole: ${String(exhaustive)}`);
}
}
}
function timestamp(time, date) {
/* TIME (UTC) */
let hours;
let minutes;
let seconds;
let milliseconds;
let year;
let month;
let day;
if (time) {
hours = int(time.slice(0, 2));
minutes = int(time.slice(2, 4));
seconds = int(time.slice(4, 6));
// NMEA time may carry a fractional tail (e.g. u-blox 10 Hz fixes arrive
// as '173456.75'). Capture up to 3 digits after the dot, right-padded,
// so '.2' -> 200, '.25' -> 250, '.2567' -> 256. Missing or non-digit
// tails degrade to 0 ms (keeps malformed-sentence handling unchanged).
const fraction = /\.(\d+)/.exec(time);
milliseconds = fraction
? parseInt((fraction[1] + '000').slice(0, 3), 10)
: 0;
}
else {
const dt = new Date();
hours = dt.getUTCHours();
minutes = dt.getUTCMinutes();
seconds = dt.getUTCSeconds();
milliseconds = 0;
}
/* DATE (UTC) */
if (date) {
day = int(date.slice(0, 2));
month = int(date.slice(2, 4)); // this will be a value 1-12
// NMEA 0183 carries a 2-digit year. Per IEC 61162-1 convention,
// YY < 80 is 20YY and YY >= 80 is 19YY. Matters only for log
// replay; live fixes in this millennium always take the 20YY
// branch. `nmea0183-utilities` 0.x always computed 20YY and so
// stamped year 2080+ onto 198x/199x sentences from archival logs.
const yy = int(date.slice(4, 6));
year = yy < 80 ? 2000 + yy : 1900 + yy;
}
else {
const dt = new Date();
year = dt.getUTCFullYear();
month = dt.getUTCMonth() + 1; // getUTCMonth() returns 0-11
day = dt.getUTCDate();
}
/* construct */
const d = new Date(Date.UTC(year, month - 1, day, hours, minutes, seconds, milliseconds)); // month is expected to be 0-11
return d.toISOString();
}
function coordinate(value, pole) {
// N 5222.3277 should be read as 52°22.3277'
// E 454.5824 should be read as 4°54.5824'
//
// 1. split at .
// 2. last two characters of split[0] (.slice(-2)) + everything after . (split[1]) are the minutes
// 3. degrees: split[0][a]
// 4. minutes: split[0][b] + '.' + split[1]
//
// 52°22'19.662'' N -> 52.372128333
// 4°54'34.944'' E -> 4.909706667
// S & W should be negative.
const split = value.split('.');
const degrees = float(split[0].slice(0, -2));
const minsec = float(split[0].slice(-2) + '.' + split[1]);
const decimal = degrees + minsec / 60;
// Exhaustive switch (see `magneticVariation` for the rationale).
switch (pole) {
case 'S':
case 'W':
return -decimal;
case 'N':
case 'E':
return decimal;
default: {
const exhaustive = pole;
throw new Error(`unsupported pole: ${String(exhaustive)}`);
}
}
}
function isValidPosition(latitude, longitude) {
// Number.isFinite rejects NaN and +/-Infinity. `typeof NaN === 'number'`
// is true, so the old `typeof` + Math.abs guard let NaN fall through.
if (!Number.isFinite(latitude) ||
!Number.isFinite(longitude) ||
Math.abs(latitude) > 90 ||
Math.abs(longitude) > 180) {
return false;
}
return true;
}
function zero(n) {
// Width-2 left-pad for integer date/time components. Non-integer or
// non-finite input produced nonsense strings in 0.x (`"NaN"`,
// `"Infinity"`, `"00.5"`) that silently corrupted downstream output;
// reject them loudly instead.
if (!Number.isInteger(n)) {
throw new TypeError(`zero() expects an integer, got ${n}`);
}
if (n < 0) {
return '-' + zero(-n);
}
if (n < 10) {
return '0' + n;
}
return '' + n;
}
function int(n) {
// parseInt('') and parseInt(' ') both return NaN, so the NaN guard
// below subsumes the previous empty-string fast path.
const parsed = parseInt(n, 10);
return Number.isNaN(parsed) ? 0 : parsed;
}
function float(n) {
const parsed = parseFloat(n);
return Number.isNaN(parsed) ? 0.0 : parsed;
}
// Null-preserving numeric parsers. An NMEA 0183 null field (IEC 61162-1
// §7.2.3.4) signals "sensor working, value not available" and must not be
// conflated with a legitimate zero. `int`/`float` above coerce to 0 for
// back-compat; prefer these when the caller wants to preserve the
// not-available semantic end-to-end.
//
// intOrNull('') -> null
// intOrNull('42') -> 42
// intOrNull('abc') -> null
function intOrNull(n) {
const parsed = parseInt(n, 10);
return Number.isNaN(parsed) ? null : parsed;
}
function floatOrNull(n) {
const parsed = parseFloat(n);
return Number.isNaN(parsed) ? null : parsed;
}
// Null-short-circuiting unit conversion. Lets callers write
// transformOrNull(parts[8], 'deg', 'rad')
// for an optional NMEA field without a surrounding emptiness check.
// Unsupported conversion pairs still throw (same contract as `transform`).
function transformOrNull(value, inputFormat, outputFormat) {
const numeric = floatOrNull(value);
if (numeric === null) {
return null;
}
if (inputFormat === outputFormat) {
return numeric;
}
const converter = CONVERSIONS[inputFormat + ':' + outputFormat];
if (!converter) {
throw new Error('unsupported conversion: ' + inputFormat + ' -> ' + outputFormat);
}
return converter(numeric);
}
// Null-preserving magnetic variation. Returns null when either the
// degrees field or the pole letter is missing or unparseable, rather
// than throwing or (via the old `float` path) silently returning 0.
// Valid pole letters still enforce the `Pole` contract; an unknown
// pole given alongside numeric degrees is treated as "not available"
// rather than fatal, matching how callers already treat the whole
// field as optional when the direction indicator is empty.
function magneticVariationOrNull(degrees, pole) {
const deg = floatOrNull(degrees);
if (deg === null) {
return null;
}
if (pole === 'N' || pole === 'E') {
return deg;
}
if (pole === 'S' || pole === 'W') {
return -deg;
}
return null;
}
// Default export aggregate so ESM consumers using
// `import utils from '@signalk/nmea0183-utilities'` get the same bag
// that `const utils = require(...)` returns in CJS.
exports.default = {
RATIOS: exports.RATIOS,
valid,
appendChecksum,
source,
transform,
magneticVariation,
timestamp,
coordinate,
isValidPosition,
zero,
int,
float,
intOrNull,
floatOrNull,
transformOrNull,
magneticVariationOrNull
};
//# sourceMappingURL=index.js.map