UNPKG

ais-web

Version:

Compact AIS decoder in TypeScript for browser and web apps

229 lines (228 loc) 9.16 kB
"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;