UNPKG

@canboat/visual-analyzer

Version:

NMEA 2000 data visualization utility (requires SK Server >= 2.15)

442 lines 16.3 kB
"use strict"; /** * Copyright 2025 Scott Bender (scott@scottbender.net) * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) { if (k2 === undefined) k2 = k; var desc = Object.getOwnPropertyDescriptor(m, k); if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) { desc = { enumerable: true, get: function() { return m[k]; } }; } Object.defineProperty(o, k2, desc); }) : (function(o, m, k, k2) { if (k2 === undefined) k2 = k; o[k2] = m[k]; })); var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) { Object.defineProperty(o, "default", { enumerable: true, value: v }); }) : function(o, v) { o["default"] = v; }); var __importStar = (this && this.__importStar) || (function () { var ownKeys = function(o) { ownKeys = Object.getOwnPropertyNames || function (o) { var ar = []; for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k; return ar; }; return ownKeys(o); }; return function (mod) { if (mod && mod.__esModule) return mod; var result = {}; if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]); __setModuleDefault(result, mod); return result; }; })(); var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", { value: true }); const events_1 = require("events"); const ws_1 = __importDefault(require("ws")); const fs = __importStar(require("fs")); const readline = __importStar(require("readline")); const n2k_device_1 = __importDefault(require("./n2k-device")); class NMEADataProvider extends events_1.EventEmitter { constructor(options, configPath) { super(); this.isConnected = false; this.authToken = null; this.authRequestId = 0; this.pendingAuthResolve = null; // Connection objects this.signalKWs = null; this.canDevice = null; // File playback specific properties this.fileStream = null; this.playbackTimer = null; this.readline = null; this.lineQueue = []; this.isProcessingQueue = false; this.currentFilePath = null; this.options = options; this.configPath = configPath; } async connect() { try { if (this.options.type === 'signalk') { await this.connectToSignalK(); } else if (this.options.type === 'file') { await this.connectToFile(); } else { this.canDevice = new n2k_device_1.default(this.getServerApp(), this.options); await this.canDevice.start(); this.isConnected = true; //this.emit('connected') } } catch (error) { console.error('Failed to connect to NMEA source:', error); this.emit('error', error); } } async connectToSignalK() { const url = this.options.signalkUrl.replace('http', 'ws') + '/signalk/v1/stream?subscribe=none&events=canboatjs:rawoutput'; console.log('Connecting to SignalK WebSocket:', url); this.signalKWs = new ws_1.default(url, { rejectUnauthorized: false, }); this.signalKWs.on('open', () => { console.log('Connected to SignalK server'); this.isConnected = true; this.emit('connected'); // Authenticate if credentials are provided if (this.options.signalkUsername && this.options.signalkPassword) { this.authenticateViaWebSocket(); } }); this.signalKWs.on('message', (data) => { try { const message = JSON.parse(data.toString()); // Handle authentication responses if (message.requestId && message.requestId.startsWith('auth-')) { this.handleAuthenticationResponse(message); return; } // Handle logout responses if (message.requestId && message.requestId.startsWith('logout-')) { this.handleLogoutResponse(message); return; } // Handle regular messages if (message.event === 'canboatjs:rawoutput') { this.emit('raw-nmea', message.data); } } catch (error) { console.error('Error processing SignalK message:', error); } }); this.signalKWs.on('error', (error) => { console.error('SignalK WebSocket error:', error); this.emit('error', error); }); this.signalKWs.on('close', () => { console.log('SignalK WebSocket connection closed'); this.isConnected = false; this.emit('disconnected'); // Reject any pending authentication promise if (this.pendingAuthResolve) { this.pendingAuthResolve(false); this.pendingAuthResolve = null; } }); } authenticateViaWebSocket() { if (!this.options.signalkUsername || !this.options.signalkPassword) { return Promise.resolve(false); } return new Promise((resolve) => { const requestId = `auth-${++this.authRequestId}`; this.pendingAuthResolve = resolve; const loginMessage = { requestId: requestId, login: { username: this.options.signalkUsername, password: this.options.signalkPassword, }, }; console.log('Sending WebSocket authentication message'); this.signalKWs.send(JSON.stringify(loginMessage)); // Timeout after 10 seconds setTimeout(() => { if (this.pendingAuthResolve === resolve) { console.error('SignalK authentication timeout'); this.pendingAuthResolve = null; resolve(false); } }, 10000); }); } handleAuthenticationResponse(message) { if (message.statusCode === 200 && message.login && message.login.token) { this.authToken = message.login.token; console.log('SignalK WebSocket authentication successful.'); if (this.pendingAuthResolve) { this.pendingAuthResolve(true); this.pendingAuthResolve = null; } } else { console.error(`SignalK WebSocket authentication failed with status ${message.statusCode}`); this.emit('error', new Error(`SignalK WebSocket authentication failed with status ${message.statusCode}`)); if (this.pendingAuthResolve) { this.pendingAuthResolve(false); this.pendingAuthResolve = null; } } } handleLogoutResponse(message) { if (message.statusCode === 200) { console.log('SignalK logout successful'); } else { console.error('SignalK logout failed:', message.statusCode); } // Clear token regardless of result this.authToken = null; } getServerApp() { return { config: { configPath: this.configPath }, setProviderError: (providerId, msg) => { console.error(`${providerId} error: ${msg}`); this.emit('error', new Error(msg)); }, setProviderStatus: (providerId, msg) => { console.log(`${providerId} status: ${msg}`); }, on: (event, callback) => { this.on(event, callback); }, removeListener: (event, callback) => { this.removeListener(event, callback); }, emit: (event, data) => { if (event === 'canboatjs:rawoutput') { this.emit('raw-nmea', data); } else { this.emit(event, data); } }, listenerCount: (event) => { return this.listenerCount(event === 'canboatjs:rawoutput' ? 'raw-nmea' : event); }, }; } async connectToFile() { try { console.log(`Opening file for playback: ${this.options.filePath}`); if (!fs.existsSync(this.options.filePath)) { throw new Error(`File not found: ${this.options.filePath}`); } this.setupFileStream(this.options.filePath); this.currentFilePath = this.options.filePath; this.isConnected = true; this.emit('connected'); } catch (error) { console.error('Failed to connect to file:', error); throw error; } } setupFileStream(filePath) { this.fileStream = fs.createReadStream(filePath); this.readline = readline.createInterface({ input: this.fileStream, crlfDelay: Infinity, }); // Read all lines into queue first this.readline.on('line', (line) => { let trimmed = line.trim(); if (trimmed && !trimmed.startsWith('#')) { if (trimmed.length > 15 && trimmed.charAt(13) === ';' && trimmed.charAt(15) === ';') { // SignalK Multiplexed format if (trimmed.charAt(14) === 'A') { trimmed = trimmed.substring(16); } else { return; // Skip unsupported SignalK formats } } this.lineQueue.push(trimmed); } }); this.readline.on('close', () => { console.log(`File loaded: ${this.lineQueue.length} lines queued for playback`); this.startFilePlayback(); }); this.readline.on('error', (error) => { console.error('File reading error:', error); this.emit('error', error); }); } processQueue() { if (this.isProcessingQueue || this.lineQueue.length === 0) { return; } this.isProcessingQueue = true; const line = this.lineQueue.shift(); // Emit the line this.emit('raw-nmea', line); // Calculate delay for next line const playbackSpeed = this.options.playbackSpeed || 1; const baseDelay = 1000; // 1 second base delay const delay = baseDelay / playbackSpeed; // Schedule next line if (this.lineQueue.length > 0) { this.playbackTimer = setTimeout(() => { this.isProcessingQueue = false; this.processQueue(); }, delay); } else { // Queue is empty - check if we should loop if (this.options.loopPlayback && this.currentFilePath) { console.log('File playback completed, restarting loop...'); this.isProcessingQueue = false; // Restart reading the file after a brief delay this.playbackTimer = setTimeout(() => { this.restartFilePlayback(); }, delay); } else { console.log('File playback completed'); this.isProcessingQueue = false; this.emit('disconnected'); } } } startFilePlayback() { console.log('Starting file playback...'); this.processQueue(); } restartFilePlayback() { // Clean up current stream if (this.readline) { this.readline.close(); this.readline = null; } if (this.fileStream) { this.fileStream.destroy(); this.fileStream = null; } // Clear the queue and restart this.lineQueue = []; // Setup file stream again if (this.currentFilePath) { this.setupFileStream(this.currentFilePath); } } processSignalKUpdate(update) { // Process SignalK delta updates and emit them this.emit('signalk-data', update); } disconnect() { console.log('Disconnecting from NMEA source...'); // Clear any pending authentication if (this.pendingAuthResolve) { this.pendingAuthResolve(false); this.pendingAuthResolve = null; } // Logout from SignalK if authenticated if (this.authToken && this.signalKWs) { this.logoutFromSignalK(); } // Close connections if (this.signalKWs) { this.signalKWs.close(); this.signalKWs = null; } if (this.canDevice) { this.canDevice.end(); this.canDevice = null; } // File playback cleanup if (this.playbackTimer) { clearTimeout(this.playbackTimer); this.playbackTimer = null; } if (this.readline) { this.readline.close(); this.readline = null; } if (this.fileStream) { this.fileStream.destroy(); this.fileStream = null; } this.lineQueue = []; this.currentFilePath = null; this.isProcessingQueue = false; this.isConnected = false; console.log('Disconnected from NMEA source'); this.emit('disconnected'); } logoutFromSignalK() { if (!this.signalKWs || !this.authToken) { return; } const requestId = `logout-${++this.authRequestId}`; const logoutMessage = { requestId: requestId, logout: {}, }; console.log('Sending SignalK logout message'); this.signalKWs.send(JSON.stringify(logoutMessage)); } getDelimiterForDevice(deviceType) { switch (deviceType) { case 'Yacht Devices': case 'Yacht Devices RAW': case 'NavLink2': return '\r\n'; case 'Actisense': case 'Actisense ASCII': return '\r\n'; case 'iKonvert': return '\n'; default: return /\r?\n/; } } isConnectionActive() { return this.isConnected; } getAuthStatus() { if (this.options.type === 'signalk') { return { isAuthenticated: !!this.authToken, token: this.authToken, username: this.options.signalkUsername, }; } return null; } sendMessage(data) { if (!this.isConnected) { throw new Error('No active connection for message transmission'); } // Implement message sending based on connection type switch (this.options.type) { case 'serial': case 'network': case 'socketcan': this.canDevice?.send(data); break; case 'file': case 'signalk': break; default: throw new Error(`Message transmission not supported for connection type: ${this.options.type}`); } } } exports.default = NMEADataProvider; //# sourceMappingURL=nmea-provider.js.map