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