Migra dal codice salvato in locale al codice condiviso

This commit is contained in:
Giuseppe Raffa
2026-01-06 17:36:58 +01:00
parent 8a88c31c75
commit ff1566d36b
30 changed files with 8985 additions and 0 deletions

BIN
.DS_Store vendored Normal file

Binary file not shown.

10
.gitignore vendored Normal file
View File

@@ -0,0 +1,10 @@
node_modules/
plugin/authorized_admins.txt
plugin/telegram_users.json
.env
plugin/datasetModels/saved_datas
plugin/datasetModels/hourly_archive.json
plugin/datasetModels/logs_references.json

View File

@@ -0,0 +1,2 @@
In questa repository è presente il plugin MEB per SignalK.
Ulteriori informazioni verranno aggiunte nelle prossime versioni

2596
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

35
package.json Normal file
View File

@@ -0,0 +1,35 @@
{
"name": "meb",
"version": "1.5.0",
"description": "Il plugin personalizzato realizzato dal MEB per tener traccia dei log della barca, implementare previsioni meteo e molto altro.",
"main": "plugin/index.cjs",
"keywords": [
"signalk-node-server-plugin",
"signalk-category-utility",
"signalk-plugin"
],
"schema": {
"type": "object",
"properties": {
"enabled": {
"type": "boolean",
"title": "Abilita plugin",
"default": true
}
}
},
"signalk-plugin-enabled-by-default": true,
"signalk": {
"displayName": "MEB"
},
"dependencies": {
"axios": "^1.12.2",
"dotenv": "^17.2.3",
"form-data": "^4.0.5",
"fs": "0.0.1-security",
"i": "^0.3.7",
"jsonwebtoken": "^9.0.2",
"node-telegram-bot-api": "^0.66.0",
"path": "^0.12.7"
}
}

BIN
plugin/.DS_Store vendored Normal file

Binary file not shown.

View File

@@ -0,0 +1,32 @@
const apiToken = "08a9a9828f8186c661d0293741fd01971bc2d2f4"
function aisStream() {
const socket = new WebSocket('wss://stream.aisstream.io/v0/stream');
socket.onopen = function (_) {
let subscriptionMessage = {
Apikey: apiToken,
BoundingBox: [[15.0, 37.5], [16.5, 38.8]]
}
socket.send(JSON.stringify(subscriptionMessage));
console.log("✅ WebSocket Connected");
};
socket.onmessage = function (event) {
event.data.text().then(text => {
try {
const json = JSON.parse(text);
console.log(json);
} catch (e) {
console.error("Invalid JSON:", text);
}
});
};
socket.onerror = (error) => console.error('WebSocket Error:', error);
socket.onclose = () => console.log('WebSocket Connection Closed');
}
module.exports = { aisStream };

View File

@@ -0,0 +1,212 @@
const axios = require('axios');
const TIMEOUT = 10000;
const HEADERS = { Accept: "application/json, text/plain;q=0.9,*/*;q=0.8" };
// Parametri API
const FORECAST_PARAMS = {
current: [
'temperature_2m',
'wind_speed_10m',
'wind_direction_10m',
'wind_gusts_10m',
'precipitation',
'rain',
'relative_humidity_2m',
'pressure_msl'
],
hourly: [
'temperature_2m',
'precipitation_probability',
'precipitation',
'rain',
'wind_speed_10m',
'cloud_cover',
'wind_direction_10m',
'relative_humidity_2m',
'pressure_msl'
]
};
const MARINE_PARAMS = {
current: [
'wave_height',
'wave_direction',
'wave_period',
'wave_peak_period',
'ocean_current_velocity',
'ocean_current_direction'
],
hourly: [
'wave_height',
'wave_direction',
'wave_period',
'wave_peak_period',
'ocean_current_velocity',
'ocean_current_direction'
]
};
// Unità di misura globali (aggiornate da OpenMeteo)
let globalUnits = {
forecast: {
temperature: '°C',
humidity: '%',
pressure: 'hPa',
windSpeed: 'km/h',
windDirection: '°',
windGusts: 'km/h',
rain: 'mm',
precipitation: 'mm'
},
waves: {
waveHeight: 'm',
wavePeriod: 's',
waveDirection: '°',
wavePeakPeriod: 's'
}
};
/**
* Ottiene le unità di misura globali
*/
function getUnits() {
return globalUnits;
}
/**
* Formatta un valore con la sua unità
*/
function formatWithUnit(value, unitKey, category = 'forecast') {
if (value === null || value === undefined) return 'n/d';
const unit = globalUnits[category]?.[unitKey] || '';
return `${value}${unit}`;
}
async function getForecast(location) {
if (!location?.latitude || !location?.longitude) {
console.warn('[OpenMeteo] Coordinate non valide per forecast');
return null;
}
const currentParams = FORECAST_PARAMS.current.join(",");
const hourlyParams = FORECAST_PARAMS.hourly.join(",");
const api = `https://api.open-meteo.com/v1/forecast?latitude=${location.latitude}&longitude=${location.longitude}&hourly=${hourlyParams}&current=${currentParams}`;
try {
const response = await axios.get(api, {
headers: HEADERS,
timeout: TIMEOUT,
validateStatus: (status) => status === 200
});
const { data } = response;
if (!data?.current) {
console.warn('[OpenMeteo Forecast] Risposta senza dati current');
return null;
}
// Aggiorna unità globali da API response
if (data.current_units) {
globalUnits.forecast = {
temperature: data.current_units.temperature_2m || '°C',
humidity: data.current_units.relative_humidity_2m || '%',
pressure: data.current_units.pressure_msl || 'hPa',
windSpeed: data.current_units.wind_speed_10m || 'km/h',
windDirection: data.current_units.wind_direction_10m || '°',
windGusts: data.current_units.wind_gusts_10m || 'km/h',
rain: data.current_units.rain || 'mm',
precipitation: data.current_units.precipitation || 'mm'
};
}
return {
temperature: data.current.temperature_2m ?? null,
humidity: data.current.relative_humidity_2m ?? null,
pressure: data.current.pressure_msl ?? null,
windSpeed: data.current.wind_speed_10m ?? null,
windDirection: data.current.wind_direction_10m ?? null,
windGusts: data.current.wind_gusts_10m ?? null,
rain: data.current.rain ?? null,
precipitation: data.current.precipitation ?? null,
// Unità di misura
units: globalUnits.forecast,
// Dati orari per grafici
hourly: {
time: data.hourly?.time,
temperature: data.hourly?.temperature_2m,
humidity: data.hourly?.relative_humidity_2m,
windSpeed: data.hourly?.wind_speed_10m
},
hourlyUnits: data.hourly_units || null
};
} catch (error) {
console.error(`[OpenMeteo Forecast] Errore: ${error.message}`);
return null;
}
}
async function getSeaConditions(location) {
if (!location?.latitude || !location?.longitude) {
console.warn('[OpenMeteo] Coordinate non valide per onde');
return null;
}
const currentParams = MARINE_PARAMS.current.join(",");
const hourlyParams = MARINE_PARAMS.hourly.join(",");
const api = `https://marine-api.open-meteo.com/v1/marine?latitude=${location.latitude}&longitude=${location.longitude}&hourly=${hourlyParams}&current=${currentParams}&models=ecmwf_wam`;
try {
const response = await axios.get(api, {
headers: HEADERS,
timeout: TIMEOUT,
validateStatus: (status) => status === 200
});
const { data } = response;
if (!data?.current) {
console.warn('[OpenMeteo Marine] Risposta senza dati current');
return null;
}
// Aggiorna unità globali da API response
if (data.current_units) {
globalUnits.waves = {
waveHeight: data.current_units.wave_height || 'm',
wavePeriod: data.current_units.wave_period || 's',
waveDirection: data.current_units.wave_direction || '°',
wavePeakPeriod: data.current_units.wave_peak_period || 's',
currentVelocity: data.current_units.ocean_current_velocity || 'm/s',
currentDirection: data.current_units.ocean_current_direction || '°'
};
}
return {
waveHeight: data.current.wave_height ?? null,
wavePeriod: data.current.wave_period ?? null,
waveDirection: data.current.wave_direction ?? null,
wavePeakPeriod: data.current.wave_peak_period ?? null,
currentDirection: data.current.ocean_current_direction ?? null,
currentVelocity: data.current.ocean_current_velocity ?? null,
// Unità di misura
units: globalUnits.waves,
// Dati orari per grafici
hourly: {
time: data.hourly?.time,
waveHeight: data.hourly?.wave_height,
wavePeriod: data.hourly?.wave_period,
waveDirection: data.hourly?.wave_direction,
currentDirection: data.hourly?.ocean_current_direction,
currentVelocity: data.hourly?.ocean_current_velocity
},
hourlyUnits: data.hourly_units || null
};
} catch (error) {
console.error(`[OpenMeteo Marine] Errore: ${error.message}`);
return null;
}
}
module.exports = { getSeaConditions, getForecast, getUnits, formatWithUnit };

935
plugin/bot/telegram.core.js Normal file
View File

