const WebSocket = require('ws'); const msgpack = require('msgpack-lite'); const { loadSensorReferencesFromServer, checkSensorReferencesVersion } = require('../config'); const dataHub = require('../tools/dataHub'); // Stato connessione let ws = null; let sendTimer = null; let isConnected = false; let reconnectTimer = null; let pingInterval = null; let configCheckTimer = null; let app = null; // Sensor references (caricati dal server) let sensorRules = null; // Buffer locale anti-perdita dati const localBuffer = []; const MAX_BUFFER_SIZE = 3600; // ~1h di dati a 1/sec // Statistiche (solo in memoria, niente file I/O) let stats = { sensorID: '', sent: 0, firstSent: null, sentEveryMLS: 1000, reconnections: 0, status: 'disconnected', buffered: 0, lastConfigVersion: null }; // Reconnection con exponential backoff const BASE_RECONNECT_DELAY = 2000; const MAX_RECONNECT_DELAY = 60000; /** * Inizializza il modulo realtime. * 1. Autentica il sensore per ottenere un ticket * 2. Usa il ticket per caricare i sensor references (endpoint autenticato) * 3. Usa lo stesso ticket per connettere il WebSocket */ async function init(signalKApp, sensorCode) { app = signalKApp; stats.sensorID = sensorCode || process.env.SENSOR_CODE || 'N/D'; stats.sentEveryMLS = parseInt(process.env.SEND_INTERVAL || '500'); console.log(`[MEB] Send interval: ${stats.sentEveryMLS}ms (SEND_INTERVAL=${process.env.SEND_INTERVAL || 'default 500'})`); // Autenticazione unica: ottieni ticket const authResult = await authenticate(); if (authResult) { // Carica sensor references con ticket (read-only, non consuma il ticket) sensorRules = await loadSensorReferencesFromServer(authResult.ticket); if (sensorRules) stats.lastConfigVersion = sensorRules.version; // Connetti WebSocket con lo stesso ticket (viene consumato qui) connectWebSocket(authResult.wsUrl, authResult.ticket); } else { // Fallback: carica references senza auth, programma riconnessione console.warn('[MEB] Auth fallita, carico references senza autenticazione'); sensorRules = await loadSensorReferencesFromServer(); if (sensorRules) stats.lastConfigVersion = sensorRules.version; scheduleReconnect(); } // Avvia polling versione config ogni 5 minuti configCheckTimer = setInterval(checkConfigUpdate, 5 * 60 * 1000); } /** * Controlla se la config sensori sul server e' cambiata e la ricarica. */ async function checkConfigUpdate() { const newVersion = await checkSensorReferencesVersion(stats.lastConfigVersion); if (newVersion) { console.log(`[MEB] Sensor config aggiornata: ${stats.lastConfigVersion} → ${newVersion}`); sensorRules = await loadSensorReferencesFromServer(); if (sensorRules) { stats.lastConfigVersion = sensorRules.version; } } } // ──────────────────── AUTENTICAZIONE ──────────────────── async function authenticate() { try { const REALTIME_URL = process.env.REALTIME_URL || 'http://localhost:3002'; const url = REALTIME_URL + '/connect/request'; const res = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ sensor_code: stats.sensorID }) }); if (!res.ok) { console.error(`[MEB] Realtime Auth failed: ${res.status}`); return null; } const data = await res.json(); if (!data.success || !data.ticket) return null; return { ticket: data.ticket, wsUrl: data.ws_url || REALTIME_URL.replace('http', 'ws') + '/ws' }; } catch (err) { console.error('[MEB] Error in Realtime auth:', err.message); return null; } } // ──────────────────── WEBSOCKET ──────────────────── function connectWebSocket(wsUrl, ticket) { const fullUrl = `${wsUrl}?ticket=${ticket}`; ws = new WebSocket(fullUrl); ws.on('open', () => { console.log('[MEB] Realtime WebSocket connected'); isConnected = true; stats.status = 'connected'; stats.reconnections = 0; // Reset su connessione riuscita startSending(); startPingInterval(); // Flush buffer locale dopo reconnessione flushBuffer(); }); ws.on('close', (code) => { console.log(`[MEB] Realtime WebSocket closed (code: ${code})`); isConnected = false; stats.status = 'disconnected'; stopSending(); stopPingInterval(); scheduleReconnect(); }); ws.on('error', (err) => { console.error('[MEB] Realtime WebSocket error:', err.message); isConnected = false; stats.status = 'error'; }); ws.on('message', (data) => { // Gestisce messaggi dal server (comandi, conferme, ecc.) try { const decoded = msgpack.decode(data); if (decoded?.type === 'connected') { console.log(`[MEB] Server confirmed connection: sensorId=${decoded.sensorId}`); } } catch { // Ignora messaggi non decodificabili } }); } // ──────────────────── INVIO DATI (1/sec) ──────────────────── function startSending() { stopSending(); sendData(); // Prima chiamata immediata sendTimer = setInterval(sendData, stats.sentEveryMLS); } function stopSending() { if (sendTimer) { clearInterval(sendTimer); sendTimer = null; } } /** * Legge un valore dal data model di SignalK. */ function getSignalKData(skPath) { const val = app.getSelfPath(skPath); return val && val.value !== undefined && val.value !== null ? val.value : null; } /** * Raccoglie TUTTI i dati sensore definiti nella config. * Produce chiavi flat: "temperature", "wind_direction", "position_latitude", ecc. * Stessa logica di logRecorder.collectSensorData(). */ function collectAllSensorData() { const data = {}; if (!sensorRules || !sensorRules.items) { // Fallback hardcoded se non ci sono regole return { service_battery_voltage: getSignalKData('electrical.batteries.service.Voltage') || 0, service_battery_stateOfCharge: (getSignalKData('electrical.batteries.service.stateOfCharge') || 0) * 100, traction_battery_power: getSignalKData('electrical.batteries.traction.power') || 0, temperature: (getSignalKData('meb.temperature') || 273.15) - 273.15, position_latitude: app.getSelfPath('navigation.position')?.value?.latitude || 0, position_longitude: app.getSelfPath('navigation.position')?.value?.longitude || 0 }; } for (const item of sensorRules.items) { const mainPath = item.main_path; if (!item.elements || item.elements === null) { // Campo singolo: usa il nome della collection data[item.collection] = getSignalKData(mainPath); } else { for (const element of item.elements) { // Separa subelements dalle proprietà campo const { subelements, ...fields } = element; const [fieldName, subPath] = Object.entries(fields)[0]; const keyName = item.collection ? `${item.collection}_${fieldName}` : fieldName; if (fieldName === 'latitude' || fieldName === 'longitude') { const baseValue = app.getSelfPath(`${mainPath}.position`)?.value; data[keyName] = (baseValue && typeof baseValue === 'object') ? baseValue[fieldName] ?? null : null; } else { data[keyName] = getSignalKData(`${mainPath}.${subPath}`); } // Gestisci subelementi (es. direction.average) if (subelements && Array.isArray(subelements)) { for (const sub of subelements) { const [subFieldName, subSubPath] = Object.entries(sub)[0]; const subKey = `${keyName}_${subFieldName}`; data[subKey] = getSignalKData(`${mainPath}.${subPath}.${subSubPath}`); } } } } } return data; } /** * Invia dati sensore al server via WebSocket (msgpack). * Se il WS e' disconnesso, buffer localmente. */ function sendData() { const data = collectAllSensorData(); // Aggiorna la cache centralizzata per Telegram e altri consumer dataHub.updateSensorData(data); const message = { type: 'sensor', ts: Date.now(), data }; if (!ws || ws.readyState !== WebSocket.OPEN) { bufferLocally(message); return; } try { ws.send(msgpack.encode(message)); stats.sent++; if (!stats.firstSent) stats.firstSent = new Date().toISOString(); } catch (err) { console.error('[MEB] Error sending realtime data:', err.message); bufferLocally(message); } } // ──────────────────── WEATHER (REST API) ──────────────────── /** * Invia dati meteo al server via REST API dedicata (POST /weather). * Non usa piu' il WebSocket per i dati meteo — endpoint REST separato. */ async function sendWeatherPayload(payload) { try { const REALTIME_URL = process.env.REALTIME_URL || 'http://localhost:3002'; const url = `${REALTIME_URL}/weather`; const res = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ sensor_code: stats.sensorID, data: payload }) }); if (res.ok) { const result = await res.json(); console.log(`[MEB] Weather payload inviato via REST — sensor: ${result.sensor}`); } else { console.error(`[MEB] Weather REST failed: ${res.status} ${res.statusText}`); } } catch (err) { console.error('[MEB] Error sending weather via REST:', err.message); } } // ──────────────────── BUFFER ANTI-PERDITA ──────────────────── function bufferLocally(message) { localBuffer.push(message); if (localBuffer.length > MAX_BUFFER_SIZE) { localBuffer.shift(); // Rimuovi il piu' vecchio } stats.buffered = localBuffer.length; } /** * Flush del buffer locale verso il server dopo reconnessione. * Invia gradualmente per non sovraccaricare il WS. */ function flushBuffer() { if (localBuffer.length === 0) return; console.log(`[MEB] Flushing ${localBuffer.length} buffered messages...`); const flushBatch = () => { if (localBuffer.length === 0 || !ws || ws.readyState !== WebSocket.OPEN) { stats.buffered = localBuffer.length; return; } // Invia 10 messaggi alla volta per non bloccare const batch = Math.min(localBuffer.length, 10); for (let i = 0; i < batch; i++) { const msg = localBuffer.shift(); try { ws.send(msgpack.encode(msg)); stats.sent++; } catch { localBuffer.unshift(msg); break; } } stats.buffered = localBuffer.length; if (localBuffer.length > 0) { setTimeout(flushBatch, 100); // Pausa tra batch } else { console.log('[MEB] Buffer flush completato'); } }; // Attendi 1s dopo la connessione prima di iniziare il flush setTimeout(flushBatch, 1000); } // ──────────────────── RECONNESSIONE ──────────────────── function scheduleReconnect() { if (reconnectTimer) return; stats.reconnections++; // Exponential backoff con jitter const delay = Math.min( BASE_RECONNECT_DELAY * Math.pow(1.5, Math.min(stats.reconnections, 15)), MAX_RECONNECT_DELAY ); const jitter = delay * 0.2 * Math.random(); const finalDelay = Math.round(delay + jitter); console.log(`[MEB] Reconnecting in ${Math.round(finalDelay / 1000)}s (tentativo ${stats.reconnections})`); reconnectTimer = setTimeout(async () => { reconnectTimer = null; start(); }, finalDelay); } // ──────────────────── PING/PONG ──────────────────── function startPingInterval() { stopPingInterval(); pingInterval = setInterval(() => { if (ws && ws.readyState === WebSocket.OPEN) { ws.ping(); } }, 25000); // 25s, server ha heartbeat a 30s } function stopPingInterval() { if (pingInterval) { clearInterval(pingInterval); pingInterval = null; } } // ──────────────────── START / STOP ──────────────────── async function start() { const result = await authenticate(); if (!result) { scheduleReconnect(); return; } connectWebSocket(result.wsUrl, result.ticket); } /** * Ferma tutto: WebSocket, timer, ping, config check. */ function stop() { stopSending(); stopPingInterval(); if (reconnectTimer) { clearTimeout(reconnectTimer); reconnectTimer = null; } if (configCheckTimer) { clearInterval(configCheckTimer); configCheckTimer = null; } if (ws) { ws.close(1000, 'Plugin stopping'); ws = null; } isConnected = false; stats.status = 'stopped'; console.log('[MEB] Realtime module stopped'); } function getStats() { return { ...stats, isConnected, bufferSize: localBuffer.length }; } function getSensorRules() { return sensorRules; } module.exports = { init, stop, sendWeatherPayload, collectAllSensorData, getSensorRules, getStats };