UNPKG

@canboat/visual-analyzer

Version:

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

511 lines (441 loc) 15.2 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 * as fs from 'fs' import * as path from 'path' import { EventEmitter } from 'events' import { pgnToActisenseSerialFormat, pgnToActisenseN2KAsciiFormat, pgnToiKonvertSerialFormat, pgnToYdgwRawFormat, pgnToYdgwFullRawFormat, pgnToPCDIN, pgnToMXPGN, pgnToCandump1, pgnToCandump2, pgnToCandump3, encodeCandump2, } from '@canboat/canboatjs' import { PGN } from '@canboat/ts-pgns' export interface RecordingStatus { isRecording: boolean fileName?: string startTime?: string messageCount: number fileSize: number format?: string error?: string } export interface RecordingFile { name: string size: number created: string messageCount: number format?: string } export interface RecordingOptions { fileName?: string format?: string } export class RecordingService extends EventEmitter { private isRecording: boolean = false private currentFileName: string | null = null private currentFormat: string = 'canboat-json' private messageCount: number = 0 private startTime: Date | null = null private fileStream: fs.WriteStream | null = null private recordingsDir: string constructor(configPath: string) { super() // Create recordings directory this.recordingsDir = path.join(configPath, 'recordings') this.ensureRecordingsDirectory() } private ensureRecordingsDirectory(): void { try { if (!fs.existsSync(this.recordingsDir)) { fs.mkdirSync(this.recordingsDir, { recursive: true }) console.log(`Created recordings directory: ${this.recordingsDir}`) } } catch (error) { console.error('Failed to create recordings directory:', error) throw new Error( `Failed to create recordings directory: ${error instanceof Error ? error.message : 'Unknown error'}`, ) } } public getStatus(): RecordingStatus { let fileSize = 0 if (this.isRecording && this.currentFileName) { try { const filePath = path.join(this.recordingsDir, this.currentFileName) const stats = fs.statSync(filePath) fileSize = stats.size } catch (error) { console.warn('Failed to get file size:', error) } } return { isRecording: this.isRecording, fileName: this.currentFileName || undefined, startTime: this.startTime?.toISOString(), messageCount: this.messageCount, fileSize, format: this.currentFormat, } } public startRecording(options: RecordingOptions = {}): RecordingStatus { if (this.isRecording) { throw new Error('Recording is already in progress') } try { // Generate filename if not provided let fileName = options.fileName if (!fileName) { const timestamp = new Date().toISOString().replace(/[:.]/g, '-') const format = options.format || 'passthrough' const extension = this.getFileExtension(format) fileName = `recording_${timestamp}.${extension}` } // Ensure filename has correct extension if (!fileName.includes('.')) { const extension = this.getFileExtension(options.format || 'passthrough') fileName += `.${extension}` } const filePath = path.join(this.recordingsDir, fileName) // Check if file already exists if (fs.existsSync(filePath)) { throw new Error(`File ${fileName} already exists`) } // Create file stream this.fileStream = fs.createWriteStream(filePath, { flags: 'w' }) // Handle stream errors this.fileStream.on('error', (error) => { console.error('Recording file stream error:', error) this.stopRecording() this.emit('error', error) }) // Set recording state this.isRecording = true this.currentFileName = fileName this.currentFormat = options.format || 'passthrough' this.messageCount = 0 this.startTime = new Date() if (this.currentFormat === 'canboat-json-pretty') { this.fileStream.write('[\n') // Start JSON array for canboat-json format } console.log(`Started recording to ${fileName} in ${this.currentFormat} format`) this.emit('started', this.getStatus()) return this.getStatus() } catch (error) { const errorMessage = error instanceof Error ? error.message : 'Unknown error' console.error('Failed to start recording:', errorMessage) throw error } } public stopRecording(): RecordingStatus { if (!this.isRecording) { throw new Error('No recording in progress') } try { // Close file stream if (this.fileStream) { if (this.currentFormat === 'canboat-json-pretty') { this.fileStream.write('\n]\n') // End JSON array for canboat-json format } this.fileStream.end() this.fileStream = null } const status = this.getStatus() // Reset recording state this.isRecording = false const fileName = this.currentFileName this.currentFileName = null this.currentFormat = 'passthrough' this.messageCount = 0 this.startTime = null console.log(`Stopped recording. Recorded ${status.messageCount} messages to ${fileName}`) this.emit('stopped', status) return { isRecording: false, messageCount: 0, fileSize: 0, } } catch (error) { const errorMessage = error instanceof Error ? error.message : 'Unknown error' console.error('Failed to stop recording:', errorMessage) throw error } } public recordMessage(raw: string | object | undefined, pgn: PGN | undefined): void { if (!this.isRecording || !this.fileStream) { return } try { let formattedMessage: string | undefined = undefined if (this.currentFormat === 'passthrough') { if (typeof raw === 'string') { formattedMessage = raw } else { // right now, only canbus sends object data formattedMessage = encodeCandump2({ ...raw, data: Buffer.from((raw as any).data) })[0] } } else if (pgn !== undefined) { switch (this.currentFormat) { case 'canboat-json': formattedMessage = JSON.stringify(pgn) break case 'canboat-json-pretty': formattedMessage = JSON.stringify(pgn, null, 2) + ',' break case 'actisense': { const actisenseResult = pgnToActisenseSerialFormat(pgn) if (!actisenseResult) { console.error(`Failed to convert PGN ${pgn.pgn} to Actisense format`) return } formattedMessage = actisenseResult break } case 'actisense-n2k-ascii': { const n2kAsciiResult = pgnToActisenseN2KAsciiFormat(pgn) if (!n2kAsciiResult) { console.error(`Failed to convert PGN ${pgn.pgn} to Actisense N2K ASCII format`) return } formattedMessage = n2kAsciiResult break } case 'ikonvert': { const ikonvertResult = pgnToiKonvertSerialFormat(pgn) if (!ikonvertResult) { console.error(`Failed to convert PGN ${pgn.pgn} to iKonvert format`) return } formattedMessage = ikonvertResult break } case 'ydwg-raw': { const ydwgResult = pgnToYdgwRawFormat(pgn) if (!ydwgResult) { console.error(`Failed to convert PGN ${pgn.pgn} to YDWG RAW format`) return } formattedMessage = Array.isArray(ydwgResult) ? ydwgResult.join('\n') : ydwgResult break } case 'ydwg-full-raw': { const ydwgFullResult = pgnToYdgwFullRawFormat(pgn) if (!ydwgFullResult) { console.error(`Failed to convert PGN ${pgn.pgn} to YDWG Full RAW format`) return } formattedMessage = Array.isArray(ydwgFullResult) ? ydwgFullResult.join('\n') : ydwgFullResult break } case 'pcdin': { const pcdinResult = pgnToPCDIN(pgn) if (!pcdinResult) { console.error(`Failed to convert PGN ${pgn.pgn} to PCDIN format`) return } formattedMessage = pcdinResult break } case 'mxpgn': { const mxpgnResult = pgnToMXPGN(pgn) if (!mxpgnResult) { console.error(`Failed to convert PGN ${pgn.pgn} to MXPGN format`) return } formattedMessage = mxpgnResult break } case 'candump1': { const candump1Result = pgnToCandump1(pgn) if (!candump1Result) { console.error(`Failed to convert PGN ${pgn.pgn} to candump1 format`) return } formattedMessage = Array.isArray(candump1Result) ? candump1Result.join('\n') : candump1Result break } case 'candump2': { const candump2Result = pgnToCandump2(pgn) if (!candump2Result) { console.error(`Failed to convert PGN ${pgn.pgn} to candump2 format`) return } formattedMessage = Array.isArray(candump2Result) ? candump2Result.join('\n') : candump2Result break } case 'candump3': { const candump3Result = pgnToCandump3(pgn) if (!candump3Result) { console.error(`Failed to convert PGN ${pgn.pgn} to candump3 format`) return } formattedMessage = Array.isArray(candump3Result) ? candump3Result.join('\n') : candump3Result break } } } if (formattedMessage !== undefined) { // Write message to file this.fileStream.write(formattedMessage + '\n') this.messageCount++ // Emit progress update every 100 messages if (this.messageCount % 10 === 0) { this.emit('progress', this.getStatus()) } } } catch (error) { console.error('Failed to record message:', error) this.emit('error', error) } } public getRecordedFiles(): RecordingFile[] { try { if (!fs.existsSync(this.recordingsDir)) { return [] } const files = fs.readdirSync(this.recordingsDir) const recordingFiles: RecordingFile[] = [] for (const file of files) { const filePath = path.join(this.recordingsDir, file) try { const stats = fs.statSync(filePath) if (stats.isFile()) { // Try to determine format from file extension or content const format = this.guessFileFormat(file) // Count messages (rough estimate based on file size and format) const messageCount = this.estimateMessageCount(filePath) recordingFiles.push({ name: file, size: stats.size, created: stats.birthtime.toISOString(), messageCount, format, }) } } catch (error) { console.warn(`Failed to get stats for file ${file}:`, error) } } // Sort by creation date (newest first) return recordingFiles.sort((a, b) => new Date(b.created).getTime() - new Date(a.created).getTime()) } catch (error) { console.error('Failed to get recorded files:', error) return [] } } public deleteRecordedFile(fileName: string): void { const filePath = path.join(this.recordingsDir, fileName) // Prevent path traversal attacks if (!filePath.startsWith(this.recordingsDir)) { throw new Error('Invalid file path') } if (!fs.existsSync(filePath)) { throw new Error('File not found') } try { fs.unlinkSync(filePath) console.log(`Deleted recording file: ${fileName}`) } catch (error) { const errorMessage = error instanceof Error ? error.message : 'Unknown error' console.error(`Failed to delete file ${fileName}:`, errorMessage) throw error } } public getRecordedFilePath(fileName: string): string { const filePath = path.join(this.recordingsDir, fileName) // Prevent path traversal attacks if (!filePath.startsWith(this.recordingsDir)) { throw new Error('Invalid file path') } if (!fs.existsSync(filePath)) { throw new Error('File not found') } return filePath } private getFileExtension(format: string): string { switch (format) { case 'canboat-json': return 'json' case 'actisense': case 'actisense-n2k-ascii': return 'n2k' case 'ikonvert': return 'iko' case 'ydwg-full-raw': case 'ydwg-raw': return 'ydwg' case 'pcdin': return 'pcd' case 'mxpgn': return 'mxp' case 'candump1': case 'candump2': case 'candump3': return 'log' default: return 'txt' } } private guessFileFormat(fileName: string): string { const extension = path.extname(fileName).toLowerCase().substring(1) switch (extension) { case 'json': return 'canboat-json' case 'n2k': return 'actisense' case 'iko': return 'ikonvert' case 'ydwg': return 'ydwg-raw' case 'pcd': return 'pcdin' case 'mxp': return 'mxpgn' case 'log': return 'candump1' default: return 'unknown' } } private estimateMessageCount(filePath: string): number { try { const stats = fs.statSync(filePath) const fileSize = stats.size if (fileSize === 0) return 0 // Read a small sample to estimate average line length const sampleSize = Math.min(4096, fileSize) const buffer = Buffer.alloc(sampleSize) const fd = fs.openSync(filePath, 'r') fs.readSync(fd, buffer, 0, sampleSize, 0) fs.closeSync(fd) const sample = buffer.toString('utf8') const lines = sample.split('\n').filter((line) => line.trim().length > 0) if (lines.length === 0) return 0 const avgLineLength = sample.length / lines.length const estimatedLines = Math.floor(fileSize / avgLineLength) return estimatedLines } catch (error) { console.warn('Failed to estimate message count:', error) return 0 } } }