@canboat/visual-analyzer
Version:
NMEA 2000 data visualization utility (requires SK Server >= 2.15)
1,052 lines • 46.6 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 });
exports.translateToSignalK = void 0;
const express_1 = __importDefault(require("express"));
const http = __importStar(require("http"));
const ws_1 = __importStar(require("ws"));
const path = __importStar(require("path"));
const fs = __importStar(require("fs"));
const os = __importStar(require("os"));
const nmea_provider_1 = __importDefault(require("./nmea-provider"));
const canboatjs_1 = require("@canboat/canboatjs");
const n2k_signalk_1 = require("@signalk/n2k-signalk");
const recording_service_1 = require("./recording-service");
class VisualAnalyzerServer {
constructor(port) {
this.nmeaProvider = null;
this.outAvailable = false;
this.publicDir = path.join(__dirname, '../public');
// Platform-appropriate config file location
let configDir;
if (process.platform === 'win32') {
// On Windows, use %APPDATA%\visual-analyzer
configDir = path.join(process.env.APPDATA || os.homedir(), 'visual-analyzer');
}
else {
// On Unix-like systems, use ~/.visual-analyzer
configDir = path.join(os.homedir(), '.visual-analyzer');
}
this.configDir = configDir;
const defaultConfigPath = path.join(configDir, 'config.json');
this.configFile = process.env.VISUAL_ANALYZER_CONFIG || defaultConfigPath;
this.currentConfig = this.loadConfiguration();
this.port = process.env.VISUAL_ANALYZER_PORT
? parseInt(process.env.VISUAL_ANALYZER_PORT, 10)
: port || this.currentConfig.port || 8080;
this.app = (0, express_1.default)();
this.server = http.createServer(this.app);
this.wss = new ws_1.WebSocketServer({ server: this.server });
// Initialize canboatjs parser for string input parsing
this.canboatParser = new canboatjs_1.FromPgn({
checkForInvalidFields: true,
useCamel: true, // Default value
useCamelCompat: false,
returnNonMatches: true,
createPGNObjects: true,
includeInputData: true,
includeRawData: true,
includeByteMapping: true,
});
this.canboatParser.on('error', (error) => {
console.debug('Canboat Parser error:', error);
});
// Initialize N2kMapper for SignalK transformation
this.n2kMapper = new n2k_signalk_1.N2kMapper({});
// Initialize recording service
this.recordingService = new recording_service_1.RecordingService(this.configDir);
// Set up recording service event listeners
this.recordingService.on('started', (status) => {
console.log('Recording started:', status);
this.broadcast({
event: 'recording:started',
data: status,
timestamp: new Date().toISOString(),
});
});
this.recordingService.on('stopped', (status) => {
console.log('Recording stopped:', status);
this.broadcast({
event: 'recording:stopped',
data: status,
timestamp: new Date().toISOString(),
});
});
this.recordingService.on('error', (error) => {
console.error('Recording error:', error);
this.broadcast({
event: 'recording:error',
data: { error: error.message },
timestamp: new Date().toISOString(),
});
});
this.recordingService.on('progress', (status) => {
this.broadcast({
event: 'recording:progress',
data: status,
timestamp: new Date().toISOString(),
});
});
// Track current connection state including errors
this.connectionState = {
isConnected: false,
lastUpdate: new Date().toISOString(),
error: null,
};
this.setupRoutes();
this.setupWebSocket();
console.log('Starting Visual Analyzer Server with configuration:');
console.log(` Port: ${this.currentConfig.port}`);
console.log(` Active Connection: ${this.currentConfig.connections?.activeConnection || 'None configured'}`);
}
loadConfiguration() {
let currentConfig = {
port: 8080,
connections: {
activeConnection: null,
profiles: {},
},
};
try {
if (fs.existsSync(this.configFile)) {
const data = fs.readFileSync(this.configFile, 'utf8');
const config = JSON.parse(data);
// Merge with defaults
currentConfig = {
...currentConfig,
...config,
};
// Update port if specified in config
if (config.server && config.server.port) {
this.port = config.server.port;
}
console.log(`Configuration loaded from ${this.configFile}`);
// Auto-connect to active connection if specified
if (currentConfig.connections.activeConnection) {
setTimeout(() => {
this.connectToActiveProfile();
}, 2000); // Wait 2 seconds after server startup
}
}
else {
console.log(`No configuration file found at ${this.configFile}`);
console.log('Using default configuration. Settings will be saved to this location when modified.');
// Create the config directory if it doesn't exist
const configDir = path.dirname(this.configFile);
if (!fs.existsSync(configDir)) {
fs.mkdirSync(configDir, { recursive: true });
console.log(`Created config directory: ${configDir}`);
}
}
}
catch (error) {
console.error('Error loading configuration:', error);
}
return currentConfig;
}
connectToActiveProfile() {
const activeProfile = this.getActiveConnectionProfile();
if (activeProfile) {
console.log(`Auto-connecting to active profile: ${activeProfile.name}`);
this.connectToNMEASource(activeProfile);
}
}
setupRoutes() {
// Add JSON parsing middleware
this.app.use(express_1.default.json());
// API routes for configuration
this.app.get('/api/config', (req, res) => {
res.json(this.getConfiguration());
});
this.app.post('/api/config', (req, res) => {
try {
this.updateConfiguration(req.body);
res.json({ success: true, message: 'Configuration updated successfully' });
}
catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
res.status(400).json({ success: false, error: errorMessage });
}
});
this.app.get('/api/connections', (req, res) => {
res.json(this.getConnectionProfiles());
});
this.app.post('/api/connections', (req, res) => {
try {
this.saveConnectionProfile(req.body);
res.json({ success: true, message: 'Connection profile saved successfully' });
}
catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
res.status(400).json({ success: false, error: errorMessage });
}
});
this.app.delete('/api/connections/:profileId', (req, res) => {
try {
this.deleteConnectionProfile(req.params.profileId);
res.json({ success: true, message: 'Connection profile deleted successfully' });
}
catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
res.status(400).json({ success: false, error: errorMessage });
}
});
this.app.post('/api/connections/:profileId/activate', (req, res) => {
try {
this.activateConnectionProfile(req.params.profileId);
res.json({ success: true, message: 'Connection profile activated successfully' });
}
catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
console.error('Error activating connection profile:', error);
res.status(400).json({ success: false, error: errorMessage });
}
});
this.app.post('/api/restart-connection', (req, res) => {
try {
this.restartNMEAConnection();
res.json({ success: true, message: 'Connection restart initiated' });
}
catch (error) {
console.error('Error restarting connection:', error);
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
res.status(500).json({ success: false, error: errorMessage });
}
});
// Recording API routes
this.app.get('/api/recording/status', (req, res) => {
try {
const status = this.recordingService.getStatus();
res.json({ success: true, result: status });
}
catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
res.status(500).json({ success: false, error: errorMessage });
}
});
this.app.post('/api/recording/start', (req, res) => {
try {
const { fileName, format } = req.body.value;
const result = this.recordingService.startRecording({ fileName, format });
res.json({ success: true, fileName: result.fileName, message: 'Recording started successfully' });
}
catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
res.status(400).json({ success: false, error: errorMessage });
}
});
this.app.post('/api/recording/stop', (req, res) => {
try {
const result = this.recordingService.stopRecording();
res.json({ success: true, message: 'Recording stopped successfully', finalStats: result });
}
catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
res.status(400).json({ success: false, error: errorMessage });
}
});
this.app.get('/api/recording/files', (req, res) => {
try {
const files = this.recordingService.getRecordedFiles();
res.json({
success: true,
results: files, // Include detailed results for each message
});
}
catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
res.status(500).json({ success: false, error: errorMessage });
}
});
this.app.delete('/api/recording/files/:fileName', (req, res) => {
try {
this.recordingService.deleteRecordedFile(req.params.fileName);
res.json({ success: true, message: 'File deleted successfully' });
}
catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
res.status(400).json({ success: false, error: errorMessage });
}
});
this.app.get('/api/recording/files/:fileName/download', (req, res) => {
try {
const filePath = this.recordingService.getRecordedFilePath(req.params.fileName);
res.download(filePath, req.params.fileName, (err) => {
if (err) {
console.error('Download error:', err);
if (!res.headersSent) {
res.status(500).json({ success: false, error: 'Download failed' });
}
}
});
}
catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
res.status(404).json({ success: false, error: errorMessage });
}
});
// SignalK-compatible input test endpoint for sending NMEA 2000 messages
this.app.post('/api/send-n2k', (req, res) => {
try {
const values = req.body.values;
if (!values) {
return res.status(400).json({
success: false,
error: 'Missing required field: values',
});
}
const pgnDataArray = [];
for (const value of values) {
// Check if input is a string (NMEA 2000 format) or JSON
if (typeof value === 'string') {
const lines = value.split(/\r?\n/).filter((line) => line.trim());
if (lines.length === 0) {
return res.status(400).json({
success: false,
error: 'No valid lines found in input',
});
}
try {
for (const line of lines) {
const trimmedLine = line.trim();
if (trimmedLine) {
try {
const parsed = this.canboatParser.parseString(trimmedLine);
if (parsed) {
pgnDataArray.push(parsed);
}
else {
console.warn(`Unable to parse line: ${trimmedLine}`);
}
}
catch (lineParseError) {
const errorMessage = lineParseError instanceof Error ? lineParseError.message : 'Unknown error';
console.warn(`Error parsing line "${trimmedLine}": ${errorMessage}`);
// Continue processing other lines instead of failing
}
}
}
if (pgnDataArray.length === 0) {
return res.status(400).json({
success: false,
error: 'Unable to parse any NMEA 2000 strings from input',
});
}
console.log(`Parsed ${pgnDataArray.length} NMEA 2000 messages from ${lines.length} lines using canboatjs`);
}
catch (canboatParseError) {
const errorMessage = canboatParseError instanceof Error ? canboatParseError.message : 'Unknown error';
return res.status(400).json({
success: false,
error: 'Error parsing NMEA 2000 strings: ' + errorMessage,
});
}
}
else if (typeof value === 'object') {
// Value is already a JSON object
pgnDataArray.push(value);
}
else {
return res.status(400).json({
success: false,
error: 'Value must be a string (JSON or NMEA 2000 format) or object',
});
}
}
// Process each parsed message
const results = [];
for (const pgnData of pgnDataArray) {
console.log('Processing NMEA 2000 message for transmission:', {
pgn: pgnData.pgn,
data: pgnData,
});
// If we have an active NMEA provider, attempt to send the message
if (this.nmeaProvider) {
try {
// Convert PGN object to raw NMEA 2000 format if the provider supports it
if (typeof this.nmeaProvider.sendMessage === 'function') {
this.nmeaProvider.sendMessage(pgnData);
//console.log('Message sent to NMEA 2000 network')
results.push({
pgn: pgnData.pgn,
transmitted: true,
parsedData: pgnData,
});
}
else {
console.log('NMEA provider does not support message transmission');
results.push({
pgn: pgnData.pgn,
transmitted: false,
error: 'NMEA provider does not support message transmission',
parsedData: pgnData,
});
}
}
catch (sendError) {
const errorMessage = sendError instanceof Error ? sendError.message : 'Unknown error';
console.error('Error sending message to NMEA 2000 network:', sendError);
results.push({
pgn: pgnData.pgn,
transmitted: false,
error: 'Error sending message: ' + errorMessage,
parsedData: pgnData,
});
}
}
else {
console.log('No active NMEA connection - message not transmitted');
results.push({
pgn: pgnData.pgn,
transmitted: false,
error: 'No active NMEA connection',
parsedData: pgnData,
});
} /*else {
results.push({
pgn: pgnData.pgn,
transmitted: false,
parsedData: pgnData,
})
}*/
}
// Return success response in SignalK format
res.json({
success: true,
message: `${pgnDataArray.length} message(s) processed successfully`,
messagesProcessed: pgnDataArray.length,
results: results, // Include detailed results for each message
});
}
catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
console.error('Error processing input test request:', error);
res.status(500).json({
success: false,
error: errorMessage,
});
}
});
// SignalK transformation endpoint
this.app.post('/api/transform/signalk', (req, res) => {
(0, exports.translateToSignalK)(req, res, this.canboatParser, this.n2kMapper);
});
// Serve static files from public directory
this.app.use(express_1.default.static(this.publicDir));
// Serve the main HTML file for any route (SPA support)
this.app.get('*', (req, res) => {
const indexPath = path.resolve(this.publicDir, 'index.html');
if (fs.existsSync(indexPath)) {
res.sendFile(indexPath);
}
else {
res.status(404).send('Visual Analyzer not built. Run npm run build first.');
}
});
}
setupWebSocket() {
this.wss.on('connection', (ws, req) => {
console.log('WebSocket client connected from:', req.socket.remoteAddress);
// Send initial connection message
ws.send(JSON.stringify({
event: 'connection',
message: 'Connected to Visual Analyzer WebSocket server',
}));
// Handle incoming messages
ws.on('message', (message) => {
try {
const data = JSON.parse(message.toString());
console.log('Received WebSocket message:', data);
// Echo back for now - can be extended for specific message handling
this.handleWebSocketMessage(ws, data);
}
catch (error) {
console.error('Error parsing WebSocket message:', error);
}
});
// Handle client disconnect
ws.on('close', () => {
console.log('WebSocket client disconnected');
});
// Handle errors
ws.on('error', (error) => {
console.error('WebSocket error:', error);
});
});
}
handleWebSocketMessage(ws, data) {
switch (data.type) {
case 'subscribe':
console.log('Client subscribing to:', data.subscription);
// If subscribing to status, send current connection state immediately
if (data.subscription === 'status') {
console.log('Sending current connection state to new status subscriber');
// Send current connection status
if (this.connectionState.isConnected) {
const statusData = {
event: 'nmea:connected',
timestamp: this.connectionState.lastUpdate,
};
// Add authentication status if this is a SignalK connection
if (this.nmeaProvider && this.nmeaProvider.options.type === 'signalk') {
statusData.auth = this.nmeaProvider.getAuthStatus?.();
}
ws.send(JSON.stringify(statusData));
}
else {
ws.send(JSON.stringify({
event: 'nmea:disconnected',
timestamp: this.connectionState.lastUpdate,
}));
}
// Send current error if any
if (this.connectionState.error) {
console.log('Sending current error to new status subscriber:', this.connectionState.error);
ws.send(JSON.stringify({
event: 'error',
error: this.connectionState.error,
timestamp: this.connectionState.lastUpdate,
}));
}
if (this.outAvailable) {
ws.send(JSON.stringify({
event: 'nmea:out-available',
timestamp: new Date().toISOString(),
}));
}
}
// Start sending data for the requested subscription
//this.startDataStream(ws, data.subscription)
break;
case 'unsubscribe':
console.log('Client unsubscribing from:', data.subscription);
// Stop sending data for the subscription
//this.stopDataStream(ws, data.subscription)
break;
default:
console.log('Unknown message type:', data.type);
}
}
stopDataStream(ws) {
// Clean up intervals when unsubscribing
if (ws.intervals) {
;
ws.intervals.forEach((interval) => clearInterval(interval));
ws.intervals = [];
}
}
generateSampleNMEAData() {
// Generate sample NMEA 2000 data in the format expected by canboatjs
const timestamp = new Date().toISOString();
const pgns = [
'127251,1,255,8,ff,ff,ff,ff,7f,ff,ff,ff', // Rate of Turn
'127250,1,255,8,00,fc,ff,ff,ff,ff,ff,ff', // Vessel Heading
'129026,1,255,8,ff,ff,00,00,ff,7f,ff,ff', // COG & SOG
'127258,1,255,8,ff,7f,ff,ff,ff,ff,ff,ff', // Magnetic Variation
];
const randomPgn = pgns[Math.floor(Math.random() * pgns.length)];
return `${timestamp},2,${randomPgn}`;
}
// Method to integrate with actual NMEA 2000 data sources
connectToNMEASource(options) {
console.log('Connecting to NMEA 2000 source with options:', options);
this.nmeaProvider = new nmea_provider_1.default(options, this.configDir);
// Set up event listeners for NMEA data
this.nmeaProvider.on('nmea-data', (data) => {
this.broadcast({
event: 'canboatjs:parsed',
data: data,
timestamp: new Date().toISOString(),
});
});
this.nmeaProvider.on('raw-nmea', (rawData) => {
// Record the message if recording is active
if (this.recordingService.getStatus().isRecording) {
if (this.recordingService.getStatus().format === 'passthrough') {
this.recordingService.recordMessage(rawData, undefined);
}
else {
try {
const pgn = this.canboatParser.parse(rawData);
if (pgn) {
this.recordingService.recordMessage(undefined, pgn);
}
}
catch (error) {
console.debug('Failed to parse raw NMEA data:', error);
}
}
}
this.broadcast({
event: 'canboatjs:rawoutput',
data: rawData,
timestamp: new Date().toISOString(),
});
});
this.nmeaProvider.on('signalk-data', (data) => {
this.broadcast({
event: 'signalk:delta',
data: data,
timestamp: new Date().toISOString(),
});
});
this.nmeaProvider.on('synthetic-nmea', (data) => {
this.broadcast({
event: 'canboatjs:synthetic',
data: data,
timestamp: new Date().toISOString(),
});
});
this.nmeaProvider.on('error', (error) => {
console.error('NMEA Provider error:', error);
// Handle different error types and extract meaningful error message
let errorMessage = error.message || error.toString();
if (error.code) {
errorMessage = `${error.code}: ${errorMessage}`;
}
if (error.errors && Array.isArray(error.errors)) {
// Handle AggregateError with multiple underlying errors
errorMessage = error.errors.map((e) => e.message || e.toString()).join(', ');
if (error.code) {
errorMessage = `${error.code}: ${errorMessage}`;
}
}
// Update persistent connection state
this.connectionState = {
isConnected: false,
error: errorMessage || 'Unknown connection error',
lastUpdate: new Date().toISOString(),
};
this.broadcast({
event: 'error',
error: errorMessage || 'Unknown connection error',
timestamp: new Date().toISOString(),
});
});
this.nmeaProvider.on('connected', () => {
console.log('NMEA data source connected');
// Update persistent connection state
this.connectionState = {
isConnected: true,
error: null, // Clear any previous errors on successful connection
lastUpdate: new Date().toISOString(),
};
const connectionData = {
event: 'nmea:connected',
timestamp: new Date().toISOString(),
};
// Add authentication status if this is a SignalK connection
if (this.nmeaProvider && this.nmeaProvider.options.type === 'signalk') {
connectionData.auth = this.nmeaProvider.getAuthStatus?.();
}
this.broadcast(connectionData);
});
this.nmeaProvider.on('nmea2000OutAvailable', () => {
this.outAvailable = true;
this.broadcast({
event: 'nmea:out-available',
timestamp: new Date().toISOString(),
});
});
this.nmeaProvider.on('disconnected', () => {
console.log('NMEA data source disconnected');
// Update persistent connection state
this.connectionState = {
isConnected: false,
error: this.connectionState.error, // Keep existing error if any
lastUpdate: new Date().toISOString(),
};
this.outAvailable = false;
this.broadcast({
event: 'nmea:disconnected',
timestamp: new Date().toISOString(),
});
});
// Connect to the NMEA source
this.nmeaProvider.connect().catch((error) => {
console.error('Failed to connect to NMEA source:', error);
});
}
broadcast(message) {
// Send message to all connected WebSocket clients
//console.log('Broadcasting message:', JSON.stringify(message))
this.wss.clients.forEach((client) => {
if (client.readyState === ws_1.default.OPEN) {
//console.log('Sending to WebSocket client')
client.send(JSON.stringify(message));
}
});
}
getConfiguration() {
return {
server: {
port: this.port,
},
connections: this.currentConfig.connections || { activeConnection: null, profiles: {} },
connection: {
isConnected: this.connectionState.isConnected,
error: this.connectionState.error,
lastUpdate: this.connectionState.lastUpdate,
activeProfile: this.getActiveConnectionProfile(),
},
};
}
getConnectionProfiles() {
return this.currentConfig.connections || { activeConnection: null, profiles: {} };
}
getActiveConnectionProfile() {
const connections = this.currentConfig.connections;
if (!connections || !connections.activeConnection)
return null;
const profile = connections.profiles[connections.activeConnection];
return profile ? { id: connections.activeConnection, ...profile } : null;
}
saveConnectionProfile(profileData) {
if (!profileData.id || !profileData.name || !profileData.type) {
throw new Error('Profile ID, name, and type are required');
}
// Validate profile data
this.validateConnectionProfile(profileData);
// Initialize connections if not exists
if (!this.currentConfig.connections) {
this.currentConfig.connections = { activeConnection: null, profiles: {} };
}
// Save the profile
const { id, ...profile } = profileData;
this.currentConfig.connections.profiles[id] = profile;
// Save to config file
this.saveConfigToFile();
}
deleteConnectionProfile(profileId) {
if (!this.currentConfig.connections || !this.currentConfig.connections.profiles[profileId]) {
throw new Error('Connection profile not found');
}
// Don't delete if it's the active connection
if (this.currentConfig.connections.activeConnection === profileId) {
throw new Error('Cannot delete the active connection profile');
}
delete this.currentConfig.connections.profiles[profileId];
this.saveConfigToFile();
}
activateConnectionProfile(profileId) {
if (!this.currentConfig.connections || !this.currentConfig.connections.profiles[profileId]) {
throw new Error('Connection profile not found');
}
this.currentConfig.connections.activeConnection = profileId;
this.saveConfigToFile();
this.restartNMEAConnection();
}
validateConnectionProfile(profile) {
switch (profile.type) {
case 'serial':
if (!profile.serialPort)
throw new Error('Serial port is required for serial connection');
if (!profile.baudRate)
throw new Error('Baud rate is required for serial connection');
if (!profile.deviceType)
throw new Error('Device type is required for serial connection');
break;
case 'network':
if (!profile.networkHost)
throw new Error('Network host is required for network connection');
if (!profile.networkPort)
throw new Error('Network port is required for network connection');
if (!['tcp', 'udp'].includes(profile.networkProtocol)) {
throw new Error('Network protocol must be tcp or udp');
}
break;
case 'signalk':
if (!profile.signalkUrl)
throw new Error('SignalK URL is required for SignalK connection');
break;
case 'socketcan':
if (!profile.socketcanInterface)
throw new Error('SocketCAN interface is required for SocketCAN connection');
break;
case 'file':
if (!profile.filePath)
throw new Error('File path is required for file connection');
if (profile.playbackSpeed !== undefined && (profile.playbackSpeed < 0 || profile.playbackSpeed > 10)) {
throw new Error('Playback speed must be between 0 and 10');
}
break;
default:
throw new Error('Connection type must be serial, network, signalk, socketcan, or file');
}
}
updateConfiguration(newConfig) {
if (newConfig.connections) {
this.currentConfig.connections = { ...this.currentConfig.connections, ...newConfig.connections };
this.saveConfigToFile();
}
}
saveConfigToFile() {
try {
const configData = {
server: {
port: this.port,
},
connections: this.currentConfig.connections,
logging: { level: 'info' },
};
// Ensure the config directory exists
const configDir = path.dirname(this.configFile);
if (!fs.existsSync(configDir)) {
fs.mkdirSync(configDir, { recursive: true });
console.log(`Created config directory: ${configDir}`);
}
fs.writeFileSync(this.configFile, JSON.stringify(configData, null, 2));
console.log(`Configuration saved to ${this.configFile}`);
}
catch (error) {
console.error('Failed to save configuration:', error);
}
}
restartNMEAConnection() {
console.log('Restarting NMEA connection...');
// Reset connection state on manual restart
this.connectionState = {
isConnected: false,
error: null, // Clear any previous errors on manual restart
lastUpdate: new Date().toISOString(),
};
// Disconnect existing connection
if (this.nmeaProvider) {
this.nmeaProvider.disconnect();
this.nmeaProvider = null;
}
// Broadcast disconnection status
this.broadcast({
event: 'nmea:disconnected',
timestamp: new Date().toISOString(),
});
// Connect to active profile
const activeProfile = this.getActiveConnectionProfile();
if (activeProfile) {
setTimeout(() => {
console.log(`Connecting to: ${activeProfile.name}`);
this.connectToNMEASource(activeProfile);
}, 1000); // Wait 1 second before reconnecting
}
}
start() {
this.server.listen(this.port, () => {
console.log(`Visual Analyzer server started on port ${this.port}`);
console.log(`Access the application at: http://localhost:${this.port}`);
console.log(`WebSocket endpoint available at: ws://localhost:${this.port}`);
// Open browser if requested via environment variable
if (process.env.VISUAL_ANALYZER_OPEN_BROWSER === 'true') {
this.openBrowser(`http://localhost:${this.port}`);
}
});
}
openBrowser(url) {
// eslint-disable-next-line @typescript-eslint/no-var-requires
const { spawn } = require('child_process');
console.log(`Opening browser at: ${url}`);
let command;
let args = [url];
// Determine the appropriate command for the platform
if (process.platform === 'darwin') {
// macOS
command = 'open';
}
else if (process.platform === 'win32') {
// Windows
command = 'start';
args = ['', url]; // start command requires empty first argument
}
else {
// Linux and others
command = 'xdg-open';
}
try {
const child = spawn(command, args, {
detached: true,
stdio: 'ignore',
});
// Allow the parent process to exit independently
child.unref();
setTimeout(() => {
console.log('Browser should have opened. If not, please visit the URL manually.');
}, 1000);
}
catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
console.warn(`Failed to open browser automatically: ${errorMessage}`);
console.log('Please open your browser manually and visit the URL above.');
}
}
stop() {
// Stop any active recording
try {
if (this.recordingService.getStatus().isRecording) {
this.recordingService.stopRecording();
console.log('Stopped active recording on server shutdown');
}
}
catch (error) {
console.warn('Failed to stop recording on shutdown:', error);
}
// Disconnect NMEA provider
if (this.nmeaProvider) {
this.nmeaProvider.disconnect();
}
this.server.close(() => {
console.log('Visual Analyzer server stopped');
});
}
}
const translateToSignalK = (req, res, canboatParser, n2kMapper) => {
try {
const values = req.body.values;
if (!values || values.length === 0) {
return res.status(400).json({
success: false,
error: 'Missing required field: values',
});
}
let nmea2000Data;
const data = values[0];
// Handle different input formats
if (typeof data === 'string') {
// If JSON parsing fails, try to parse as NMEA 2000 string(s) using canboatjs
const lines = data.split(/\r?\n/).filter((line) => line.trim());
if (lines.length === 0) {
return res.status(400).json({
success: false,
error: 'No valid lines found in input',
});
}
nmea2000Data = [];
for (const line of lines) {
const trimmedLine = line.trim();
if (trimmedLine) {
try {
const parsed = canboatParser.parseString(trimmedLine);
if (parsed) {
nmea2000Data.push(parsed);
}
else {
console.warn(`Unable to parse line: ${trimmedLine}`);
}
}
catch (lineParseError) {
const errorMessage = lineParseError instanceof Error ? lineParseError.message : 'Unknown error';
console.warn(`Error parsing line "${trimmedLine}": ${errorMessage}`);
// Continue processing other lines instead of failing
}
}
}
if (nmea2000Data.length === 0) {
return res.status(400).json({
success: false,
error: 'No valid NMEA 2000 messages could be parsed from input',
});
}
}
else if (Array.isArray(data)) {
nmea2000Data = data;
}
else if (typeof data === 'object') {
nmea2000Data = [data];
}
else {
return res.status(400).json({
success: false,
error: 'Invalid data format. Expected string, object, or array.',
});
}
// Transform each NMEA 2000 message to SignalK using N2kMapper
const signalKDeltas = [];
const errors = [];
for (const nmea2000Message of nmea2000Data) {
try {
const signalKDelta = n2kMapper.toDelta(nmea2000Message);
if (signalKDelta && signalKDelta.updates && signalKDelta.updates.length > 0) {
signalKDeltas.push(signalKDelta);
}
else {
console.warn('N2kMapper returned empty or invalid delta for:', nmea2000Message);
}
}
catch (transformError) {
const errorMessage = transformError instanceof Error ? transformError.message : 'Unknown error';
console.error('Error transforming NMEA 2000 to SignalK:', transformError);
errors.push({
message: nmea2000Message,
error: errorMessage,
});
}
}
// Return success response with SignalK deltas
res.json({
success: true,
message: `${nmea2000Data.length} message(s) processed, ${signalKDeltas.length} SignalK delta(s) generated`,
messagesProcessed: nmea2000Data.length,
signalKDeltas: signalKDeltas,
errors: errors.length > 0 ? errors : undefined,
});
}
catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
console.error('Error processing SignalK transformation request:', error);
res.status(500).json({
success: false,
error: errorMessage,
});
}
};
exports.translateToSignalK = translateToSignalK;
exports.default = VisualAnalyzerServer;
//# sourceMappingURL=server.js.map