@canboat/visual-analyzer
Version:
NMEA 2000 data visualization utility (requires SK Server >= 2.15)
456 lines • 19 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;
};
})();
Object.defineProperty(exports, "__esModule", { value: true });
exports.RecordingService = void 0;
const fs = __importStar(require("fs"));
const path = __importStar(require("path"));
const events_1 = require("events");
const canboatjs_1 = require("@canboat/canboatjs");
class RecordingService extends events_1.EventEmitter {
constructor(configPath) {
super();
this.isRecording = false;
this.currentFileName = null;
this.currentFormat = 'canboat-json';
this.messageCount = 0;
this.startTime = null;
this.fileStream = null;
// Create recordings directory
this.recordingsDir = path.join(configPath, 'recordings');
this.ensureRecordingsDirectory();
}
ensureRecordingsDirectory() {
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'}`);
}
}
getStatus() {
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,
};
}
startRecording(options = {}) {
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;
}
}
stopRecording() {
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;
}
}
recordMessage(raw, pgn) {
if (!this.isRecording || !this.fileStream) {
return;
}
try {
let formattedMessage = undefined;
if (this.currentFormat === 'passthrough') {
if (typeof raw === 'string') {
formattedMessage = raw;
}
else {
// right now, only canbus sends object data
formattedMessage = (0, canboatjs_1.encodeCandump2)({ ...raw, data: Buffer.from(raw.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 = (0, canboatjs_1.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 = (0, canboatjs_1.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 = (0, canboatjs_1.pgnToiKonvertSerialFormat)(pgn);
if (!ikonvertResult) {
console.error(`Failed to convert PGN ${pgn.pgn} to iKonvert format`);
return;
}
formattedMessage = ikonvertResult;
break;
}
case 'ydwg-raw': {
const ydwgResult = (0, canboatjs_1.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 = (0, canboatjs_1.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 = (0, canboatjs_1.pgnToPCDIN)(pgn);
if (!pcdinResult) {
console.error(`Failed to convert PGN ${pgn.pgn} to PCDIN format`);
return;
}
formattedMessage = pcdinResult;
break;
}
case 'mxpgn': {
const mxpgnResult = (0, canboatjs_1.pgnToMXPGN)(pgn);
if (!mxpgnResult) {
console.error(`Failed to convert PGN ${pgn.pgn} to MXPGN format`);
return;
}
formattedMessage = mxpgnResult;
break;
}
case 'candump1': {
const candump1Result = (0, canboatjs_1.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 = (0, canboatjs_1.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 = (0, canboatjs_1.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);
}
}
getRecordedFiles() {
try {
if (!fs.existsSync(this.recordingsDir)) {
return [];
}
const files = fs.readdirSync(this.recordingsDir);
const recordingFiles = [];
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 [];
}
}
deleteRecordedFile(fileName) {
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;
}
}
getRecordedFilePath(fileName) {
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;
}
getFileExtension(format) {
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';
}
}
guessFileFormat(fileName) {
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';
}
}
estimateMessageCount(filePath) {
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;
}
}
}
exports.RecordingService = RecordingService;
//# sourceMappingURL=recording-service.js.map