UNPKG

@canboat/visual-analyzer

Version:

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

485 lines (416 loc) 13.9 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 { EventEmitter } from 'events' import WebSocket from 'ws' import * as fs from 'fs' import * as readline from 'readline' import { SignalKMessage, SignalKLoginMessage, SignalKLoginResponse, INMEAProvider, ConnectionProfile } from './types' import CanDevice from './n2k-device' class NMEADataProvider extends EventEmitter implements INMEAProvider { public options: ConnectionProfile private configPath: string private isConnected: boolean = false private authToken: string | null = null private authRequestId: number = 0 private pendingAuthResolve: ((value: boolean) => void) | null = null // Connection objects private signalKWs: WebSocket | null = null private canDevice: CanDevice | null = null // File playback specific properties private fileStream: fs.ReadStream | null = null private playbackTimer: NodeJS.Timeout | null = null private readline: readline.Interface | null = null private lineQueue: string[] = [] private isProcessingQueue: boolean = false private currentFilePath: string | null = null constructor(options: ConnectionProfile, configPath: string) { super() this.options = options this.configPath = configPath } public async connect(): Promise<void> { try { if (this.options.type === 'signalk') { await this.connectToSignalK() } else if (this.options.type === 'file') { await this.connectToFile() } else { this.canDevice = new CanDevice(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) } } private async connectToSignalK(): Promise<void> { 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 WebSocket(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: WebSocket.Data) => { try { const message: SignalKMessage = JSON.parse(data.toString()) // Handle authentication responses if (message.requestId && message.requestId.startsWith('auth-')) { this.handleAuthenticationResponse(message as SignalKLoginResponse) 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: 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 } }) } private authenticateViaWebSocket(): Promise<boolean> { 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: SignalKLoginMessage = { 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) }) } private handleAuthenticationResponse(message: SignalKLoginResponse): void { 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 } } } private handleLogoutResponse(message: SignalKMessage): void { 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 } public getServerApp(): any { return { config: { configPath: this.configPath }, setProviderError: (providerId: string, msg: string) => { console.error(`${providerId} error: ${msg}`) this.emit('error', new Error(msg)) }, setProviderStatus: (providerId: string, msg: string) => { console.log(`${providerId} status: ${msg}`) }, on: (event: string, callback: (...args: any[]) => void) => { this.on(event, callback) }, removeListener: (event: string, callback: (...args: any[]) => void) => { this.removeListener(event, callback) }, emit: (event: string, data: any) => { if (event === 'canboatjs:rawoutput') { this.emit('raw-nmea', data) } else { this.emit(event, data) } }, listenerCount: (event: string) => { return this.listenerCount(event === 'canboatjs:rawoutput' ? 'raw-nmea' : event) }, } } private async connectToFile(): Promise<void> { 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 } } private setupFileStream(filePath: string): void { 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: string) => { 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: Error) => { console.error('File reading error:', error) this.emit('error', error) }) } private processQueue(): void { 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') } } } private startFilePlayback(): void { console.log('Starting file playback...') this.processQueue() } private restartFilePlayback(): void { // 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) } } private processSignalKUpdate(update: any): void { // Process SignalK delta updates and emit them this.emit('signalk-data', update) } public disconnect(): void { 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') } private logoutFromSignalK(): void { 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)) } private getDelimiterForDevice(deviceType: string): string | RegExp { 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/ } } public isConnectionActive(): boolean { return this.isConnected } public getAuthStatus(): any { if (this.options.type === 'signalk') { return { isAuthenticated: !!this.authToken, token: this.authToken, username: this.options.signalkUsername, } } return null } public sendMessage(data: any): void { 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}`) } } /* private sendToSignalK(data: any): void { if (!this.signalKWs || this.signalKWs.readyState !== WebSocket.OPEN) { throw new Error('SignalK WebSocket not connected') } // Send as SignalK delta update const message = { context: 'vessels.self', updates: [ { source: { label: 'visual-analyzer', }, timestamp: new Date().toISOString(), values: data, }, ], } this.signalKWs.send(JSON.stringify(message)) }*/ } export default NMEADataProvider