ais-web
Version:
Compact AIS decoder in TypeScript for browser and web apps
229 lines (228 loc) • 9.16 kB
JavaScript
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.AisReceiver = void 0;
// aisDecoder.ts
const events_1 = require("events");
class TypedEventEmitter extends events_1.EventEmitter {
on(eventName, listener) {
return super.on(eventName, listener);
}
off(eventName, listener) {
return super.off(eventName, listener);
}
once(eventName, listener) {
return super.once(eventName, listener);
}
emit(eventName, payload) {
return super.emit(eventName, payload);
}
}
class AisReceiver extends TypedEventEmitter {
constructor() {
super(...arguments);
this.multipartBuffers = new Map();
}
/**
* Process one AIS sentence. Pass your messages to this function, once the message is decoded, it triggers an event
* on('position') or on('static') depending on the type of sentence decoded.
* @param sentence Raw NMEA AIS sentence, e.g. "!AIVDM,1,1,,A,...*hh"
* @param enableChecksum Whether to verify the checksum (default: true)
*/
onMessage(sentence, enableChecksum = true) {
const match = sentence.match(/^!(AIVDM|AIVDO),(\d+),(\d+),([^,]*),([AB]),([^,]*),(\d+)\*([0-9A-F]{2})/i);
if (!match)
return;
if (enableChecksum && !this.verifyChecksum(sentence))
return;
const { channel, payload, total, part, fillBits, key } = this.extractRawData(match);
if (total === 1) {
// Single part message - decode immediately
const bits = this.payloadToBits(payload, fillBits);
this.processBits(bits, channel);
return;
}
// Multipart message - buffer parts until all received
let entry = this.multipartBuffers.get(key);
if (!entry) {
entry = {
total,
receivedParts: new Map(),
fillBits,
timer: setTimeout(() => {
this.multipartBuffers.delete(key);
}, AisReceiver.MULTIPART_TIMEOUT_MS),
};
this.multipartBuffers.set(key, entry);
}
entry.receivedParts.set(part, payload);
if (entry.receivedParts.size === total) {
clearTimeout(entry.timer);
this.multipartBuffers.delete(key);
let fullPayload = '';
for (let i = 1; i <= total; i++) {
const partPayload = entry.receivedParts.get(i);
if (!partPayload)
return;
fullPayload += partPayload;
}
const bits = this.payloadToBits(fullPayload, entry.fillBits);
this.processBits(bits, channel);
}
}
/**
* Extracts raw AIS sentence raw fields along with the decoded MMSI.
*
* Parses a raw AIS NMEA sentence (e.g. "!AIVDM,...") to extract:
* - total number of fragments,
* - current fragment number,
* - sequence ID,
* - radio channel,
* - payload string,
* - fill bits,
* and additionally decodes the MMSI number from the AIS payload.
*
* @param sentence - Raw AIS NMEA sentence string.
* @param enableChecksum - Whether to verify the NMEA checksum (default: true).
* @returns An object containing:
* - channel: radio channel ('A' or 'B'),
* - payload: AIS payload string,
* - total: total number of sentence fragments,
* - part: current fragment number,
* - fillBits: number of fill bits in the payload,
* - key: sequence ID for multipart messages (or 'noprefix'),
* - mmsi: decoded MMSI number extracted from the payload,
* or undefined if the sentence is invalid or checksum verification fails.
*/
extractSentenceRawFields(sentence, enableChecksum = true) {
const match = sentence.match(/^!(AIVDM|AIVDO),(\d+),(\d+),([^,]*),([AB]),([^,]*),(\d+)\*([0-9A-F]{2})/i);
if (!match)
return;
if (enableChecksum && !this.verifyChecksum(sentence))
return;
const rawData = this.extractRawData(match);
// Decode MMSI from payload
const bits = this.payloadToBits(rawData.payload, rawData.fillBits);
const mmsi = this.readUInt(bits, 8, 30);
return { ...rawData, mmsi };
}
extractRawData(match) {
const [, , totalStr, partStr, seqId, channel, payload, fillBitsStr] = match;
const total = parseInt(totalStr, 10);
const part = parseInt(partStr, 10);
const fillBits = parseInt(fillBitsStr, 10);
const key = seqId || 'noprefix';
return { channel, payload, total, part, fillBits, key };
}
verifyChecksum(sentence) {
const starIndex = sentence.indexOf('*');
if (starIndex === -1)
return false;
const toCheck = sentence.slice(1, starIndex);
let checksum = 0;
for (let i = 0; i < toCheck.length; i++) {
checksum ^= toCheck.charCodeAt(i);
}
const expected = sentence.slice(starIndex + 1).toUpperCase();
const calculated = checksum.toString(16).toUpperCase().padStart(2, '0');
return calculated === expected;
}
payloadToBits(payload, fillBits) {
let bits = '';
for (const c of payload) {
let val = c.charCodeAt(0) - 48;
if (val > 40)
val -= 8;
bits += val.toString(2).padStart(6, '0');
}
return fillBits > 0 ? bits.slice(0, -fillBits) : bits;
}
processBits(bits, channel) {
const type = this.readUInt(bits, 0, 6);
const mmsi = this.readUInt(bits, 8, 30);
if (type === 5) {
const msg = this.decodeType5(bits, mmsi, channel);
if (msg)
this.emit('static', msg);
}
else if ([1, 2, 3].includes(type)) {
const msg = this.decodePosition(bits, type, mmsi, channel);
if (msg)
this.emit('position', msg);
}
}
decodePosition(bits, type, mmsi, channel) {
if (bits.length < 168)
return null;
return {
type,
channel,
repeat: this.readUInt(bits, 6, 2),
mmsi,
navStatus: this.readUInt(bits, 38, 4),
rateOfTurn: this.readInt(bits, 42, 8),
speedOverGround: this.readUInt(bits, 50, 10) / 10,
accuracy: this.readUInt(bits, 60, 1) === 1,
lon: this.readInt(bits, 61, 28) / 600000,
lat: this.readInt(bits, 89, 27) / 600000,
courseOverGround: this.readUInt(bits, 116, 12) / 10,
heading: this.readUInt(bits, 128, 9),
utcSecond: this.readUInt(bits, 137, 6),
specialManoeuvre: this.readUInt(bits, 143, 2),
raim: this.readUInt(bits, 145, 1) === 1,
radio: this.readUInt(bits, 146, 19),
};
}
decodeType5(bits, mmsi, channel) {
if (bits.length < 424)
return null;
return {
type: 5,
mmsi,
repeat: this.readUInt(bits, 6, 2),
aisVersion: this.readUInt(bits, 38, 2),
imo: this.readUInt(bits, 40, 30),
callsign: this.decodeText(bits, 70, 7),
name: this.decodeText(bits, 112, 20),
shipType: this.readUInt(bits, 232, 8),
dimensionToBow: this.readUInt(bits, 240, 9),
dimensionToStern: this.readUInt(bits, 249, 9),
dimensionToPort: this.readUInt(bits, 258, 6),
dimensionToStarboard: this.readUInt(bits, 264, 6),
epfd: this.readUInt(bits, 270, 4), // ✅ ADD THIS LINE
etaMonth: this.readUInt(bits, 274, 4),
etaDay: this.readUInt(bits, 278, 5),
etaHour: this.readUInt(bits, 283, 5),
etaMinute: this.readUInt(bits, 288, 6),
draught: this.readUInt(bits, 294, 8) / 10,
destination: this.decodeText(bits, 302, 20),
dteAvailable: this.readUInt(bits, 422, 1) === 0,
channel,
};
}
readUInt(bits, start, length) {
return parseInt(bits.slice(start, start + length), 2);
}
readInt(bits, start, length) {
const value = bits.slice(start, start + length);
if (value[0] === '0')
return parseInt(value, 2);
const inverted = [...value].map((b) => (b === '0' ? '1' : '0')).join('');
return -(parseInt(inverted, 2) + 1);
}
decodeText(bits, start, chars) {
var _a;
// Accurate AIS 6-bit ASCII table (as per ITU-R M.1371)
const table = '@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\\]^_ !"#$%&\'()*+,-./0123456789:;<=>?';
let text = '';
for (let i = 0; i < chars; i++) {
const bitIndex = start + i * 6;
if (bitIndex + 6 > bits.length)
break;
const val = parseInt(bits.slice(bitIndex, bitIndex + 6), 2);
text += (_a = table[val]) !== null && _a !== void 0 ? _a : ' ';
}
return text.replace(/@+$/, '').trim();
}
}
exports.AisReceiver = AisReceiver;
AisReceiver.MULTIPART_TIMEOUT_MS = 30000;