@@ -0,0 +1,935 @@
/**
* telegram.core.js - Bot Telegram ottimizzato per MEB SignalK
* Gestione utenti, comandi e live updates
*/
const fs = require("fs");
const path = require("path");
const {
encrypt,
decrypt,
generateToken,
generateReadableToken,
encryptLog,
decryptLog,
loadSecureFile,
saveSecureFile
} = require("../tools/crypt");
const TelegramBot = require('node-telegram-bot-api');
function getSK(path) {
if (!app) return null;
const v = app.getSelfPath(path);
return v && v.value !== undefined && v.value !== null ? v.value : null;
}
// ==================== INIZIALIZZAZIONE BOT ====================
const BOT_TOKEN = process.env.TELEGRAM_BOT_TOKEN;
let bot = null;
function initBot() {
if (!BOT_TOKEN) {
console.warn("[Telegram] BOT_TOKEN non impostato: bot disabilitato.");
return null;
}
// Riusa istanza esistente se disponibile
if (global.__meb_telegram_bot) {
bot = global.__meb_telegram_bot;
console.log("[Telegram] Riutilizzo istanza bot esistente");
} else {
bot = new TelegramBot(BOT_TOKEN, { polling: true });
global.__meb_telegram_bot = bot;
console.log("[Telegram] Nuova istanza bot creata");
}
// Registra handlers solo una volta
if (!global.__meb_telegram_handlers) {
global.__meb_telegram_handlers = true;
registerHandlers();
console.log("[Telegram] Handlers registrati");
}
return bot;
}
// Inizializza all'import
bot = initBot();
// ==================== CONFIGURAZIONE ====================
const CONFIG = {
filesPerPage: 8,
liveUpdateInterval: 3000,
fileExpirationTime: 10
};
const telegram_users_file = path.join(__dirname, "..", "telegram_users.json");
const logs_references_file = path.join(__dirname, "..", "datasetModels/logs_references.json");
const authorized_admins_file = path.join(__dirname, "..", "authorized_admins.txt");
let app = null;
// Maps per gestione timer e stati
const liveParamIntervals = new Map();
const keyExpirationTimers = new Map();
// ==================== GESTIONE FILE SENSIBILI ====================
function loadAuthorizedAdmins() {
try {
if (!fs.existsSync(authorized_admins_file)) {
return new Set();
}
const content = fs.readFileSync(authorized_admins_file, 'utf8');
const admins = content
.split('\n')
.map(line => line.trim())
.filter(line => line && !line.startsWith('#'));
return new Set(admins);
} catch (error) {
console.error('[Telegram] Errore caricamento admin:', error.message);
return new Set();
}
}
function saveAuthorizedAdmins(admins) {
try {
const adminArray = Array.from(admins);
const content = '# Authorized Admin ChatIDs (one per line)\n' + adminArray.join('\n');
fs.writeFileSync(authorized_admins_file, content, 'utf8');
return true;
} catch (error) {
console.error('[Telegram] Errore salvataggio admin:', error.message);
return false;
}
}
function isAdmin(chatID) {
const admins = loadAuthorizedAdmins();
return admins.has(String(chatID));
}
function loadUsers() {
return loadSecureFile(telegram_users_file, []);
}
function saveUsers(users) {
saveSecureFile(telegram_users_file, users);
}
function loadLogsReferences() {
return loadSecureFile(logs_references_file, { references: [] });
}
function saveLogsReferences(data) {
saveSecureFile(logs_references_file, data);
}
function isAuthenticated(chatID) {
const user = getUserByChatID(chatID);
return user && user.hasLoggedYet;
}
function createNewUser(permissions = ["basic"]) {
const users = loadUsers();
const newUser = {
token: generateReadableToken(24),
chatID: null,
isAuthorized: permissions,
hasLoggedYet: false
};
users.push(newUser);
saveUsers(users);
return newUser;
}
function login(token, chatID) {
const users = loadUsers();
const userIDX = users.findIndex(u => u.token === token);
if (userIDX === -1) {
throw new Error("Token non valido");
}
const user = users[userIDX];
if (user.hasLoggedYet && user.chatID && user.chatID !== String(chatID)) {
throw new Error("Questo token è già associato ad un altro account");
}
if (!user.hasLoggedYet) {
const newToken = generateReadableToken(32);
user.token = newToken;
user.hasLoggedYet = true;
user.chatID = String(chatID);
users[userIDX] = user;
saveUsers(users);
return { ...user, isFirstLogin: true, newToken };
}
user.chatID = String(chatID);
users[userIDX] = user;
saveUsers(users);
return { ...user, isFirstLogin: false };
}
function logout(chatID) {
const users = loadUsers();
const userIDX = users.findIndex(u => u.chatID === String(chatID));
if (userIDX === -1) {
return null;
}
saveUsers(users);
return users[userIDX];
}
function getUserWith(token) {
const users = loadUsers();
return users.find(u => u.token === token);
}
function getUserByChatID(chatID) {
const users = loadUsers();
return users.find(u => u.chatID === String(chatID));
}
async function linkBot(appInstance) {
app = appInstance;
if (!bot) {
console.warn("[MEB TELEGRAM] linkBot chiamato senza TOKEN: ritorno null.");
return null;
}
return bot;
}
function fetchFiles(chatId, page = 0) {
const logDirectory = path.join(__dirname, "..", "datasetModels/saved_datas");
try {
const logsData = loadLogsReferences();
const registeredFiles = new Set((logsData.references || []).map(r => r.name));
const items = fs.readdirSync(logDirectory);
const files = items.filter(item => {
const fullPath = path.join(logDirectory, item);
return fs.statSync(fullPath).isFile() && registeredFiles.has(item);
});
if (files.length === 0) {
bot.sendMessage(chatId, "📂 Non ci sono log salvati.");
return;
}
const sortedFiles = files
.map(file => ({
name: file,
time: fs.statSync(path.join(logDirectory, file)).mtime.getTime()
}))
.sort((a, b) => b.time - a.time)
.map(file => file.name);
const totalPages = Math.ceil(sortedFiles.length / CONFIG.filesPerPage);
let currentPage = page;
if (currentPage < 0) currentPage = 0;
if (currentPage > totalPages - 1) currentPage = totalPages - 1;
const startIdx = currentPage * CONFIG.filesPerPage;
const endIdx = startIdx + CONFIG.filesPerPage;
const pageFiles = sortedFiles.slice(startIdx, endIdx);
const fileButtons = pageFiles.map(file => [
{ text: `📄 ${file}`, callback_data: `request_file_${file}` }
]);
const navigationButtons = [];
if (totalPages > 1) {
const navRow = [];
if (currentPage > 0) {
navRow.push({ text: "←", callback_data: `page_${currentPage - 1}` });
}
navRow.push({ text: `📖 ${currentPage + 1}/${totalPages}`, callback_data: `page_info` });
if (currentPage < totalPages - 1) {
navRow.push({ text: "→", callback_data: `page_${currentPage + 1}` });
}
navigationButtons.push(navRow);
}
navigationButtons.push([{ text: "Annulla", callback_data: "dismiss" }]);
bot.sendMessage(chatId,
`📥 *Logs di Bordo*\n` +
`Ogni file corrisponde ad una *sessione*. Seleziona un file per scaricarlo.\n` +
`⚠️ Avrai solo *10 secondi* per salvare file e chiave.`,
{
parse_mode: 'Markdown',
reply_markup: { inline_keyboard: [...fileButtons, ...navigationButtons] }
}
);
} catch (error) {
bot.sendMessage(chatId, `Errore lettura directory: ${error.message}`);
}
}
function getCurrentPosition() {
if (!app) return null;
const position = app.getSelfPath('navigation.position');
if (!position) return null;
return {
latitude: position.value.latitude,
longitude: position.value.longitude,
};
}
async function send(message) {
if (!bot) return;
const users = loadUsers();
const loggedUsers = users.filter(u => u.hasLoggedYet && u.chatID);
for (const user of loggedUsers) {
try {
await bot.sendMessage(user.chatID, message);
} catch (error) {
console.error(`[Telegram] Send error to ${user.chatID}:`, error.message);
}
}
}
// ==================== RENDER FUNCTIONS ====================
function renderPositionText() {
if (!app) return "❌ App non disponibile";
const pos = app.getSelfPath('navigation.position')?.value;
const sog = getSK('navigation.speedOverGround');
const cog = getSK('navigation.courseOverGroundTrue');
const heading = getSK('navigation.headingTrue');
const lat = pos?.latitude?.toFixed(5) ?? "N/A";
const lon = pos?.longitude?.toFixed(5) ?? "N/A";
const speed = sog != null ? (sog * 1.94384).toFixed(1) : "N/A"; // m/s to knots
const course = cog != null ? (cog * 180 / Math.PI).toFixed(0) : "N/A"; // rad to deg
const headingDeg = heading != null ? (heading * 180 / Math.PI).toFixed(0) : "N/A";
return `📍 *Posizione & Velocità*\n\n` +
`Latitudine: \`${lat}\`\n` +
`Longitudine: \`${lon}\`\n` +
`SOG: ${speed} kn\n` +
`COG: ${course}°\n` +
`Heading: ${headingDeg}°`;
}
function renderWindText() {
const speed = getSK('meb.wind.speed');
const direction = getSK('meb.wind.direction');
return `🌬️ *Vento*\n\n` +
`Velocità: ${speed} km/h\n` +
`Direzione: ${direction}°\n`;
}
function renderWavesText() {
const height = getSK('meb.waves.height');
const period = getSK('meb.waves.period');
const dir = getSK('meb.waves.direction');
return `🌊 *Onde*\n\n` +
`Altezza: ${height} m\n` +
`Periodo: ${period} s\n` +
`Direzione: ${dir}°`;
}
function renderForecastsText() {
const temp = getSK('meb.temperature');
const humidity = getSK('meb.humidity');
const pressure = getSK('meb.pressure');
const rain = getSK('meb.precipitation');
return `⛅️ *Previsioni Meteo*\n\n` +
`Temperatura: ${temp} °C\n` +
`Umidità: ${humidity} %\n` +
`Pressione: ${pressure} hPa\n`;
}
function renderBatteriesText() {
const voltage = getSK('electrical.batteries.traction.voltage');
const current = getSK('electrical.batteries.traction.current');
const soc = getSK('electrical.batteries.traction.stateOfCharge');
const power = getSK('electrical.batteries.traction.power');
return `🔋 *Batterie*\n\n` +
`Tensione: ${voltage?.toFixed(1) ?? "N/A"} V\n` +
`Corrente: ${current?.toFixed(1) ?? "N/A"} A\n` +
`SOC: ${soc != null ? (soc * 100).toFixed(0) : "N/A"} %\n` +
`Potenza: ${power?.toFixed(0) ?? "N/A"} W`;
}
function renderDashboardText() {
const posText = renderPositionText()
const windText = renderWindText()
const wavesText = renderWavesText()
const forecastText = renderForecastsText()
const battText = renderBatteriesText()
return `📊 *Dashboard Completa*\n` +
`\n${posText}\n\n` +
`\n${forecastText}\n\n` +
`\n${windText}\n\n` +
`\n${wavesText}\n\n` +
`\n${battText}`;
}
// ==================== REGISTRAZIONE HANDLERS ====================
function registerHandlers() {
if (!bot) return;
// Handler: /start
bot.onText(/\/start/, (msg) => {
const chatId = msg.chat.id;
if (isAuthenticated(chatId)) {
const menu = {
keyboard: [
[{ text: "📊 Dashboard" }],
[{ text: "Parametri di Bordo" }],
[{ text: "File di Logs" }],
[{ text: "Genera un nuovo log" }],
[{ text: "Stato dei Log" }]
],
resize_keyboard: true,
one_time_keyboard: false
};
bot.sendMessage(chatId,
"Benvenuto nel Data Console.\n" +
"• Visualizza i dati del computer di bordo\n" +
"• Ricevi aggiornamenti su parametri a scelta\n" +
"• Scarica i file di log della barca",
{ parse_mode: 'Markdown', reply_markup: menu }
);
} else {
bot.sendMessage(chatId,
"Benvenuto nel MEB Data Console!\n" +
"Per accedere ai dati è necessario un token di accesso.",
{ parse_mode: 'Markdown' }
);
bot.sendMessage(chatId, "👤 Login", {
reply_markup: {
inline_keyboard: [
[{ text: "❓ Come ottengo un token", callback_data: "token_login_question" }],
[{ text: "🔑 Ho un token", callback_data: "token_ready" }]
]
},
parse_mode: 'Markdown'
});
}
});
// Menu testuale
bot.onText(/📊 Dashboard/, (msg) => {
const chatId = msg.chat.id;
if (!isAuthenticated(chatId)) {
bot.sendMessage(chatId, "Effettua prima il login con /login <token>");
return;
}
const dashboardMsg = renderDashboardText();
bot.sendMessage(chatId, dashboardMsg, {
parse_mode: 'Markdown',
reply_markup: {
inline_keyboard: [
[{ text: "🔄 Aggiorna", callback_data: "refresh_dashboard" }],
[{ text: "📡 Live (3s)", callback_data: "live_dashboard" }]
]
}
});
});
bot.onText(/File di Logs/, (msg) => {
const chatId = msg.chat.id;
if (!isAuthenticated(chatId)) {
bot.sendMessage(chatId, "Effettua prima il login con /login <token>");
return;
}
fetchFiles(chatId, 0);
});
bot.onText(/Parametri di Bordo/, (msg) => {
const chatId = msg.chat.id;
if (!isAuthenticated(chatId)) {
bot.sendMessage(chatId, "Effettua il login con /login <token>");
return;
}
bot.sendMessage(chatId, "*Parametri di Bordo*\nQui potrai visualizzare i parametri attuali del computer di bordo. Scegli il parametro che vuoi visualizzare dal menu qui sotto.", {
parse_mode: 'Markdown',
reply_markup: {
inline_keyboard: [
[{ text: "📊 Dashboard", callback_data: "get_dashboard" }],
[{ text: "⛅️ Meteo", callback_data: "get_forecasts" }],
[{ text: "📍 Posizione", callback_data: "get_position" }],
[{ text: "🌬️ Vento", callback_data: "get_wind" }],
[{ text: "🌊 Onde", callback_data: "get_waves" }],
[{ text: "🔋 Batterie", callback_data: "get_batteries" }],
[{ text: "Annulla", callback_data: "dismiss" }]
]
}
});
});
// Login
bot.onText(/\/login\s+(.+)/, (msg, match) => {
const chatID = msg.chat.id;
const token = (match && match[1] || "").trim();
if (!token) {
bot.sendMessage(chatID, "Inserisci il token: /login <token>");
return;
}
try {
const result = login(token, chatID);
if (!result) {
bot.sendMessage(chatID, "Token non valido.");
return;
}
if (result.isFirstLogin) {
bot.sendMessage(chatID,
`*Primo accesso completato!*\n\n` +
`Il tuo nuovo token permanente:\n\`${result.newToken}\`\n\n` +
`Salvalo! Non potrà essere usato da altri account.`,
{ parse_mode: 'Markdown' }
);
} else {
bot.sendMessage(chatID, "✅ Login effettuato!");
}
const menu = {
keyboard: [
[{ text: "📊 Dashboard" }],
[{ text: "Parametri di Bordo" }],
[{ text: "File di Logs" }],
[{ text: "Genera un nuovo log" }],
[{ text: "Stato dei Log" }]
],
resize_keyboard: true
};
bot.sendMessage(chatID, "Menu principale:", { reply_markup: menu });
} catch (error) {
bot.sendMessage(chatID, `${error.message}`);
}
});
bot.onText(/\/logout/, (msg) => {
const chatID = msg.chat.id;
const user = logout(chatID);
if (!user) {
bot.sendMessage(chatID, "Non sei loggato.");
return;
}
bot.sendMessage(chatID, "Logout effettuato. Usa /login <token> per rientrare.");
});
// Admin commands
bot.onText(/\/newuser(?:\s+(.*))?/, (msg, match) => {
const chatID = msg.chat.id;
if (!isAdmin(chatID)) {
bot.sendMessage(chatID, "⛔ Non autorizzato.");
return;
}
const permissionsArg = (match && match[1] || "").trim();
const permissions = permissionsArg
? permissionsArg.split(',').map(p => p.trim()).filter(p => p)
: ["basic"];
try {
const newUser = createNewUser(permissions);
bot.sendMessage(chatID,
`✅ *Nuovo utente creato*\n\nToken: \`${newUser.token}\``,
{ parse_mode: 'Markdown' }
);
} catch (error) {
bot.sendMessage(chatID, `${error.message}`);
}
});
bot.onText(/\/addadmin\s+(\d+)/, (msg, match) => {
const chatID = msg.chat.id;
const newAdminID = match && match[1];
if (!isAdmin(chatID)) {
bot.sendMessage(chatID, "⛔ Non autorizzato.");
return;
}
const admins = loadAuthorizedAdmins();
if (admins.has(newAdminID)) {
bot.sendMessage(chatID, "Già admin.");
return;
}
admins.add(newAdminID);
saveAuthorizedAdmins(admins);
bot.sendMessage(chatID, `✅ Admin \`${newAdminID}\` aggiunto.`, { parse_mode: 'Markdown' });
});
bot.onText(/\/removeadmin\s+(\d+)/, (msg, match) => {
const chatID = msg.chat.id;
const adminToRemove = match && match[1];
if (!isAdmin(chatID)) {
bot.sendMessage(chatID, "⛔ Non autorizzato.");
return;
}
if (adminToRemove === String(chatID)) {
bot.sendMessage(chatID, "Non puoi rimuovere te stesso.");
return;
}
const admins = loadAuthorizedAdmins();
if (!admins.has(adminToRemove)) {
bot.sendMessage(chatID, "Non è admin.");
return;
}
admins.delete(adminToRemove);
saveAuthorizedAdmins(admins);
bot.sendMessage(chatID, `✅ Admin \`${adminToRemove}\` rimosso.`, { parse_mode: 'Markdown' });
});
bot.onText(/\/listusers/, (msg) => {
const chatID = msg.chat.id;
if (!isAdmin(chatID)) {
bot.sendMessage(chatID, "⛔ Non autorizzato.");
return;
}
const users = loadUsers();
if (users.length === 0) {
bot.sendMessage(chatID, "Nessun utente.");
return;
}
let message = `👥 *Utenti:* ${users.length}\n\n`;
users.forEach((user, idx) => {
const status = user.hasLoggedYet ? '✅' : '⏳';
message += `${idx + 1}. ${status} \`${user.chatID || 'N/A'}\`\n`;
});
bot.sendMessage(chatID, message, { parse_mode: 'Markdown' });
});
bot.onText(/\/mychatid/, (msg) => {
bot.sendMessage(msg.chat.id, `ChatID: \`${msg.chat.id}\``, { parse_mode: 'Markdown' });
});
// Interval control
bot.onText(/\/changei\s+(log|api)\s+(\d+)/, (msg, match) => {
const chatID = msg.chat.id;
if (!isAdmin(chatID)) {
bot.sendMessage(chatID, "⛔ Non autorizzato.");
return;
}
const type = match[1];
const seconds = parseInt(match[2], 10);
if (isNaN(seconds) || seconds < 1) {
bot.sendMessage(chatID, "❌ Secondi non validi (min 1).");
return;
}
const newIntervalMs = seconds * 1000;
// Debug: verifica stato app
if (!app) {
bot.sendMessage(chatID, "❌ App non inizializzata. Riprova tra qualche secondo.");
console.error('[Telegram] app è null in change_interval');
return;
}
if (!app.intervalControl) {
bot.sendMessage(chatID, "❌ Sistema intervalControl non disponibile. Il plugin potrebbe non essere ancora avviato.");
console.error('[Telegram] app.intervalControl non esiste');
return;
}
try {
const result = app.intervalControl.updateInterval(type, newIntervalMs);
if (result) {
const typeLabel = type === 'log' ? 'Log recording' : 'OpenMeteo API';
bot.sendMessage(chatID,
`✅ *${typeLabel}* aggiornato a *${seconds}s*`,
{ parse_mode: 'Markdown' }
);
} else {
bot.sendMessage(chatID, "❌ Tipo non valido. Usa: `log` o `api`", { parse_mode: 'Markdown' });
}
} catch (error) {
console.error('[Telegram] Errore change_interval:', error);
bot.sendMessage(chatID, `❌ Errore: ${error.message}`);
}
});
bot.onText(/\/intervals/, (msg) => {
const chatID = msg.chat.id;
if (!isAdmin(chatID)) {
bot.sendMessage(chatID, "⛔ Non autorizzato.");
return;
}
if (!app) {
bot.sendMessage(chatID, "❌ App non inizializzata.");
return;
}
if (!app.intervalControl) {
bot.sendMessage(chatID, "❌ Sistema intervalControl non disponibile.");
return;
}
try {
const intervals = app.intervalControl.getIntervals();
bot.sendMessage(chatID,
`⏱️ *Intervalli Attuali*\n\n` +
`📝 Log: *${intervals.log_interval / 1000}s*\n` +
`🌤️ API: *${intervals.openmeteo_interval / 1000}s*\n\n` +
`Per modificare:\n` +
`\`/changei log <sec>\`\n` +
`\`/changei api <sec>\``,
{ parse_mode: 'Markdown' }
);
} catch (error) {
console.error('[Telegram] Errore intervals:', error);
bot.sendMessage(chatID, `❌ Errore: ${error.message}`);
}
});
// Callback query handler
bot.on('callback_query', async (query) => {
const chatId = query.message.chat.id;
const messageId = query.message.message_id;
const data = query.data;
await bot.answerCallbackQuery(query.id);
if (!isAuthenticated(chatId) && !['token_login_question', 'token_ready'].includes(data)) {
bot.sendMessage(chatId, "Effettua prima il login.");
return;
}
switch (data) {
case 'dismiss':
bot.deleteMessage(chatId, messageId).catch(() => {});
break;
case 'token_login_question':
bot.sendMessage(chatId,
"Per ottenere un token, contatta un amministratore del sistema."
);
break;
case 'token_ready':
bot.sendMessage(chatId, "Usa: /login <token>");
break;
case 'get_dashboard':
case 'refresh_dashboard':
bot.editMessageText(renderDashboardText(), {
chat_id: chatId,
message_id: messageId,
parse_mode: 'Markdown',
reply_markup: {
inline_keyboard: [
[{ text: "🔄 Aggiorna", callback_data: "refresh_dashboard" }],
[{ text: "📡 Live (3s)", callback_data: "live_dashboard" }],
[{ text: "⏹️ Chiudi", callback_data: "dismiss" }]
]
}
}).catch(() => {});
break;
case 'live_dashboard':
// Ferma eventuali live precedenti
if (liveParamIntervals.has(chatId)) {
clearInterval(liveParamIntervals.get(chatId));
}
const interval = setInterval(() => {
bot.editMessageText(renderDashboardText(), {
chat_id: chatId,
message_id: messageId,
parse_mode: 'Markdown',
reply_markup: {
inline_keyboard: [
[{ text: "⏹️ Stop Live", callback_data: "stop_live" }]
]
}
}).catch(() => {
clearInterval(interval);
liveParamIntervals.delete(chatId);
});
}, CONFIG.liveUpdateInterval);
liveParamIntervals.set(chatId, interval);
break;
case 'stop_live':
if (liveParamIntervals.has(chatId)) {
clearInterval(liveParamIntervals.get(chatId));
liveParamIntervals.delete(chatId);
}
bot.editMessageText(renderDashboardText(), {
chat_id: chatId,
message_id: messageId,
parse_mode: 'Markdown',
reply_markup: {
inline_keyboard: [
[{ text: "🔄 Aggiorna", callback_data: "refresh_dashboard" }],
[{ text: "📡 Live (3s)", callback_data: "live_dashboard" }],
[{ text: "⏹️ Chiudi", callback_data: "dismiss" }]
]
}
}).catch(() => {});
break;
case 'get_position':
bot.editMessageText(renderPositionText(), {
chat_id: chatId,
message_id: messageId,
parse_mode: 'Markdown',
reply_markup: { inline_keyboard: [[{ text: "← Indietro", callback_data: "back_to_params" }]] }
}).catch(() => {});
break;
case 'get_wind':
bot.editMessageText(renderWindText(), {
chat_id: chatId,
message_id: messageId,
parse_mode: 'Markdown',
reply_markup: { inline_keyboard: [[{ text: "← Indietro", callback_data: "back_to_params" }]] }
}).catch(() => {});
break;
case 'get_waves':
bot.editMessageText(renderWavesText(), {
chat_id: chatId,
message_id: messageId,
parse_mode: 'Markdown',
reply_markup: { inline_keyboard: [[{ text: "← Indietro", callback_data: "back_to_params" }]] }
}).catch(() => {});
break;
case 'get_forecasts':
bot.editMessageText(renderForecastsText(), {
chat_id: chatId,
message_id: messageId,
parse_mode: 'Markdown',
reply_markup: { inline_keyboard: [[{ text: "← Indietro", callback_data: "back_to_params" }]] }
}).catch(() => {});
break;
case 'get_batteries':
bot.editMessageText(renderBatteriesText(), {
chat_id: chatId,
message_id: messageId,
parse_mode: 'Markdown',
reply_markup: { inline_keyboard: [[{ text: "← Indietro", callback_data: "back_to_params" }]] }
}).catch(() => {});
break;
case 'back_to_params':
bot.editMessageText("*Parametri di Bordo*\nQui potrai visualizzare i parametri attuali del computer di bordo. Scegli il parametro che vuoi visualizzare dal menu qui sotto.", {
chat_id: chatId,
message_id: messageId,
parse_mode: 'Markdown',
reply_markup: {
inline_keyboard: [
[{ text: "📊 Dashboard Completa", callback_data: "get_dashboard" }],
[{ text: "⛅️ Meteo", callback_data: "get_forecasts" }],
[{ text: "📍 Posizione", callback_data: "get_position" }],
[{ text: "🌬️ Vento", callback_data: "get_wind" }],
[{ text: "🌊 Onde", callback_data: "get_waves" }],
[{ text: "🔋 Batterie", callback_data: "get_batteries" }],
[{ text: "Annulla", callback_data: "dismiss" }]
]
}
}).catch(() => {});
break;
default:
// Gestione paginazione file
if (data.startsWith('page_')) {
const page = parseInt(data.replace('page_', ''), 10);
if (!isNaN(page)) {
bot.deleteMessage(chatId, messageId).catch(() => {});
fetchFiles(chatId, page);
}
}
// Gestione richiesta file
else if (data.startsWith('request_file_')) {
const fileName = data.replace('request_file_', '');
const filePath = path.join(__dirname, "..", "datasetModels/saved_datas", fileName);
if (fs.existsSync(filePath)) {
const logsData = loadLogsReferences();
const fileRef = (logsData.references || []).find(r => r.name === fileName);
const key = fileRef?.key || "Chiave non trovata";
try {
const fileMsg = await bot.sendDocument(chatId, filePath, {
caption: `🔑 Chiave: \`${key}\`\n⚠️ Questo messaggio verrà eliminato tra 10 secondi.`,
parse_mode: 'Markdown'
});
// Elimina dopo 10 secondi
setTimeout(() => {
bot.deleteMessage(chatId, fileMsg.message_id).catch(() => {});
}, CONFIG.fileExpirationTime * 1000);
} catch (error) {
bot.sendMessage(chatId, `❌ Errore invio file: ${error.message}`);
}
} else {
bot.sendMessage(chatId, "❌ File non trovato.");
}
bot.deleteMessage(chatId, messageId).catch(() => {});
}
break;
}
});
} // Fine registerHandlers
module.exports = {
linkBot,
send,
loadUsers,
saveUsers,
getUserByChatID,
isAuthenticated,
isAdmin
};

View File

@@ -0,0 +1,24 @@
[
{
"token": "eccef678c73b825fd2af7a3ce76603aeef68c6280862f1c2",
"hasLogged": true,
"chatId": 5868470977,
"chatID": 5868470977
},
{
"token": "5A6MjMd6amSGgZbk6PZ9T9sdJKjWwbHM",
"chatID": "5868470977",
"isAuthorized": [
"basic"
],
"hasLoggedYet": true
},
{
"token": "af9aBSY9taEedmZXFhy3Fhns3VHtXSxT",
"chatID": "838642766",
"isAuthorized": [
"basic"
],
"hasLoggedYet": true
}
]

11
plugin/config.js Normal file
View File

@@ -0,0 +1,11 @@
const dotenv = require("dotenv");
const path = require("path");
// Carica il file .env dalla root del plugin
dotenv.config({ path: path.resolve(__dirname, "..", ".env"), quiet: true });
const config = {
telegramBotToken: process.env.TELEGRAM_BOT_TOKEN,
};
module.exports = { config };

View File

