596 lines
23 KiB
JavaScript
596 lines
23 KiB
JavaScript
const { config } = 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(__dirname + '/datasetModels/saved_datas');
|
|
const logsReferencesFile = path.join(__dirname, 'datasetModels/logs_references.json');
|
|
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);
|
|
await send("✅ Computer di bordo attivo e pronto.");
|
|
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;
|
|
}; |