signalk-parquet
Version:
SignalK plugin to save marine data directly to Parquet files with regimen-based control
281 lines • 11 kB
JavaScript
;
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