@@ -0,0 +1,105 @@
const fs = require("fs");
const path = require("path");
// Coda di scrittura per gestire backpressure
let writeQueue = [];
let isDraining = false;
/**
* Inizializza il dataset e lo prepara per essere salvato.
*
* @param {String[]} headers Un array di stringhe che rappresentano i tipi di dati.
* @param {WriteStream} streamer Lo stream di scrittura del file.
* @returns {boolean} True se l'inizializzazione ha successo
*/
function datasetInit(headers, streamer) {
if (!streamer || streamer.destroyed) {
console.error('[DatasetCore] Stream non valido per inizializzazione');
return false;
}
if (!Array.isArray(headers) || headers.length === 0) {
console.error('[DatasetCore] Headers non validi');
return false;
}
writeQueue = [];
isDraining = false;
streamer.write(headers.join(',') + '\n');
return true;
}
/**
* Aggiunge una riga di dati al dataset con gestione backpressure.
*
* @param {Object} data I dati da scrivere
* @param {String[]} headers Gli header delle colonne
* @param {WriteStream} streamer Lo stream di scrittura
* @returns {boolean} True se la scrittura è andata a buon fine
*/
function appendData(data, headers, streamer) {
if (!streamer || streamer.destroyed) {
console.error('[DatasetCore] Stream non disponibile o distrutto');
return false;
}
if (!data || typeof data !== 'object') {
console.warn('[DatasetCore] Dati non validi, skip scrittura');
return false;
}
// Escape valori che contengono virgole o newline per CSV valido
const escapeCSV = (val) => {
if (val === undefined || val === null) return '';
const str = String(val);
if (str.includes(',') || str.includes('\n') || str.includes('"')) {
return `"${str.replace(/"/g, '""')}"`;
}
return str;
};
const row = headers.map(header => escapeCSV(data[header])).join(',');
// Gestione backpressure con coda
const canWrite = streamer.write(row + '\n');
if (!canWrite) {
if (!isDraining) {
isDraining = true;
console.warn('[DatasetCore] Buffer saturo, attendo drain...');
streamer.once('drain', () => {
isDraining = false;
// Processa coda pendente
while (writeQueue.length > 0 && !streamer.destroyed) {
const pendingRow = writeQueue.shift();
if (!streamer.write(pendingRow + '\n')) {
writeQueue.unshift(pendingRow);
break;
}
}
});
}
// Aggiungi alla coda solo se non troppo piena (max 1000 entries)
if (writeQueue.length < 1000) {
writeQueue.push(row);
} else {
console.error('[DatasetCore] Coda piena, scarto dati');
return false;
}
}
return true;
}
/**
* Ottiene la dimensione della coda di scrittura pendente
* @returns {number} Numero di righe in attesa
*/
function getPendingWrites() {
return writeQueue.length;
}
module.exports = {
datasetInit,
appendData,
getPendingWrites
};

View File

@@ -0,0 +1,274 @@
const fs = require('fs');
const path = require('path');
/**
* Searches for a directory. If not found, creates it.
* @param {string} dirPath - The absolute or relative path to the directory.
* @returns {string} - The absolute path to the directory.
*/
function getDirectory(dirPath) {
const absolutePath = path.resolve(dirPath);
if (!fs.existsSync(absolutePath)) {
fs.mkdirSync(absolutePath, { recursive: true });
}
return absolutePath;
}
/**
* Searches for a file. If not found, creates it with initialData.
* @param {string} filePath - The absolute or relative path to the file.
* @param {object} [initialData={}] - The initial data to write if the file is created.
* @returns {object} - The content of the file as an object.
*/
function write(filePath, initialData = {}) {
const absolutePath = path.resolve(filePath);
const dir = path.dirname(absolutePath);
getDirectory(dir);
if (!fs.existsSync(absolutePath)) {
fs.writeFileSync(absolutePath, JSON.stringify(initialData, null, 2), 'utf-8');
return initialData;
}
try {
const content = fs.readFileSync(absolutePath, 'utf-8');
return JSON.parse(content);
} catch (error) {
console.error(`Error reading or parsing JSON file at ${absolutePath}:`, error);
throw error;
}
}
/**
* Scrive dati in un file JSON.
* @param {string} filePath - Il path assoluto o relativo al file JSON.
* @param {object} data - Gli elementi da aggiungere nel file JSON.
*/
function update(filePath, data) {
const absolutePath = path.resolve(filePath);
const dir = path.dirname(absolutePath);
getDirectory(dir);
fs.writeFileSync(absolutePath, JSON.stringify(data, null, 2), 'utf-8');
}
/**
* Aggiunge un elemento all'array del file specificato
* Se il file non esiste, lo crea con un array contenente l'elemento.
* Se il file esiste ma non è un array, genera un errore.
* @param {string} filePath - Il path del file JSON.
* @param {any} element - L'elemento da aggiungere all'array.
* @returns {array} - L'array aggiornato.
*/
function appendTo(filePath, element) {
const absolutePath = path.resolve(filePath);
let data = [];
if (fs.existsSync(absolutePath)) {
try {
const content = fs.readFileSync(absolutePath, 'utf-8');
data = JSON.parse(content);
} catch (error) {
console.error(`Error reading or parsing JSON file at ${absolutePath}:`, error);
throw error;
}
} else {
// Ensure directory exists if we are creating the file
const dir = path.dirname(absolutePath);
getDirectory(dir);
}
if (!Array.isArray(data)) {
throw new Error(`File at ${absolutePath} exists but is not a JSON array.`);
}
data.push(element);
fs.writeFileSync(absolutePath, JSON.stringify(data, null, 2), 'utf-8');
return data;
}
/**
* Aggiunge un elemento a un array specifico all'interno di un oggetto JSON
* Es: JSON = {date: "now", elements: [], security: false}
* appendToElement(filePath, 'elements', {title: "", description: ""})
*
* @param {string} filePath - Il path del file JSON.
* @param {string} arrayKey - La chiave dell'array nell'oggetto JSON (es: 'elements').
* @param {any} element - L'elemento da aggiungere all'array specificato.
* @returns {boolean} - Se l'operazione è andata a buon fine, restituisce true.
*/
function appendToElement(filePath, arrayKey, element) {
const absolutePath = path.resolve(filePath);
let data = {};
if (fs.existsSync(absolutePath)) {
try {
const content = fs.readFileSync(absolutePath, 'utf-8');
data = JSON.parse(content);
} catch (error) {
console.error(`Error reading or parsing JSON file at ${absolutePath}:`, error);
throw error;
}
} else {
const dir = path.dirname(absolutePath);
getDirectory(dir);
data = {};
}
if (!data.hasOwnProperty(arrayKey)) {
data[arrayKey] = [];
}
if (!Array.isArray(data[arrayKey])) {
throw new Error(`Property '${arrayKey}' in file at ${absolutePath} exists but is not an array.`);
}
data[arrayKey].push(element);
fs.writeFileSync(absolutePath, JSON.stringify(data, null, 2), 'utf-8');
return true
}
/**
* Rimuove un elemento da un array specifico all'interno di un oggetto JSON
* cercando per proprietà "name"
* Es: JSON = {date: "now", elements: [{name: "item1"}, {name: "item2"}], security: false}
* removeFromElement(filePath, 'elements', 'item1')
*
* @param {string} filePath - Il path del file JSON.
* @param {string} arrayKey - La chiave dell'array nell'oggetto JSON (es: 'elements').
* @param {string} nameToRemove - Il valore della proprietà "name" dell'elemento da rimuovere.
* @returns {object} - Oggetto con {success: boolean, removed: object|null, remaining: number}
*/
function removeFromElement(filePath, arrayKey, nameToRemove) {
const absolutePath = path.resolve(filePath);
let data = {};
if (fs.existsSync(absolutePath)) {
try {
const content = fs.readFileSync(absolutePath, 'utf-8');
data = JSON.parse(content);
} catch (error) {
console.error(`Error reading or parsing JSON file at ${absolutePath}:`, error);
throw error;
}
} else {
throw new Error(`File at ${absolutePath} does not exist.`);
}
if (!data.hasOwnProperty(arrayKey)) {
throw new Error(`Property '${arrayKey}' does not exist in file at ${absolutePath}.`);
}
if (!Array.isArray(data[arrayKey])) {
throw new Error(`Property '${arrayKey}' in file at ${absolutePath} is not an array.`);
}
const initialLength = data[arrayKey].length;
const indexToRemove = data[arrayKey].findIndex(item => item.name === nameToRemove);
if (indexToRemove === -1) {
return {
success: false,
removed: null,
remaining: initialLength,
message: `Element with name '${nameToRemove}' not found in array '${arrayKey}'.`
};
}
const removedElement = data[arrayKey].splice(indexToRemove, 1)[0];
fs.writeFileSync(absolutePath, JSON.stringify(data, null, 2), 'utf-8');
return true
}
function findInElement(filePath, arrayKey, name) {
const absolutePath = path.resolve(filePath);
let data = {};
if (fs.existsSync(absolutePath)) {
try {
const content = fs.readFileSync(absolutePath, 'utf-8');
data = JSON.parse(content);
} catch (error) {
console.error(`Error reading or parsing JSON file at ${absolutePath}:`, error);
throw error;
}
} else {
throw new Error(`File at ${absolutePath} does not exist.`);
}
if (!data.hasOwnProperty(arrayKey)) {
throw new Error(`Property '${arrayKey}' does not exist in file at ${absolutePath}.`);
}
if (!Array.isArray(data[arrayKey])) {
throw new Error(`Property '${arrayKey}' in file at ${absolutePath} is not an array.`);
}
const index = data[arrayKey].findIndex(item => item.name === name);
return data[arrayKey][index]
}
/**
* Aggiorna un elemento in un array specifico all'interno di un oggetto JSON
* cercando per proprietà "name" e sostituendolo con un nuovo elemento
* Es: JSON = {date: "now", elements: [{name: "item1", value: 10}, {name: "item2", value: 20}]}
* updateInElement(filePath, 'elements', 'item1', {name: "item1", value: 99})
*
* @param {string} filePath - Il path del file JSON.
* @param {string} arrayKey - La chiave dell'array nell'oggetto JSON (es: 'elements').
* @param {string} nameToUpdate - Il valore della proprietà "name" dell'elemento da aggiornare.
* @param {any} newElement - Il nuovo elemento che sostituirà quello trovato.
* @returns {boolean} - True se l'operazione ha successo, false se l'elemento non è stato trovato.
*/
function updateInElement(filePath, arrayKey, nameToUpdate, newElement) {
const absolutePath = path.resolve(filePath);
let data = {};
if (fs.existsSync(absolutePath)) {
try {
const content = fs.readFileSync(absolutePath, 'utf-8');
data = JSON.parse(content);
} catch (error) {
console.error(`Error reading or parsing JSON file at ${absolutePath}:`, error);
throw error;
}
} else {
throw new Error(`File at ${absolutePath} does not exist.`);
}
if (!data.hasOwnProperty(arrayKey)) {
throw new Error(`Property '${arrayKey}' does not exist in file at ${absolutePath}.`);
}
if (!Array.isArray(data[arrayKey])) {
throw new Error(`Property '${arrayKey}' in file at ${absolutePath} is not an array.`);
}
const index = data[arrayKey].findIndex(item => item.name === nameToUpdate);
if (index === -1) {
return false;
}
data[arrayKey][index] = newElement;
fs.writeFileSync(absolutePath, JSON.stringify(data, null, 2), 'utf-8');
return true;
}
module.exports = {
getDirectory,
write,
update,
appendToElement,
findInElement,
removeFromElement,
updateInElement
};

View File

@@ -0,0 +1,350 @@
const fs = require('fs');
const path = require('path');
const ARCHIVE_FILE = path.join(__dirname, 'hourly_archive.json');
// Cache dati OpenMeteo condivisi (evita chiamate duplicate)
let sharedWeatherData = {
forecast: null,
waves: null,
units: null, // Unità di misura globali
lastUpdate: null,
updateInterval: 2 * 60 * 1000 // 2 minuti
};
// Archivio dati orari
let hourlyArchive = {
temperature: [],
windSpeed: [],
windDirection: [],
waveHeight: [],
wavePeriod: [],
waveDirection: [],
humidity: [],
pressure: []
};
/**
* Carica l'archivio da file
*/
function loadArchive() {
try {
if (fs.existsSync(ARCHIVE_FILE)) {
const data = fs.readFileSync(ARCHIVE_FILE, 'utf8');
const parsed = JSON.parse(data);
// Valida struttura archivio
if (parsed && typeof parsed === 'object') {
hourlyArchive = {
temperature: Array.isArray(parsed.temperature) ? parsed.temperature : [],
windSpeed: Array.isArray(parsed.windSpeed) ? parsed.windSpeed : [],
windDirection: Array.isArray(parsed.windDirection) ? parsed.windDirection : [],
waveHeight: Array.isArray(parsed.waveHeight) ? parsed.waveHeight : [],
wavePeriod: Array.isArray(parsed.wavePeriod) ? parsed.wavePeriod : [],
waveDirection: Array.isArray(parsed.waveDirection) ? parsed.waveDirection : [],
humidity: Array.isArray(parsed.humidity) ? parsed.humidity : [],
pressure: Array.isArray(parsed.pressure) ? parsed.pressure : []
};
console.log('[GraphsCore] Archivio caricato');
}
}
} catch (error) {
console.error('[GraphsCore] Errore caricamento archivio:', error.message);
// Resetta archivio se corrotto
hourlyArchive = {
temperature: [], windSpeed: [], windDirection: [],
waveHeight: [], wavePeriod: [], waveDirection: [],
humidity: [], pressure: []
};
}
}
/**
* Salva l'archivio su file
*/
function saveArchive() {
try {
fs.writeFileSync(ARCHIVE_FILE, JSON.stringify(hourlyArchive, null, 2));
} catch (error) {
console.error('[GraphsCore] Errore salvataggio archivio:', error.message);
}
}
/**
* Aggiorna i dati meteo condivisi
* @param {object} forecastData - Dati forecast da OpenMeteo
* @param {object} wavesData - Dati onde da OpenMeteo
*/
function updateSharedWeatherData(forecastData, wavesData) {
if (forecastData) {
sharedWeatherData.forecast = forecastData;
}
if (wavesData) {
sharedWeatherData.waves = wavesData;
}
// Aggiorna unità se disponibili
if (forecastData?.units || wavesData?.units) {
sharedWeatherData.units = {
forecast: forecastData?.units || sharedWeatherData.units?.forecast || {},
waves: wavesData?.units || sharedWeatherData.units?.waves || {}
};
}
sharedWeatherData.lastUpdate = Date.now();
}
/**
* Ottiene i dati meteo condivisi
* @returns {object} Dati meteo attuali
*/
function getSharedWeatherData() {
return {
forecast: sharedWeatherData.forecast,
waves: sharedWeatherData.waves,
units: sharedWeatherData.units,
lastUpdate: sharedWeatherData.lastUpdate,
isValid: sharedWeatherData.lastUpdate &&
(Date.now() - sharedWeatherData.lastUpdate) < sharedWeatherData.updateInterval * 2
};
}
/**
* Ottiene le unità di misura globali
*/
function getUnits() {
return sharedWeatherData.units || {
forecast: {
temperature: '°C',
humidity: '%',
pressure: 'hPa',
windSpeed: 'km/h',
windDirection: '°'
},
waves: {
waveHeight: 'm',
wavePeriod: 's',
waveDirection: '°'
}
};
}
/**
* Formatta un valore con la sua unità
*/
function formatValue(value, unitKey, category = 'forecast') {
if (value === null || value === undefined) return 'n/d';
const units = getUnits();
const unit = units[category]?.[unitKey] || '';
return `${value}${unit}`;
}
/**
* Verifica se i dati condivisi sono ancora validi
*/
function isWeatherDataValid() {
if (!sharedWeatherData.lastUpdate) return false;
return (Date.now() - sharedWeatherData.lastUpdate) < sharedWeatherData.updateInterval;
}
/**
* Archivia un punto dati orario
*/
function archiveHourlyData(data) {
if (!data || typeof data !== 'object') {
console.warn('[GraphsCore] archiveHourlyData: dati non validi');
return;
}
const timestamp = new Date().toISOString();
const maxPoints = 168; // 7 giorni di dati orari
const addPoint = (arr, value) => {
if (value === null || value === undefined || Number.isNaN(value)) return;
arr.push({ timestamp, value });
if (arr.length > maxPoints) arr.shift();
};
addPoint(hourlyArchive.temperature, data.temperature);
addPoint(hourlyArchive.windSpeed, data.windSpeed);
addPoint(hourlyArchive.windDirection, data.windDirection);
addPoint(hourlyArchive.waveHeight, data.waveHeight);
addPoint(hourlyArchive.wavePeriod, data.wavePeriod);
addPoint(hourlyArchive.waveDirection, data.waveDirection);
addPoint(hourlyArchive.humidity, data.humidity);
addPoint(hourlyArchive.pressure, data.pressure);
saveArchive();
console.log('[GraphsCore] Dati orari archiviati');
}
/**
* Ottiene i dati per un grafico specifico
* @param {string} parameter - temperatura, vento, onde, etc.
* @param {number} hours - ultimi N ore (default 24)
*/
function getGraphData(parameter, hours = 24) {
const paramMap = {
'temperature': hourlyArchive.temperature,
'windSpeed': hourlyArchive.windSpeed,
'windDirection': hourlyArchive.windDirection,
'waveHeight': hourlyArchive.waveHeight,
'wavePeriod': hourlyArchive.wavePeriod,
'waveDirection': hourlyArchive.waveDirection,
'humidity': hourlyArchive.humidity,
'pressure': hourlyArchive.pressure
};
const data = paramMap[parameter] || [];
const cutoff = Date.now() - (hours * 60 * 60 * 1000);
return data.filter(point => new Date(point.timestamp).getTime() > cutoff);
}
/**
* Genera dati formattati per Chart.js
*/
function formatForChart(parameter, hours = 24) {
const data = getGraphData(parameter, hours);
return {
labels: data.map(p => {
const d = new Date(p.timestamp);
return `${d.getHours()}:${String(d.getMinutes()).padStart(2, '0')}`;
}),
datasets: [{
label: getParameterLabel(parameter),
data: data.map(p => p.value),
borderColor: getParameterColor(parameter),
backgroundColor: getParameterColor(parameter, 0.2),
tension: 0.3,
fill: true
}]
};
}
/**
* Label leggibili per i parametri
*/
function getParameterLabel(param) {
const labels = {
'temperature': 'Temperatura (°C)',
'windSpeed': 'Velocità Vento (km/h)',
'windDirection': 'Direzione Vento (°)',
'waveHeight': 'Altezza Onde (m)',
'wavePeriod': 'Periodo Onde (s)',
'waveDirection': 'Direzione Onde (°)',
'humidity': 'Umidità (%)',
'pressure': 'Pressione (hPa)'
};
return labels[param] || param;
}
/**
* Colori per i grafici
*/
function getParameterColor(param, alpha = 1) {
const colors = {
'temperature': `rgba(255, 99, 132, ${alpha})`,
'windSpeed': `rgba(54, 162, 235, ${alpha})`,
'windDirection': `rgba(75, 192, 192, ${alpha})`,
'waveHeight': `rgba(153, 102, 255, ${alpha})`,
'wavePeriod': `rgba(255, 159, 64, ${alpha})`,
'waveDirection': `rgba(255, 205, 86, ${alpha})`,
'humidity': `rgba(201, 203, 207, ${alpha})`,
'pressure': `rgba(100, 149, 237, ${alpha})`
};
return colors[param] || `rgba(128, 128, 128, ${alpha})`;
}
/**
* Ottiene tutti i dati disponibili per dashboard
*/
function getAllGraphsData(hours = 24) {
return {
temperature: formatForChart('temperature', hours),
windSpeed: formatForChart('windSpeed', hours),
waveHeight: formatForChart('waveHeight', hours),
humidity: formatForChart('humidity', hours)
};
}
/**
* Statistiche sull'archivio
*/
function getArchiveStats() {
return {
temperature: hourlyArchive.temperature.length,
windSpeed: hourlyArchive.windSpeed.length,
waveHeight: hourlyArchive.waveHeight.length,
oldestData: getOldestTimestamp(),
newestData: getNewestTimestamp()
};
}
function getOldestTimestamp() {
const all = [
...hourlyArchive.temperature,
...hourlyArchive.windSpeed,
...hourlyArchive.waveHeight
];
if (all.length === 0) return null;
return all.reduce((oldest, p) =>
new Date(p.timestamp) < new Date(oldest.timestamp) ? p : oldest
).timestamp;
}
function getNewestTimestamp() {
const all = [
...hourlyArchive.temperature,
...hourlyArchive.windSpeed,
...hourlyArchive.waveHeight
];
if (all.length === 0) return null;
return all.reduce((newest, p) =>
new Date(p.timestamp) > new Date(newest.timestamp) ? p : newest
).timestamp;
}
/**
* Pulisce l'archivio
*/
function clearArchive() {
hourlyArchive = {
temperature: [],
windSpeed: [],
windDirection: [],
waveHeight: [],
wavePeriod: [],
waveDirection: [],
humidity: [],
pressure: []
};
saveArchive();
console.log('[GraphsCore] Archivio pulito');
}
// Carica archivio all'avvio
loadArchive();
module.exports = {
// Gestione dati condivisi
updateSharedWeatherData,
getSharedWeatherData,
isWeatherDataValid,
// Unità di misura
getUnits,
formatValue,
// Archivio orario
archiveHourlyData,
getGraphData,
formatForChart,
getAllGraphsData,
getArchiveStats,
clearArchive,
// Utility
getParameterLabel,
getParameterColor
};

