@canboat/visual-analyzer
Version:
NMEA 2000 data visualization utility (requires SK Server >= 2.15)
442 lines • 16.3 kB
JavaScript
"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