UNPKG

signalk-parquet

Version:

SignalK plugin to save marine data directly to Parquet files with regimen-based control

281 lines 11 kB
"use strict"; 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.StreamingService = void 0; const WebSocket = __importStar(require("ws")); const core_1 = require("@js-joda/core"); class StreamingService { constructor(httpServer, options) { this.activeSubscriptions = new Map(); this.connectedClients = new Set(); this.historyAPI = options.historyAPI; this.selfId = options.selfId; this.debug = options.debug || false; try { // Create WebSocket server this.wss = new WebSocket.Server({ server: httpServer, path: '/signalk-parquet-stream' }); this.setupEventHandlers(); this.log('Streaming service initialized with direct HistoryAPI calls'); } catch (error) { this.log('Failed to initialize WebSocket server:', error); throw error; } } setupEventHandlers() { this.wss.on('connection', (ws) => { this.log('Client connected'); this.connectedClients.add(ws); ws.on('message', (data) => { try { const message = JSON.parse(data.toString()); this.handleMessage(ws, message); } catch (error) { this.log('Error parsing message:', error); ws.send(JSON.stringify({ error: 'Invalid JSON' })); } }); ws.on('close', () => { this.log('Client disconnected'); this.connectedClients.delete(ws); }); ws.on('error', (error) => { this.log('WebSocket error:', error); this.connectedClients.delete(ws); }); // Send welcome message ws.send(JSON.stringify({ type: 'welcome', message: 'Connected to SignalK Parquet Streaming Service' })); }); } handleMessage(ws, message) { switch (message.type) { case 'subscribe': this.handleSubscribe(ws, message); break; case 'unsubscribe': this.handleUnsubscribe(ws, message); break; default: ws.send(JSON.stringify({ error: `Unknown message type: ${message.type}` })); } } handleSubscribe(ws, message) { const { subscriptionId, path, timeWindow, aggregates, refreshInterval } = message; if (!subscriptionId || !path || !timeWindow) { ws.send(JSON.stringify({ error: 'Missing required fields: subscriptionId, path, timeWindow' })); return; } // Create subscription const subscription = { id: subscriptionId, path, timeWindow, aggregates: aggregates || ['current'], refreshInterval: refreshInterval || 1000 }; // Start streaming data this.startStreaming(subscription); this.activeSubscriptions.set(subscriptionId, subscription); ws.send(JSON.stringify({ type: 'subscribed', subscriptionId, message: `Subscribed to ${path} with ${timeWindow} window` })); this.log(`Created subscription: ${subscriptionId} for path ${path}`); } handleUnsubscribe(ws, message) { const { subscriptionId } = message; if (this.activeSubscriptions.has(subscriptionId)) { this.stopStreaming(subscriptionId); this.activeSubscriptions.delete(subscriptionId); ws.send(JSON.stringify({ type: 'unsubscribed', subscriptionId })); this.log(`Removed subscription: ${subscriptionId}`); } else { ws.send(JSON.stringify({ error: `Subscription not found: ${subscriptionId}` })); } } startStreaming(subscription) { const fetchData = async () => { try { // Calculate time window const { fromTime, toTime } = this.calculateTimeWindow(subscription.timeWindow); this.log(`Fetching data for ${subscription.path} from ${fromTime} to ${toTime}`); // Parse times to ZonedDateTime (same as HistoryAPI does) const from = core_1.ZonedDateTime.parse(fromTime + (fromTime.endsWith('Z') ? '' : 'Z')); const to = core_1.ZonedDateTime.parse(toTime + (toTime.endsWith('Z') ? '' : 'Z')); const context = `vessels.${this.selfId}`; // Create mock request/response to call getValues directly const mockReq = { query: { paths: subscription.path, // Let HistoryAPI calculate resolution automatically } }; let capturedResult = null; const mockRes = { json: (data) => { capturedResult = data; }, status: () => mockRes }; // Call HistoryAPI.getValues directly (same as REST API) await this.historyAPI.getValues(context, from, to, false, // shouldRefresh (msg) => this.log(msg), // debug function mockReq, mockRes); if (capturedResult && capturedResult.data) { // Transform to streaming format const streamData = { type: 'data', subscriptionId: subscription.id, path: subscription.path, timeWindow: subscription.timeWindow, timestamp: new Date().toISOString(), data: capturedResult.data, // Full dataset with buckets meta: { range: capturedResult.range, dataPoints: capturedResult.data.length } }; // Broadcast to all connected clients this.broadcast(streamData); } else { this.log(`No data returned for subscription ${subscription.id}`); } } catch (error) { this.log(`Error fetching data for subscription ${subscription.id}:`, error); // Send error to clients this.broadcast({ type: 'error', subscriptionId: subscription.id, error: error instanceof Error ? error.message : 'Unknown error' }); } }; // Fetch initial data immediately fetchData(); // Set up periodic refresh subscription.timer = setInterval(fetchData, subscription.refreshInterval); } stopStreaming(subscriptionId) { const subscription = this.activeSubscriptions.get(subscriptionId); if (subscription && subscription.timer) { clearInterval(subscription.timer); subscription.timer = undefined; } } calculateTimeWindow(timeWindow) { const now = new Date(); const toTime = now.toISOString(); // Parse duration like "5m", "1h", "30s" const match = timeWindow.match(/^(\d+)([smhd])$/); if (!match) { throw new Error(`Invalid time window format: ${timeWindow}`); } const amount = parseInt(match[1]); const unit = match[2]; const fromDate = new Date(now); switch (unit) { case 's': fromDate.setSeconds(fromDate.getSeconds() - amount); break; case 'm': fromDate.setMinutes(fromDate.getMinutes() - amount); break; case 'h': fromDate.setHours(fromDate.getHours() - amount); break; case 'd': fromDate.setDate(fromDate.getDate() - amount); break; default: throw new Error(`Unsupported time unit: ${unit}`); } const fromTime = fromDate.toISOString(); return { fromTime, toTime }; } broadcast(data) { const message = JSON.stringify(data); this.connectedClients.forEach(client => { if (client.readyState === WebSocket.OPEN) { client.send(message); } }); } getStats() { return { connectedClients: this.connectedClients.size, activeSubscriptions: this.activeSubscriptions.size }; } shutdown() { this.log('Shutting down streaming service'); // Stop all subscriptions this.activeSubscriptions.forEach((subscription) => { this.stopStreaming(subscription.id); }); this.activeSubscriptions.clear(); // Close all WebSocket connections this.connectedClients.forEach(client => { client.close(); }); this.connectedClients.clear(); // Close WebSocket server if (this.wss) { this.wss.close(); } } log(message, ...args) { if (this.debug) { console.log(`[StreamingService] ${message}`, ...args); } } } exports.StreamingService = StreamingService; //# sourceMappingURL=streaming-service.js.map