596
plugin/index.cjs Normal file
View File

@@ -0,0 +1,596 @@
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;
};

View File

@@ -0,0 +1,58 @@
.data-console-container {
font-family: sans-serif;
background-color: #f4f4f4;
padding: 15px;
border-radius: 25px;
display: flex;
align-items: center;
}
#error-popup {
position: fixed;
z-index: 9999;
inset: 0;
background: rgba(0,0,0,0.4);
display: none;
justify-content: center;
align-items: center;
}
#error-popup-content {
background: #fff;
padding: 20px 25px;
border-radius: 8px;
max-width: 400px;
width: 90%;
box-shadow: 0 2px 10px rgba(0,0,0,0.2);
position: relative;
font-family: sans-serif;
}
#error-popup-close {
position: absolute;
right: 10px;
top: 5px;
cursor: pointer;
font-size: 20px;
}
table {
width: 100%;
border-collapse: collapse;
margin-top: 30px;
border: 0px solid #f9f9f9;
font-family: sans-serif;
}
table thead {
background-color: #e5effa;
font-size: 13px;
color: rgb(0, 0, 0);
}
table.th, table td {
padding: 10px 10px;
text-align: left;
border-bottom: 1px solid #ddd;
}

View File

@@ -0,0 +1,177 @@
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
display: flex;
justify-content: center;
align-items: center;
flex-direction: column;
min-height: 100vh;
background-color: #f4f4f4;
color: #333;
margin: 0;
padding: 20px;
}
.widget-container {
width: 100%;
max-width: 600px;
padding: 30px;
background: #ffffff;
border-radius: 10px;
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.1);
text-align: center;
}
.visualization-container {
position: relative;
width: 100%;
height: 300px;
margin: 20px auto;
display: flex;
justify-content: center;
align-items: center;
background: #fafafa;
border-radius: 8px;
border: 1px solid #eee;
}
.arrow-svg {
width: 300px;
height: 300px;
overflow: visible;
}
.arrow-head {
fill: none;
stroke: #007aff;
stroke-width: 12;
stroke-linecap: round;
stroke-linejoin: round;
filter: drop-shadow(0 2px 4px rgba(0, 122, 255, 0.3));
transition: transform 0.1s linear;
}
.arrow-stem {
fill: none;
stroke: #007aff;
stroke-width: 12;
stroke-linecap: round;
transition: d 0.1s linear;
filter: drop-shadow(0 2px 4px rgba(0, 122, 255, 0.3));
transform: rotate(-40deg);
}
.controls-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 15px;
margin: 30px 0 20px;
text-align: left;
}
.control-group {
background: #f8f9fa;
padding: 15px;
border-radius: 8px;
}
.control-group label {
display: block;
font-size: 12px;
font-weight: 600;
color: #666;
margin-bottom: 8px;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.control-group input[type="number"] {
width: 100%;
padding: 10px;
border: 2px solid #e0e0e0;
border-radius: 6px;
font-size: 16px;
font-weight: 600;
color: #333;
transition: border-color 0.3s ease;
box-sizing: border-box;
}
.control-group input[type="number"]:focus {
outline: none;
border-color: #007aff;
}
.slider-container {
width: 100%;
margin-top: 30px;
position: relative;
padding: 0 10px;
box-sizing: border-box;
}
input[type=range] {
width: 100%;
cursor: pointer;
height: 8px;
border-radius: 5px;
outline: none;
-webkit-appearance: none;
appearance: none;
background: #e0e0e0;
transition: background 0.3s ease;
}
input[type=range]::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 24px;
height: 24px;
border-radius: 50%;
background: #007aff;
cursor: pointer;
box-shadow: 0 2px 8px rgba(0, 122, 255, 0.4);
transition: transform 0.2s ease;
margin-top: -8px;
}
input[type=range]::-webkit-slider-thumb:hover {
transform: scale(1.1);
}
.stats {
display: flex;
justify-content: space-around;
margin-top: 30px;
padding-top: 20px;
border-top: 1px solid #e0e0e0;
}
.stat-item {
text-align: center;
}
.stat-value {
font-size: 24px;
font-weight: bold;
color: #007aff;
transition: all 0.3s ease;
}
.stat-label {
font-size: 12px;
color: #888;
margin-top: 5px;
text-transform: uppercase;
}
.percentage-display {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
font-size: 40px;
font-weight: 800;
color: rgba(0, 0, 0, 0.1);
pointer-events: none;
z-index: 0;
}

View File

