const { config, paths } = require("./config.js"); const { setupRoutes, getOpenApiSpec } = require("./tools/routes.js"); const { aisStream } = require("./api_models/aisstream.js") const mapHandler = require("./tools/map.handler.js"); const { linkBot, send } = require("./bot/telegram.core.js"); const dataset = require("./datasetModels/datasetCore.js"); const dataUtils = require("./datasetModels/datasetUtils.js"); const graphsCore = require("./datasetModels/graphsCore.js"); const { generateToken, encryptLog, loadSecureFile, saveSecureFile } = require("./tools/crypt.js"); const fs = require("fs"); const path = require("path"); const { getForecast, getSeaConditions } = require("./api_models/openmeteo.js"); const { publish } = require("./tools/publisher.js"); // CONFIG modificabile runtime (non più frozen per permettere modifiche admin) const CONFIG = { log_interval: 2000, // Dataset entry ogni 2 secondi openmeteo_interval: 300000, // OpenMeteo ogni 5 minuti hourly_archive_interval: 3600000, // Archivio orario per grafici number_value_fallback: 999999999999, value_fallback: "Funzionalità da Sviluppare" }; // Funzione per aggiornare gli intervalli runtime function updateInterval(type, newIntervalMs) { if (type === 'api' || type === 'openmeteo') { CONFIG.openmeteo_interval = newIntervalMs; return { type: 'openmeteo_interval', value: newIntervalMs }; } else if (type === 'log') { CONFIG.log_interval = newIntervalMs; return { type: 'log_interval', value: newIntervalMs }; } return null; } // Getter per CONFIG (usato da altri moduli) function getConfig() { return { ...CONFIG }; } const CSV_HEADERS = Object.freeze([ 'timestamp', 'wavesHeight', 'wavesPeriod', 'wavesDirection', 'windSpeed', 'windDirection', 'temperature', // 'currentSpeed', // 'currentDirection', 'speedOverGround', 'courseOverGround', 'headingTrue', 'latitude', 'longitude', '1Voltage', '1Current', '1StateOfCharge', '1Temperature', '0Voltage', '0Current', '0CellsStateOfCharge', '0AverageCellTemperature', '0Power', 'propultionShaftSpeed', 'systemUptime' ]); const state = { logTimer: null, logStreamer: null, logsCount: 0, isRecordingLogs: false, currentLogFile: null, currentLogKey: null, openMeteoTimer: null, hourlyArchiveTimer: null, unsubPos: null, app: null, startTime: null }; const logsDirectory = dataUtils.getDirectory(paths.savedDatas); const logsReferencesFile = paths.logsReferences; const lastCallRef = { current: null }; const getSKValue = (path, fallback = CONFIG.value_fallback) => { if (!state.app) { console.warn(`[getSKValue] App not initialized, returning fallback for path: ${path}`); return fallback; } try { const value = state.app.getSelfPath(path)?.value; return (value !== undefined && value !== null) ? value : fallback; } catch (error) { console.error(`[getSKValue] Error reading path ${path}:`, error.message); return fallback; } }; const closeStream = (stream) => { return new Promise((resolve) => { if (!stream || stream.destroyed) { resolve(); return; } stream.end(() => { resolve(); }); setTimeout(resolve, 1000); }); }; const clearIntervalSafe = (timerId) => { if (timerId) { clearInterval(timerId); } return null; }; const collectSensorData = (settings = {}) => { // Prendi la posizione dalla navigazione se disponibile const position = state.app?.getSelfPath('navigation.position')?.value; const lat = position?.latitude ?? settings.latitude ?? CONFIG.number_value_fallback; const lon = position?.longitude ?? settings.longitude ?? CONFIG.number_value_fallback; return { timestamp: new Date().toISOString(), wavesHeight: getSKValue("meb.waves.height"), wavesPeriod: getSKValue("meb.waves.period"), wavesDirection: getSKValue("meb.waves.direction"), windSpeed: getSKValue("meb.wind.speed"), windDirection: getSKValue("meb.wind.direction"), temperature: getSKValue("meb.temperature"), // currentSpeed: getSKValue("meb.currents.speed"), // currentDirection: getSKValue("meb.currents.direction"), speedOverGround: getSKValue("navigation.speedOverGround"), courseOverGround: getSKValue("navigation.courseOverGroundTrue"), headingTrue: getSKValue("navigation.headingTrue"), latitude: lat, longitude: lon, '1Voltage': getSKValue("electrical.batteries.service.Voltage"), '1Current': getSKValue("electrical.batteries.service.current"), '1StateOfCharge': getSKValue("electrical.batteries.service.stateOfCharge"), '1Temperature': getSKValue("electrical.batteries.service.temperature"), '0Voltage': getSKValue("electrical.batteries.traction.Voltage"), '0Current': getSKValue("electrical.batteries.traction.current"), '0CellsStateOfCharge': getSKValue("electrical.batteries.traction.stateOfCharge"), '0AverageCellTemperature': getSKValue("electrical.batteries.traction.temperature"), '0Power': getSKValue("electrical.batteries.traction.power"), propultionShaftSpeed: getSKValue("propulsion.0.revolutions"), systemUptime: process.uptime() ?? CONFIG.number_value_fallback }; }; function createNewFiles() { try { const now = new Date(); const dateStr = now.toLocaleString('it-IT', { timeZone: 'Europe/Rome', day: 'numeric', month: 'long', year: 'numeric', hour: '2-digit', minute: '2-digit', hour12: false }).replace(/:/g, '-'); const logFileName = `log_${dateStr}.csv`; const logFile = path.join(logsDirectory, logFileName); // Close existing stream gracefully if (state.logStreamer && !state.logStreamer.destroyed) { state.logStreamer.end(); } state.logStreamer = fs.createWriteStream(logFile, { flags: 'a' }); state.logStreamer.on('error', (err) => { console.error('[log_file] Errore nello stream:', err); }); dataset.datasetInit(CSV_HEADERS, state.logStreamer); state.logsCount = 0; state.currentLogFile = logFileName; state.currentLogKey = generateToken(); return true; } catch (error) { console.error('[log_file] Errore nella creazione di un nuovo file:', error); return false; } } // ==================== RECORDING CONTROL ==================== /** * Stops the data recording process * @returns {boolean} True if stopped successfully, false if already stopped */ function stopRecording() { if (!state.isRecordingLogs) { return false; } try { state.logTimer = clearIntervalSafe(state.logTimer); if (state.logStreamer && !state.logStreamer.destroyed) { state.logStreamer.end(); } state.isRecordingLogs = false; // Usa la chiave generata all'inizio della sessione if (state.currentLogFile && state.currentLogKey) { const logFilePath = path.join(logsDirectory, state.currentLogFile); // Carica, aggiorna e salva references criptate const logsData = loadSecureFile(logsReferencesFile, { references: [] }); logsData.references.push({ name: state.currentLogFile, token: state.currentLogKey }); saveSecureFile(logsReferencesFile, logsData); // Cripta il file log con la stessa chiave // encryptLog(logFilePath, state.currentLogKey); console.log(`[stopRecording] Log ${state.currentLogFile} criptato e salvato.`); } state.logsCount = 0; state.currentLogFile = null; state.currentLogKey = null; return true; } catch (error) { console.error('[log_stop] Errore durante l\'arresto della registrazione:', error); return false; } } /** * Starts the data recording process * @param {object} settings - Plugin settings * @returns {boolean} True if started successfully, false if already running */ function startRecording(settings = {}) { if (state.isRecordingLogs) { return false; } try { state.isRecordingLogs = true; state.startTime = Date.now(); if (!createNewFiles()) { state.isRecordingLogs = false; return false; } state.logTimer = setInterval(() => { try { if (!state.logStreamer || state.logStreamer.destroyed) { console.error('[log_dataset_error] Stream non disponibile'); return; } const data = collectSensorData(settings); const success = dataset.appendData(data, CSV_HEADERS, state.logStreamer); if (success) { state.logsCount++; } } catch (error) { console.error('[log_dataset_error] Errore durante la raccolta dei dati:', error); } }, CONFIG.log_interval); return true; } catch (error) { console.error('[log_dataset_error] Errore nell\'avvio della registrazione', error); state.isRecordingLogs = false; return false; } } /** * Restarts the recording process * @param {object} settings - Plugin settings * @returns {boolean} Success status */ function restartRecording(settings = {}) { stopRecording(); startRecording(settings); return true; } /** * Gets current recording status with detailed metrics * @returns {object} Status object */ function getRecordingStatus() { return { isRecording: state.isRecordingLogs, recordCount: state.logsCount, recordingInterval: CONFIG.log_interval, uptime: state.startTime ? Date.now() - state.startTime : 0, timestamp: new Date().toISOString() }; } module.exports = function (app) { state.app = app; const plugin = { id: "meb", name: "MEB Plugin", start: async (settings) => { try { // ==================== WEB SOCKET AISSTREAM ==================== try { aisStream(); } catch (error) { console.error('[ERROR] Errore in AISStream:', error); } // ==================== WEATHER UPDATES (OpenMeteo condiviso ogni 2 min) ==================== let location = { latitude: app.getSelfPath('navigation.position')?.value?.latitude, longitude: app.getSelfPath('navigation.position')?.value?.longitude, }; const updateWeatherData = async () => { const currentPos = app.getSelfPath('navigation.position')?.value; if (currentPos?.latitude && currentPos?.longitude) { location = { latitude: currentPos.latitude, longitude: currentPos.longitude }; } else if (!location.latitude || !location.longitude) { location = { latitude: Number(settings?.latitude), longitude: Number(settings?.longitude), }; } if (!location.latitude || !location.longitude) { console.warn("[OpenMeteo] Posizione non disponibile"); return; } try { const [forecastData, wavesData] = await Promise.all([ getForecast(location), getSeaConditions(location) ]); // Log per debug if (forecastData) { console.log("[OpenMeteo] Forecast ricevuto:", { temp: forecastData.temperature, wind: forecastData.windSpeed, humidity: forecastData.humidity }); } if (wavesData) { console.log("[OpenMeteo] Marine ricevuto:", { waveHeight: wavesData.waveHeight, wavePeriod: wavesData.wavePeriod }); } // Aggiorna dati condivisi per grafici graphsCore.updateSharedWeatherData(forecastData, wavesData); // Pubblica su SignalK solo se abbiamo dati validi const weatherPayload = { temperature: forecastData?.temperature ?? null, humidity: forecastData?.humidity ?? null, pressure: forecastData?.pressure ?? null, wind: { speed: forecastData?.windSpeed ?? null, direction: forecastData?.windDirection ?? null, gusts: forecastData?.windGusts ?? null }, waves: { height: wavesData?.waveHeight ?? null, period: wavesData?.wavePeriod ?? null, direction: wavesData?.waveDirection ?? null }, rain: forecastData?.rain ?? null, precipitation: forecastData?.precipitation ?? null }; publish(app, weatherPayload, settings); console.log("[OpenMeteo] Dati pubblicati su SignalK"); } catch (error) { console.error("[OpenMeteo] Errore aggiornamento:", error.message); } }; // Funzione per archiviare dati orari per grafici const archiveHourlyData = () => { const sharedData = graphsCore.getSharedWeatherData(); if (sharedData.forecast || sharedData.waves) { graphsCore.archiveHourlyData({ temperature: sharedData.forecast?.temperature, humidity: sharedData.forecast?.humidity, pressure: sharedData.forecast?.pressure, windSpeed: sharedData.forecast?.windSpeed, windDirection: sharedData.forecast?.windDirection, waveHeight: sharedData.waves?.waveHeight, wavePeriod: sharedData.waves?.wavePeriod, waveDirection: sharedData.waves?.waveDirection, // currentSpeed: sharedData.waves?.currentVelocity, // currentDirection: sharedData.waves?.currentDirection }); } }; // Avvia aggiornamento meteo immediato + timer 2 minuti updateWeatherData(); state.openMeteoTimer = setInterval(updateWeatherData, CONFIG.openmeteo_interval); // Archivia dati ogni ora per i grafici state.hourlyArchiveTimer = setInterval(archiveHourlyData, CONFIG.hourly_archive_interval); // ==================== MAPPA INTERATTIVA ==================== try { mapHandler(app, settings); } catch (error) { console.error('[ERROR] Errore nell\'avvio della mappa:', error); } // ==================== LOG DATI ==================== try { startRecording(settings); } catch (error) { console.error('[ERROR] Errore nell\'avvio dei log:', error); } app.datasetControl = { start: () => startRecording(settings), stop: stopRecording, restart: () => restartRecording(settings), getStatus: getRecordingStatus }; // Esponi funzioni per modifica intervalli app.intervalControl = { updateInterval: (type, newIntervalMs) => { const result = updateInterval(type, newIntervalMs); if (!result) return null; // Riavvia il timer appropriato if (result.type === 'openmeteo_interval') { state.openMeteoTimer = clearIntervalSafe(state.openMeteoTimer); updateWeatherData(); // Aggiorna subito state.openMeteoTimer = setInterval(updateWeatherData, newIntervalMs); console.log(`[IntervalControl] OpenMeteo interval aggiornato a ${newIntervalMs}ms`); } else if (result.type === 'log_interval') { // Riavvia recording con nuovo intervallo const wasRecording = state.isRecordingLogs; if (wasRecording) { state.logTimer = clearIntervalSafe(state.logTimer); state.logTimer = setInterval(() => { try { if (!state.logStreamer || state.logStreamer.destroyed) { console.error('[log_dataset_error] Stream non disponibile'); return; } const data = collectSensorData(settings); const success = dataset.appendData(data, CSV_HEADERS, state.logStreamer); if (success) { state.logsCount++; } } catch (error) { console.error('[log_dataset_error] Errore durante la raccolta dei dati:', error); } }, newIntervalMs); } console.log(`[IntervalControl] Log interval aggiornato a ${newIntervalMs}ms`); } return result; }, getIntervals: () => ({ log_interval: CONFIG.log_interval, openmeteo_interval: CONFIG.openmeteo_interval, hourly_archive_interval: CONFIG.hourly_archive_interval }) }; // ==================== BOT TELEGRAM (dopo intervalControl) ==================== if (config.telegramBotToken) { try { await linkBot(app); let deviceName = process.env.HOST_NAME || 'Dispositivo Sconosciuto'; await send(`Il bot è di nuovo disponibile. (Avviato da ${deviceName})`); console.log('[MEB TELEGRAM] Bot avviato con app.intervalControl disponibile'); } catch (error) { console.error('[ERROR] Errore nell\'avvio del bot telegram', error); } } else { console.warn('[MEB TELEGRAM] Bot disabilitato: TELEGRAM_BOT_TOKEN non configurato.'); } // ===== Shutdown Hooks ===== const shutdown = async (reason = 'signal') => { try { console.log(`[shutdown] Received ${reason}. Stopping plugin...`); await plugin.stop(); process.exit(0); } catch (err) { console.error('[shutdown] Error during stop:', err); process.exit(1); } }; // Evita di registrare multipli handler if (!process.__meb_shutdown_hooks_installed) { process.__meb_shutdown_hooks_installed = true; process.on('SIGINT', () => shutdown('SIGINT')); process.on('SIGTERM', () => shutdown('SIGTERM')); process.on('uncaughtException', (err) => { console.error('[uncaughtException]', err); shutdown('uncaughtException'); }); process.on('unhandledRejection', (reason) => { console.error('[unhandledRejection]', reason); shutdown('unhandledRejection'); }); } } catch (error) { console.error('[Errore] Errore durante l\'avvio del plugin:', error); throw error; } }, stop: async () => { try { state.openMeteoTimer = clearIntervalSafe(state.openMeteoTimer); state.hourlyArchiveTimer = clearIntervalSafe(state.hourlyArchiveTimer); if (typeof state.unsubPos === "function") { try { state.unsubPos(); state.unsubPos = null; } catch (error) { console.error('[ERROR] Errore durante la cancellazione dell\'iscrizione alla posizione:', error); } } // stopRecording gestisce già criptazione e salvataggio reference if (app.datasetControl) { try { app.datasetControl.stop(); } catch (error) { console.error('[ERROR] Errore durante l\'arresto del controllo del dataset:', error); } } await closeStream(state.logStreamer); console.log('[stop] Plugin arrestato correttamente.'); } catch (error) { console.error('[ERROR] Errore durante l\'arresto del plugin:', error); } }, schema: () => ({ type: "object", required: [], properties: {}, }), registerWithRouter: (router) => { setupRoutes(router, lastCallRef, app); }, getOpenApi: getOpenApiSpec, }; return plugin; };