UNPKG

@canboat/visual-analyzer

Version:

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

1,136 lines (991 loc) 39.1 kB
/** * 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. */ import express, { Express, Request, Response } from 'express' import * as http from 'http' import WebSocket, { WebSocketServer } from 'ws' import * as path from 'path' import * as fs from 'fs' import * as os from 'os' import NMEADataProvider from './nmea-provider' import { FromPgn } from '@canboat/canboatjs' import { N2kMapper } from '@signalk/n2k-signalk' import { RecordingService } from './recording-service' import { Config, ConnectionState, WebSocketMessage, BroadcastMessage, ApiResponse, ConfigurationResponse, ConnectionProfile, ConnectionsConfig, INMEAProvider, } from './types' class VisualAnalyzerServer { private port: number private publicDir: string private configFile: string private configDir: string private app: Express private server: http.Server private wss: WebSocketServer private nmeaProvider: INMEAProvider | null = null private currentConfig: Config private canboatParser: FromPgn private n2kMapper: N2kMapper private connectionState: ConnectionState private recordingService: RecordingService private outAvailable: boolean = false constructor(port?: number) { this.publicDir = path.join(__dirname, '../public') // Platform-appropriate config file location let configDir: string 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 = express() this.server = http.createServer(this.app) this.wss = new WebSocketServer({ server: this.server }) // Initialize canboatjs parser for string input parsing this.canboatParser = new 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 N2kMapper({}) // Initialize recording service this.recordingService = new 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'}`) } private loadConfiguration(): Config { 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 } private connectToActiveProfile(): void { const activeProfile = this.getActiveConnectionProfile() if (activeProfile) { console.log(`Auto-connecting to active profile: ${activeProfile.name}`) this.connectToNMEASource(activeProfile) } } private setupRoutes(): void { // Add JSON parsing middleware this.app.use(express.json()) // API routes for configuration this.app.get('/api/config', (req: Request, res: Response) => { res.json(this.getConfiguration()) }) this.app.post('/api/config', (req: Request, res: Response) => { 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: Request, res: Response) => { res.json(this.getConnectionProfiles()) }) this.app.post('/api/connections', (req: Request, res: Response) => { 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: Request, res: Response) => { 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: Request, res: Response) => { 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: Request, res: Response) => { 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: Request, res: Response) => { try { const status = this.recordingService.getStatus() res.json({ success: true, result: status } as ApiResponse) } 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: Request, res: Response) => { 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' } as ApiResponse) } catch (error) { const errorMessage = error instanceof Error ? error.message : 'Unknown error' res.status(400).json({ success: false, error: errorMessage } as ApiResponse) } }) this.app.post('/api/recording/stop', (req: Request, res: Response) => { 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: Request, res: Response) => { try { const files = this.recordingService.getRecordedFiles() res.json({ success: true, results: files, // Include detailed results for each message } as ApiResponse) } catch (error) { const errorMessage = error instanceof Error ? error.message : 'Unknown error' res.status(500).json({ success: false, error: errorMessage } as ApiResponse) } }) this.app.delete('/api/recording/files/:fileName', (req: Request, res: Response) => { 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: Request, res: Response) => { 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: Request, res: Response) => { try { const values = req.body.values if (!values) { return res.status(400).json({ success: false, error: 'Missing required field: values', } as ApiResponse) } const pgnDataArray: any[] = [] 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', } as ApiResponse) } 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', } as ApiResponse) } 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, } as ApiResponse) } } 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', } as ApiResponse) } } // Process each parsed message const results: any[] = [] 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 } as ApiResponse) } 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, } as ApiResponse) } }) // SignalK transformation endpoint this.app.post('/api/transform/signalk', (req: Request, res: Response) => { translateToSignalK(req, res, this.canboatParser, this.n2kMapper) }) // Serve static files from public directory this.app.use(express.static(this.publicDir)) // Serve the main HTML file for any route (SPA support) this.app.get('*', (req: Request, res: Response) => { 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.') } }) } private setupWebSocket(): void { this.wss.on('connection', (ws: WebSocket, req: http.IncomingMessage) => { 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: WebSocket.Data) => { try { const data: WebSocketMessage = 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: Error) => { console.error('WebSocket error:', error) }) }) } private handleWebSocketMessage(ws: WebSocket, data: WebSocketMessage): void { 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: any = { 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) } } private stopDataStream(ws: WebSocket): void { // Clean up intervals when unsubscribing if ((ws as any).intervals) { ;(ws as any).intervals.forEach((interval: NodeJS.Timeout) => clearInterval(interval)) ;(ws as any).intervals = [] } } private generateSampleNMEAData(): string { // 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 public connectToNMEASource(options: ConnectionProfile): void { console.log('Connecting to NMEA 2000 source with options:', options) this.nmeaProvider = new NMEADataProvider(options, this.configDir) // Set up event listeners for NMEA data this.nmeaProvider.on('nmea-data', (data: any) => { this.broadcast({ event: 'canboatjs:parsed', data: data, timestamp: new Date().toISOString(), }) }) this.nmeaProvider.on('raw-nmea', (rawData: any) => { // 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: any) => { this.broadcast({ event: 'signalk:delta', data: data, timestamp: new Date().toISOString(), }) }) this.nmeaProvider.on('synthetic-nmea', (data: any) => { this.broadcast({ event: 'canboatjs:synthetic', data: data, timestamp: new Date().toISOString(), }) }) this.nmeaProvider.on('error', (error: any) => { 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: any) => 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: any = { 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: Error) => { console.error('Failed to connect to NMEA source:', error) }) } private broadcast(message: BroadcastMessage): void { // Send message to all connected WebSocket clients //console.log('Broadcasting message:', JSON.stringify(message)) this.wss.clients.forEach((client) => { if (client.readyState === WebSocket.OPEN) { //console.log('Sending to WebSocket client') client.send(JSON.stringify(message)) } }) } public getConfiguration(): ConfigurationResponse { 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(), }, } } public getConnectionProfiles(): ConnectionsConfig { return this.currentConfig.connections || { activeConnection: null, profiles: {} } } public getActiveConnectionProfile(): ConnectionProfile | null { const connections = this.currentConfig.connections if (!connections || !connections.activeConnection) return null const profile = connections.profiles[connections.activeConnection] return profile ? { id: connections.activeConnection, ...profile } : null } public saveConnectionProfile(profileData: ConnectionProfile): void { 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() } public deleteConnectionProfile(profileId: string): void { 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() } public activateConnectionProfile(profileId: string): void { 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() } private validateConnectionProfile(profile: ConnectionProfile): void { 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') } } public updateConfiguration(newConfig: Partial<Config>): void { if (newConfig.connections) { this.currentConfig.connections = { ...this.currentConfig.connections, ...newConfig.connections } this.saveConfigToFile() } } private saveConfigToFile(): void { 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) } } public restartNMEAConnection(): void { 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 } } public start(): void { 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}`) } }) } private openBrowser(url: string): void { // eslint-disable-next-line @typescript-eslint/no-var-requires const { spawn } = require('child_process') console.log(`Opening browser at: ${url}`) let command: string let args: string[] = [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.') } } public stop(): void { // 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') }) } } export const translateToSignalK = (req: Request, res: Response, canboatParser: FromPgn, n2kMapper: N2kMapper) => { try { const values = req.body.values as any[] if (!values || values.length === 0) { return res.status(400).json({ success: false, error: 'Missing required field: values', }) } let nmea2000Data: any[] 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: string) => 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: any[] = [] const errors: Array<{ message: any; error: string }> = [] 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, }) } } export default VisualAnalyzerServer