@@ -0,0 +1,785 @@
<!DOCTYPE html>
<html lang="it">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>MEB - Decryption Tool</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 50%, #0f3460 100%);
min-height: 100vh;
color: #e4e4e4;
padding: 20px;
}
.container {
max-width: 900px;
margin: 0 auto;
}
header {
text-align: center;
margin-bottom: 40px;
padding: 20px;
}
header h1 {
font-size: 2.5rem;
color: #00d4ff;
text-shadow: 0 0 20px rgba(0, 212, 255, 0.5);
margin-bottom: 10px;
}
header p {
color: #888;
font-size: 1rem;
}
.card {
background: rgba(255, 255, 255, 0.05);
border-radius: 16px;
padding: 30px;
margin-bottom: 20px;
backdrop-filter: blur(10px);
border: 1px solid rgba(255, 255, 255, 0.1);
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3);
}
.card h2 {
color: #00d4ff;
margin-bottom: 20px;
font-size: 1.3rem;
display: flex;
align-items: center;
gap: 10px;
}
.card h2 .icon {
font-size: 1.5rem;
}
/* Drop Zone */
.drop-zone {
border: 2px dashed rgba(0, 212, 255, 0.4);
border-radius: 12px;
padding: 40px;
text-align: center;
cursor: pointer;
transition: all 0.3s ease;
background: rgba(0, 212, 255, 0.05);
}
.drop-zone:hover,
.drop-zone.dragover {
border-color: #00d4ff;
background: rgba(0, 212, 255, 0.15);
transform: scale(1.01);
}
.drop-zone .icon {
font-size: 3rem;
margin-bottom: 15px;
display: block;
}
.drop-zone p {
color: #aaa;
margin-bottom: 10px;
}
.drop-zone .hint {
font-size: 0.85rem;
color: #666;
}
/* File Input Hidden */
#fileInput {
display: none;
}
/* Key Input */
.key-section {
margin-top: 20px;
}
.input-group {
display: flex;
gap: 10px;
align-items: center;
}
.input-group input {
flex: 1;
padding: 14px 18px;
border: 1px solid rgba(255, 255, 255, 0.2);
border-radius: 8px;
background: rgba(0, 0, 0, 0.3);
color: #fff;
font-size: 1rem;
font-family: 'Consolas', 'Monaco', monospace;
transition: all 0.3s ease;
}
.input-group input:focus {
outline: none;
border-color: #00d4ff;
box-shadow: 0 0 15px rgba(0, 212, 255, 0.3);
}
.input-group input::placeholder {
color: #666;
}
/* Buttons */
.btn {
padding: 14px 28px;
border: none;
border-radius: 8px;
font-size: 1rem;
font-weight: 600;
cursor: pointer;
transition: all 0.3s ease;
display: inline-flex;
align-items: center;
gap: 8px;
}
.btn-primary {
background: linear-gradient(135deg, #00d4ff, #0099cc);
color: #000;
}
.btn-primary:hover {
transform: translateY(-2px);
box-shadow: 0 8px 25px rgba(0, 212, 255, 0.4);
}
.btn-primary:disabled {
background: #444;
color: #888;
cursor: not-allowed;
transform: none;
box-shadow: none;
}
.btn-secondary {
background: rgba(255, 255, 255, 0.1);
color: #fff;
border: 1px solid rgba(255, 255, 255, 0.2);
}
.btn-secondary:hover {
background: rgba(255, 255, 255, 0.2);
}
.btn-success {
background: linear-gradient(135deg, #00ff88, #00cc6a);
color: #000;
}
.btn-success:hover {
box-shadow: 0 8px 25px rgba(0, 255, 136, 0.4);
}
/* File Info */
.file-info {
display: none;
margin-top: 20px;
padding: 15px;
background: rgba(0, 212, 255, 0.1);
border-radius: 8px;
border-left: 4px solid #00d4ff;
}
.file-info.visible {
display: block;
animation: slideIn 0.3s ease;
}
.file-info .file-name {
font-weight: 600;
color: #00d4ff;
word-break: break-all;
}
.file-info .file-size {
color: #888;
font-size: 0.9rem;
margin-top: 5px;
}
/* Status Messages */
.status {
margin-top: 20px;
padding: 15px;
border-radius: 8px;
display: none;
animation: slideIn 0.3s ease;
}
.status.visible {
display: block;
}
.status.success {
background: rgba(0, 255, 136, 0.15);
border: 1px solid rgba(0, 255, 136, 0.3);
color: #00ff88;
}
.status.error {
background: rgba(255, 68, 68, 0.15);
border: 1px solid rgba(255, 68, 68, 0.3);
color: #ff4444;
}
.status.info {
background: rgba(0, 212, 255, 0.15);
border: 1px solid rgba(0, 212, 255, 0.3);
color: #00d4ff;
}
/* Preview Section */
.preview-section {
display: none;
margin-top: 20px;
}
.preview-section.visible {
display: block;
animation: slideIn 0.3s ease;
}
.preview-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 15px;
}
.preview-content {
background: rgba(0, 0, 0, 0.4);
border-radius: 8px;
padding: 15px;
max-height: 400px;
overflow: auto;
font-family: 'Consolas', 'Monaco', monospace;
font-size: 0.85rem;
line-height: 1.5;
white-space: pre-wrap;
word-break: break-all;
}
.preview-content::-webkit-scrollbar {
width: 8px;
height: 8px;
}
.preview-content::-webkit-scrollbar-track {
background: rgba(255, 255, 255, 0.05);
border-radius: 4px;
}
.preview-content::-webkit-scrollbar-thumb {
background: rgba(0, 212, 255, 0.4);
border-radius: 4px;
}
/* Action Buttons */
.action-buttons {
display: flex;
gap: 10px;
margin-top: 20px;
flex-wrap: wrap;
}
/* Algorithm Info */
.algo-info {
margin-top: 20px;
padding: 15px;
background: rgba(255, 193, 7, 0.1);
border-radius: 8px;
border-left: 4px solid #ffc107;
font-size: 0.9rem;
}
.algo-info code {
background: rgba(0, 0, 0, 0.3);
padding: 2px 6px;
border-radius: 4px;
color: #ffc107;
}
/* Animations */
@keyframes slideIn {
from {
opacity: 0;
transform: translateY(-10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
/* Responsive */
@media (max-width: 600px) {
header h1 {
font-size: 1.8rem;
}
.card {
padding: 20px;
}
.input-group {
flex-direction: column;
}
.input-group input {
width: 100%;
}
.action-buttons {
flex-direction: column;
}
.btn {
width: 100%;
justify-content: center;
}
}
/* Toggle visibility button */
.toggle-visibility {
background: none;
border: none;
color: #888;
cursor: pointer;
padding: 10px;
font-size: 1.2rem;
transition: color 0.3s ease;
}
.toggle-visibility:hover {
color: #00d4ff;
}
/* Loading spinner */
.spinner {
display: inline-block;
width: 16px;
height: 16px;
border: 2px solid rgba(255, 255, 255, 0.3);
border-radius: 50%;
border-top-color: #fff;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
</style>
</head>
<body>
<div class="container">
<header>
<h1>🔐 MEB Decryption Tool</h1>
<p>Decripta i file CSV criptati con AES-256-GCM</p>
</header>
<!-- Upload Section -->
<div class="card">
<h2><span class="icon">📁</span> Importa File Criptato</h2>
<div class="drop-zone" id="dropZone">
<span class="icon">⬆️</span>
<p>Trascina qui il file criptato o clicca per selezionarlo</p>
<span class="hint">Formati supportati: .csv (criptati)</span>
</div>
<input type="file" id="fileInput" accept=".csv,.bin,.enc">
<div class="file-info" id="fileInfo">
<div class="file-name" id="fileName"></div>
<div class="file-size" id="fileSize"></div>
</div>
</div>
<!-- Key Section -->
<div class="card">
<h2><span class="icon">🔑</span> Chiave di Decriptazione</h2>
<div class="key-section">
<div class="input-group">
<input type="password" id="decryptKey" placeholder="Inserisci la chiave di decriptazione (token)">
<button class="toggle-visibility" id="toggleKey" title="Mostra/Nascondi chiave">👁️</button>
</div>
</div>
<div class="algo-info">
<strong> Formato chiave supportato:</strong><br>
• Token esadecimale (48 caratteri): <code>217af80a15d54289...</code><br>
• Qualsiasi stringa (verrà hashata con SHA-256)<br>
• Algoritmo: <code>AES-256-GCM</code> con IV (12 byte) + Auth Tag (16 byte)
</div>
</div>
<!-- Decrypt Button -->
<div class="card">
<button class="btn btn-primary" id="decryptBtn" disabled>
<span id="decryptBtnText">🔓 Decripta File</span>
</button>
<div class="status" id="status"></div>
<!-- Preview Section -->
<div class="preview-section" id="previewSection">
<div class="preview-header">
<h3>📄 Anteprima Contenuto</h3>
<span id="previewLines"></span>
</div>
<div class="preview-content" id="previewContent"></div>
<div class="action-buttons">
<button class="btn btn-success" id="downloadBtn">
⬇️ Scarica File Decriptato
</button>
<button class="btn btn-secondary" id="copyBtn">
📋 Copia negli Appunti
</button>
<button class="btn btn-secondary" id="resetBtn">
🔄 Nuovo File
</button>
</div>
</div>
</div>
</div>
<script>
// ==================== CRYPTO FUNCTIONS (Same as crypt.js) ====================
/**
* Normalizza qualsiasi chiave a 32 byte per AES-256
* Replica la logica di normalizeKey() in crypt.js
*/
async function normalizeKey(customKey) {
if (!customKey) {
throw new Error("Chiave non fornita");
}
// Se è hex di 64 caratteri, converti direttamente
if (/^[0-9a-fA-F]{64}$/.test(customKey)) {
return hexToArrayBuffer(customKey);
}
// Se è hex di 48 caratteri (token standard), hash con SHA-256
// Altrimenti hash SHA-256 per ottenere 32 byte
const encoder = new TextEncoder();
const data = encoder.encode(customKey);
const hashBuffer = await crypto.subtle.digest('SHA-256', data);
return hashBuffer;
}
/**
* Converte stringa hex in ArrayBuffer
*/
function hexToArrayBuffer(hex) {
const bytes = new Uint8Array(hex.length / 2);
for (let i = 0; i < hex.length; i += 2) {
bytes[i / 2] = parseInt(hex.substr(i, 2), 16);
}
return bytes.buffer;
}
/**
* Decripta dati con AES-256-GCM
* Struttura file: [IV(12 byte) + TAG(16 byte) + CIPHERTEXT]
*/
async function decryptAES256GCM(encryptedBuffer, keyBuffer) {
const encryptedArray = new Uint8Array(encryptedBuffer);
if (encryptedArray.length < 28) {
throw new Error("File troppo corto o corrotto");
}
// Estrai IV (primi 12 byte)
const iv = encryptedArray.slice(0, 12);
// Estrai Auth Tag (byte 12-28)
const tag = encryptedArray.slice(12, 28);
// Estrai ciphertext (resto del file)
const ciphertext = encryptedArray.slice(28);
// In WebCrypto, il tag è concatenato al ciphertext per la decrittazione
const ciphertextWithTag = new Uint8Array(ciphertext.length + tag.length);
ciphertextWithTag.set(ciphertext);
ciphertextWithTag.set(tag, ciphertext.length);
// Importa la chiave
const cryptoKey = await crypto.subtle.importKey(
'raw',
keyBuffer,
{ name: 'AES-GCM' },
false,
['decrypt']
);
// Decripta
const decryptedBuffer = await crypto.subtle.decrypt(
{
name: 'AES-GCM',
iv: iv,
tagLength: 128 // 16 byte = 128 bit
},
cryptoKey,
ciphertextWithTag
);
return decryptedBuffer;
}
/**
* Controlla se il file è già in chiaro (CSV/testo)
*/
function isPlainText(buffer) {
const arr = new Uint8Array(buffer);
if (arr.length < 10) return false;
// soglia: se più del 10% dei byte nei primi 256 sono non stampabili, consideriamo binario
const maxCheck = Math.min(256, arr.length);
let nonPrintable = 0;
for (let i = 0; i < maxCheck; i++) {
const b = arr[i];
// caratteri ammessi: tab (9), newline (10), carriage return (13),
// spazio (32) fino a ~ carattere 126 (tilde)
const isAllowedWhitespace = b === 9 || b === 10 || b === 13;
const isPrintable = b >= 32 && b <= 126;
if (!(isAllowedWhitespace || isPrintable)) {
nonPrintable++;
}
}
const ratio = nonPrintable / maxCheck;
return ratio < 0.1; // se meno del 10% sono strani, lo consideriamo testo
}
// ==================== DOM ELEMENTS ====================
const dropZone = document.getElementById('dropZone');
const fileInput = document.getElementById('fileInput');
const fileInfo = document.getElementById('fileInfo');
const fileName = document.getElementById('fileName');
const fileSize = document.getElementById('fileSize');
const decryptKey = document.getElementById('decryptKey');
const toggleKey = document.getElementById('toggleKey');
const decryptBtn = document.getElementById('decryptBtn');
const decryptBtnText = document.getElementById('decryptBtnText');
const status = document.getElementById('status');
const previewSection = document.getElementById('previewSection');
const previewContent = document.getElementById('previewContent');
const previewLines = document.getElementById('previewLines');
const downloadBtn = document.getElementById('downloadBtn');
const copyBtn = document.getElementById('copyBtn');
const resetBtn = document.getElementById('resetBtn');
// ==================== STATE ====================
let selectedFile = null;
let decryptedContent = null;
// ==================== EVENT LISTENERS ====================
// Drop zone click
dropZone.addEventListener('click', () => fileInput.click());
// Drag and drop
dropZone.addEventListener('dragover', (e) => {
e.preventDefault();
dropZone.classList.add('dragover');
});
dropZone.addEventListener('dragleave', () => {
dropZone.classList.remove('dragover');
});
dropZone.addEventListener('drop', (e) => {
e.preventDefault();
dropZone.classList.remove('dragover');
const files = e.dataTransfer.files;
if (files.length > 0) {
handleFile(files[0]);
}
});
// File input change
fileInput.addEventListener('change', (e) => {
if (e.target.files.length > 0) {
handleFile(e.target.files[0]);
}
});
// Toggle key visibility
toggleKey.addEventListener('click', () => {
const type = decryptKey.type === 'password' ? 'text' : 'password';
decryptKey.type = type;
toggleKey.textContent = type === 'password' ? '👁️' : '🙈';
});
// Key input
decryptKey.addEventListener('input', updateDecryptButton);
// Decrypt button
decryptBtn.addEventListener('click', decryptFile);
// Download button
downloadBtn.addEventListener('click', downloadDecrypted);
// Copy button
copyBtn.addEventListener('click', copyToClipboard);
// Reset button
resetBtn.addEventListener('click', resetAll);
// ==================== FUNCTIONS ====================
function handleFile(file) {
selectedFile = file;
fileName.textContent = `📄 ${file.name}`;
fileSize.textContent = `Dimensione: ${formatSize(file.size)}`;
fileInfo.classList.add('visible');
updateDecryptButton();
hideStatus();
previewSection.classList.remove('visible');
decryptedContent = null;
}
function formatSize(bytes) {
if (bytes < 1024) return bytes + ' B';
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(2) + ' KB';
return (bytes / (1024 * 1024)).toFixed(2) + ' MB';
}
function updateDecryptButton() {
decryptBtn.disabled = !(selectedFile && decryptKey.value.trim());
}
function showStatus(message, type) {
status.textContent = message;
status.className = `status visible ${type}`;
}
function hideStatus() {
status.classList.remove('visible');
}
async function decryptFile() {
if (!selectedFile || !decryptKey.value.trim()) return;
// Show loading
decryptBtnText.innerHTML = '<span class="spinner"></span> Decrittazione...';
decryptBtn.disabled = true;
try {
// Read file
const fileBuffer = await selectedFile.arrayBuffer();
// Check if already plain text
if (isPlainText(fileBuffer)) {
decryptedContent = new TextDecoder().decode(fileBuffer);
showStatus('⚠️ Il file è già in chiaro (non criptato)', 'info');
} else {
// Normalize key
const keyBuffer = await normalizeKey(decryptKey.value.trim());
// Decrypt
const decryptedBuffer = await decryptAES256GCM(fileBuffer, keyBuffer);
decryptedContent = new TextDecoder().decode(decryptedBuffer);
showStatus('✅ File decriptato con successo!', 'success');
}
// Show preview
showPreview(decryptedContent);
} catch (error) {
console.error('Decryption error:', error);
let errorMsg = '❌ Errore nella decrittazione: ';
if (error.message.includes('tag') || error.message.includes('decrypt')) {
errorMsg += 'Chiave non valida o file corrotto';
} else {
errorMsg += error.message;
}
showStatus(errorMsg, 'error');
previewSection.classList.remove('visible');
} finally {
decryptBtnText.innerHTML = '🔓 Decripta File';
updateDecryptButton();
}
}
function showPreview(content) {
const lines = content.split('\n');
const previewText = lines.slice(0, 50).join('\n');
previewContent.textContent = previewText;
previewLines.textContent = `${lines.length} righe totali`;
if (lines.length > 50) {
previewContent.textContent += '\n\n... [Anteprima troncata a 50 righe] ...';
}
previewSection.classList.add('visible');
}
function downloadDecrypted() {
if (!decryptedContent || !selectedFile) return;
const blob = new Blob([decryptedContent], { type: 'text/csv;charset=utf-8' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = selectedFile.name.replace('.csv', '_decrypted.csv');
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
showStatus('✅ File scaricato!', 'success');
}
async function copyToClipboard() {
if (!decryptedContent) return;
try {
await navigator.clipboard.writeText(decryptedContent);
showStatus('✅ Contenuto copiato negli appunti!', 'success');
} catch (error) {
showStatus('❌ Errore nella copia: ' + error.message, 'error');
}
}
function resetAll() {
selectedFile = null;
decryptedContent = null;
fileInput.value = '';
decryptKey.value = '';
fileInfo.classList.remove('visible');
previewSection.classList.remove('visible');
hideStatus();
updateDecryptButton();
}
</script>
</body>
</html>

386
plugin/public/graphs.html Normal file
View File

@@ -0,0 +1,386 @@
<!DOCTYPE html>
<html lang="it">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Previsioni - 7 giorni</title>
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%);
min-height: 100vh;
color: #fff;
}
.header {
background: rgba(255, 255, 255, 0.05);
backdrop-filter: blur(10px);
padding: 20px 30px;
display: flex;
justify-content: space-between;
align-items: center;
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
}
.header h1 {
font-size: 24px;
font-weight: 600;
}
.header .status {
display: flex;
align-items: center;
gap: 10px;
font-size: 14px;
color: rgba(255, 255, 255, 0.7);
}
.status-dot {
width: 10px;
height: 10px;
border-radius: 50%;
background: #4ade80;
animation: pulse 2s infinite;
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
.controls {
padding: 20px 30px;
display: flex;
gap: 15px;
flex-wrap: wrap;
}
.control-btn {
padding: 10px 20px;
background: rgba(255, 255, 255, 0.1);
border: 1px solid rgba(255, 255, 255, 0.2);
border-radius: 8px;
color: #fff;
cursor: pointer;
transition: all 0.3s ease;
font-size: 14px;
}
.control-btn:hover {
background: rgba(255, 255, 255, 0.2);
transform: translateY(-2px);
}
.control-btn.active {
background: #3b82f6;
border-color: #3b82f6;
}
.dashboard {
padding: 20px 30px;
display: grid;
grid-template-columns: repeat(auto-fit, minmax(500px, 1fr));
gap: 25px;
}
.chart-card {
background: rgba(255, 255, 255, 0.05);
border-radius: 16px;
padding: 25px;
border: 1px solid rgba(255, 255, 255, 0.1);
}
.chart-card h3 {
margin-bottom: 20px;
font-weight: 500;
color: rgba(255, 255, 255, 0.9);
display: flex;
align-items: center;
gap: 10px;
}
.chart-card .icon {
font-size: 20px;
}
.chart-container {
position: relative;
height: 250px;
}
.stats-grid {
padding: 20px 30px;
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 15px;
margin-bottom: 20px;
}
.stat-card {
background: rgba(255, 255, 255, 0.05);
border-radius: 12px;
padding: 20px;
border: 1px solid rgba(255, 255, 255, 0.1);
}
.stat-card .label {
font-size: 12px;
color: rgba(255, 255, 255, 0.6);
text-transform: uppercase;
letter-spacing: 0.5px;
}
.stat-card .value {
font-size: 28px;
font-weight: 600;
margin-top: 5px;
}
.stat-card .unit {
font-size: 14px;
color: rgba(255, 255, 255, 0.5);
}
.loading {
display: flex;
justify-content: center;
align-items: center;
height: 200px;
color: rgba(255, 255, 255, 0.5);
}
.no-data {
text-align: center;
padding: 40px;
color: rgba(255, 255, 255, 0.5);
}
@media (max-width: 768px) {
.dashboard {
grid-template-columns: 1fr;
}
.chart-card {
padding: 15px;
}
}
</style>
</head>
<body>
<div class="header">
<h1>📊 MEB Grafici Meteo</h1>
<div class="status">
<div class="status-dot"></div>
<span id="last-update">Aggiornamento...</span>
</div>
</div>
<div class="controls">
<button class="control-btn active" data-hours="24">24 Ore</button>
<button class="control-btn" data-hours="48">48 Ore</button>
<button class="control-btn" data-hours="168">7 Giorni</button>
<button class="control-btn" onclick="refreshData()">🔄 Aggiorna</button>
</div>
<div class="stats-grid">
<div class="stat-card">
<div class="label">🌡️ Temperatura Attuale</div>
<div class="value" id="current-temp">--</div>
<div class="unit" id="unit-temp">°C</div>
</div>
<div class="stat-card">
<div class="label">🌬️ Vento</div>
<div class="value" id="current-wind">--</div>
<div class="unit" id="unit-wind">km/h</div>
</div>
<div class="stat-card">
<div class="label">🌊 Altezza Onde</div>
<div class="value" id="current-waves">--</div>
<div class="unit" id="unit-waves">m</div>
</div>
<div class="stat-card">
<div class="label">💧 Umidità</div>
<div class="value" id="current-humidity">--</div>
<div class="unit" id="unit-humidity">%</div>
</div>
</div>
<div class="dashboard">
<div class="chart-card">
<h3><span class="icon">🌡️</span> Temperatura</h3>
<div class="chart-container">
<canvas id="temperatureChart"></canvas>
</div>
</div>
<div class="chart-card">
<h3><span class="icon">🌬️</span> Velocità Vento</h3>
<div class="chart-container">
<canvas id="windChart"></canvas>
</div>
</div>
<div class="chart-card">
<h3><span class="icon">🌊</span> Altezza Onde</h3>
<div class="chart-container">
<canvas id="waveChart"></canvas>
</div>
</div>
<div class="chart-card">
<h3><span class="icon">💧</span> Umidità</h3>
<div class="chart-container">
<canvas id="humidityChart"></canvas>
</div>
</div>
</div>
<script>
// Configurazione globale Chart.js
Chart.defaults.color = 'rgba(255, 255, 255, 0.7)';
Chart.defaults.borderColor = 'rgba(255, 255, 255, 0.1)';
let charts = {};
let selectedHours = 24;
// Opzioni comuni per i grafici
const chartOptions = {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: { display: false }
},
scales: {
x: {
grid: { color: 'rgba(255, 255, 255, 0.05)' },
ticks: { maxTicksLimit: 8 }
},
y: {
grid: { color: 'rgba(255, 255, 255, 0.05)' },
beginAtZero: false
}
},
elements: {
point: { radius: 2, hoverRadius: 5 },
line: { borderWidth: 2 }
}
};
// Inizializza grafici
function initCharts() {
const tempCtx = document.getElementById('temperatureChart').getContext('2d');
charts.temperature = new Chart(tempCtx, {
type: 'line',
data: { labels: [], datasets: [] },
options: chartOptions
});
const windCtx = document.getElementById('windChart').getContext('2d');
charts.wind = new Chart(windCtx, {
type: 'line',
data: { labels: [], datasets: [] },
options: chartOptions
});
const waveCtx = document.getElementById('waveChart').getContext('2d');
charts.wave = new Chart(waveCtx, {
type: 'line',
data: { labels: [], datasets: [] },
options: chartOptions
});
const humidityCtx = document.getElementById('humidityChart').getContext('2d');
charts.humidity = new Chart(humidityCtx, {
type: 'line',
data: { labels: [], datasets: [] },
options: chartOptions
});
}
// Aggiorna dati dai grafici
async function refreshData() {
try {
const response = await fetch(`/plugins/meb/api/graphs?hours=${selectedHours}`);
const data = await response.json();
if (data.temperature) {
charts.temperature.data = data.temperature;
charts.temperature.update('none');
}
if (data.windSpeed) {
charts.wind.data = data.windSpeed;
charts.wind.update('none');
}
if (data.waveHeight) {
charts.wave.data = data.waveHeight;
charts.wave.update('none');
}
if (data.humidity) {
charts.humidity.data = data.humidity;
charts.humidity.update('none');
}
// Aggiorna valori attuali
if (data.current) {
document.getElementById('current-temp').textContent =
data.current.temperature?.toFixed(1) ?? '--';
document.getElementById('current-wind').textContent =
data.current.windSpeed?.toFixed(1) ?? '--';
document.getElementById('current-waves').textContent =
data.current.waveHeight?.toFixed(2) ?? '--';
document.getElementById('current-humidity').textContent =
data.current.humidity?.toFixed(0) ?? '--';
}
// Aggiorna unità dinamiche
if (data.units) {
const { forecast, waves } = data.units;
if (forecast) {
document.getElementById('unit-temp').textContent = forecast.temperature || '°C';
document.getElementById('unit-wind').textContent = forecast.windSpeed || 'km/h';
document.getElementById('unit-humidity').textContent = forecast.humidity || '%';
}
if (waves) {
document.getElementById('unit-waves').textContent = waves.waveHeight || 'm';
}
}
document.getElementById('last-update').textContent =
`Ultimo aggiornamento: ${new Date().toLocaleTimeString('it-IT')}`;
} catch (error) {
console.error('Errore caricamento dati:', error);
document.getElementById('last-update').textContent = 'Errore connessione';
}
}
// Gestione pulsanti periodo
document.querySelectorAll('.control-btn[data-hours]').forEach(btn => {
btn.addEventListener('click', () => {
document.querySelectorAll('.control-btn').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
selectedHours = parseInt(btn.dataset.hours);
refreshData();
});
});
// Inizializzazione
document.addEventListener('DOMContentLoaded', () => {
initCharts();
refreshData();
// Aggiorna ogni 2 minuti
setInterval(refreshData, 2 * 60 * 1000);
});
</script>
</body>
</html>

View File

@@ -0,0 +1,336 @@
<!DOCTYPE html>
<html lang="it">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Helm Steering UI</title>
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
display: flex;
justify-content: center;
align-items: center;
flex-direction: column;
min-height: 100vh;
background-color: #f4f4f4;
color: #333;
margin: 0;
padding: 20px;
}
.widget-container {
width: 100%;
max-width: 600px;
padding: 30px;
background: #ffffff;
border-radius: 10px;
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.1);
text-align: center;
}
.visualization-container {
position: relative;
width: 100%;
height: 300px;
margin: 20px auto;
display: flex;
justify-content: center;
align-items: center;
background: #fafafa;
border-radius: 8px;
border: 1px solid #eee;
}
.arrow-svg {
width: 300px;
height: 300px;
overflow: visible;
}
.arrow-head {
fill: none;
stroke: #007aff;
stroke-width: 12;
stroke-linecap: round;
stroke-linejoin: round;
filter: drop-shadow(0 2px 4px rgba(0, 122, 255, 0.3));
transition: transform 0.1s linear;
}
.arrow-stem {
fill: none;
stroke: #007aff;
stroke-width: 12;
stroke-linecap: round;
transition: d 0.1s linear;
filter: drop-shadow(0 2px 4px rgba(0, 122, 255, 0.3));
transform: rotate(-40deg);
}
.controls-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 15px;
margin: 30px 0 20px;
text-align: left;
}
.control-group {
background: #f8f9fa;
padding: 15px;
border-radius: 8px;
}
.control-group label {
display: block;
font-size: 12px;
font-weight: 600;
color: #666;
margin-bottom: 8px;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.control-group input[type="number"] {
width: 100%;
padding: 10px;
border: 2px solid #e0e0e0;
border-radius: 6px;
font-size: 16px;
font-weight: 600;
color: #333;
transition: border-color 0.3s ease;
box-sizing: border-box;
}
.control-group input[type="number"]:focus {
outline: none;
border-color: #007aff;
}
.slider-container {
width: 100%;
margin-top: 30px;
position: relative;
padding: 0 10px;
box-sizing: border-box;
}
input[type=range] {
width: 100%;
cursor: pointer;
height: 8px;
border-radius: 5px;
outline: none;
-webkit-appearance: none;
appearance: none;
background: #e0e0e0;
transition: background 0.3s ease;
}
input[type=range]::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 24px;
height: 24px;
border-radius: 50%;
background: #007aff;
cursor: pointer;
box-shadow: 0 2px 8px rgba(0, 122, 255, 0.4);
transition: transform 0.2s ease;
margin-top: -8px;
}
input[type=range]::-webkit-slider-thumb:hover {
transform: scale(1.1);
}
.stats {
display: flex;
justify-content: space-around;
margin-top: 30px;
padding-top: 20px;
border-top: 1px solid #e0e0e0;
}
.stat-item {
text-align: center;
}
.stat-value {
font-size: 24px;
font-weight: bold;
color: #007aff;
transition: all 0.3s ease;
}
.stat-label {
font-size: 12px;
color: #888;
margin-top: 5px;
text-transform: uppercase;
}
.percentage-display {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
font-size: 40px;
font-weight: 800;
color: rgba(0, 0, 0, 0.1);
pointer-events: none;
z-index: 0;
}
</style>
</head>
<body>
<div class="widget-container">
<h2>Helm Steering Control - Destra</h2>
<div class="visualization-container" style="scale: 1 1; rotate: 0;">
<div class=" percentage-display" id="bg-percentage">0%</div>
<svg class="arrow-svg" viewBox="0 0 200 200">
<defs>
<filter id="shadow" x="-20%" y="-20%" width="140%" height="140%">
<feDropShadow dx="0" dy="2" stdDeviation="2" flood-color="#000000" flood-opacity="0.2" />
</filter>
</defs>
<g transform="translate(100, 100)">
<path id="arrow-stem" class="arrow-stem" />
<g id="elemento-svg" transform="rotate(75 0 0) translate(-30 -115) scale(1)" fill="#007aff">
<path
d="M36.5299 45.7049L1.75948 80.3705C0.492705 81.5658 0 83.1127 0 84.8004C0 88.3162 2.60419 91.0583 6.12344 91.0583C8.02389 91.0583 9.43165 90.3553 10.6281 89.23L48.8474 50.6972C50.3958 49.0098 51.0292 47.4629 51.0292 45.7049C51.0292 44.0174 50.2548 42.2597 48.8474 40.783L10.6281 1.96883C9.43165 0.773469 8.02389 -3.6458e-07 6.05309 -3.6458e-07C2.60419 -3.6458e-07 0 2.88294 0 6.3987C0 8.01594 0.492707 9.70352 1.68914 10.8989L36.5299 45.7049Z"
fill="#007aff" />
</g>
</g>
</svg>
</div>
<div class=" controls-grid">
<div class="control-group">
<label for="start-val">Valore Inizio</label>
<input type="number" id="start-val" value="0" step="1">
</div>
<div class="control-group">
<label for="end-val">Valore Fine</label>
<input type="number" id="end-val" value="100" step="1">
</div>
</div>
<div class="slider-container">
<input type="range" id="main-slider" min="0" max="100" value="0" step="1">
</div>
<div class="stats">
<div class="stat-item">
<div class="stat-value" id="val-current">0</div>
<div class="stat-label">Valore Attuale</div>
</div>
<div class="stat-item">
<div class="stat-value" id="val-percent">0%</div>
<div class="stat-label">Progresso</div>
</div>
</div>
</div>
<script>
const startInput = document.getElementById('start-val');
const endInput = document.getElementById('end-val');
const slider = document.getElementById('main-slider');
const valCurrentDisplay = document.getElementById('val-current');
const valPercentDisplay = document.getElementById('val-percent');
const bgPercentage = document.getElementById('bg-percentage');
const arrowStem = document.getElementById('arrow-stem');
const arrowHead = document.getElementById('arrow-head');
const RADIUS = 70;
const HEAD_ANGLE = 30;
const ANGLE_END = 30;
const MAX_ARC_LENGTH = 240;
function polarToCartesian(centerX, centerY, radius, angleInDegrees) {
let radians = (angleInDegrees) * Math.PI / 180.0;
return {
x: centerX + (radius * Math.cos(radians)),
y: centerY + (radius * Math.sin(radians))
};
}
function updateVisualization() {
const start = parseFloat(startInput.value) || 0;
const end = parseFloat(endInput.value) || 100;
const current = parseFloat(slider.value);
const minVal = Math.min(start, end);
const maxVal = Math.max(start, end);
if (parseFloat(slider.min) !== minVal) slider.min = minVal;
if (parseFloat(slider.max) !== maxVal) slider.max = maxVal;
let percentage;
//Range + 20 perche' così al 100% la freccia rimane visibile. * (current / 100) è per adattarsi con il valore
// di fine in modo tale sia proporzionato se la fine è 100 o 5.
const range = (end + (17 * (current / 100))) - start;
if (range === 0) {
percentage = 1;
} else {
percentage = (current - (start)) / range;
}
const clampedPct = Math.max(0, Math.min(1, percentage));
const currentArcLength = MAX_ARC_LENGTH * (1 - clampedPct);
const currentStartAngle = ANGLE_END - currentArcLength;
const startPt = polarToCartesian(0, 0, RADIUS, currentStartAngle);
const endPt = polarToCartesian(0, 0, RADIUS, ANGLE_END);
const largeArc = (ANGLE_END - currentStartAngle) > 180 ? 1 : 0;
const d = `M ${startPt.x} ${startPt.y} A ${RADIUS} ${RADIUS} 0 ${largeArc} 1 ${endPt.x} ${endPt.y}`;
arrowStem.setAttribute('d', d);
const headPt = polarToCartesian(0, 0, RADIUS, ANGLE_END);
const headRot = ANGLE_END + 90;
arrowHead.setAttribute('transform', `translate(${headPt.x}, ${headPt.y}) rotate(${headRot})`);
valCurrentDisplay.textContent = current.toFixed(1);
const pctText = (clampedPct * 100).toFixed(0) + '%';
valPercentDisplay.textContent = pctText;
bgPercentage.textContent = pctText;
const sliderPct = ((current - minVal) / (maxVal - minVal)) * 100;
slider.style.background = `linear-gradient(to right, #007aff 0%, #007aff ${sliderPct}%, #e0e0e0 ${sliderPct}%, #e0e0e0 100%)`;
}
startInput.addEventListener('input', updateVisualization);
endInput.addEventListener('input', updateVisualization);
slider.addEventListener('input', updateVisualization);
updateVisualization();
</script>
</body>
</html>

View File

@@ -0,0 +1,158 @@
<!DOCTYPE html>
<html lang="it">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Helm Steering UI</title>
<link rel="stylesheet" href="/plugin/public/css/helm_suggestions.css">
</head>
<body>
<div class="widget-container">
<h2>Helm Steering Control - Sinistra</h2>
<div class="visualization-container" style="scale: -1 1; rotate: 90;">
<div class=" percentage-display" id="bg-percentage">0%</div>
<svg class="arrow-svg" viewBox="0 0 200 200">
<defs>
<filter id="shadow" x="-20%" y="-20%" width="140%" height="140%">
<feDropShadow dx="0" dy="2" stdDeviation="2" flood-color="#000000" flood-opacity="0.2" />
</filter>
</defs>
<g transform="translate(100, 100)">
<path id="arrow-stem" class="arrow-stem" />
<g id="elemento-svg" transform="rotate(75 0 0) translate(-30 -115) scale(1)" fill="#007aff">
<path
d="M36.5299 45.7049L1.75948 80.3705C0.492705 81.5658 0 83.1127 0 84.8004C0 88.3162 2.60419 91.0583 6.12344 91.0583C8.02389 91.0583 9.43165 90.3553 10.6281 89.23L48.8474 50.6972C50.3958 49.0098 51.0292 47.4629 51.0292 45.7049C51.0292 44.0174 50.2548 42.2597 48.8474 40.783L10.6281 1.96883C9.43165 0.773469 8.02389 -3.6458e-07 6.05309 -3.6458e-07C2.60419 -3.6458e-07 0 2.88294 0 6.3987C0 8.01594 0.492707 9.70352 1.68914 10.8989L36.5299 45.7049Z"
fill="#007aff"/>
</g>
</g>
</svg>
</div>
<div class=" controls-grid">
<div class="control-group">
<label for="start-val">Valore Inizio</label>
<input type="number" id="start-val" value="0" step="1">
</div>
<div class="control-group">
<label for="end-val">Valore Fine</label>
<input type="number" id="end-val" value="100" step="1">
</div>
</div>
<div class="slider-container">
<input type="range" id="main-slider" min="0" max="100" value="0" step="1">
</div>
<div class="stats">
<div class="stat-item">
<div class="stat-value" id="val-current">0</div>
<div class="stat-label">Valore Attuale</div>
</div>
<div class="stat-item">
<div class="stat-value" id="val-percent">0%</div>
<div class="stat-label">Progresso</div>
</div>
</div>
</div>
<script>
const startInput = document.getElementById('start-val');
const endInput = document.getElementById('end-val');
const slider = document.getElementById('main-slider');
const valCurrentDisplay = document.getElementById('val-current');
const valPercentDisplay = document.getElementById('val-percent');
const bgPercentage = document.getElementById('bg-percentage');
const arrowStem = document.getElementById('arrow-stem');
const arrowHead = document.getElementById('arrow-head');
const RADIUS = 70;
const HEAD_ANGLE = 30;
const ANGLE_END = 30;
const MAX_ARC_LENGTH = 240;
function polarToCartesian(centerX, centerY, radius, angleInDegrees) {
let radians = (angleInDegrees) * Math.PI / 180.0;
return {
x: centerX + (radius * Math.cos(radians)),
y: centerY + (radius * Math.sin(radians))
};
}
function updateVisualization() {
const start = parseFloat(startInput.value) || 0;
const end = parseFloat(endInput.value) || 100;
const current = parseFloat(slider.value);
const minVal = Math.min(start, end);
const maxVal = Math.max(start, end);
if (parseFloat(slider.min) !== minVal) slider.min = minVal;
if (parseFloat(slider.max) !== maxVal) slider.max = maxVal;
let percentage;
//Range + 20 perche' così al 100% la freccia rimane visibile. * (current / 100) è per adattarsi con il valore
// di fine in modo tale sia proporzionato se la fine è 100 o 5.
const range = (end + (17 * (current / 100))) - start;
if (range === 0) {
percentage = 1;
} else {
percentage = (current - (start)) / range;
}
const clampedPct = Math.max(0, Math.min(1, percentage));
const currentArcLength = MAX_ARC_LENGTH * (1 - clampedPct);
const currentStartAngle = ANGLE_END - currentArcLength;
const startPt = polarToCartesian(0, 0, RADIUS, currentStartAngle);
const endPt = polarToCartesian(0, 0, RADIUS, ANGLE_END);
const largeArc = (ANGLE_END - currentStartAngle) > 180 ? 1 : 0;
const d = `M ${startPt.x} ${startPt.y} A ${RADIUS} ${RADIUS} 0 ${largeArc} 1 ${endPt.x} ${endPt.y}`;
arrowStem.setAttribute('d', d);
const headPt = polarToCartesian(0, 0, RADIUS, ANGLE_END);
const headRot = ANGLE_END + 90;
arrowHead.setAttribute('transform', `translate(${headPt.x}, ${headPt.y}) rotate(${headRot})`);
valCurrentDisplay.textContent = current.toFixed(1);
const pctText = (clampedPct * 100).toFixed(0) + '%';
valPercentDisplay.textContent = pctText;
bgPercentage.textContent = pctText;
const sliderPct = ((current - minVal) / (maxVal - minVal)) * 100;
slider.style.background = `linear-gradient(to right, #007aff 0%, #007aff ${sliderPct}%, #e0e0e0 ${sliderPct}%, #e0e0e0 100%)`;
}
startInput.addEventListener('input', updateVisualization);
endInput.addEventListener('input', updateVisualization);
slider.addEventListener('input', updateVisualization);
updateVisualization();
</script>
</body>
</html>

View File

@@ -0,0 +1,588 @@
<!DOCTYPE html>
<html lang="it">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Steering Suggestions Widget</title>
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
display: flex;
justify-content: center;
align-items: center;
flex-direction: column;
min-height: 100vh;
background-color: #f4f4f4;
color: #333;
margin: 0;
padding: 20px;
}
.widget-container {
width: 100%;
max-width: 700px;
padding: 30px;
background: #ffffff;
border-radius: 10px;
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.1);
text-align: center;
}
h2 {
margin-top: 0;
color: #555;
font-weight: 600;
}
.progress-circle-container {
position: relative;
width: 500px;
height: 220px;
margin: 20px auto;
}
.progress-circle {
width: 100%;
height: 100%;
}
.progress-circle-bg {
fill: none;
stroke: #e0e0e0;
stroke-width: 14;
}
.progress-circle-fill {
fill: none;
stroke: #007aff;
stroke-width: 14;
stroke-linecap: round;
transition: stroke-dashoffset 0.5s cubic-bezier(0.4, 0, 0.2, 1),
stroke-dasharray 0.5s cubic-bezier(0.4, 0, 0.2, 1),
transform 0.5s cubic-bezier(0.4, 0, 0.2, 1);
filter: drop-shadow(0 2px 4px rgba(0, 122, 255, 0.3));
}
.progress-text {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
font-size: 48px;
font-weight: bold;
color: #007aff;
transition: all 0.3s ease;
}
.controls-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 15px;
margin: 20px 0;
text-align: left;
}
.control-group {
background: #f8f9fa;
padding: 15px;
border-radius: 8px;
}
.control-group label {
display: block;
font-size: 12px;
font-weight: 600;
color: #666;
margin-bottom: 8px;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.control-group input[type="number"] {
width: 100%;
padding: 10px;
border: 2px solid #e0e0e0;
border-radius: 6px;
font-size: 16px;
font-weight: 600;
color: #333;
transition: border-color 0.3s ease;
box-sizing: border-box;
}
.control-group input[type="number"]:focus {
outline: none;
border-color: #007aff;
}
.toggle-container {
display: flex;
align-items: center;
justify-content: center;
gap: 15px;
margin: 20px 0;
padding: 15px;
background: #f8f9fa;
border-radius: 8px;
}
.toggle-label {
font-size: 14px;
font-weight: 600;
color: #666;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.toggle-switch {
position: relative;
display: inline-block;
width: 60px;
height: 34px;
}
.toggle-switch input {
opacity: 0;
width: 0;
height: 0;
}
.toggle-slider {
position: absolute;
cursor: pointer;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: #ccc;
transition: 0.4s;
border-radius: 34px;
}
.toggle-slider:before {
position: absolute;
content: "";
height: 26px;
width: 26px;
left: 4px;
bottom: 4px;
background-color: white;
transition: 0.4s;
border-radius: 50%;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
}
input:checked+.toggle-slider {
background-color: #007aff;
}
input:checked+.toggle-slider:before {
transform: translateX(26px);
}
.direction-indicator {
display: flex;
align-items: center;
gap: 8px;
font-size: 13px;
font-weight: 600;
color: #007aff;
}
.direction-arrow {
font-size: 18px;
transition: transform 0.3s ease;
}
.slider-container {
width: 100%;
margin-top: 20px;
position: relative;
}
#mio-slider {
width: 100%;
cursor: pointer;
height: 10px;
border-radius: 5px;
outline: none;
-webkit-appearance: none;
background: linear-gradient(to right, #007aff 0%, #007aff 0%, #e0e0e0 0%, #e0e0e0 100%);
transition: background 0.3s ease;
}
#mio-slider::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 24px;
height: 24px;
border-radius: 50%;
background: #007aff;
cursor: pointer;
box-shadow: 0 2px 8px rgba(0, 122, 255, 0.4);
transition: transform 0.2s ease, box-shadow 0.2s ease;
}
#mio-slider::-webkit-slider-thumb:hover {
transform: scale(1.1);
box-shadow: 0 3px 12px rgba(0, 122, 255, 0.6);
}
#mio-slider::-moz-range-thumb {
width: 24px;
height: 24px;
border-radius: 50%;
background: #007aff;
cursor: pointer;
border: none;
box-shadow: 0 2px 8px rgba(0, 122, 255, 0.4);
transition: transform 0.2s ease, box-shadow 0.2s ease;
}
#mio-slider::-moz-range-thumb:hover {
transform: scale(1.1);
box-shadow: 0 3px 12px rgba(0, 122, 255, 0.6);
}
.labels {
display: flex;
justify-content: space-between;
width: 100%;
padding: 10px 5px 0;
box-sizing: border-box;
font-weight: bold;
color: #888;
font-size: 14px;
}
.stats {
display: flex;
justify-content: space-around;
margin-top: 20px;
padding-top: 20px;
border-top: 1px solid #e0e0e0;
}
.stat-item {
text-align: center;
}
.stat-value {
font-size: 24px;
font-weight: bold;
color: #007aff;
transition: all 0.3s ease;
}
.stat-label {
font-size: 12px;
color: #888;
margin-top: 5px;
}
.info-badge {
display: inline-block;
background: #e3f2fd;
color: #1976d2;
padding: 4px 8px;
border-radius: 4px;
font-size: 11px;
font-weight: 600;
margin-top: 5px;
}
</style>
</head>
<body>
<div class="widget-container">
<h2>Progress Circle</h2>
<div class="progress-circle-container">
<svg class="progress-circle" viewBox="0 0 200 200">
<circle id="progress-circle-fill" class="progress-circle-fill" cx="100" cy="100" r="90">
</circle>
<g id="elemento-svg" transform="rotate(70 0 0) translate(80 -150) scale(1)" fill="#007aff">
<path
d="M36.5299 45.7049L1.75948 80.3705C0.492705 81.5658 0 83.1127 0 84.8004C0 88.3162 2.60419 91.0583 6.12344 91.0583C8.02389 91.0583 9.43165 90.3553 10.6281 89.23L48.8474 50.6972C50.3958 49.0098 51.0292 47.4629 51.0292 45.7049C51.0292 44.0174 50.2548 42.2597 48.8474 40.783L10.6281 1.96883C9.43165 0.773469 8.02389 -3.6458e-07 6.05309 -3.6458e-07C2.60419 -3.6458e-07 0 2.88294 0 6.3987C0 8.01594 0.492707 9.70352 1.68914 10.8989L36.5299 45.7049Z"
fill="black" fill-opacity="0.85" />
</g>
</svg>
<div class="progress-text" id="progress-text">0%</div>
</div>
<div class="controls-grid">
<div class="control-group">
<label for="start-angle">Inizio (gradi)</label>
<input type="number" id="start-angle" min="0" max="360" value="0" step="1">
<div class="info-badge">Punto di partenza</div>
</div>
<div class="control-group">
<label for="max-angle">Limite (gradi)</label>
<input type="number" id="max-angle" min="1" max="360" value="40" step="1">
<div class="info-badge">Arco massimo</div>
</div>
</div>
<!-- Toggle per la direzione -->
<div class="toggle-container">
<span class="toggle-label">Direzione</span>
<div class="direction-indicator">
<span class="direction-arrow" id="direction-arrow"></span>
<span id="direction-text">Oraria</span>
</div>
<label class="toggle-switch">
<input type="checkbox" id="direction-toggle">
<span class="toggle-slider"></span>
</label>
</div>
<div class="slider-container">
<input type="range" id="mio-slider" min="0" max="100" value="0" step="0.5">
<div class="labels">
<span>0%</span>
<span>50%</span>
<span>100%</span>
</div>
</div>
<div class="stats">
<div class="stat-item">
<div class="stat-value" id="stat-degrees"></div>
<div class="stat-label">Progresso</div>
</div>
<div class="stat-item">
<div class="stat-value" id="stat-arc"></div>
<div class="stat-label">Arco attuale</div>
</div>
<div class="stat-item">
<div class="stat-value" id="stat-remaining">100%</div>
<div class="stat-label">Rimanente</div>
</div>
</div>
<div class="container"
style="max-width:500px; background:white; padding:30px; border-radius:10px; box-shadow:0 4px 15px rgba(0,0,0,0.1);">
<h2>Controllo Trasformazioni Freccia</h2>
<div class="controls">
<div class="control-group">
<label for="rotate-input">Rotazione (°)</label>
<input type="number" id="rotate-input" value="70" step="1">
<div class="value-display" id="rotate-display">70°</div>
</div>
<div class="control-group">
<label for="rotate-x">Centro Rot. X</label>
<input type="number" id="rotate-x" value="0" step="1">
<div class="value-display" id="rotate-x-display">0</div>
</div>
<div class="control-group">
<label for="rotate-y">Centro Rot. Y</label>
<input type="number" id="rotate-y" value="0" step="1">
<div class="value-display" id="rotate-y-display">0</div>
</div>
<div class="control-group">
<label for="scale-input">Scala</label>
<input type="number" id="scale-input" value="1" step="0.1" min="0.1" max="5">
<div class="value-display" id="scale-display">1x</div>
</div>
<div class="control-group">
<label for="translate-x">Posizione X</label>
<input type="number" id="translate-x" value="80" step="1">
<div class="value-display" id="translate-x-display">80</div>
</div>
<div class="control-group">
<label for="translate-y">Posizione Y</label>
<input type="number" id="translate-y" value="-150" step="1">
<div class="value-display" id="translate-y-display">-150</div>
</div>
<div class="control-group full-width">
<label>Transform String</label>
<textarea id="transform-output" readonly></textarea>
</div>
</div>
</div>
</div>
<script>
const slider = document.getElementById('mio-slider');
const progressCircle = document.getElementById('progress-circle-fill');
const progressText = document.getElementById('progress-text');
const startAngleInput = document.getElementById('start-angle');
const maxAngleInput = document.getElementById('max-angle');
const directionToggle = document.getElementById('direction-toggle');
const directionText = document.getElementById('direction-text');
const directionArrow = document.getElementById('direction-arrow');
const fineFreccia = document.getElementById('fine-freccia');
const statDegrees = document.getElementById('stat-degrees');
const statArc = document.getElementById('stat-arc');
const statRemaining = document.getElementById('stat-remaining');
// Calcoliamo la circonferenza del cerchio
const raggio = 90;
const circonferenza = 2 * Math.PI * raggio;
function aggiornaProgresso() {
const valore = parseFloat(slider.value);
const startAngle = parseFloat(startAngleInput.value) || 0;
const maxAngle = parseFloat(maxAngleInput.value) || 40;
const isReverse = directionToggle.checked;
// Calcoliamo l'angolo effettivo del progresso
const angoloProgressoEffettivo = (valore / 100) * maxAngle;
// Calcoliamo la lunghezza dell'arco
const lunghezzaArcoAttuale = (angoloProgressoEffettivo / 360) * circonferenza;
// Impostiamo stroke-dasharray
progressCircle.style.strokeDasharray = `${lunghezzaArcoAttuale} ${circonferenza}`;
// Calcoliamo la rotazione in base alla direzione
let rotazione;
let rotazioneFreccia;
if (isReverse) {
// Direzione antioraria
rotazione = startAngle - 90;
progressCircle.style.transform = `rotate(${rotazione}deg) scale(-1, 1)`;
// La freccia deve ruotare al contrario e compensare lo scale
rotazioneFreccia = -startAngle + 90;
} else {
// Direzione oraria
rotazione = startAngle - 90;
progressCircle.style.transform = `rotate(${rotazione}deg)`;
rotazioneFreccia = startAngle - 90;
}
progressCircle.style.transformOrigin = 'center';
// Ruotiamo la freccia per allinearla al punto iniziale
fineFreccia.style.transform = `rotate(${rotazioneFreccia}deg)`;
fineFreccia.style.transformOrigin = '100px 100px'; // Centro del cerchio
// Aggiorniamo il testo
progressText.textContent = valore.toFixed(1) + '%';
// Aggiorniamo le statistiche
statDegrees.textContent = angoloProgressoEffettivo.toFixed(1) + '°';
statArc.textContent = maxAngle + '°';
statRemaining.textContent = (100 - valore).toFixed(1) + '%';
// Aggiorniamo il colore dello slider
slider.style.background = `linear-gradient(to right, #007aff 0%, #007aff ${valore}%, #e0e0e0 ${valore}%, #e0e0e0 100%)`;
}
function aggiornaIndicatoreDirection() {
if (directionToggle.checked) {
directionText.textContent = 'Antioraria';
directionArrow.textContent = '↺';
} else {
directionText.textContent = 'Oraria';
directionArrow.textContent = '↻';
}
}
// Event listeners
slider.addEventListener('input', aggiornaProgresso);
startAngleInput.addEventListener('input', aggiornaProgresso);
maxAngleInput.addEventListener('input', aggiornaProgresso);
directionToggle.addEventListener('change', () => {
aggiornaIndicatoreDirection();
aggiornaProgresso();
});
const elementoSvg = document.getElementById('elemento-svg');
const rotateInput = document.getElementById('rotate-input');
const rotateX = document.getElementById('rotate-x');
const rotateY = document.getElementById('rotate-y');
const scaleInput = document.getElementById('scale-input');
const translateX = document.getElementById('translate-x');
const translateY = document.getElementById('translate-y');
const transformOutput = document.getElementById('transform-output');
const rotateDisplay = document.getElementById('rotate-display');
const rotateXDisplay = document.getElementById('rotate-x-display');
const rotateYDisplay = document.getElementById('rotate-y-display');
const scaleDisplay = document.getElementById('scale-display');
const translateXDisplay = document.getElementById('translate-x-display');
const translateYDisplay = document.getElementById('translate-y-display');
function aggiornaTransform() {
const rotate = parseFloat(rotateInput.value) || 0;
const rotX = parseFloat(rotateX.value) || 0;
const rotY = parseFloat(rotateY.value) || 0;
const scale = parseFloat(scaleInput.value) || 1;
const transX = parseFloat(translateX.value) || 0;
const transY = parseFloat(translateY.value) || 0;
const transformString = `rotate(${rotate} ${rotX} ${rotY}) translate(${transX} ${transY}) scale(${scale})`;
elementoSvg.setAttribute('transform', transformString);
rotateDisplay.textContent = `${rotate}°`;
rotateXDisplay.textContent = rotX;
rotateYDisplay.textContent = rotY;
scaleDisplay.textContent = `${scale}x`;
translateXDisplay.textContent = transX;
translateYDisplay.textContent = transY;
transformOutput.value = transformString;
}
[rotateInput, rotateX, rotateY, scaleInput, translateX, translateY].forEach(el =>
el.addEventListener('input', aggiornaTransform)
);
aggiornaTransform();
// Inizializzazione
aggiornaIndicatoreDirection();
aggiornaProgresso();
// --- NEW PROGRESS CIRCLE LOGIC ---
const newSlider = document.getElementById('new-slider');
const newProgressCircle = document.getElementById('new-progress-circle-fill');
const newProgressText = document.getElementById('new-progress-text');
const newRaggio = 90;
const newCirconferenza = 2 * Math.PI * newRaggio;
function aggiornaNuovoProgresso() {
const valore = parseFloat(newSlider.value);
const offset = newCirconferenza - (valore / 100) * newCirconferenza;
newProgressCircle.style.strokeDasharray = `${newCirconferenza} ${newCirconferenza}`;
newProgressCircle.style.strokeDashoffset = offset;
newProgressText.textContent = valore.toFixed(0) + '%';
// Update slider background
newSlider.style.background = `linear-gradient(to right, #007aff 0%, #007aff ${valore}%, #e0e0e0 ${valore}%, #e0e0e0 100%)`;
}
newSlider.addEventListener('input', aggiornaNuovoProgresso);
// Initialize
aggiornaNuovoProgresso();
</script>
</body>
</html>

