Migra dal codice salvato in locale al codice condiviso
This commit is contained in:
10
.gitignore
vendored
Normal file
10
.gitignore
vendored
Normal 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
|
||||
@@ -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
2596
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
35
package.json
Normal file
35
package.json
Normal 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
BIN
plugin/.DS_Store
vendored
Normal file
Binary file not shown.
32
plugin/api_models/aisstream.js
Normal file
32
plugin/api_models/aisstream.js
Normal 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 };
|
||||
212
plugin/api_models/openmeteo.js
Normal file
212
plugin/api_models/openmeteo.js
Normal 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}¤t=${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}¤t=${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
935
plugin/bot/telegram.core.js
Normal 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
|
||||
};
|
||||
|
||||
|
||||
|
||||
24
plugin/bot/telegram_users.json
Normal file
24
plugin/bot/telegram_users.json
Normal 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
11
plugin/config.js
Normal 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 };
|
||||
105
plugin/datasetModels/datasetCore.js
Normal file
105
plugin/datasetModels/datasetCore.js
Normal 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
|
||||
};
|
||||
274
plugin/datasetModels/datasetUtils.js
Normal file
274
plugin/datasetModels/datasetUtils.js
Normal 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
|
||||
};
|
||||
350
plugin/datasetModels/graphsCore.js
Normal file
350
plugin/datasetModels/graphsCore.js
Normal 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
596
plugin/index.cjs
Normal 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;
|
||||
};
|
||||
58
plugin/public/css/data_console.css
Normal file
58
plugin/public/css/data_console.css
Normal 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;
|
||||
}
|
||||
177
plugin/public/css/helm_suggestions.css
Normal file
177
plugin/public/css/helm_suggestions.css
Normal 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;
|
||||
}
|
||||
785
plugin/public/decrypt_tool.html
Normal file
785
plugin/public/decrypt_tool.html
Normal 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
386
plugin/public/graphs.html
Normal 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>
|
||||
336
plugin/public/steering_support/helm_steering_destra.html
Normal file
336
plugin/public/steering_support/helm_steering_destra.html
Normal 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>
|
||||
158
plugin/public/steering_support/helm_steering_sinistra.html
Normal file
158
plugin/public/steering_support/helm_steering_sinistra.html
Normal 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>
|
||||
588
plugin/public/steering_support/steering_helm_tip_builder.html
Normal file
588
plugin/public/steering_support/steering_helm_tip_builder.html
Normal 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">0°</div>
|
||||
<div class="stat-label">Progresso</div>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<div class="stat-value" id="stat-arc">0°</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>
|
||||
68
plugin/sensors.references.json
Normal file
68
plugin/sensors.references.json
Normal 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
258
plugin/tools/crypt.js
Normal 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
219
plugin/tools/logRecorder.js
Normal 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
|
||||
};
|
||||
35
plugin/tools/map.handler.js
Normal file
35
plugin/tools/map.handler.js
Normal 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);
|
||||
});
|
||||
}
|
||||
262
plugin/tools/public/map.html
Normal file
262
plugin/tools/public/map.html
Normal 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
74
plugin/tools/publisher.js
Normal 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
321
plugin/tools/routes.js
Normal 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
78
plugin/tools/utils.js
Normal 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 d’uso
|
||||
* (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,
|
||||
}
|
||||
Reference in New Issue
Block a user