View File

@@ -0,0 +1,68 @@
{
"production": [
{
"collection": "temperature",
"main_path": "meb.temperature",
"elements": null
},
{
"collection": "wind",
"main_path": "meb.wind",
"elements": [
{"direction": "direction"},
{"speed": "speed"}
]
},
{
"collection": "waves",
"main_path": "meb.waves",
"elements": [
{"direction": "direction"},
{"height": "height"},
{"period": "period"}
]
},
{
"collection": "position",
"main_path": "navigation",
"elements": [
{"latitude": "position.latitude"},
{"longitude": "position.longitude"},
{"headingTrue": "headingTrue"},
{"speedOverGround": "speedOverGround"},
{"courseOverGround": "courseOverGroundTrue"}
]
},
{
"collection": "service_battery",
"main_path": "electrical.batteries.service",
"elements": [
{"voltage": "Voltage"},
{"current": "current"},
{"stateOfCharge": "stateOfCharge"}
]
},
{
"collection": "traction_battery",
"main_path": "electrical.batteries.traction",
"elements": [
{"voltage": "Voltage"},
{"current": "current"},
{"stateOfCharge": "stateOfCharge"},
{"temperature": "temperature"},
{"power": "power"}
]
},
{
"collection": "engine",
"main_path": "propulsion.0",
"elements": [
{"proipultionShaftSpeed": "revolutions"}
]
},
{
"collection": "system",
"main_path": "system.uptime"
}
]
}

258
plugin/tools/crypt.js Normal file
View File

@@ -0,0 +1,258 @@
/**
* Modulo di crittografia centralizzato per MEB Plugin
* Supporta AES-256-GCM per file sensibili e log CSV
*
* BEST PRACTICES SICUREZZA ENTERPRISE:
* 1. La MASTER_KEY dovrebbe essere in variabile d'ambiente (process.env.MEB_MASTER_KEY)
* 2. In produzione usare AWS KMS, HashiCorp Vault, o Azure Key Vault
* 3. Rotazione periodica delle chiavi (ogni 90 giorni)
* 4. Separazione chiavi: una per users, una per logs_references, una per log files
* 5. Audit log di ogni accesso ai file sensibili
*
* GENERAZIONE CHIAVE SICURA:
* Esegui nel terminale: node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
* Poi imposta: export MEB_MASTER_KEY="la_chiave_generata"
*/
const crypto = require("crypto");
const fs = require('fs');
const MASTER_KEY_HEX = process.env.CRYPTOKEY || null;
const TOKEN_CHARSET = 'ABCDEFGHJKLMNPQRSTUVWXYZabcdefghjkmnpqrstuvwxyz23456789';
const specialCharset = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789!@#$%^&*_+-=';
/**
* Ottiene la chiave master (32 byte per AES-256)
* @returns {Buffer} Chiave di 32 byte
*/
function getMasterKey() {
if (!MASTER_KEY_HEX) {
throw new Error("MASTER_KEY non definita. Imposta MEB_MASTER_KEY nelle variabili d'ambiente.");
}
const key = Buffer.from(MASTER_KEY_HEX, 'hex');
if (key.length !== 32) {
throw new Error("MASTER_KEY deve essere di 32 byte (64 caratteri hex).");
}
return key;
}
/**
* Normalizza qualsiasi chiave custom a 32 byte Buffer per AES-256.
* Accetta chiavi di qualsiasi lunghezza/formato.
* @param {string|Buffer|null} customKey - Chiave custom o null per usare master key
* @returns {Buffer} Chiave di 32 byte
*/
function normalizeKey(customKey) {
if (!customKey) return getMasterKey();
if (typeof customKey === 'string') {
// Se è hex di 64 caratteri, convertilo direttamente
if (/^[0-9a-fA-F]{64}$/.test(customKey)) {
return Buffer.from(customKey, 'hex');
}
// Altrimenti hash SHA-256 per ottenere 32 byte
return crypto.createHash('sha256').update(customKey, 'utf8').digest();
}
if (Buffer.isBuffer(customKey)) {
if (customKey.length === 32) return customKey;
return crypto.createHash('sha256').update(customKey).digest();
}
throw new Error("customKey deve essere una stringa o un Buffer");
}
// ==================== GENERAZIONE TOKEN ====================
/**
* Genera un token esadecimale casuale UNICO ogni volta
* @param {number} bytes - Numero di byte (default 24 = 48 caratteri hex)
* @returns {string} Token esadecimale unico
*/
function generateToken(bytes = 24) {
return crypto.randomBytes(bytes).toString('hex');
}
/**
* Genera un token leggibile (senza caratteri ambigui come 0/O, 1/l/I)
* Più facile da comunicare verbalmente
* @param {number} length - Lunghezza del token (default 32)
* @returns {string} Token alfanumerico leggibile
*/
function generateReadableToken(length = 32) {
const bytes = crypto.randomBytes(length);
let result = '';
for (let i = 0; i < length; i++) {
result += TOKEN_CHARSET[bytes[i] % TOKEN_CHARSET.length];
}
return result;
}
/**
* Genera un token con caratteri speciali (più sicuro per chiavi sensibili)
* @param {number} length - Lunghezza del token (default 64)
* @returns {string} Token con caratteri speciali
*/
function generateSecureToken(length = 64) {
const bytes = crypto.randomBytes(length);
let result = '';
for (let i = 0; i < length; i++) {
result += specialCharset[bytes[i] % specialCharset.length];
}
return result;
}
// ==================== CRITTOGRAFIA OGGETTI JSON (per file sensibili) ====================
/**
* Cripta un oggetto JSON in Buffer binario (AES-256-GCM)
* Usato per telegram_users.json e logs_references.json
* @param {object} obj - Oggetto da criptare
* @param {string|Buffer|null} customKey - Chiave custom (opzionale)
* @returns {Buffer} Dati criptati [IV(12) + TAG(16) + CIPHERTEXT]
*/
// DISABILITATO: salviamo in chiaro
function encrypt(obj, customKey = null) {
const plaintext = Buffer.from(JSON.stringify(obj), 'utf8');
return plaintext; // ritorna direttamente il contenuto in chiaro
}
/**
* Decripta un Buffer in oggetto JSON
* @param {Buffer} buffer - Dati criptati
* @param {string|Buffer|null} customKey - Chiave custom (opzionale)
* @returns {object} Oggetto decriptato (array vuoto se fallisce)
*/
// DISABILITATO: leggiamo direttamente in chiaro
function decrypt(buffer, customKey = null) {
try {
if (!buffer) return [];
const content = buffer.toString('utf8');
return JSON.parse(content);
} catch (error) {
console.error('[decrypt] Errore:', error.message);
return [];
}
}
// ==================== CRITTOGRAFIA FILE LOG CSV ====================
/**
* Cripta un file CSV/testo sul disco
* @param {string} filePath - Percorso del file
* @param {string|Buffer|null} customKey - Chiave custom (qualsiasi lunghezza)
* @returns {boolean} True se successo
*/
// DISABILITATO: i file log rimangono sempre in chiaro
function encryptLog(filePath, customKey = null) {
try {
// Non fare nulla, lascia il file in chiaro
return true;
} catch (error) {
console.error('[encryptLog] Errore:', error.message);
return false;
}
}
/**
* Decripta un file CSV/testo e lo riscrive sul disco
* @param {string} filePath - Percorso del file criptato
* @param {string|Buffer|null} customKey - Chiave custom
* @returns {string|null} Contenuto decriptato o null se errore
*/
// DISABILITATO: i file sono già in chiaro
function decryptLog(filePath, customKey = null) {
try {
const content = fs.readFileSync(filePath, 'utf8');
return content; // ritorna contenuto in chiaro senza modifiche
} catch (error) {
console.error('[decryptLog] Errore:', error.message);
return null;
}
}
/**
* Decripta un file log e restituisce il contenuto SENZA modificare il file
* @param {string} filePath - Percorso del file criptato
* @param {string|Buffer|null} customKey - Chiave custom
* @returns {string|null} Contenuto decriptato o null se errore
*/
// DISABILITATO: i file sono già in chiaro
function decryptLogToMemory(filePath, customKey = null) {
try {
return fs.readFileSync(filePath, 'utf8');
} catch (error) {
console.error('[decryptLogToMemory] Errore:', error.message);
return null;
}
}
// ==================== GESTIONE FILE SENSIBILI (telegram_users, logs_references) ====================
/**
* Carica e decripta un file JSON sensibile
* Gestisce automaticamente file in chiaro (migrazione) e criptati
* @param {string} filePath - Percorso del file
* @param {object} defaultValue - Valore di default se file non esiste
* @returns {object} Dati decriptati
*/
function loadSecureFile(filePath, defaultValue = {}) {
try {
if (!fs.existsSync(filePath)) {
return defaultValue;
}
const content = fs.readFileSync(filePath, 'utf8').trim();
try {
return JSON.parse(content);
} catch (e) {
console.error(`[loadSecureFile] JSON non valido in ${filePath}:`, e.message);
return defaultValue;
}
} catch (error) {
console.error(`[loadSecureFile] Errore caricamento ${filePath}:`, error.message);
return defaultValue;
}
}
/**
* Cripta e salva un file JSON sensibile
* @param {string} filePath - Percorso del file
* @param {object} data - Dati da salvare
* @returns {boolean} True se successo
*/
function saveSecureFile(filePath, data) {
try {
const content = JSON.stringify(data, null, 2);
fs.writeFileSync(filePath, content, 'utf8');
return true;
} catch (error) {
console.error(`[saveSecureFile] Errore salvataggio ${filePath}:`, error.message);
return false;
}
}
module.exports = {
// Generazione token
generateToken,
generateReadableToken,
generateSecureToken,
// Crittografia oggetti JSON
encrypt,
decrypt,
// Crittografia file log
encryptLog,
decryptLog,
decryptLogToMemory,
// Gestione file sensibili
loadSecureFile,
saveSecureFile,
// Utility
normalizeKey
};

219
plugin/tools/logRecorder.js Normal file
View File

@@ -0,0 +1,219 @@
/**
* logRecorder.js - Gestione registrazione dati separata
* Centralizza tutte le funzioni di logging del dataset
*/
const path = require('path');
const { datasetInit, appendData } = require('../datasetModels/datasetCore');
let app = null;
let recordingInterval = null;
let isRecording = false;
// Stato condiviso della registrazione
const recordingState = {
active: false,
startTime: null,
entryCount: 0,
currentFile: null,
stream: null
};
/**
* Inizializza il recorder con l'istanza di SignalK app
*/
function init(signalkApp) {
app = signalkApp;
console.log('[LogRecorder] Inizializzato');
}
/**
* Raccoglie i dati dai sensori SignalK
*/
function collectSensorData() {
const getSK = (p) => {
const v = app.getSelfPath(p);
return v && v.value !== undefined && v.value !== null ? v.value : null;
};
return {
timestamp: new Date().toISOString(),
// Posizione
latitude: getSK('navigation.position')?.latitude ?? null,
longitude: getSK('navigation.position')?.longitude ?? null,
speed: getSK('navigation.speedOverGround'),
heading: getSK('navigation.headingTrue'),
// Batteria Trazione
traction_voltage: getSK('electrical.batteries.traction.Voltage'),
traction_current: getSK('electrical.batteries.traction.current'),
traction_soc: getSK('electrical.batteries.traction.stateOfCharge'),
traction_temperature: getSK('electrical.batteries.traction.temperature'),
traction_power: getSK('electrical.batteries.traction.power'),
// Batteria Servizio
service_voltage: getSK('electrical.batteries.service.Voltage'),
service_current: getSK('electrical.batteries.service.current'),
service_soc: getSK('electrical.batteries.service.stateOfCharge'),
service_temperature: getSK('electrical.batteries.service.temperature'),
// Meteo (da OpenMeteo condiviso)
temperature: getSK('meb.temperature'),
windSpeed: getSK('meb.appleWindSpeed'),
windDirection: getSK('meb.appleWindDirection'),
// Onde
waveHeight: getSK('meb.waves.waveHeight'),
wavePeriod: getSK('meb.waves.wavePeriod'),
waveDirection: getSK('meb.waves.waveDirection')
};
}
/**
* Crea un nuovo file di log
*/
function createNewLogFile() {
const headers = [
'timestamp',
'latitude', 'longitude', 'speed', 'heading',
'traction_voltage', 'traction_current', 'traction_soc', 'traction_temperature', 'traction_power',
'service_voltage', 'service_current', 'service_soc', 'service_temperature',
'temperature', 'windSpeed', 'windDirection',
'waveHeight', 'wavePeriod', 'waveDirection'
];
const result = datasetInit(headers);
if (result) {
recordingState.currentFile = result.fileName;
recordingState.stream = result.stream;
console.log(`[LogRecorder] Nuovo file: ${result.fileName}`);
}
return result;
}
/**
* Scrive una riga di dati nel log
*/
function writeLogEntry(data) {
if (!recordingState.stream) return false;
const values = [
data.timestamp,
data.latitude, data.longitude, data.speed, data.heading,
data.traction_voltage, data.traction_current, data.traction_soc, data.traction_temperature, data.traction_power,
data.service_voltage, data.service_current, data.service_soc, data.service_temperature,
data.temperature, data.windSpeed, data.windDirection,
data.waveHeight, data.wavePeriod, data.waveDirection
];
appendData(values);
recordingState.entryCount++;
return true;
}
/**
* Avvia la registrazione
* @param {number} intervalMs - Intervallo in millisecondi (default 2000)
*/
function startRecording(intervalMs = 2000) {
if (isRecording) {
console.log('[LogRecorder] Registrazione già attiva');
return false;
}
if (!app) {
console.error('[LogRecorder] App non inizializzata');
return false;
}
const fileResult = createNewLogFile();
if (!fileResult) {
console.error('[LogRecorder] Impossibile creare file di log');
return false;
}
recordingState.active = true;
recordingState.startTime = Date.now();
recordingState.entryCount = 0;
isRecording = true;
recordingInterval = setInterval(() => {
const data = collectSensorData();
writeLogEntry(data);
}, intervalMs);
console.log(`[LogRecorder] Registrazione avviata (ogni ${intervalMs}ms)`);
return true;
}
/**
* Ferma la registrazione
*/
function stopRecording() {
if (!isRecording) {
console.log('[LogRecorder] Nessuna registrazione attiva');
return false;
}
if (recordingInterval) {
clearInterval(recordingInterval);
recordingInterval = null;
}
if (recordingState.stream) {
recordingState.stream.end();
}
const duration = Date.now() - recordingState.startTime;
console.log(`[LogRecorder] Registrazione fermata. Durata: ${Math.round(duration / 1000)}s, Entries: ${recordingState.entryCount}`);
recordingState.active = false;
recordingState.stream = null;
isRecording = false;
return {
duration,
entries: recordingState.entryCount,
file: recordingState.currentFile
};
}
/**
* Riavvia la registrazione (nuovo file)
*/
function restartRecording(intervalMs = 2000) {
stopRecording();
return startRecording(intervalMs);
}
/**
* Ottiene lo stato corrente della registrazione
*/
function getStatus() {
return {
isRecording,
active: recordingState.active,
startTime: recordingState.startTime,
entryCount: recordingState.entryCount,
currentFile: recordingState.currentFile,
runningTime: isRecording ? Date.now() - recordingState.startTime : 0
};
}
/**
* Verifica se la registrazione è attiva
*/
function isActive() {
return isRecording;
}
module.exports = {
init,
startRecording,
stopRecording,
restartRecording,
getStatus,
isActive,
collectSensorData
};

View File

@@ -0,0 +1,35 @@
const fs = require("fs");
const path = require("path");
module.exports = function(app, settings) {
// Serve mappa
app.get('/meb/map', (req, res) => {
const filePath = path.join(__dirname, "public", "map.html");
fs.readFile(filePath, "utf8", (err, html) => {
if (err) {
res.status(500).send("Errore nel caricamento della mappa");
return;
}
const token = settings?.mapboxKey ?? "";
const finalHtml = html.replace("{{MAPBOX_KEY}}", token);
res.setHeader("Content-Type", "text/html; charset=utf-8");
res.send(finalHtml);
});
});
// WebSocket forward: posizione in tempo reale
let lastPosition = null;
app.streambundle.getSelfStream("navigation.position").onValue(pos => {
lastPosition = pos;
});
// Endpoint JSON per marker barca (se vuoi usarlo invece del WS SignalK)
app.get('/meb/map/boat', (req, res) => {
if (!lastPosition) {
res.json({ error: "No position data available" });
return;
}
res.json(lastPosition);
});
}

View File

@@ -0,0 +1,262 @@
<!DOCTYPE html>
<html lang="it">
<head>
<meta charset="UTF-8">
<title>Mappa Meteo SignalK</title>
<script src="https://api.mapbox.com/mapbox-gl-js/v3.2.0/mapbox-gl.js"></script>
<link href="https://api.mapbox.com/mapbox-gl-js/v3.2.0/mapbox-gl.css" rel="stylesheet">
<style>
html, body {
margin: 0;
padding: 0;
width: 100%;
height: 100%;
overflow: hidden;
}
#map {
width: 100%;
height: 100%;
background-color: #e0e0e0;
}
.icon {
width: 80px;
height: 80px;
transform-origin: center center;
display: flex;
justify-content: center;
align-items: center;
}
.info {
position: absolute;
top: 10px;
right: 10px;
color: white;
background-color: rgba(60, 60, 60, 0.85);
padding: 12px 18px;
border-radius: 10px;
font-size: 20px;
font-family: Arial, sans-serif;
line-height: 1.25;
min-width: 270px;
z-index: 999;
}
</style>
</head>
<body>
<div id="map"></div>
<div id="infoBox" class="info">
Caricamento dati...
</div>
<script>
// MAPBOX TOKEN
mapboxgl.accessToken =
"pk.eyJ1Ijoic2VzZWUzIiwiYSI6ImNtZ2dydndkMDBsNjUya3NjeW91dW41MzcifQ.M2qxj0wL1W7plRzIataojQ";
// Centro predefinito
const defaultCenter = [9.19, 44.41];
// MAPPA
const map = new mapboxgl.Map({
container: "map",
style: "mapbox://styles/mapbox/streets-v12",
center: defaultCenter,
zoom: 15
});
// FUNZIONE CALCOLO DESTINAZIONE VETTORE
function destinationPoint([lon, lat], bearingDeg, distanceMeters) {
const R = 6371000;
const brng = bearingDeg * Math.PI / 180;
const d = distanceMeters;
const lat1 = lat * Math.PI / 180;
const lon1 = lon * Math.PI / 180;
const lat2 = Math.asin(
Math.sin(lat1) * Math.cos(d / R) +
Math.cos(lat1) * Math.sin(d / R) * Math.cos(brng)
);
const lon2 = lon1 + Math.atan2(
Math.sin(brng) * Math.sin(d / R) * Math.cos(lat1),
Math.cos(d / R) - Math.sin(lat1) * Math.sin(lat2)
);
return [ lon2 * 180/Math.PI, lat2 * 180/Math.PI ];
}
// QUANDO LA MAPPA È PRONTA
map.on("load", () => {
map.addSource("waveVector", {
type: "geojson",
data: { type: "Feature", geometry: { type: "LineString", coordinates: [defaultCenter, defaultCenter] }}
});
map.addLayer({
id: "waveVectorLine",
type: "line",
source: "waveVector",
paint: { "line-color": "#0000ff", "line-width": 6 }
});
map.addLayer({
id: "waveLabelText",
type: "symbol",
source: "waveVector",
layout: {
"symbol-placement": "line",
"text-field": "Direzione Onde",
"text-size": 30,
"text-allow-overlap": true
},
paint: {
"text-color": "#0000ff",
"text-halo-color": "white",
"text-halo-width": 4
}
});
map.addSource("windVector", {
type: "geojson",
data: { type: "Feature", geometry: { type: "LineString", coordinates: [defaultCenter, defaultCenter] }}
});
map.addLayer({
id: "windVectorLine",
type: "line",
source: "windVector",
paint: { "line-color": "lime", "line-width": 6 }
});
map.addLayer({
id: "windLabelText",
type: "symbol",
source: "windVector",
layout: {
"symbol-placement": "line",
"text-field": "Direzione Vento",
"text-size": 30,
"text-allow-overlap": true
},
paint: {
"text-color": "lime",
"text-halo-color": "black",
"text-halo-width": 3
}
});
});
// MARKER BARCA
const boatEl = document.createElement("div");
boatEl.className = "icon";
boatEl.innerHTML = "⛵";
boatEl.style.fontSize = "60px";
const boatMarker = new mapboxgl.Marker({ element: boatEl }).setLngLat(defaultCenter).addTo(map);
// FRECCE
const arrowEl = document.createElement("div");
arrowEl.className = "icon";
arrowEl.innerHTML = `<svg width="80" height="80" viewBox="0 0 100 100"><g id="arrowGroup" transform="rotate(0 50 50)"><polygon points="50,0 90,100 50,75 10,100" fill="blue" /></g></svg>`;
const arrowMarker = new mapboxgl.Marker({ element: arrowEl }).setLngLat(defaultCenter).addTo(map);
const windEl = document.createElement("div");
windEl.className = "icon";
windEl.innerHTML = `<svg width="80" height="80" viewBox="0 0 100 100"><g id="windArrowGroup" transform="rotate(0 50 50)"><polygon points="50,0 90,100 50,75 10,100" fill="lime" /></g></svg>`;
const windMarker = new mapboxgl.Marker({ element: windEl }).setLngLat(defaultCenter).addTo(map);
// SIGNALK VARIABILI
let position=null, waveDir=null, waveHeight=null, wavePeriod=null, windDir=null, windSpeed=null, temperature=null;
// WebSocket SK
const ws = new WebSocket(`ws://${location.host}/signalk/v1/stream?subscribe=all`);
ws.onmessage = msg => {
const data = JSON.parse(msg.data);
if (!data.updates) return;
data.updates.forEach(u => {
u.values?.forEach(v => {
if (v.path === "navigation.position") position = [v.value.longitude, v.value.latitude];
if (v.path === "meb.waves.direction") waveDir = v.value;
if (v.path === "meb.waves.height") waveHeight = v.value;
if (v.path === "meb.waves.period") wavePeriod = v.value;
if (v.path === "meb.wind.direction") windDir = v.value;
if (v.path === "environment.wind.speedTrue") windSpeed = v.value;
if (v.path === "environment.outside.temperature") temperature = v.value;
});
});
updateMap();
};
// UPDATE MAP
function updateMap() {
if (!position) return;
boatMarker.setLngLat(position);
if (waveDir !== null) {
const endPoint = destinationPoint(position, waveDir, 1000);
map.getSource("waveVector").setData({
type: "Feature",
geometry: { type: "LineString", coordinates: [position, endPoint] }
});
arrowMarker.setLngLat(endPoint);
document.getElementById("arrowGroup").setAttribute("transform", `rotate(${waveDir} 50 50)`);
}
if (windDir !== null) {
const windHeading = (windDir + 180) % 360;
const windEndPoint = destinationPoint(position, windHeading, 1000);
map.getSource("windVector").setData({
type: "Feature",
geometry: { type: "LineString", coordinates: [position, windEndPoint] }
});
windMarker.setLngLat(windEndPoint);
document.getElementById("windArrowGroup").setAttribute("transform", `rotate(${windHeading} 50 50)`);
}
updateInfoBox();
map.easeTo({ center: position });
}
// UPDATE INFO BOX
function updateInfoBox() {
const box = document.getElementById("infoBox");
if (!position) return;
const lat = position[1].toFixed(5);
const lon = position[0].toFixed(5);
const waveDeg = waveDir !== null ? `${waveDir.toFixed(0)}°` : "Need Request";
const heightM = waveHeight !== null ? `${waveHeight.toFixed(2)} m` : "Need Request";
const periodS = wavePeriod !== null ? `${wavePeriod.toFixed(1)} s` : "Need Request";
const windDeg = windDir !== null ? `${windDir.toFixed(0)}°` : "Need Request";
const windKMH = windSpeed !== null ? `${(windSpeed*3.6).toFixed(1)} km/h` : "Need Request";
const tempC = temperature !== null ? `${(temperature - 273.15).toFixed(1)} °C` : "Need Request";
box.innerHTML = `
<b>Direzione Onde:</b> ${waveDeg}<br>
<b>Altezza Onda:</b> ${heightM}<br>
<b>Periodo Onda:</b> ${periodS}<br>
<b>Direzione Vento:</b> ${windDeg}<br>
<b>Intensità Vento:</b> ${windKMH}<br>
<b>Temperatura:</b> ${tempC}<br>
<b>Lat:</b> ${lat} | <b>Lon:</b> ${lon}
`;
}
</script>
</body>
</html>

74
plugin/tools/publisher.js Normal file
View File

@@ -0,0 +1,74 @@
/**
* publisher.js - Pubblica dati su SignalK
*/
/**
* Genera valori SignalK da un oggetto dati
* @param {Object} data - Dati da convertire
* @param {string} prefix - Prefisso per i path SignalK
* @returns {Array} Array di valori SignalK
*/
function generateValues(data, prefix = "meb") {
if (!data || typeof data !== 'object') {
return [];
}
const values = [];
function traverse(obj, pathParts) {
for (const key in obj) {
if (!Object.prototype.hasOwnProperty.call(obj, key)) continue;
const val = obj[key];
if (val === undefined || val === null) continue;
const newPath = [...pathParts, key];
if (typeof val === "object" && !Array.isArray(val)) {
traverse(val, newPath);
} else if (!Array.isArray(val)) {
// Ignora array, pubblica solo valori primitivi
values.push({
path: newPath.join("."),
value: val,
meta: { displayName: key },
});
}
}
}
traverse(data, [prefix]);
return values;
}
/**
* Pubblica dati meteo su SignalK
* @param {Object} app - Istanza app SignalK
* @param {Object} weatherData - Dati meteo da pubblicare
* @param {Object} settings - Impostazioni plugin
*/
function publishWeatherData(app, weatherData, settings) {
if (!app || !weatherData) {
console.warn('[Publisher] App o dati non disponibili');
return;
}
const values = generateValues(weatherData);
if (values.length === 0) {
console.debug('[Publisher] Nessun valore da pubblicare');
return;
}
console.debug(`📤 Pubblicazione ${values.length} valori SignalK`);
try {
app.handleMessage("meb", {
updates: [{ values }],
});
} catch (error) {
console.error('[Publisher] Errore pubblicazione:', error.message);
}
}
module.exports = { publish: publishWeatherData };

321
plugin/tools/routes.js Normal file
View File

@@ -0,0 +1,321 @@
function setupRoutes(router, lastCallRef, app) {
router.get("/ping", async (req, res) => {
try {
const text = lastCallRef.current || "pong";
res.status(200).sendFile(__dirname + "/steering_support/helm_steering_destra.html");
} catch (e) {
res.status(500).json({ error: e.message });
}
});
router.get("/helm_steering_destro", (req, res) => {
try {
res.status(200).sendFile(__dirname + "/steering_support/helm_steering_destro.html");
} catch (e) {
res.status(500).json({ error: e.message });
}
});
router.get("/tools", (req, res) => {
try {
const path = require("path");
const filePath = path.join(__dirname, "..", "public", "decrypt_tool.html");
res.status(200).sendFile(filePath);
} catch (error) {
res.status(500).json({ error: error.message });
}
});
// LOGS DATASETS
router.post("/dataset/start", (req, res) => {
try {
if (!app.datasetControl) {
return res.status(503).json({ error: "Dataset control non disponibile" });
}
const result = app.datasetControl.start();
res.json({ success: result, message: result ? "Registrazione avviata" : "Registrazione già in corso" });
} catch (e) {
res.status(500).json({ error: e.message });
}
});
router.post("/dataset/stop", (req, res) => {
try {
if (!app.datasetControl) {
return res.status(503).json({ error: "Dataset control non disponibile" });
}
const result = app.datasetControl.stop();
res.json({ success: result, message: result ? "Registrazione fermata" : "Nessuna registrazione in corso" });
} catch (e) {
res.status(500).json({ error: e.message });
}
});
router.post("/dataset/restart", (req, res) => {
try {
if (!app.datasetControl) {
return res.status(503).json({ error: "Dataset control non disponibile" });
}
const result = app.datasetControl.restart();
res.json({ success: result, message: "Registrazione riavviata" });
} catch (e) {
res.status(500).json({ error: e.message });
}
});
router.get("/dataset/status", (req, res) => {
try {
if (!app.datasetControl) {
return res.status(503).json({ error: "Dataset control non disponibile" });
}
const status = app.datasetControl.getStatus();
res.json(status);
} catch (e) {
res.status(500).json({ error: e.message });
}
});
router.get("/dataset/files", (req, res) => {
try {
const fs = require('fs');
const path = require('path');
const logsDirectory = path.join(__dirname, '..', 'datasetModels', 'saved_datas');
if (!fs.existsSync(logsDirectory)) {
return res.json({ files: [], count: 0 });
}
const items = fs.readdirSync(logsDirectory);
const files = items
.filter(item => {
const fullPath = path.join(logsDirectory, item);
return fs.statSync(fullPath).isFile();
})
.map(file => {
const fullPath = path.join(logsDirectory, file);
const stats = fs.statSync(fullPath);
return {
name: file,
size: stats.size,
created: stats.birthtime,
modified: stats.mtime
};
})
.sort((a, b) => b.modified.getTime() - a.modified.getTime());
res.json({ files, count: files.length });
} catch (e) {
res.status(500).json({ error: e.message });
}
});
// ==================== GRAPHS API ====================
const graphsCore = require('../datasetModels/graphsCore.js');
// Serve la pagina HTML dei grafici
router.get("/graphs", (req, res) => {
try {
const path = require("path");
const filePath = path.join(__dirname, "..", "public", "graphs.html");
res.status(200).sendFile(filePath);
} catch (error) {
res.status(500).json({ error: error.message });
}
});
// API per ottenere dati grafici
router.get("/api/graphs", (req, res) => {
try {
const hours = parseInt(req.query.hours) || 24;
const data = graphsCore.getAllGraphsData(hours);
// Aggiungi valori attuali dalla cache condivisa
const sharedData = graphsCore.getSharedWeatherData();
data.current = {
temperature: sharedData.forecast?.temperature,
windSpeed: sharedData.forecast?.windSpeed,
waveHeight: sharedData.waves?.waveHeight,
humidity: sharedData.forecast?.humidity
};
// Aggiungi unità di misura
data.units = graphsCore.getUnits();
res.json(data);
} catch (e) {
res.status(500).json({ error: e.message });
}
});
// API per statistiche archivio
router.get("/api/graphs/stats", (req, res) => {
try {
const stats = graphsCore.getArchiveStats();
res.json(stats);
} catch (e) {
res.status(500).json({ error: e.message });
}
});
}
function getOpenApiSpec() {
return {
openapi: "3.0.0",
info: { title: "MebWeather API Portal", version: "1.0.0" },
servers: [{ url: "/plugins/meb-weather" }],
paths: {
"/ping": {
get: {
summary: "Called /ping route",
responses: {
200: {
description: "OK",
content: {
"application/json": {
schema: {
type: "object",
properties: { message: { type: "string" } },
},
},
},
},
},
},
},
"/meb/suggestion": {
get: {
summary: "Pagina di test MEB Suggestion",
responses: {
200: {
description: "OK",
content: {
"text/html": {
schema: { type: "string" },
},
},
},
},
},
},
"/dataset/start": {
post: {
summary: "Avvia la registrazione dataset",
responses: {
200: {
description: "Registrazione avviata",
content: {
"application/json": {
schema: {
type: "object",
properties: {
success: { type: "boolean" },
message: { type: "string" }
},
},
},
},
},
},
},
},
"/dataset/stop": {
post: {
summary: "Ferma la registrazione dataset",
responses: {
200: {
description: "Registrazione fermata",
content: {
"application/json": {
schema: {
type: "object",
properties: {
success: { type: "boolean" },
message: { type: "string" }
},
},
},
},
},
},
},
},
"/dataset/restart": {
post: {
summary: "Riavvia la registrazione dataset",
responses: {
200: {
description: "Registrazione riavviata",
content: {
"application/json": {
schema: {
type: "object",
properties: {
success: { type: "boolean" },
message: { type: "string" }
},
},
},
},
},
},
},
},
"/dataset/status": {
get: {
summary: "Ottieni lo stato della registrazione dataset",
responses: {
200: {
description: "Stato corrente",
content: {
"application/json": {
schema: {
type: "object",
properties: {
isRecording: { type: "boolean" },
recordCount: { type: "number" }
},
},
},
},
},
},
},
},
"/dataset/files": {
get: {
summary: "Ottieni la lista dei file log salvati",
responses: {
200: {
description: "Lista file log",
content: {
"application/json": {
schema: {
type: "object",
properties: {
files: {
type: "array",
items: {
type: "object",
properties: {
name: { type: "string" },
size: { type: "number" },
created: { type: "string" },
modified: { type: "string" }
}
}
},
count: { type: "number" }
},
},
},
},
},
},
},
},
},
};
}
module.exports = { setupRoutes, getOpenApiSpec };

78
plugin/tools/utils.js Normal file
View File

@@ -0,0 +1,78 @@
const fs = require('fs');
const path = require('path');
//Ottieni il percodi dal nome di una cartella, se questa non esiste, viene creata
function getDirectory(directoryName) {
const directoryPath = path.resolve(__dirname, directoryName);
if (!fs.existsSync(directoryPath)) {
fs.mkdirSync(directoryPath, { recursive: true });
} else {
return directoryPath;
}
}
/**
* Scrivi un file con
* @param {string} fileName - Il nome del file.
* @param {string} extension - L'estensione
* @param {string} content - Il contenuto del file.
* @param {string} inDirectory - Il percorso in cui scrivere il file. Se non viene specificato, il file verrà aggiunto alla cartella principale del server.
*
* 🧠 Esempio duso
* (async () => {
* await writeFileToFolder("data", "prova.json", JSON.stringify({ name: "Giuseppe", age: 17 }, null, 2));
* })();
*
*/
async function write(fileName, extension, content, inDirectory) {
try {
const directoryPath = inDirectory ? getDirectory(inDirectory) : path.resolve(__dirname, '..');
fs.mkdirSync(directoryPath, {recursive: true});
const filePath = path.join(directoryPath, `${fileName}.${extension}`);
await fs.writeFileSync(filePath, content, 'utf-8');
} catch (error) {
console.error(`Error writing file ${fileName}.${extension}:`, error);
}
}
//Funzione per ottenere la data nel formato dd/mm/yyyy hh:mm
function getDate(isoString) {
const date = new Date(isoString);
const day = String(date.getDate()).padStart(2, "0");
const month = String(date.getMonth() + 1).padStart(2, "0");
const year = date.getFullYear();
const hours = String(date.getHours()).padStart(2, "0");
const minutes = String(date.getMinutes()).padStart(2, "0");
return `${day}/${month}/${year} ${hours}:${minutes}`;
}
// Funzione per ottenere il tempo relativo ("2 ore fa", "tra 4 ore")
function relativeData(isoString) {
const date = new Date(isoString);
const now = new Date();
const diffMs = date - now; // differenza in millisecondi
const diffSec = Math.round(diffMs / 1000);
const diffMin = Math.round(diffSec / 60);
const diffHr = Math.round(diffMin / 60);
const diffDay = Math.round(diffHr / 24);
const rtf = new Intl.RelativeTimeFormat("it", { numeric: "auto" });
if (Math.abs(diffSec) < 60) return rtf.format(diffSec, "second");
if (Math.abs(diffMin) < 60) return rtf.format(diffMin, "minute");
if (Math.abs(diffHr) < 24) return rtf.format(diffHr, "hour");
return rtf.format(diffDay, "day");
}
module.exports = {
getDirectory,
write,
getDate,
relativeData,
}