Aggiunta stili CSS per Kiosk, struttura HTML per la Mappa e Riferimenti ai Sensori

• Creato un nuovo file CSS per gli stili del chiosco (kiosk) con variabili, stili per le schede (card) e animazioni.
• Aggiunto un file HTML per l'interfaccia della mappa utilizzando Mapbox, inclusi gli stili e il JavaScript per le funzionalità della mappa.
• Introdotto un file JSON per i riferimenti ai sensori, definendo percorsi ed elementi per i dati di temperatura, vento, onde, posizione, batteria, motore e sistema.

Co-authored-by: Copilot <copilot@github.com>
This commit is contained in:
Giuseppe Raffa
2026-04-23 16:19:11 +02:00
parent 41f33ce181
commit bb8d267cd4
85 changed files with 4293 additions and 5083 deletions

7
.gitignore vendored
View File

@@ -5,10 +5,13 @@ plugin/telegram_users.json
.env
plugin/datasetModels/saved_datas
plugin/datasetModels/saved_data
plugin/datasetModels/hourly_archive.json
plugin/datasetModels/logs_references.json
.DS_Store
*.DS_Store
.vscode/
.idea/
data/
docker-compose.yml

View File

@@ -1,46 +0,0 @@
In questa repository è presente il plugin MEB per SignalK.
Ulteriori informazioni verranno aggiunte nelle prossime versioni
# Variabili d'Ambiente
Prima di avviare il plugin sul SignalK, è necessario configurare il file di variabili d'ambiente.
**Ricorda**:
Inserisci i valori di chiavi, percorsi di file o altro subito dopo il simbolo di uguale, senza parentesi, virgolette o altro
_Una volta creato il file env, aggiungi:_
### Chiave di Criptazione
Utilizzata per criptare i dati come i riferimenti dei log, i file di log registrati o gli utenti admin.
Aggiungi nel file env una riga come questa:
CRYPTOKEY=
### Nome dell'Host
Usato per identificare meglio, sopratutto nei casi di test temporanei, il dispositivo in cui il server SignalK è attivo.
Aggiungi nel file env una riga come questa:
HOST_NAME=
### Token Telegram
Il plugin anima un Bot Telegram. Per ragioni di sicurezza, il token che è l'identificativo del bot deve essere protetto e salvato all'interno delle variabili d'ambiente.
Aggiungi nel file env una riga come questa:
TELEGRAM_BOT_TOKEN=
### Percorso dei file
Il plugin genera file all'interno del server SignalK, come i log o file criptati. Specifica il percorso globale all'interno del tuo dispositivo nel quale vuoi che il plugin inserisca e modifichi i dati generati.
Aggiungi nel file env una riga come questa:
SIGNALK_FILES=

View File

@@ -2,26 +2,19 @@ services:
signalk:
image: signalk/signalk-server:latest
container_name: signalk
env_file:
- .env
environment:
- NODE_ENV=development # <--- Aggiunto per attivare l'hot-reload del nostro plugin
restart: unless-stopped
ports:
- "3001:3000"
volumes:
- /Users/sese/Local/dev/MEB/meb-plugin:/home/node/.signalk/node_modules/meb:ro
- /Users/sese/Local/dev/MEB/local-plugin-data:/home/node/.signalk/meb-data
dns:
- 8.8.8.8
- 1.1.1.1
networks:
- meb-proxy-net
- meb-internal
deploy:
resources:
limits:
memory: 2G
cpus: '1.5'
networks:
meb-proxy-net:
external: true
meb-internal:
external: true
volumes:
- /Users/sese/Local/dev/MEB/signalk/data:/home/node/.signalk:rw
- /Users/sese/Local/dev/MEB/meb-plugin:/home/node/.signalk/node_modules/meb:rw
- /Users/sese/Local/dev/MEB/meb-plugin/data:/home/node/.signalk/node_modules/meb/data:rw

1442
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -2,30 +2,17 @@
"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",
"main": "plugin/index.js",
"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": {
"@msgpack/msgpack": "^3.1.3",
"axios": "^1.12.2",
"dotenv": "^17.2.3",
"msgpack-lite": "^0.1.26",
"express": "^5.2.1",
"node-telegram-bot-api": "^0.66.0",
"ws": "^8.19.0"
}

View File

@@ -1,246 +0,0 @@
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, options = { mode: 'both' }) {
const mode = options.mode || 'both';
const params = [];
const currentParams = FORECAST_PARAMS.current.join(",");
const hourlyParams = FORECAST_PARAMS.hourly.join(",");
if (mode === 'both' || mode === 'current') {
params.push('current=' + currentParams);
}
if (mode === 'both' || mode === 'hourly') {
params.push('hourly=' + hourlyParams);
}
if (!location?.latitude || !location?.longitude) {
console.warn('[OpenMeteo] Coordinate non valide per forecast');
return null;
}
const api = `https://api.open-meteo.com/v1/forecast?latitude=${location.latitude}&longitude=${location.longitude}&${params.join('&')}`;
try {
const response = await axios.get(api, {
headers: HEADERS,
timeout: TIMEOUT,
validateStatus: (status) => status === 200
});
const { data } = response;
// 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 {
timestamp: Date.now(),
temperature: data.current?.temperature_2m ?? null,
humidity: data.current?.relative_humidity_2m ?? null,
pressure: data.current?.pressure_msl ?? null,
// Refactored to match sensorsReferences.json hierarchy
wind: {
speed: data.current?.wind_speed_10m ?? null,
direction: data.current?.wind_direction_10m ?? null,
gusts: data.current?.wind_gusts_10m ?? null,
},
rain: data.current?.rain ?? null,
precipitation: data.current?.precipitation ?? null, // Keeping simple properties flat
// Unita di misura
units: globalUnits.forecast,
// Parametri orari
hourly: data.hourly ? {
time: data.hourly?.time,
temperature: data.hourly?.temperature_2m,
pressure: data.hourly?.pressure_msl,
precipitationProbability: data.hourly?.precipitation_probability,
precipitation: data.hourly?.precipitation,
rain: data.hourly?.rain,
cloudCover: data.hourly?.cloud_cover,
windDirection: data.hourly?.wind_direction_10m,
humidity: data.hourly?.relative_humidity_2m,
windSpeed: data.hourly?.wind_speed_10m
} : null,
hourlyUnits: data.hourly_units || null
};
} catch (error) {
console.error(`[OpenMeteo Forecast] Errore: ${error.message}`);
return null;
}
}
async function getSeaConditions(location, options = { mode: 'both' }) {
const mode = options.mode || 'both';
const params = [];
const currentParams = MARINE_PARAMS.current.join(",");
const hourlyParams = MARINE_PARAMS.hourly.join(",");
if (mode === 'both' || mode === 'current') {
params.push('current=' + currentParams);
}
if (mode === 'both' || mode === 'hourly') {
params.push('hourly=' + hourlyParams);
}
if (!location?.latitude || !location?.longitude) {
console.warn('[OpenMeteo] Coordinate non valide per onde');
return null;
}
const api = `https://marine-api.open-meteo.com/v1/marine?latitude=${location.latitude}&longitude=${location.longitude}&${params.join('&')}&models=ecmwf_wam`;
try {
const response = await axios.get(api, {
headers: HEADERS,
timeout: TIMEOUT,
validateStatus: (status) => status === 200
});
const { data } = response;
// 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 {
// Refactored to match sensorsReferences.json hierarchy
waves: {
height: data.current?.wave_height ?? null,
period: data.current?.wave_period ?? null,
direction: data.current?.wave_direction ?? null,
peakPeriod: data.current?.wave_peak_period ?? null
},
// Keeping these flat essentially
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: data.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
} : null,
hourlyUnits: data.hourly_units || null
};
} catch (error) {
console.error(`[OpenMeteo Marine] Errore: ${error.message}`);
return null;
}
}
module.exports = { getSeaConditions, getForecast, getUnits, formatWithUnit };

View File

@@ -1,138 +0,0 @@
const dotenv = require("dotenv");
const path = require("path");
const fs = require("fs");
dotenv.config({ path: path.resolve(__dirname, "..", ".env"), quiet: true });
const SIGNALK_FILES = process.env.SIGNALK_FILES || path.resolve(__dirname);
function checkFolder(dirPath) {
try {
fs.accessSync(dirPath, fs.constants.R_OK | fs.constants.W_OK);
} catch (err) {
if (err.code === 'ENOENT') {
fs.mkdirSync(dirPath, {
recursive: true,
mode: 0o777
});
} else {
throw new Error(`Permission denied for ${dirPath}`);
}
}
return dirPath;
}
const paths = {
base: SIGNALK_FILES,
logs: checkFolder(path.join(SIGNALK_FILES, "logs")),
hourlyArchive: path.join(SIGNALK_FILES, "logs", "hourly_archive.json"),
logsReferences: path.join(SIGNALK_FILES, "logs", "logs_references.json"),
savedDatas: checkFolder(path.join(SIGNALK_FILES, "logs", "saved_datas")),
private: checkFolder(path.join(SIGNALK_FILES, "private")),
authorizedAdmins: path.join(SIGNALK_FILES, "private", "authorized_admins.txt"),
telegramUsers: path.join(SIGNALK_FILES, "private", "telegram_users.json"),
sensorsReferences: path.join(__dirname, "sensors", "sensors.references.json")
};
const config = {
telegramBotToken: process.env.TELEGRAM_BOT_TOKEN,
mapboxKey: process.env.MAPBOX_KEY,
cloudUrl: process.env.CLOUD_URL || "https://realtime.mebcloud.it",
cloudApiKey: process.env.CLOUD_API_KEY,
realtimeUrl: process.env.REALTIME_URL || 'http://realtime:3002',
paths
};
/**
* Carica la configurazione sensori dal server (con fallback locale).
* Se viene fornito un ticket, usa l'endpoint autenticato /sensors/data/references.
* Altrimenti usa l'endpoint pubblico /sensors/references (legacy).
*
* @param {string|null} ticket - Ticket di autenticazione (opzionale)
* @returns {Promise<Object|null>} { items, version, isActive } o null
*/
async function loadSensorReferencesFromServer(ticket = null) {
// Tentativo 1: Endpoint autenticato (con ticket)
if (ticket) {
const authUrl = config.realtimeUrl + '/sensors/data/references';
try {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 5000);
const res = await fetch(authUrl, {
signal: controller.signal,
headers: { 'Authorization': `Bearer ${ticket}` }
});
clearTimeout(timeout);
if (res.ok) {
const data = await res.json();
console.log(`[MEB] Sensor references caricati (autenticato, v: ${data.version})`);
return data;
}
console.warn(`[MEB] Endpoint autenticato HTTP ${res.status}, provo fallback pubblico`);
} catch (err) {
console.warn(`[MEB] Endpoint autenticato fallito: ${err.message}, provo fallback pubblico`);
}
}
// Tentativo 2: Endpoint pubblico (legacy)
const publicUrl = config.realtimeUrl + '/sensors/references';
try {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 5000);
const res = await fetch(publicUrl, { signal: controller.signal });
clearTimeout(timeout);
if (res.ok) {
const data = await res.json();
console.log(`[MEB] Sensor references caricati dal server (versione: ${data.version})`);
return data;
}
console.warn(`[MEB] Server sensor refs HTTP ${res.status}, uso fallback locale`);
} catch (err) {
console.warn(`[MEB] Impossibile caricare sensor refs dal server: ${err.message}`);
}
// Tentativo 3: File locale
try {
const raw = fs.readFileSync(paths.sensorsReferences, 'utf-8');
const data = JSON.parse(raw);
console.log(`[MEB] Sensor references caricati da file locale (versione: ${data.version})`);
return data;
} catch (err) {
console.error(`[MEB] Nessuna sorgente sensor refs disponibile: ${err.message}`);
return null;
}
}
/**
* Controlla se la versione dei sensor references sul server e' cambiata.
* Ritorna la nuova versione se diversa, null altrimenti.
*/
async function checkSensorReferencesVersion(currentVersion) {
const url = config.realtimeUrl + '/sensors/references/version';
try {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 3000);
const res = await fetch(url, { signal: controller.signal });
clearTimeout(timeout);
if (res.ok) {
const data = await res.json();
if (data.version && data.version !== currentVersion) {
return data.version;
}
}
} catch {
// Silenzioso: il polling della versione non e' critico
}
return null;
}
module.exports = { config, paths, loadSensorReferencesFromServer, checkSensorReferencesVersion };

View File

@@ -0,0 +1,73 @@
/**
* Gestore centralizzato della configurazione del plugin.
* Questa soluzione permette di leggere i valori di configurazione dinamicamente,
* quindi cambiano in tempo reale quando l'utente li modifica dalle impostazioni del plugin.
*/
let pluginOptions = {};
/**
* Inizializza il ConfigManager con le opzioni del plugin.
* Deve essere chiamato all'avvio del plugin.
* @param {Object} options - Le opzioni passate da Signal K al plugin
*/
function init(options) {
pluginOptions = options || {};
console.log('[CONFIG] ConfigManager inizializzato');
}
/**
* Ottiene il Telegram Bot Token
*/
function getTelegramToken() {
return process.env.TELEGRAM_BOT_TOKEN || '';
}
/**
* Ottiene il codice sensore
*/
function getSensorCode() {
return pluginOptions.sensor_code || '';
}
/**
* Ottiene il nome sensore
*/
function getSensorName() {
return pluginOptions.sensor_name || '';
}
/**
* Ottiene l'intervallo di invio dati (in millisecondi, converte da secondi)
*/
function getSendInterval() {
const seconds = pluginOptions.sensor_interval || process.env.SEND_INTERVAL;
return (typeof seconds === 'number' ? seconds : parseInt(seconds)) * 1000 || 60000;
}
/**
* Ottiene il ritardo di riconnessione (in millisecondi, converte da secondi)
*/
function getReconnectDelay() {
const seconds = pluginOptions.reconnect_delay || process.env.RECONNECT_DELAY;
return (typeof seconds === 'number' ? seconds : parseInt(seconds)) * 1000 || 5000;
}
/**
* Ottiene un valore di configurazione generico
* @param {string} key - La chiave della configurazione
* @param {*} defaultValue - Il valore di default
*/
function get(key, defaultValue = null) {
return pluginOptions[key] !== undefined ? pluginOptions[key] : defaultValue;
}
module.exports = {
init,
getTelegramToken,
getSensorCode,
getSensorName,
getSendInterval,
getReconnectDelay,
get
};

171
plugin/config/skFlow.js Normal file
View File

@@ -0,0 +1,171 @@
let skApp = null;
/**
* Inizializza il modulo con l'istanza dell'app Signal K.
* Da chiamare una sola volta nel plugin.start()
* @param {Object} app - l'istanza dell'applicazione Signal K
*/
function init(app) {
skApp = app;
}
/**
* Pubblica un set di dati nel data browser di Signal K tramite i delta.
* @param {Object} data - Oggetto JSON dove le chiavi sono i percorsi e i valori sono i dati da pubblicare
*/
function publish(data) {
//TODO: Controlla se serve aggiungere typeof skApp.handleMessage !== 'function' (controlla che esista la funzione handleMessage, ma in teoria esiste sempre)
if (!skApp) {
console.error('[SKFLOW] skApp non inizializzato')
return;
}
if (!data || typeof data !== 'object') {
console.error('[SKFLOW] Dati non validi')
return;
}
const values = Object.entries(data).map(([path, value]) => {
return {
path: path,
value: value
};
});
//La funzione non continua se non ci sono dati
//TODO: Controllare se serve davvero, non dovrebbe interrompersi già al check di data?
if (values.length === 0) return;
// Viene creato un "Delta Update" con l'ID del plugin 'meb.plugin' e l'array di valori.
skApp.handleMessage('meb.plugin', {
updates: [
{
values: values
}
]
});
}
/**
* Ottieni i dati dal Data-Browser di Signal K
* @param {String} path - Il path Signal K
* @returns {*} Il dato
*/
function get(path) {
if (!skApp) {
return null;
}
const valObj = skApp.getSelfPath(path);
return valObj ? valObj.value : null;
}
/**
* Ottieni tutti i dati nel databrowser di Signal K che corrispondono ad un ID o ad una sorgente specifica.
* @param {String} source - Il parametro da confrontare con il sorgente ($source o source) o ID del dato.
* @returns {Object} Un oggetto contenente path e valori trovati.
*/
function getBySource(source) {
if (!skApp) return {};
const results = {};
const self = skApp.signalk?.self || skApp.signalk?.retrieve()?.vessels?.[skApp.selfId] || {};
if (!self || Object.keys(self).length === 0) {
console.log('[SKFLOW] Nessun dato trovato nel databrowser');
return results;
}
const traverse = (obj, path = '') => {
if (!obj || typeof obj !== 'object') return;
// Se l'oggetto ha una proprietà 'value', verifichiamo la sorgente
if (Object.prototype.hasOwnProperty.call(obj, 'value')) {
const hasSource = obj.$source === source || obj.source === source || obj.id === source;
if (hasSource) {
results[path] = obj.value;
}
}
// Esplora i sotto-oggetti escludendo chiavi di sistema che non sono percorsi SK
const skip = ['value', 'timestamp', '$source', 'source', 'meta', 'sentence', 'talker'];
for (const key in obj) {
if (skip.includes(key)) continue;
const subPath = path ? `${path}.${key}` : key;
traverse(obj[key], subPath);
}
};
traverse(self);
return results;
}
/**
* Ottieni tutti i dati nel databrowser di Signal K il cui path inizia con la stringa specificata.
* @param {String} filterPath - La stringa con cui deve iniziare il path (es. "custom.plugin").
* @returns {Object} Un oggetto contenente path e valori trovati.
*/
function getWithFilter(filterPath) {
if (!skApp) return {};
const results = {};
const self = skApp.signalk?.self || skApp.signalk?.retrieve()?.vessels?.[skApp.selfId] || {};
if (!self || Object.keys(self).length === 0) {
return results;
}
const traverse = (obj, path = '') => {
if (!obj || typeof obj !== 'object') return;
// Se l'oggetto ha una proprietà 'value', verifichiamo se il path corrisponde al filtro
if (Object.prototype.hasOwnProperty.call(obj, 'value')) {
if (path.startsWith(filterPath)) {
results[path] = obj.value;
}
}
// Esplora i sotto-oggetti escludendo chiavi di sistema
const skip = ['value', 'timestamp', '$source', 'source', 'meta', 'sentence', 'talker'];
for (const key in obj) {
if (skip.includes(key)) continue;
const subPath = path ? `${path}.${key}` : key;
// Ottimizzazione: se `subPath` non inizia con `filterPath` E `filterPath` non inizia con `subPath`,
// possiamo evitare di scendere in rami completamente irrilevanti.
// (es. filter = "environment." e subPath = "navigation." -> skippa)
if (!subPath.startsWith(filterPath) && !filterPath.startsWith(subPath)) continue;
traverse(obj[key], subPath);
}
};
traverse(self);
return results;
}
/**
* Ottieni un oggetto con path e valori per una lista specifica di path.
* @param {Array<String>} data - Un array contenente i path di Signal K da recuperare.
* @returns {Object} Un oggetto JSON con elementi path: value.
*/
function getFrom(data) {
if (!Array.isArray(data)) return {};
const results = {};
for (const path of data) {
results[path] = get(path);
}
return results;
}
module.exports = {
init,
publish,
get,
getBySource,
getWithFilter,
getFrom
};

View File

@@ -0,0 +1,53 @@
module.exports = {
type: 'object',
properties: { // impostazioni
wthr_update_interval: {
type: 'number',
title: 'Aggiornamento meteo (in secondi)',
description: 'Imposta ogni quanto verranno aggiornati i dati meteo attuali',
default: 60, //1m
},
wthr_longterm_interval: {
type: 'number',
title: 'Aggiornamenti Previsioni (in minuti)',
description: 'Imposta ogni quanti minuti verranno aggiornate le previsioni meteo fino a 7 ore',
default: 60, //1h
},
telemetry_log_interval: {
type: 'number',
title: 'Registrazione dei dati (in secondi)',
description: 'Imposta ogni quanto la telemetria della barca verrà registrata',
default: 10, //10sec
},
telegam_token: {
type: 'string',
title: 'Telegram Bot Token',
description: 'Inserisci il token del tuo bot Telegram per ricevere notifiche e interagire con la tua barca da remoto',
default: '',
},
sensor_code: {
type: 'string',
title: 'Sensore',
description: 'Inserisci un codice identificativo per inviare i dati al server',
default: '',
},
sensor_name: {
type: 'string',
title: 'Nome Sensore',
description: 'Inserisci un nome per il tuo sensore, che verrà visualizzato nel server',
default: '',
},
sensor_interval: {
type: 'number',
title: 'Aggiornamento dati (in secondi)',
description: 'Imposta ogni quanto i dati del sensore verranno inviati al server',
default: 60, //1m
},
reconnect_delay: {
type: 'number',
title: 'Ritardo di riconnessione (in secondi)',
description: 'Imposta il ritardo prima di tentare una nuova connessione al server',
default: 10, //10sec
}
}
}

250
plugin/cores/logs.local.js Normal file
View File

@@ -0,0 +1,250 @@
const fs = require('fs').promises;
const fsSync = require('fs');
const pth = require('path');
const os = require('os');
const skFlow = require('../config/skFlow');
const realtime = require('./realtime/core');
const logsDirectory = pth.join(__dirname, '../../data/logs/');
// Intervallo di scrittura fisso: 1 secondo
const WRITE_INTERVAL = 1000;
// Stato della sessione attiva
let session = null;
let writeInterval = null;
// Paths da registrare (impostati da init)
let logPaths = [];
/**
* Risolutori speciali per path che non sono direttamente accessibili via skFlow.get().
* Per ogni path speciale, definisce una funzione che restituisce il valore.
*/
const SPECIAL_RESOLVERS = {
'navigation.position.latitude': () => {
const pos = skFlow.get('navigation.position');
return pos?.latitude ?? null;
},
'navigation.position.longitude': () => {
const pos = skFlow.get('navigation.position');
return pos?.longitude ?? null;
},
'system.uptime': () => Math.floor(process.uptime())
};
/**
* Risolve il valore di un path, gestendo i casi speciali.
* @param {String} path - il path da risolvere
* @returns {*} il valore
*/
function resolveValue(path) {
// Controlla se c'e un risolutore speciale
if (SPECIAL_RESOLVERS[path]) {
return SPECIAL_RESOLVERS[path]();
}
// Path standard: leggi dal databrowser Signal K
return skFlow.get(path);
}
/**
* Inizializza i path da registrare.
* @param {Array<String>} paths - array di path da rules.js LOG_PATHS
*/
function init(paths) {
logPaths = paths;
console.log(`[LOGS] Inizializzati ${paths.length} path`);
}
/**
* Assicura che la cartella logs esista
*/
async function ensureDir() {
try {
await fs.mkdir(logsDirectory, { recursive: true });
} catch (e) {}
}
/**
* Avvia la registrazione: crea un nuovo file CSV e inizia a scrivere ogni secondo.
* @param {String} name - nome del file (opzionale, default: data/ora corrente)
*/
async function startRecording(name) {
// Se c'e gia una sessione attiva, fermala prima
if (session) {
await stopRecording();
}
if (!name) {
const now = new Date();
name = now.toISOString().replace(/[:.]/g, '-');
}
if (logPaths.length === 0) {
console.warn('[LOGS] Nessun path configurato, la registrazione non catturera dati');
}
await ensureDir();
// Header CSV: timestamp + tutti i path
const header = ['timestamp', ...logPaths].join(',') + '\n';
const filePath = pth.join(logsDirectory, `${name}.csv`);
await fs.writeFile(filePath, header);
session = {
name: name,
paths: logPaths,
startTime: new Date(),
elements: 0,
filePath: filePath
};
// Scrivi ogni secondo
writeInterval = setInterval(() => {
writeLog();
}, WRITE_INTERVAL);
console.log(`[LOGS] Registrazione avviata: ${name} (${logPaths.length} colonne, intervallo ${WRITE_INTERVAL}ms)`);
return session;
}
/**
* Scrive una riga nel CSV e invia i dati al server via WebSocket.
*/
async function writeLog() {
if (!session) return;
try {
const timestamp = new Date().toISOString();
// Risolvi tutti i valori
const fields = {};
const csvValues = [];
for (const path of session.paths) {
const val = resolveValue(path);
fields[path] = val;
// Formatta per CSV
if (val === null || val === undefined) {
csvValues.push('');
} else if (typeof val === 'object') {
csvValues.push(JSON.stringify(val).replace(/,/g, ';'));
} else {
csvValues.push(val);
}
}
// Scrivi riga CSV nel file locale
const row = [timestamp, ...csvValues].join(',') + '\n';
await fs.appendFile(session.filePath, row);
session.elements++;
// Invia al server via WebSocket (se connesso)
if (realtime.isConnected()) {
realtime.send([Date.now(), 'logs', fields]);
}
} catch (error) {
console.error('[LOGS] Errore scrittura:', error.message);
}
}
/**
* Interrompe la registrazione e chiude il file.
*/
async function stopRecording() {
if (writeInterval) {
clearInterval(writeInterval);
writeInterval = null;
}
if (session) {
console.log(`[LOGS] Registrazione fermata: ${session.name} (${session.elements} righe)`);
session = null;
}
}
/**
* Ottieni i dati del file come stringa CSV.
* @param {String} name - il nome del file (senza estensione)
* @returns {String|null}
*/
async function getLog(name) {
try {
const filePath = pth.join(logsDirectory, `${name}.csv`);
const content = await fs.readFile(filePath, 'utf-8');
return content;
} catch (error) {
console.error('[LOGS] Errore lettura log:', error.message);
return null;
}
}
/**
* Ottieni il percorso del file CSV.
* @param {String} name - il nome del file (senza estensione)
* @returns {String|null}
*/
function getLogFile(name) {
const filePath = pth.join(logsDirectory, `${name}.csv`);
if (fsSync.existsSync(filePath)) {
return filePath;
}
return null;
}
/**
* Ottieni la lista di tutti i file di log disponibili.
* @returns {Array}
*/
async function listLogs() {
await ensureDir();
try {
const files = await fs.readdir(logsDirectory);
const csvFiles = files.filter(f => f.endsWith('.csv'));
const result = [];
for (const file of csvFiles) {
const filePath = pth.join(logsDirectory, file);
const stat = await fs.stat(filePath);
result.push({
name: file.replace('.csv', ''),
filename: file,
size: (stat.size / (1024 * 1024)).toFixed(2),
created: stat.birthtime,
modified: stat.mtime
});
}
return result.sort((a, b) => b.modified - a.modified);
} catch (error) {
console.error('[LOGS] Errore lista log:', error.message);
return [];
}
}
/**
* Ottieni informazioni sulla sessione di registrazione attiva.
* @returns {Object|null}
*/
function getSession() {
if (!session) return null;
return {
name: session.name,
paths: session.paths,
startTime: session.startTime,
elements: session.elements,
delay: WRITE_INTERVAL
};
}
module.exports = {
init,
startRecording,
stopRecording,
getLog,
getLogFile,
getSession,
listLogs
};

261
plugin/cores/openmeteo.js Normal file
View File

@@ -0,0 +1,261 @@
const skFlow = require('../config/skFlow');
const realtimeCore = require('./realtime/core');
const {
FORECAST_CURRENT,
FORECAST_HOURLY,
MARINE_CURRENT,
MARINE_HOURLY
} = require('../rules');
const FETCH_TIMEOUT = 10000;
const FORECAST_API = 'https://api.open-meteo.com/v1/forecast';
const MARINE_API = 'https://marine-api.open-meteo.com/v1/marine';
/**
* Mapping da parametri API Open-Meteo a path Signal K.
* Questi path vengono pubblicati sul databrowser e letti dai log.
*/
const FORECAST_PATH_MAP = {
'temperature_2m': 'meb.forecasts.temperature',
'wind_speed_10m': 'meb.forecast.wind.speed',
'wind_direction_10m': 'meb.forecast.wind.direction',
'wind_gusts_10m': 'meb.forecast.wind.gusts',
'precipitation': 'meb.forecast.precipitation',
'rain': 'meb.forecast.rain',
'relative_humidity_2m': 'meb.forecast.humidity',
'pressure_msl': 'meb.forecast.pressure',
'precipitation_probability':'meb.forecast.precipitationProbability',
'cloud_cover': 'meb.forecast.cloudCover',
};
const MARINE_PATH_MAP = {
'wave_height': 'meb.waves.height',
'wave_direction': 'meb.waves.direction',
'wave_period': 'meb.waves.period',
'wave_peak_period': 'meb.waves.peakPeriod',
'ocean_current_velocity': 'meb.waves.currentVelocity',
'ocean_current_direction': 'meb.waves.currentDirection',
};
/**
* Fetch JSON con timeout
*/
async function fetchJSON(url) {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), FETCH_TIMEOUT);
try {
const res = await fetch(url, { signal: controller.signal });
clearTimeout(timeoutId);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return await res.json();
} catch (err) {
clearTimeout(timeoutId);
throw err;
}
}
/**
* Pubblica i dati current su Signal K usando i path mappati.
*/
function publishCurrentToSignalK(forecastData, marineData) {
const skData = {};
if (forecastData?.current) {
for (const [key, value] of Object.entries(forecastData.current)) {
if (key === 'time' || key === 'interval') continue;
const skPath = FORECAST_PATH_MAP[key];
if (skPath && value != null) skData[skPath] = value;
}
}
if (marineData?.current) {
for (const [key, value] of Object.entries(marineData.current)) {
if (key === 'time' || key === 'interval') continue;
const skPath = MARINE_PATH_MAP[key];
if (skPath && value != null) skData[skPath] = value;
}
}
if (Object.keys(skData).length > 0) {
skData['meb.weather.timestamp'] = Date.now();
skFlow.publish(skData);
console.log(`[OPENMETEO] Pubblicati ${Object.keys(skData).length} valori su Signal K`);
}
}
/**
* Invia i dati current weather al server realtime (measurement: weather).
* Usa i path mappati come field keys per InfluxDB.
*/
function sendCurrentToRealtime(forecastData, marineData) {
const fields = {};
if (forecastData?.current) {
for (const [key, value] of Object.entries(forecastData.current)) {
if (key === 'time' || key === 'interval') continue;
const skPath = FORECAST_PATH_MAP[key];
if (skPath && value != null) fields[skPath] = value;
}
}
if (marineData?.current) {
for (const [key, value] of Object.entries(marineData.current)) {
if (key === 'time' || key === 'interval') continue;
const skPath = MARINE_PATH_MAP[key];
if (skPath && value != null) fields[skPath] = value;
}
}
if (Object.keys(fields).length > 0) {
realtimeCore.send([Date.now(), 'weather', fields]);
}
}
/**
* Invia i dati hourly forecast come batch al server realtime (measurement: weather_forecast).
* Ogni punto usa i path mappati come field keys.
*/
function sendForecastBatchToRealtime(forecastData, marineData) {
const forecastHourly = forecastData?.hourly;
const marineHourly = marineData?.hourly;
if (!forecastHourly?.time && !marineHourly?.time) return;
const times = forecastHourly?.time || marineHourly?.time;
const points = [];
for (let i = 0; i < times.length; i++) {
const ts = new Date(times[i]).getTime();
const fields = {};
if (forecastHourly) {
for (const [key, values] of Object.entries(forecastHourly)) {
if (key === 'time') continue;
const skPath = FORECAST_PATH_MAP[key];
if (skPath && values?.[i] != null) fields[skPath] = values[i];
}
}
if (marineHourly) {
for (const [key, values] of Object.entries(marineHourly)) {
if (key === 'time') continue;
const skPath = MARINE_PATH_MAP[key];
if (skPath && values?.[i] != null) fields[skPath] = values[i];
}
}
if (Object.keys(fields).length > 0) {
points.push([ts, fields]);
}
}
if (points.length > 0) {
realtimeCore.sendRaw({ ts: 0, _m: 'forecast_batch', points });
console.log(`[OPENMETEO] Batch forecast inviato: ${points.length} punti orari`);
}
}
// ========== FUNZIONI PRINCIPALI ==========
/**
* Fetch dati meteo current (ogni 5 minuti).
*/
async function fetchCurrentWeather(location) {
if (!location?.latitude || !location?.longitude) {
console.warn('[OPENMETEO] Coordinate non valide');
return;
}
if (FORECAST_CURRENT.length === 0 && MARINE_CURRENT.length === 0) {
console.warn('[OPENMETEO] Nessun parametro current configurato');
return;
}
console.log(`[OPENMETEO] Fetch current — forecast: ${FORECAST_CURRENT.length} params, marine: ${MARINE_CURRENT.length} params`);
let forecastData = null, marineData = null;
try {
const promises = [];
if (FORECAST_CURRENT.length > 0) {
const url = `${FORECAST_API}?latitude=${location.latitude}&longitude=${location.longitude}&current=${FORECAST_CURRENT.join(',')}`;
promises.push(fetchJSON(url).then(d => { forecastData = d; }).catch(e => {
console.error(`[OPENMETEO] Errore forecast current: ${e.message}`);
}));
}
if (MARINE_CURRENT.length > 0) {
const url = `${MARINE_API}?latitude=${location.latitude}&longitude=${location.longitude}&current=${MARINE_CURRENT.join(',')}&models=ecmwf_wam`;
promises.push(fetchJSON(url).then(d => { marineData = d; }).catch(e => {
console.error(`[OPENMETEO] Errore marine current: ${e.message}`);
}));
}
await Promise.all(promises);
publishCurrentToSignalK(forecastData, marineData);
sendCurrentToRealtime(forecastData, marineData);
} catch (err) {
console.error(`[OPENMETEO] Errore fetch current: ${err.message}`);
}
}
/**
* Fetch previsioni orarie 7 giorni (ogni 1 ora).
*/
async function fetchHourlyForecasts(location) {
if (!location?.latitude || !location?.longitude) {
console.warn('[OPENMETEO] Coordinate non valide per forecast');
return;
}
if (FORECAST_HOURLY.length === 0 && MARINE_HOURLY.length === 0) {
console.warn('[OPENMETEO] Nessun parametro hourly configurato');
return;
}
console.log(`[OPENMETEO] Fetch hourly 7gg — forecast: ${FORECAST_HOURLY.length} params, marine: ${MARINE_HOURLY.length} params`);
let forecastData = null, marineData = null;
try {
const promises = [];
if (FORECAST_HOURLY.length > 0) {
const url = `${FORECAST_API}?latitude=${location.latitude}&longitude=${location.longitude}&hourly=${FORECAST_HOURLY.join(',')}&forecast_days=7`;
promises.push(fetchJSON(url).then(d => { forecastData = d; }).catch(e => {
console.error(`[OPENMETEO] Errore forecast hourly: ${e.message}`);
}));
}
if (MARINE_HOURLY.length > 0) {
const url = `${MARINE_API}?latitude=${location.latitude}&longitude=${location.longitude}&hourly=${MARINE_HOURLY.join(',')}&forecast_days=7&models=ecmwf_wam`;
promises.push(fetchJSON(url).then(d => { marineData = d; }).catch(e => {
console.error(`[OPENMETEO] Errore marine hourly: ${e.message}`);
}));
}
await Promise.all(promises);
sendForecastBatchToRealtime(forecastData, marineData);
} catch (err) {
console.error(`[OPENMETEO] Errore fetch hourly: ${err.message}`);
}
}
/**
* Fetch completo: current + hourly. Chiamato all'avvio.
*/
async function fetchAll(location) {
await fetchCurrentWeather(location);
await fetchHourlyForecasts(location);
}
module.exports = {
fetchCurrentWeather,
fetchHourlyForecasts,
fetchAll
};

View File

@@ -0,0 +1,50 @@
const configManager = require('../../config/configManager.js');
const REALTIME_URL = process.env.REALTIME_URL;
/**
* Autentica il sensore per connetterlo al server.
* Il token viene inviato al server, che restituisce un token temporaneo per la connessione websocket.
* @returns {Promise<{socketToken: string, sensorId: string, expiresIn: number}|null>}
*/
async function authenticate() {
const SENSOR_CODE = configManager.getSensorCode();
const SENSOR_NAME = configManager.getSensorName();
if (!REALTIME_URL || !SENSOR_CODE || !SENSOR_NAME) {
console.error('[REALTIME|AUTH] REALTIME_URL, SENSOR_CODE o SENSOR_NAME non configurati');
return null;
}
try {
const response = await fetch(`${REALTIME_URL}/connect`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name: SENSOR_NAME,
code: SENSOR_CODE
})
});
if (!response.ok) {
const err = await response.json().catch(() => ({}));
console.error(`[REALTIME|AUTH] auth error (${response.status}):`, err.error || 'unknown');
return null;
}
const data = await response.json();
// Server risponde { s: 'ok', t: token }
if (data.s !== 'ok' || !data.t) {
console.error('[REALTIME|AUTH] Risposta inattesa:', data);
return null;
}
console.log(`[REALTIME|AUTH] Autenticato: ${SENSOR_NAME}, token valido 5s`);
return { socketToken: data.t };
} catch (error) {
console.error(`[REALTIME|AUTH] error: ${error.message}`);
return null;
}
}
module.exports = { authenticate };

View File

@@ -0,0 +1,106 @@
const os = require('os');
const auth = require('./auth');
const socket = require('./socket');
const configManager = require('../../config/configManager.js');
let reconnectTimer = null;
let isShuttingDown = false;
/**
* Inizializza la connessione al server realtime.
* Autentica il sensore e apre la connessione WebSocket.
* In caso di disconnessione, tenta di riconnettersi.
*/
async function init() {
isShuttingDown = false;
await connectToServer();
}
/**
* Esegue il flusso di connessione: auth → websocket
*/
async function connectToServer() {
if (isShuttingDown) return;
console.log('CONNECTING......')
const result = await auth.authenticate();
console.log('AUTH RESULT:', result);
if (!result) {
scheduleReconnect();
return;
}
const connected = await socket.connect(result.socketToken, () => {
if (!isShuttingDown) {
scheduleReconnect();
}
});
if (!connected) {
scheduleReconnect();
}
}
/**
* Pianifica un tentativo di riconnessione dopo il ritardo configurato.
*/
function scheduleReconnect() {
if (reconnectTimer || isShuttingDown) return;
const RECONNECT_DELAY = configManager.getReconnectDelay();
console.log(`[REALTIME] riconnessione in ${RECONNECT_DELAY / 1000}s...`);
reconnectTimer = setTimeout(async () => {
reconnectTimer = null;
await connectToServer();
}, RECONNECT_DELAY);
}
/**
* Invia dati al server se la connessione è attiva.
* @param {Array} data - Array nel formato [timestamp, measurement, fields]
*/
function send(data) {
if (socket.isConnected()) {
socket.send(data);
}
}
/**
* Invia un oggetto raw al server (senza trasformazione [ts, _m, fields]).
* Usato per forecast_batch e altri messaggi con struttura custom.
*/
function sendRaw(obj) {
if (socket.isConnected()) {
socket.sendRaw(obj);
}
}
/**
* @returns {boolean} true se la connessione WebSocket è attiva
*/
function isConnected() {
return socket.isConnected();
}
/**
* Ferma la connessione e i tentativi di riconnessione.
*/
function stop() {
isShuttingDown = true;
if (reconnectTimer) {
clearTimeout(reconnectTimer);
reconnectTimer = null;
}
socket.close();
}
module.exports = {
init,
send,
sendIfConnected: send,
sendRaw,
isConnected,
stop
};

View File

@@ -0,0 +1,114 @@
const WebSocket = require('ws');
const os = require('os');
const { encode } = require('@msgpack/msgpack');
const SOCKET_URL = process.env.REALTIME_SOCKET_URL;
let ws = null;
let onDisconnect = null;
/**
* Apre una connessione WebSocket al server realtime usando il token temporaneo.
* @param {string} socketToken - Token temporaneo ottenuto da auth.authenticate()
* @param {Function} onClose - Callback chiamata quando la connessione si chiude
* @returns {Promise<boolean>} true se la connessione è riuscita
*/
function connect(socketToken, onClose) {
return new Promise((resolve) => {
if (!SOCKET_URL) {
console.error('[REALTIME|WS] REALTIME_SOCKET_URL non configurato nel .env');
return resolve(false);
}
onDisconnect = onClose;
try {
const wsUrl = `${SOCKET_URL}/?token=${encodeURIComponent(socketToken)}`;
ws = new WebSocket(wsUrl);
} catch (err) {
console.error('[REALTIME|WS] Errore creazione:', err.message);
return resolve(false);
}
ws.on('open', () => {
console.log('[REALTIME|WS] Connesso');
// Invia init con system uptime
const initPayload = {
_t: 'init',
uptime: Math.floor(os.uptime())
};
ws.send(encode(initPayload));
console.log('[REALTIME|WS] Init inviato:', initPayload);
resolve(true);
});
ws.on('message', () => {
// Il server non invia messaggi ai sensori per ora
});
ws.on('ping', () => {
// ws risponde automaticamente con pong
});
ws.on('error', (err) => {
console.error(`[REALTIME|WS] Errore: ${err.message}`);
resolve(false);
});
ws.on('close', (code) => {
console.log(`[REALTIME|WS] Disconnesso (code: ${code})`);
ws = null;
if (onDisconnect) onDisconnect();
});
});
}
/**
* Invia dati al server tramite WebSocket, codificati in msgpack.
* @param {Array} data - Array nel formato [timestamp, measurement, fields]
*/
function send(data) {
if (!ws || ws.readyState !== WebSocket.OPEN) return;
try {
const [timestamp, measurement, fields] = data;
const packet = { ts: timestamp, _m: measurement, ...fields };
ws.send(encode(packet));
} catch (err) {
console.error('[REALTIME|WS] Errore invio:', err.message);
}
}
/**
* Invia un oggetto raw al server, codificato in msgpack.
* A differenza di send(), non fa transform [ts, measurement, fields].
* @param {Object} obj - Oggetto da inviare direttamente
*/
function sendRaw(obj) {
if (!ws || ws.readyState !== WebSocket.OPEN) return;
try {
ws.send(encode(obj));
} catch (err) {
console.error('[REALTIME|WS] Errore invio raw:', err.message);
}
}
/**
* @returns {boolean} true se la connessione è attiva
*/
function isConnected() {
return ws !== null && ws.readyState === WebSocket.OPEN;
}
/**
* Chiude la connessione WebSocket.
*/
function close() {
onDisconnect = null;
if (ws) {
ws.close();
ws = null;
}
}
module.exports = { connect, send, sendRaw, isConnected, close };

View File

View File

@@ -1,276 +0,0 @@
const { config } = require("./config.js");
const registerRoutes = require("./routes");
const { linkBotToApp } = require("./telegram/telegram.core.js");
const { getForecast, getSeaConditions } = require("./api_models/openmeteo.js");
const { publish } = require("./tools/publisher.js");
const realtime = require("./realtime/core.js");
const dataHub = require("./tools/dataHub.js");
const CONFIG = {
forecast_current_frequency: 300000, // 5 min default in ms
forecast_hourly_frequency: 3600000, // 1 hour default
};
const state = {
openMeteoTimer: null,
app: null,
startTime: null,
};
const clearIntervalSafe = (timerId) => {
if (timerId) clearInterval(timerId);
return null;
};
module.exports = function (app) {
state.app = app;
let lastHourlyUpdate = 0;
const fetchAndPublishWeather = async (forceHourly = false) => {
try {
const pos = app.getSelfPath('navigation.position')?.value;
if (!pos?.latitude || !pos?.longitude) {
console.debug('[MEB] Posizione non disponibile per meteo');
return;
}
const now = Date.now();
// Richiedi 'hourly' se forzato, o se e' passata piu' di 1 ora
const shouldFetchHourly = forceHourly || (now - lastHourlyUpdate > CONFIG.forecast_hourly_frequency);
const mode = shouldFetchHourly ? 'both' : 'current';
if (shouldFetchHourly) console.log('[MEB] Scaricamento previsioni complete (hourly + current)...');
else console.debug('[MEB] Aggiornamento meteo (current)...');
const [forecast, sea] = await Promise.all([
getForecast(pos, { mode }),
getSeaConditions(pos, { mode })
]);
if (forecast) publish(app, forecast, {});
if (sea) publish(app, sea, {});
if (shouldFetchHourly) {
lastHourlyUpdate = now;
}
if (forecast || sea) {
// Aggiorna cache centralizzata per Telegram on-demand
dataHub.updateWeatherData(forecast, sea);
// Invia al server SOLO quando è hourly (contiene previsioni 7gg)
// I dati current-only non vengono inviati — sono già disponibili localmente
if (shouldFetchHourly) {
realtime.sendWeatherPayload({ forecast, sea });
}
}
} catch (error) {
console.error('[MEB] Errore ciclo meteo:', error.message);
}
};
const plugin = {
id: "meb",
name: "MEB Plugin",
start: async (settings) => {
const randomVal = (min, max) => parseFloat((Math.random() * (max - min) + min).toFixed(2));
// Dati di test — i path SignalK DEVONO corrispondere alle sensor-references rules
// Le regole definiscono main_path e subPath, quindi i dati devono seguire esattamente quei path
publish(app, {
// engine (main_path: propulsion.0, subPath: revolutions)
"propulsion.0.revolutions": randomVal(1000, 5000),
// navigation (main_path: navigation)
"navigation.courseOverGroundTrue": randomVal(0, 360),
"navigation.speedOverGround": randomVal(0, 30),
"navigation.headingTrue": randomVal(0, 360),
// position (lat/lon sotto navigation.position come oggetto)
"navigation.position.latitude": randomVal(40, 45),
"navigation.position.longitude": randomVal(9, 14),
// service battery (main_path: electrical.batteries.service) — NB: spelling "electrical"
"electrical.batteries.service.current": randomVal(-50, 50),
"electrical.batteries.service.Voltage": randomVal(0, 500),
"electrical.batteries.service.stateOfCharge": randomVal(0.1, 1),
// traction battery (main_path: electrical.batteries.traction)
"electrical.batteries.traction.current": randomVal(-100, 100),
"electrical.batteries.traction.power": randomVal(0, 5000),
"electrical.batteries.traction.stateOfCharge": randomVal(0.1, 1),
"electrical.batteries.traction.temperature": randomVal(20, 45),
"electrical.batteries.traction.Voltage": randomVal(48, 58),
// temperatura (main_path: meb.temperature, single field)
"meb.temperature": randomVal(15, 35),
// waves (main_path: meb.waves)
"meb.waves.direction": randomVal(0, 360),
"meb.waves.height": randomVal(0, 4),
"meb.waves.period": randomVal(1, 12),
// wind (main_path: meb.wind)
"meb.wind.direction": randomVal(0, 360),
"meb.wind.speed": randomVal(0, 40),
// system uptime (main_path: system.uptime, single field)
"system.uptime": Math.floor(process.uptime())
})
try {
// Aggiorna CONFIG dai settings di SignalK
if (settings && settings.forecast_current_frequency) {
CONFIG.forecast_current_frequency = settings.forecast_current_frequency * 1000;
}
if (settings && settings.forecast_hourly_frequency) {
CONFIG.forecast_hourly_frequency = settings.forecast_hourly_frequency * 1000;
}
state.startTime = Date.now();
// Inizializza realtime (async: carica sensor refs dal server)
await realtime.init(app, settings.sensor_code);
// Telegram Bot
if (config.telegramBotToken) {
try {
await linkBotToApp(app);
console.log('[MEB] Telegram bot started');
} catch (error) {
console.error('[MEB] Error starting Telegram bot:', error);
}
} else {
console.warn('[MEB] Telegram bot disabled: TELEGRAM_BOT_TOKEN not set');
}
// Map & API routes
try {
registerRoutes(app, settings);
console.log('[MEB] Routes registered');
} catch (error) {
console.error('[MEB] Error registering routes:', error);
}
// Avvio ciclo meteo: Prima esecuzione immediata (con hourly)
fetchAndPublishWeather(true);
// Timer ricorrente
state.openMeteoTimer = setInterval(() => {
fetchAndPublishWeather(false);
}, CONFIG.forecast_current_frequency);
console.log(`[MEB] Meteo polling avviato ogni ${CONFIG.forecast_current_frequency / 1000}s`);
// Shutdown hooks (register once)
const shutdown = async (reason = 'signal') => {
try {
console.log(`[MEB] Received ${reason}. Stopping plugin...`);
await plugin.stop();
process.exit(0);
} catch (err) {
console.error('[MEB] Error during shutdown:', err);
process.exit(1);
}
};
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('[MEB] uncaughtException:', err);
shutdown('uncaughtException');
});
process.on('unhandledRejection', (reason) => {
console.error('[MEB] unhandledRejection:', reason);
shutdown('unhandledRejection');
});
}
} catch (error) {
console.error('[MEB] Error during plugin startup:', error);
throw error;
}
},
stop: async () => {
try {
state.openMeteoTimer = clearIntervalSafe(state.openMeteoTimer);
realtime.stop();
console.log('[MEB] Plugin stopped');
} catch (error) {
console.error('[MEB] Error during plugin stop:', error);
}
},
schema: () => ({}),
// Aggiorna la configurazione (da Telegram o API)
setConfig: (key, value) => {
if (key === 'forecast_current_frequency') {
const ms = value * 1000;
CONFIG.forecast_current_frequency = ms;
// Riavvia il timer con la nuova frequenza
state.openMeteoTimer = clearIntervalSafe(state.openMeteoTimer);
state.openMeteoTimer = setInterval(() => {
fetchAndPublishWeather(false);
}, ms);
console.log(`[MEB] Intervallo current aggiornato a ${value} s`);
return true;
}
if (key === 'forecast_hourly_frequency') {
CONFIG.forecast_hourly_frequency = value * 1000;
console.log(`[MEB] Intervallo Hourly aggiornato a ${value} s`);
return true;
}
return false;
},
// Gestione Polling Meteo (Start/Stop/Force)
startPolling: () => {
if (state.openMeteoTimer) {
console.log('[MEB] Polling già attivo.');
return false;
}
fetchAndPublishWeather(false);
state.openMeteoTimer = setInterval(() => {
fetchAndPublishWeather(false);
}, CONFIG.forecast_current_frequency);
console.log(`[MEB] Meteo AVVIATO (freq: ${CONFIG.forecast_current_frequency / 1000}s)`);
return true;
},
stopPolling: () => {
if (!state.openMeteoTimer) {
console.log('[MEB] Polling già fermo.');
return false;
}
state.openMeteoTimer = clearIntervalSafe(state.openMeteoTimer);
console.log('[MEB] Meteo polling FERMATO.');
return true;
},
isPollingActive: () => !!state.openMeteoTimer,
forceUpdate: async () => {
console.log('[MEB] Aggiornamento Meteo Forzato da Utente.');
await fetchAndPublishWeather(false);
return true;
},
getOpenApi: () => ({
openapi: "3.0.0",
info: { title: "MEB Plugin API", version: "2.0.0" },
servers: [{ url: "/plugins/meb" }],
paths: {}
}),
};
app.mebPlugin = plugin;
return plugin;
};

118
plugin/index.js Normal file
View File

@@ -0,0 +1,118 @@
const settingsSchema = require('./config/skSettings.js')
const configManager = require('./config/configManager.js')
const routes = require('./routes/main.js')
const openmeteo = require('./cores/openmeteo.js')
const skFlow = require('./config/skFlow.js')
const telegram = require('./telegram/core.js')
const recorder = require('./cores/logs.local.js')
const realtime = require('./cores/realtime/core.js')
const { LOG_PATHS } = require('./rules')
module.exports = function(app) {
const plugin = {};
plugin.id = 'meb.plugin';
plugin.name = 'MEB Plugin';
plugin.description = 'MEB custom plugin';
plugin.version = '1.5.0';
plugin.start = async function(options) {
// Inizializza il gestore della configurazione con le opzioni del plugin
configManager.init(options);
// Setup routing
if (process.env.NODE_ENV === 'development') {
app.debug('Running in DEVELOPMENT mode: routes will be reloaded on every request');
app.use('/meb', (req, res, next) => {
const path = require('path');
const routesPath = path.resolve(__dirname, 'routes');
Object.keys(require.cache).forEach(key => {
if (key.startsWith(routesPath)) {
delete require.cache[key];
}
});
require('./routes/main.js')(req, res, next);
});
} else {
app.debug('Running in PRODUCTION mode: routes are cached');
app.use('/meb', routes);
}
// Inizializza il modulo per la pubblicazione dei dati Signal K
skFlow.init(app);
// Inizializza il bot Telegram
telegram.init();
// Avvia la connessione realtime al server
realtime.init();
// Inizializza e avvia subito la registrazione log (1 riga/secondo)
recorder.init(LOG_PATHS);
try {
await recorder.startRecording();
} catch (err) {
console.warn('[INDEX] Errore avvio recording, proseguo:', err.message);
}
// ===== Weather & Forecast =====
const fetchWeather = async () => {
try {
const position = skFlow.get('navigation.position');
if (position) {
await openmeteo.fetchCurrentWeather(position);
}
} catch (err) {
console.error('[INDEX] Errore fetchCurrentWeather:', err.message);
}
};
const fetchForecasts = async () => {
try {
const position = skFlow.get('navigation.position');
if (position) {
await openmeteo.fetchHourlyForecasts(position);
}
} catch (err) {
console.error('[INDEX] Errore fetchHourlyForecasts:', err.message);
}
};
// Intervalli: current ogni 5 min, hourly ogni 1 ora
const startWeatherIntervals = () => {
setInterval(fetchWeather, 5 * 60 * 1000);
setInterval(fetchForecasts, 60 * 60 * 1000);
};
// Aspetta la posizione GPS, poi avvia il fetch meteo
const position = skFlow.get('navigation.position');
if (position) {
await openmeteo.fetchAll(position);
startWeatherIntervals();
} else {
const waitForPosition = setInterval(async () => {
const pos = skFlow.get('navigation.position');
if (pos) {
clearInterval(waitForPosition);
await openmeteo.fetchAll(pos);
startWeatherIntervals();
}
}, 2000);
}
}
plugin.stop = function() {
recorder.stopRecording();
realtime.stop();
app.debug('Plugin stopped');
}
plugin.schema = settingsSchema
return plugin;
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,450 +0,0 @@
const WebSocket = require('ws');
const msgpack = require('msgpack-lite');
const { loadSensorReferencesFromServer, checkSensorReferencesVersion } = require('../config');
const dataHub = require('../tools/dataHub');
// Stato connessione
let ws = null;
let sendTimer = null;
let isConnected = false;
let reconnectTimer = null;
let pingInterval = null;
let configCheckTimer = null;
let app = null;
// Sensor references (caricati dal server)
let sensorRules = null;
// Buffer locale anti-perdita dati
const localBuffer = [];
const MAX_BUFFER_SIZE = 3600; // ~1h di dati a 1/sec
// Statistiche (solo in memoria, niente file I/O)
let stats = {
sensorID: '',
sent: 0,
firstSent: null,
sentEveryMLS: 1000,
reconnections: 0,
status: 'disconnected',
buffered: 0,
lastConfigVersion: null
};
// Reconnection con exponential backoff
const BASE_RECONNECT_DELAY = 2000;
const MAX_RECONNECT_DELAY = 60000;
/**
* Inizializza il modulo realtime.
* 1. Autentica il sensore per ottenere un ticket
* 2. Usa il ticket per caricare i sensor references (endpoint autenticato)
* 3. Usa lo stesso ticket per connettere il WebSocket
*/
async function init(signalKApp, sensorCode) {
app = signalKApp;
stats.sensorID = sensorCode || process.env.SENSOR_CODE || 'N/D';
stats.sentEveryMLS = parseInt(process.env.SEND_INTERVAL || '500');
console.log(`[MEB] Send interval: ${stats.sentEveryMLS}ms (SEND_INTERVAL=${process.env.SEND_INTERVAL || 'default 500'})`);
// Autenticazione unica: ottieni ticket
const authResult = await authenticate();
if (authResult) {
// Carica sensor references con ticket (read-only, non consuma il ticket)
sensorRules = await loadSensorReferencesFromServer(authResult.ticket);
if (sensorRules) stats.lastConfigVersion = sensorRules.version;
// Connetti WebSocket con lo stesso ticket (viene consumato qui)
connectWebSocket(authResult.wsUrl, authResult.ticket);
} else {
// Fallback: carica references senza auth, programma riconnessione
console.warn('[MEB] Auth fallita, carico references senza autenticazione');
sensorRules = await loadSensorReferencesFromServer();
if (sensorRules) stats.lastConfigVersion = sensorRules.version;
scheduleReconnect();
}
// Avvia polling versione config ogni 5 minuti
configCheckTimer = setInterval(checkConfigUpdate, 5 * 60 * 1000);
}
/**
* Controlla se la config sensori sul server e' cambiata e la ricarica.
*/
async function checkConfigUpdate() {
const newVersion = await checkSensorReferencesVersion(stats.lastConfigVersion);
if (newVersion) {
console.log(`[MEB] Sensor config aggiornata: ${stats.lastConfigVersion}${newVersion}`);
sensorRules = await loadSensorReferencesFromServer();
if (sensorRules) {
stats.lastConfigVersion = sensorRules.version;
}
}
}
// ──────────────────── AUTENTICAZIONE ────────────────────
async function authenticate() {
try {
const REALTIME_URL = process.env.REALTIME_URL || 'http://localhost:3002';
const url = REALTIME_URL + '/connect/request';
const res = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ sensor_code: stats.sensorID })
});
if (!res.ok) {
console.error(`[MEB] Realtime Auth failed: ${res.status}`);
return null;
}
const data = await res.json();
if (!data.success || !data.ticket) return null;
return {
ticket: data.ticket,
wsUrl: data.ws_url || REALTIME_URL.replace('http', 'ws') + '/ws'
};
} catch (err) {
console.error('[MEB] Error in Realtime auth:', err.message);
return null;
}
}
// ──────────────────── WEBSOCKET ────────────────────
function connectWebSocket(wsUrl, ticket) {
const fullUrl = `${wsUrl}?ticket=${ticket}`;
ws = new WebSocket(fullUrl);
ws.on('open', () => {
console.log('[MEB] Realtime WebSocket connected');
isConnected = true;
stats.status = 'connected';
stats.reconnections = 0; // Reset su connessione riuscita
startSending();
startPingInterval();
// Flush buffer locale dopo reconnessione
flushBuffer();
});
ws.on('close', (code) => {
console.log(`[MEB] Realtime WebSocket closed (code: ${code})`);
isConnected = false;
stats.status = 'disconnected';
stopSending();
stopPingInterval();
scheduleReconnect();
});
ws.on('error', (err) => {
console.error('[MEB] Realtime WebSocket error:', err.message);
isConnected = false;
stats.status = 'error';
});
ws.on('message', (data) => {
// Gestisce messaggi dal server (comandi, conferme, ecc.)
try {
const decoded = msgpack.decode(data);
if (decoded?.type === 'connected') {
console.log(`[MEB] Server confirmed connection: sensorId=${decoded.sensorId}`);
}
} catch {
// Ignora messaggi non decodificabili
}
});
}
// ──────────────────── INVIO DATI (1/sec) ────────────────────
function startSending() {
stopSending();
sendData(); // Prima chiamata immediata
sendTimer = setInterval(sendData, stats.sentEveryMLS);
}
function stopSending() {
if (sendTimer) {
clearInterval(sendTimer);
sendTimer = null;
}
}
/**
* Legge un valore dal data model di SignalK.
*/
function getSignalKData(skPath) {
const val = app.getSelfPath(skPath);
return val && val.value !== undefined && val.value !== null ? val.value : null;
}
/**
* Raccoglie TUTTI i dati sensore definiti nella config.
* Produce chiavi flat: "temperature", "wind_direction", "position_latitude", ecc.
* Stessa logica di logRecorder.collectSensorData().
*/
function collectAllSensorData() {
const data = {};
if (!sensorRules || !sensorRules.items) {
// Fallback hardcoded se non ci sono regole
return {
service_battery_voltage: getSignalKData('electrical.batteries.service.Voltage') || 0,
service_battery_stateOfCharge: (getSignalKData('electrical.batteries.service.stateOfCharge') || 0) * 100,
traction_battery_power: getSignalKData('electrical.batteries.traction.power') || 0,
temperature: (getSignalKData('meb.temperature') || 273.15) - 273.15,
position_latitude: app.getSelfPath('navigation.position')?.value?.latitude || 0,
position_longitude: app.getSelfPath('navigation.position')?.value?.longitude || 0
};
}
for (const item of sensorRules.items) {
const mainPath = item.main_path;
if (!item.elements || item.elements === null) {
// Campo singolo: usa il nome della collection
data[item.collection] = getSignalKData(mainPath);
} else {
for (const element of item.elements) {
// Separa subelements dalle proprietà campo
const { subelements, ...fields } = element;
const [fieldName, subPath] = Object.entries(fields)[0];
const keyName = item.collection
? `${item.collection}_${fieldName}`
: fieldName;
if (fieldName === 'latitude' || fieldName === 'longitude') {
const baseValue = app.getSelfPath(`${mainPath}.position`)?.value;
data[keyName] = (baseValue && typeof baseValue === 'object')
? baseValue[fieldName] ?? null
: null;
} else {
data[keyName] = getSignalKData(`${mainPath}.${subPath}`);
}
// Gestisci subelementi (es. direction.average)
if (subelements && Array.isArray(subelements)) {
for (const sub of subelements) {
const [subFieldName, subSubPath] = Object.entries(sub)[0];
const subKey = `${keyName}_${subFieldName}`;
data[subKey] = getSignalKData(`${mainPath}.${subPath}.${subSubPath}`);
}
}
}
}
}
return data;
}
/**
* Invia dati sensore al server via WebSocket (msgpack).
* Se il WS e' disconnesso, buffer localmente.
*/
function sendData() {
const data = collectAllSensorData();
// Aggiorna la cache centralizzata per Telegram e altri consumer
dataHub.updateSensorData(data);
const message = {
type: 'sensor',
ts: Date.now(),
data
};
if (!ws || ws.readyState !== WebSocket.OPEN) {
bufferLocally(message);
return;
}
try {
ws.send(msgpack.encode(message));
stats.sent++;
if (!stats.firstSent) stats.firstSent = new Date().toISOString();
} catch (err) {
console.error('[MEB] Error sending realtime data:', err.message);
bufferLocally(message);
}
}
// ──────────────────── WEATHER (REST API) ────────────────────
/**
* Invia dati meteo al server via REST API dedicata (POST /weather).
* Non usa piu' il WebSocket per i dati meteo — endpoint REST separato.
*/
async function sendWeatherPayload(payload) {
try {
const REALTIME_URL = process.env.REALTIME_URL || 'http://localhost:3002';
const url = `${REALTIME_URL}/weather`;
const res = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
sensor_code: stats.sensorID,
data: payload
})
});
if (res.ok) {
const result = await res.json();
console.log(`[MEB] Weather payload inviato via REST — sensor: ${result.sensor}`);
} else {
console.error(`[MEB] Weather REST failed: ${res.status} ${res.statusText}`);
}
} catch (err) {
console.error('[MEB] Error sending weather via REST:', err.message);
}
}
// ──────────────────── BUFFER ANTI-PERDITA ────────────────────
function bufferLocally(message) {
localBuffer.push(message);
if (localBuffer.length > MAX_BUFFER_SIZE) {
localBuffer.shift(); // Rimuovi il piu' vecchio
}
stats.buffered = localBuffer.length;
}
/**
* Flush del buffer locale verso il server dopo reconnessione.
* Invia gradualmente per non sovraccaricare il WS.
*/
function flushBuffer() {
if (localBuffer.length === 0) return;
console.log(`[MEB] Flushing ${localBuffer.length} buffered messages...`);
const flushBatch = () => {
if (localBuffer.length === 0 || !ws || ws.readyState !== WebSocket.OPEN) {
stats.buffered = localBuffer.length;
return;
}
// Invia 10 messaggi alla volta per non bloccare
const batch = Math.min(localBuffer.length, 10);
for (let i = 0; i < batch; i++) {
const msg = localBuffer.shift();
try {
ws.send(msgpack.encode(msg));
stats.sent++;
} catch {
localBuffer.unshift(msg);
break;
}
}
stats.buffered = localBuffer.length;
if (localBuffer.length > 0) {
setTimeout(flushBatch, 100); // Pausa tra batch
} else {
console.log('[MEB] Buffer flush completato');
}
};
// Attendi 1s dopo la connessione prima di iniziare il flush
setTimeout(flushBatch, 1000);
}
// ──────────────────── RECONNESSIONE ────────────────────
function scheduleReconnect() {
if (reconnectTimer) return;
stats.reconnections++;
// Exponential backoff con jitter
const delay = Math.min(
BASE_RECONNECT_DELAY * Math.pow(1.5, Math.min(stats.reconnections, 15)),
MAX_RECONNECT_DELAY
);
const jitter = delay * 0.2 * Math.random();
const finalDelay = Math.round(delay + jitter);
console.log(`[MEB] Reconnecting in ${Math.round(finalDelay / 1000)}s (tentativo ${stats.reconnections})`);
reconnectTimer = setTimeout(async () => {
reconnectTimer = null;
start();
}, finalDelay);
}
// ──────────────────── PING/PONG ────────────────────
function startPingInterval() {
stopPingInterval();
pingInterval = setInterval(() => {
if (ws && ws.readyState === WebSocket.OPEN) {
ws.ping();
}
}, 25000); // 25s, server ha heartbeat a 30s
}
function stopPingInterval() {
if (pingInterval) {
clearInterval(pingInterval);
pingInterval = null;
}
}
// ──────────────────── START / STOP ────────────────────
async function start() {
const result = await authenticate();
if (!result) {
scheduleReconnect();
return;
}
connectWebSocket(result.wsUrl, result.ticket);
}
/**
* Ferma tutto: WebSocket, timer, ping, config check.
*/
function stop() {
stopSending();
stopPingInterval();
if (reconnectTimer) {
clearTimeout(reconnectTimer);
reconnectTimer = null;
}
if (configCheckTimer) {
clearInterval(configCheckTimer);
configCheckTimer = null;
}
if (ws) {
ws.close(1000, 'Plugin stopping');
ws = null;
}
isConnected = false;
stats.status = 'stopped';
console.log('[MEB] Realtime module stopped');
}
function getStats() {
return { ...stats, isConnected, bufferSize: localBuffer.length };
}
function getSensorRules() {
return sensorRules;
}
module.exports = {
init,
stop,
sendWeatherPayload,
collectAllSensorData,
getSensorRules,
getStats
};

View File

@@ -0,0 +1,27 @@
const router = require('express').Router();
const {
FORECAST_CURRENT,
FORECAST_HOURLY,
MARINE_CURRENT,
MARINE_HOURLY,
LOG_PATHS
} = require('../../rules');
const api_url = process.env.API_URL || 'http://api-services:3003';
router.get('/status', (req, res) => {
res.json({ cloud: 'active', api: api_url, version: '2.0' });
});
// Ritorna la configurazione statica corrente
router.get('/config', (req, res) => {
res.json({
forecast_current: FORECAST_CURRENT,
forecast_hourly: FORECAST_HOURLY,
marine_current: MARINE_CURRENT,
marine_hourly: MARINE_HOURLY,
log_paths: LOG_PATHS
});
});
module.exports = router;

View File

@@ -0,0 +1,16 @@
const router = require('express').Router();
const express = require('express');
const path = require('path')
const kioskPath = path.join(__dirname, '../../tools/kiosk');
router.use('/', express.static(kioskPath));
router.get('/', (req, res) => {
res.sendFile(path.join(kioskPath, 'dashboard.html'));
});
router.get('/api/', (req, res) => {});
module.exports = router;

View File

@@ -0,0 +1,38 @@
const router = require('express').Router();
const db = require('../../config/skFlow')
const config = require('../../config/configManager.js')
router.get('/', (req, res) => {
const { path } = req.query;
const data = db.get(path);
res.json(data);
});
router.get('/', (req, res) => {
const { source } = req.query;
const data = db.getBySource(source);
res.json(data);
});
router.get('/info', (req, res) => {
const info = {
telegram: config.getTelegramToken(),
sensor: {
name: config.getSensorName(),
code: config.getSensorCode()
},
other: {
api_url: process.env.API_URL,
realtime_url: process.env.REALTIME_URL,
realtime_socket_url: process.env.REALTIME_SOCKET_URL,
reconnect_delay: config.getReconnectDelay()
}
}
res.json(info);
});
module.exports = router;

View File

@@ -0,0 +1,34 @@
const router = require('express').Router();
const express = require('express');
const path = require('path');
const fs = require('fs');
const configManager = require('../../config/configManager.js');
const kioskPath = path.join(__dirname, '../../tools/kiosk');
const htmlFile = path.join(kioskPath, 'kiosk.html');
router.use('/', express.static(kioskPath));
router.get('/', (req, res) => {
const apiUrl = process.env.API_URL || 'https://api.mebboat.it';
const realtimeUrl = process.env.REALTIME_URL || 'https://realtime.mebboat.it';
const realtimeWsUrl = process.env.REALTIME_SOCKET_URL || 'wss://realtime.mebboat.it';
const sensorCode = configManager.getSensorCode();
const sensorName = configManager.getSensorName();
const esc = (s) => String(s || '').replace(/"/g, '&quot;');
const metas = `
<meta name="api-url" content="${esc(apiUrl)}">
<meta name="realtime-url" content="${esc(realtimeUrl)}">
<meta name="realtime-ws-url" content="${esc(realtimeWsUrl)}">
<meta name="sensor-code" content="${esc(sensorCode)}">
<meta name="sensor-name" content="${esc(sensorName)}">
`;
let html;
try { html = fs.readFileSync(htmlFile, 'utf8'); }
catch (e) { return res.status(500).send('kiosk.html not found'); }
html = html.replace('</head>', metas + '</head>');
res.set('Content-Type', 'text/html').send(html);
});
module.exports = router;

View File

@@ -0,0 +1,9 @@
const router = require('express').Router();
const path = require('path')
//Endpoints per controllare lo stato di un servizio di mappe da implementare poi..
router.get('/', (req, res) => {
res.sendFile(path.join(__dirname, '../tools/map/map.html'));
});
module.exports = router;

View File

@@ -0,0 +1,55 @@
const core = require('../../cores/logs.local')
const router = require('express').Router();
router.post('/start', async (req, res) => {
const { name } = req.body;
const session = await core.startRecording(name);
res.status(200).send({
status: 'Started',
session: session
});
});
router.get('/', (req, res) => {
const session = core.getSession();
if (session) {
res.status(200).send(session);
} else {
res.status(404).send({ session: 'Nessuna sessione attiva' });
}
});
router.get('/list', async (req, res) => {
const logs = await core.listLogs();
res.status(200).send(logs);
});
router.get('/:log', async (req, res) => {
const { log } = req.params
const data = await core.getLog(log);
if (data) {
res.status(200).send(data);
} else {
res.status(404).send({ error: 'Log non trovato' });
}
});
router.get('/download/:name', (req, res) => {
const name = req.params.name;
const filePath = core.getLogFile(name);
if (filePath) {
res.download(filePath);
} else {
res.status(404).send({ error: 'File non trovato' });
}
});
router.post('/stop', async (req, res) => {
await core.stopRecording();
res.status(200).send({
status: 'Stopped'
});
});
module.exports = router;

View File

@@ -1,56 +0,0 @@
/**
* Registers dataset recording control routes.
* @param {Object} router - Route wrapper with get/post methods
* @param {Object} app - SignalK app instance
*/
function registerDatasetRoutes(router, app) {
router.post("/dataset/start", (req, res) => {
try {
if (!app.datasetControl) {
return res.status(503).json({ error: "Dataset control not available" });
}
const result = app.datasetControl.start();
res.json({ success: result, message: result ? "Recording started" : "Already recording" });
} 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 not available" });
}
const result = app.datasetControl.stop();
res.json({ success: result, message: result ? "Recording stopped" : "No active recording" });
} 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 not available" });
}
const result = app.datasetControl.restart();
res.json({ success: result, message: "Recording restarted" });
} 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 not available" });
}
const status = app.datasetControl.getStatus();
res.json(status);
} catch (e) {
res.status(500).json({ error: e.message });
}
});
}
module.exports = registerDatasetRoutes;

View File

@@ -1,63 +0,0 @@
const { getForecast, getSeaConditions } = require("../api_models/openmeteo.js");
/**
* Registers forecast-related routes.
* @param {Object} router - Route wrapper with get/post methods
* @param {Object} app - SignalK app instance
*/
function registerForecastRoutes(router, app) {
// Get current forecast data directly from OpenMeteo
router.get("/forecasts/data", async (req, res) => {
try {
const position = app.getSelfPath('navigation.position')?.value;
if (!position?.latitude || !position?.longitude) {
return res.status(503).json({ error: "Position not available" });
}
const mode = req.query.mode || 'both';
const [forecastData, wavesData] = await Promise.all([
getForecast(position, { mode }),
getSeaConditions(position, { mode })
]);
res.status(200).json({
forecast: forecastData,
sea: wavesData
});
} catch (error) {
console.error('[MEB] Error in /meb/forecasts/data:', error);
res.status(500).json({ error: error.message });
}
});
// Force update: fetch fresh hourly data from OpenMeteo
router.post("/forecasts/update", async (req, res) => {
try {
const position = app.getSelfPath('navigation.position')?.value;
if (!position?.latitude || !position?.longitude) {
return res.status(503).json({ error: "Position not available" });
}
const [forecastData, wavesData] = await Promise.all([
getForecast(position, { mode: 'both' }),
getSeaConditions(position, { mode: 'both' })
]);
if (!forecastData?.hourly || !wavesData?.hourly) {
return res.status(500).json({ error: "Hourly data not available from API" });
}
res.status(200).json({
forecast: forecastData,
sea: wavesData
});
} catch (error) {
console.error('[MEB] Error in /meb/forecasts/update:', error);
res.status(500).json({ error: error.message });
}
});
}
module.exports = registerForecastRoutes;

View File

@@ -1,30 +0,0 @@
const path = require("path");
const websPath = path.join(__dirname, "..", "public");
/**
* Registers helm/steering support routes.
* @param {Object} router - Route wrapper with get/post methods
*/
function registerHelmRoutes(router) {
router.get("/helm", (req, res) => {
try {
const side = req.query.side || "destra";
const helmPath = path.join(websPath, "steering_support", `helm_steering_${side}.html`);
res.status(200).sendFile(helmPath);
} catch (e) {
res.status(500).json({ error: e.message });
}
});
router.get("/helm/support", (req, res) => {
try {
const indexPath = path.join(websPath, "steering_support", "steering_support.html");
res.status(200).sendFile(indexPath);
} catch (error) {
res.status(500).json({ error: error.message });
}
});
}
module.exports = registerHelmRoutes;

View File

@@ -1,34 +0,0 @@
const registerMapRoutes = require("./map");
const registerHelmRoutes = require("./helm");
const registerDatasetRoutes = require("./dataset");
const registerForecastRoutes = require("./forecasts");
const registerTelegramRoutes = require("./telegram");
/**
* Registers all plugin routes under the /meb prefix.
* @param {Object} app - SignalK app instance
* @param {Object} settings - Plugin settings
*/
module.exports = function (app, settings) {
const router = {
get: (subPath, handler) => {
const fullPath = '/meb' + (subPath.startsWith('/') ? subPath : `/${subPath}`);
app.get(fullPath, handler);
},
post: (subPath, handler) => {
const fullPath = '/meb' + (subPath.startsWith('/') ? subPath : `/${subPath}`);
app.post(fullPath, handler);
}
};
// Health check
router.get("/ping", (req, res) => {
res.status(200).send("Ping is active!");
});
registerMapRoutes(router, app, settings);
registerHelmRoutes(router);
registerDatasetRoutes(router, app);
registerForecastRoutes(router, app);
registerTelegramRoutes(router);
};

18
plugin/routes/main.js Normal file
View File

@@ -0,0 +1,18 @@
// Il file generale che raggruppa le api
const router = require('express').Router();
const cloudRoutes = require('./collection/cloud')
const mapRoutes = require('./collection/map')
const dataRoutes = require('./collection/data')
const recRoutes = require('./collection/rec')
const dashboard = require('./collection/dashboard')
const kiosk = require('./collection/kiosk')
router.use('/cloud', cloudRoutes)
router.use('/map', mapRoutes)
router.use('/data', dataRoutes)
router.use('/rec', recRoutes)
router.use('/dashboard', dashboard)
router.use('/kiosk', kiosk)
module.exports = router

View File

@@ -1,43 +0,0 @@
const fs = require("fs");
const path = require("path");
const websPath = path.join(__dirname, "..", "public");
/**
* Registers map-related routes.
* @param {Object} router - Route wrapper with get/post methods
* @param {Object} app - SignalK app instance
* @param {Object} settings - Plugin settings
*/
function registerMapRoutes(router, app, settings) {
// Serve interactive map with Mapbox token injected
router.get('/map', (req, res) => {
const filePath = path.join(websPath, "map.html");
fs.readFile(filePath, "utf8", (err, html) => {
if (err) {
res.status(500).send("Error loading map");
return;
}
const token = settings?.mapboxKey ?? "";
const finalHtml = html.replace("{{MAPBOX_KEY}}", token);
res.setHeader("Content-Type", "text/html; charset=utf-8");
res.send(finalHtml);
});
});
// Stream boat position and serve latest via API
let lastPosition = null;
app.streambundle.getSelfStream("navigation.position").onValue(pos => {
lastPosition = pos;
});
router.get('/map/boat', (req, res) => {
if (!lastPosition) {
return res.json({ error: "No position data available" });
}
res.json(lastPosition);
});
}
module.exports = registerMapRoutes;

View File

@@ -1,13 +0,0 @@
const { reloadBot } = require("../telegram/telegram.core.js");
module.exports = function (router) {
router.post("/telegram/reload", (req, res) => {
try {
reloadBot();
res.status(200).json({ status: "success", message: "Bot ricaricato." });
} catch (error) {
console.error("[MEB] Errore nel ricaricamento del bot da API:", error);
res.status(500).json({ status: "error", message: "Errore durante il reload del bot." });
}
});
};

72
plugin/rules.js Normal file
View File

@@ -0,0 +1,72 @@
const FORECAST_CURRENT = [
'temperature_2m',
'wind_speed_10m',
'wind_direction_10m',
'wind_gusts_10m',
'precipitation',
'rain',
'relative_humidity_2m',
'pressure_msl'
];
const FORECAST_HOURLY = [
'temperature_2m',
'precipitation_probability',
'precipitation',
'rain',
'wind_speed_10m',
'cloud_cover',
'wind_direction_10m',
'relative_humidity_2m',
'pressure_msl'
];
const MARINE_CURRENT = [
'wave_height',
'wave_direction',
'wave_period',
'wave_peak_period',
'ocean_current_velocity',
'ocean_current_direction'
];
const MARINE_HOURLY = [
'wave_height',
'wave_direction',
'wave_period',
'wave_peak_period',
'ocean_current_velocity',
'ocean_current_direction'
];
const LOG_PATHS = [
'meb.forecasts.temperature',
'meb.forecast.wind.direction',
'meb.forecast.wind.speed',
'meb.waves.direction',
'meb.waves.height',
'meb.waves.period',
'navigation.position.latitude',
'navigation.position.longitude',
'navigation.headingTrue',
'navigation.speedOverGround',
'navigation.courseOverGroundTrue',
'electrical.batteries.service.Voltage',
'electrical.batteries.service.current',
'electrical.batteries.service.stateOfCharge',
'electrical.batteries.traction.Voltage',
'electrical.batteries.traction.current',
'electrical.batteries.traction.stateOfCharge',
'electrical.batteries.traction.temperature',
'electrical.batteries.traction.power',
'propulsion.0.revolutions',
'system.uptime'
];
module.exports = {
FORECAST_CURRENT,
FORECAST_HOURLY,
MARINE_CURRENT,
MARINE_HOURLY,
LOG_PATHS
};

View File

@@ -0,0 +1,29 @@
const { listDataFiles, buildPage } = require('../commands/backuplogs');
module.exports = {
prefix: 'bkback:',
handler: async (bot, query) => {
const chatId = query.message.chat.id;
const botMessageId = query.message.message_id;
const parts = query.data.split(':');
const page = parseInt(parts[1]);
const userMessageId = parts[2];
const files = await listDataFiles();
const keyboard = buildPage(files, page, userMessageId);
const text = `*Backup & Logs*\n\n${files.length} file disponibili nella cartella data.\n_Seleziona un file per info e download._`;
try {
await bot.editMessageText(text, {
chat_id: chatId,
message_id: botMessageId,
parse_mode: 'Markdown',
reply_markup: { inline_keyboard: keyboard }
});
} catch (e) {}
bot.answerCallbackQuery(query.id);
}
};

View File

@@ -0,0 +1,33 @@
const fs = require('fs');
const { listDataFiles } = require('../commands/backuplogs');
module.exports = {
prefix: 'bkdl:',
handler: async (bot, query) => {
const chatId = query.message.chat.id;
const parts = query.data.split(':');
const fileIdx = parseInt(parts[1]);
const userMessageId = parts[2];
const files = await listDataFiles();
const file = files[fileIdx];
if (!file || !fs.existsSync(file.path)) {
bot.answerCallbackQuery(query.id, { text: 'File non trovato', show_alert: true });
return;
}
try {
await bot.sendDocument(chatId, file.path, {
caption: `\`${file.name}\``,
parse_mode: 'Markdown'
});
} catch (e) {
bot.answerCallbackQuery(query.id, { text: 'Errore invio file', show_alert: true });
return;
}
bot.answerCallbackQuery(query.id, { text: 'File inviato' });
}
};

View File

@@ -0,0 +1,73 @@
const fs = require('fs');
const fsPromises = require('fs').promises;
const readline = require('readline');
const { listDataFiles, formatSize, buildPage } = require('../commands/backuplogs');
/**
* Conta le righe di un file in modo efficiente (stream)
*/
function countLines(filePath) {
return new Promise((resolve) => {
let count = 0;
const stream = fs.createReadStream(filePath);
const rl = readline.createInterface({ input: stream, crlfDelay: Infinity });
rl.on('line', () => count++);
rl.on('close', () => resolve(count));
rl.on('error', () => resolve(-1));
});
}
module.exports = {
prefix: 'bkfile:',
handler: async (bot, query) => {
const chatId = query.message.chat.id;
const botMessageId = query.message.message_id;
const parts = query.data.split(':');
const fileIdx = parseInt(parts[1]);
const userMessageId = parts[2];
const files = await listDataFiles();
const file = files[fileIdx];
if (!file || !fs.existsSync(file.path)) {
bot.answerCallbackQuery(query.id, { text: 'File non trovato', show_alert: true });
return;
}
// Conta righe
let lineCount = '—';
try {
const ext = file.name.split('.').pop().toLowerCase();
if (['csv', 'txt', 'log', 'json'].includes(ext)) {
lineCount = await countLines(file.path);
}
} catch (e) {}
const modified = new Date(file.modified).toLocaleDateString('it-IT', {
day: '2-digit', month: 'long', year: 'numeric',
hour: '2-digit', minute: '2-digit'
});
const text = `*File:* \`${file.name}\`\n\n` +
`*Dimensione:* ${formatSize(file.size)}\n` +
`*Ultima modifica:* ${modified}\n` +
`*Righe:* ${lineCount}\n`;
const keyboard = [
[{ text: 'Scarica file', callback_data: `bkdl:${fileIdx}:${userMessageId}` }],
[{ text: '<- Torna alla lista', callback_data: `bkback:0:${userMessageId}` }]
];
try {
await bot.editMessageText(text, {
chat_id: chatId,
message_id: botMessageId,
parse_mode: 'Markdown',
reply_markup: { inline_keyboard: keyboard }
});
} catch (e) {}
bot.answerCallbackQuery(query.id);
}
};

View File

@@ -0,0 +1,6 @@
module.exports = {
prefix: 'bknoop:',
handler: async (bot, query) => {
bot.answerCallbackQuery(query.id);
}
};

View File

@@ -0,0 +1,29 @@
const { listDataFiles, buildPage } = require('../commands/backuplogs');
module.exports = {
prefix: 'bkpage:',
handler: async (bot, query) => {
const chatId = query.message.chat.id;
const botMessageId = query.message.message_id;
const parts = query.data.split(':');
const page = parseInt(parts[1]);
const userMessageId = parts[2];
const files = await listDataFiles();
const keyboard = buildPage(files, page, userMessageId);
const text = `*Backup & Logs*\n\n${files.length} file disponibili nella cartella data.\n_Seleziona un file per info e download._`;
try {
await bot.editMessageText(text, {
chat_id: chatId,
message_id: botMessageId,
parse_mode: 'Markdown',
reply_markup: { inline_keyboard: keyboard }
});
} catch (e) {}
bot.answerCallbackQuery(query.id);
}
};

View File

@@ -0,0 +1,25 @@
module.exports = {
prefix: 'close',
handler: async (bot, query) => {
const chatId = query.message.chat.id;
const botMessageId = query.message.message_id;
// L'ID del messaggio dell'utente è passato nel callback_data (close:<userMsgId>)
const userMessageId = query.data.split(':')[1];
try {
// Elimina il messaggio del bot
await bot.deleteMessage(chatId, botMessageId);
// Elimina il messaggio dell'utente (il comando /data)
if (userMessageId) {
await bot.deleteMessage(chatId, parseInt(userMessageId));
}
} catch (error) {
console.error('[TELEGRAM] Errore eliminazione messaggi:', error.message);
}
// Rispondi alla callback per togliere il "loading" dal bottone
bot.answerCallbackQuery(query.id);
}
};

View File

@@ -1,152 +0,0 @@
// Mappa globale per salvare gli interval id anche dopo un "hot-reload"
if (!global.__meb_live_dashboards) {
global.__meb_live_dashboards = new Map();
}
module.exports = [
{
id: 'dashboard-refresh',
execute: async ({ bot, chatId, msg }) => {
const dash = require('../commands/dashboard.js');
const newText = dash.formatSensorData();
try {
await bot.editMessageText(newText, {
chat_id: chatId,
message_id: msg.message_id,
parse_mode: 'Markdown',
reply_markup: {
inline_keyboard: [
[
{ text: "⚡️ Avvia Live Tracker (2s)", callback_data: 'dashboard-live-start' }
],
[
{ text: "🔄 Ricarica Dati", callback_data: 'dashboard-refresh' }
]
]
}
});
} catch (e) {
if (!e.message.includes('message is not modified')) {
console.error("Errore nel refresh dashboard:", e);
}
}
}
},
{
id: 'dashboard-live-start',
execute: async ({ bot, chatId, msg }) => {
const dash = require('../commands/dashboard.js');
const messageId = msg.message_id;
const liveKey = `${chatId}_${messageId}`;
// Se è già attivo un live per questo messaggio, non fare nulla
if (global.__meb_live_dashboards.has(liveKey)) return;
// Avvisa che sta partendo
const startMarkup = {
inline_keyboard: [
[
{ text: "🛑 Ferma Live Tracker", callback_data: 'dashboard-live-stop' }
]
]
};
await bot.editMessageReplyMarkup(startMarkup, { chat_id: chatId, message_id: messageId });
// Inizializza l'interval a 2 secondi. Autodistruzione dopo 30s
let count = 15; // 15 tick da 2 secondi = 30 secondi
const intervalTimer = setInterval(async () => {
count--;
const baseText = dash.formatSensorData();
// Se il tempo scade, disattiva il live e ripristina i tasti normali
if (count <= 0) {
if (global.__meb_live_dashboards.has(liveKey)) {
clearInterval(global.__meb_live_dashboards.get(liveKey));
global.__meb_live_dashboards.delete(liveKey);
}
try {
await bot.editMessageText(baseText + `\n🛑 _Live tracker terminato automaticamente (30s) per risparmiare risorse._`, {
chat_id: chatId,
message_id: messageId,
parse_mode: 'Markdown',
reply_markup: {
inline_keyboard: [
[{ text: "⚡️ Avvia Live Tracker (2s)", callback_data: 'dashboard-live-start' }],
[{ text: "🔄 Ricarica Dati", callback_data: 'dashboard-refresh' }]
]
}
});
} catch (e) { }
return;
}
// Altrimenti prosegui con l'aggiornamento e la stringa del countdown
const newText = baseText + `\n⏳ _Live attivo: arresto automatico tra *${count * 2}s*_`;
try {
await bot.editMessageText(newText, {
chat_id: chatId,
message_id: messageId,
parse_mode: 'Markdown',
reply_markup: startMarkup
});
} catch (e) {
// API limits o the message was not modified
if (e.response && e.response.statusCode === 400 && e.message.includes("message is not modified")) {
// ignore
} else if (e.response && e.response.statusCode === 429) {
// Troppe richieste Telegram
console.warn("[Telegram Dashboard] Rate Limit raggionto. Riprovo più tardi...");
} else if (e.response && e.response.statusCode === 400 && e.message.includes("message to edit not found")) {
// Il messaggio è stato cancellato dall'utente
clearInterval(intervalTimer);
global.__meb_live_dashboards.delete(liveKey);
} else {
console.error("[Telegram Dashboard] Errore update live:", e);
}
}
}, 2000);
global.__meb_live_dashboards.set(liveKey, intervalTimer);
}
},
{
id: 'dashboard-live-stop',
execute: async ({ bot, chatId, msg }) => {
const dash = require('../commands/dashboard.js');
const messageId = msg.message_id;
const liveKey = `${chatId}_${messageId}`;
// Pulisci l'interval se esiste
if (global.__meb_live_dashboards.has(liveKey)) {
clearInterval(global.__meb_live_dashboards.get(liveKey));
global.__meb_live_dashboards.delete(liveKey);
}
// Ripristina la formattazione iniziale
const newText = dash.formatSensorData();
try {
await bot.editMessageText(newText, {
chat_id: chatId,
message_id: messageId,
parse_mode: 'Markdown',
reply_markup: {
inline_keyboard: [
[
{ text: "⚡️ Avvia Live Tracker (2s)", callback_data: 'dashboard-live-start' }
],
[
{ text: "🔄 Ricarica Dati", callback_data: 'dashboard-refresh' }
]
]
}
});
} catch (e) { }
}
}
];

View File

@@ -1,26 +0,0 @@
module.exports = [
{
id: 'data-refresh',
execute: async ({ bot, chatId, msg }) => {
const dataCmd = require('../commands/data.js');
const newText = dataCmd.formatSensorData();
try {
await bot.editMessageText(newText, {
chat_id: chatId,
message_id: msg.message_id,
parse_mode: 'Markdown',
reply_markup: {
inline_keyboard: [
[{ text: 'Aggiorna', callback_data: 'data-refresh' }]
]
}
});
} catch (e) {
if (!e.message.includes('message is not modified')) {
console.error('[Telegram Data] Errore refresh:', e.message);
}
}
}
}
];

View File

@@ -1,141 +1,104 @@
// Mappa globale per salvare gli interval id anche dopo un "hot-reload"
if (!global.__meb_live_trackers) {
global.__meb_live_trackers = new Map();
}
const skFlow = require('../../config/skFlow');
const { startSession } = require('../utility/live');
module.exports = [
{
id: 'live-refresh',
execute: async ({ bot, chatId, msg }) => {
const liveCmd = require('../commands/live.js');
const newText = liveCmd.formatLiveData();
const logsPaths = [
"navigation.position",
"navigation.headingTrue",
"navigation.speedOverGround",
"propulsion.p1.temperature"
];
try {
await bot.editMessageText(newText, {
chat_id: chatId,
message_id: msg.message_id,
parse_mode: 'Markdown',
reply_markup: {
inline_keyboard: [
[{ text: 'Avvia Live (2s)', callback_data: 'live-start' }],
[{ text: 'Aggiorna', callback_data: 'live-refresh' }]
]
}
});
} catch (e) {
if (!e.message.includes('message is not modified')) {
console.error('[Telegram Live] Errore refresh:', e.message);
}
}
// Funzioni per generare il testo aggiornato per ogni tipo di dato
const textGenerators = {
logs: () => {
const data = skFlow.getFrom(logsPaths);
if (!data || Object.keys(data).length === 0) return 'Nessun log disponibile.';
let text = '*Telemetria di Bordo*\n\n';
for (const [path, value] of Object.entries(data)) {
const displayValue = typeof value === 'object' ? JSON.stringify(value) : value;
text += `*${path}*: ${displayValue}\n`;
}
return text;
},
{
id: 'live-start',
execute: async ({ bot, chatId, msg }) => {
const liveCmd = require('../commands/live.js');
const messageId = msg.message_id;
const liveKey = `${chatId}_${messageId}`;
// Se gia' attivo per questo messaggio, ignora
if (global.__meb_live_trackers.has(liveKey)) return;
const stopMarkup = {
inline_keyboard: [
[{ text: 'Ferma Live', callback_data: 'live-stop' }]
]
};
await bot.editMessageReplyMarkup(stopMarkup, {
chat_id: chatId,
message_id: messageId
});
// 30 tick da 2 secondi = 60 secondi, poi auto-stop
let count = 30;
const intervalTimer = setInterval(async () => {
count--;
const baseText = liveCmd.formatLiveData();
// Auto-stop quando il tempo scade
if (count <= 0) {
if (global.__meb_live_trackers.has(liveKey)) {
clearInterval(global.__meb_live_trackers.get(liveKey));
global.__meb_live_trackers.delete(liveKey);
weather: () => {
const data = skFlow.getWithFilter('meb.forecast');
if (!data || Object.keys(data).length === 0) return 'Nessun dato meteo disponibile.';
let text = '*Dati Meteo*\n\n';
for (const [path, value] of Object.entries(data)) {
const displayValue = typeof value === 'object' ? JSON.stringify(value) : value;
text += `*${path}*: ${displayValue}\n`;
}
try {
await bot.editMessageText(
baseText + `\n_Live terminato automaticamente (60s)._`,
{
chat_id: chatId,
message_id: messageId,
parse_mode: 'Markdown',
reply_markup: {
inline_keyboard: [
[{ text: 'Avvia Live (2s)', callback_data: 'live-start' }],
[{ text: 'Aggiorna', callback_data: 'live-refresh' }]
]
return text;
},
marine: () => {
const data = skFlow.getWithFilter('meb.marine');
if (!data || Object.keys(data).length === 0) return 'Nessun dato sul mare disponibile.';
let text = '*Dati Meteo del mare*\n\n';
for (const [path, value] of Object.entries(data)) {
const displayValue = typeof value === 'object' ? JSON.stringify(value) : value;
text += `*${path}*: ${displayValue}\n`;
}
return text;
},
data: () => {
let text = '';
const logs = skFlow.getFrom(logsPaths);
text += '*Telemetria di Bordo*\n\n';
if (logs && Object.keys(logs).length > 0) {
for (const [path, value] of Object.entries(logs)) {
const displayValue = typeof value === 'object' ? JSON.stringify(value) : value;
text += `*${path}*: ${displayValue}\n`;
}
);
} catch (e) { /* ignore */ }
} else {
text += 'Nessun dato disponibile.\n';
}
const weather = skFlow.getWithFilter('meb.forecast');
text += '\n*Dati Meteo*\n\n';
if (weather && Object.keys(weather).length > 0) {
for (const [path, value] of Object.entries(weather)) {
const displayValue = typeof value === 'object' ? JSON.stringify(value) : value;
text += `*${path}*: ${displayValue}\n`;
}
} else {
text += 'Nessun dato disponibile.\n';
}
const marine = skFlow.getWithFilter('meb.marine');
text += '\n*Dati Meteo del mare*\n\n';
if (marine && Object.keys(marine).length > 0) {
for (const [path, value] of Object.entries(marine)) {
const displayValue = typeof value === 'object' ? JSON.stringify(value) : value;
text += `*${path}*: ${displayValue}\n`;
}
} else {
text += 'Nessun dato disponibile.\n';
}
return text;
}
};
module.exports = {
prefix: 'live:',
handler: async (bot, query) => {
const chatId = query.message.chat.id;
const botMessageId = query.message.message_id;
// callback_data = live:<dataType>:<userMessageId>
const parts = query.data.split(':');
const dataType = parts[1];
const userMessageId = parts[2];
const getTextFn = textGenerators[dataType];
if (!getTextFn) {
bot.answerCallbackQuery(query.id, { text: 'Tipo non supportato' });
return;
}
// Aggiornamento live con countdown
const newText = baseText + `\n_Live attivo: arresto tra *${count * 2}s*_`;
try {
await bot.editMessageText(newText, {
chat_id: chatId,
message_id: messageId,
parse_mode: 'Markdown',
reply_markup: stopMarkup
});
} catch (e) {
if (e.response && e.response.statusCode === 429) {
console.warn('[Telegram Live] Rate limit raggiunto');
} else if (e.message && e.message.includes('message to edit not found')) {
// Messaggio cancellato dall'utente
clearInterval(intervalTimer);
global.__meb_live_trackers.delete(liveKey);
}
// Ignora "message is not modified"
}
}, 2000);
global.__meb_live_trackers.set(liveKey, intervalTimer);
}
startSession(bot, chatId, botMessageId, userMessageId, getTextFn);
bot.answerCallbackQuery(query.id, { text: 'Live avviato' });
},
{
id: 'live-stop',
execute: async ({ bot, chatId, msg }) => {
const liveCmd = require('../commands/live.js');
const messageId = msg.message_id;
const liveKey = `${chatId}_${messageId}`;
// Pulisci l'interval se esiste
if (global.__meb_live_trackers.has(liveKey)) {
clearInterval(global.__meb_live_trackers.get(liveKey));
global.__meb_live_trackers.delete(liveKey);
}
const newText = liveCmd.formatLiveData();
try {
await bot.editMessageText(newText + '\n_Live fermato._', {
chat_id: chatId,
message_id: messageId,
parse_mode: 'Markdown',
reply_markup: {
inline_keyboard: [
[{ text: 'Avvia Live (2s)', callback_data: 'live-start' }],
[{ text: 'Aggiorna', callback_data: 'live-refresh' }]
]
}
});
} catch (e) { /* ignore */ }
}
}
];
textGenerators
};

View File

@@ -0,0 +1,15 @@
const { stopSession, getSession } = require('../utility/live');
module.exports = {
prefix: 'livestop',
handler: async (bot, query) => {
const chatId = query.message.chat.id;
const botMessageId = query.message.message_id;
// callback_data = livestop:<userMessageId>
const userMessageId = query.data.split(':')[1];
await stopSession(bot, chatId, botMessageId, userMessageId);
bot.answerCallbackQuery(query.id);
}
};

View File

@@ -0,0 +1,12 @@
module.exports = {
prefix: 'logbusy:',
handler: async (bot, query) => {
const logName = query.data.split(':')[1];
// Mostra un alert all'utente che il file non si puo scaricare
bot.answerCallbackQuery(query.id, {
text: `La registrazione "${logName}" è in corso. Fermala per scaricare il file.`,
show_alert: true
});
}
};

View File

@@ -0,0 +1,69 @@
const recorder = require('../../cores/logs.local');
module.exports = {
prefix: 'logfile:',
handler: async (bot, query) => {
const chatId = query.message.chat.id;
const listMessageId = query.message.message_id;
// callback_data = logfile:<name>:<userMessageId>
const parts = query.data.split(':');
const logName = parts[1];
const userMessageId = parts[2];
// Elimina il messaggio con la lista dei file
try {
await bot.deleteMessage(chatId, listMessageId);
} catch (e) {}
// Ottieni il file e le sue informazioni
const filePath = recorder.getLogFile(logName);
if (!filePath) {
bot.answerCallbackQuery(query.id, { text: 'File non trovato' });
return;
}
// Controllo aggiuntivo: se il file è quello in registrazione attiva
const session = recorder.getSession();
if (session && session.name === logName) {
bot.answerCallbackQuery(query.id, {
text: `Il file "${logName}" è attualmente in uso per la registrazione attiva. Fermala per scaricarlo.`,
show_alert: true
});
return;
}
// Ottieni info del file
const fs = require('fs');
const stat = fs.statSync(filePath);
const sizeMB = (stat.size / (1024 * 1024)).toFixed(2);
const created = new Date(stat.birthtime).toLocaleDateString('it-IT', {
day: '2-digit', month: '2-digit', year: 'numeric',
hour: '2-digit', minute: '2-digit'
});
const caption = `*CSV\nCreato: ${created}\ ${sizeMB} MB`;
// Invia il file
const docMessage = await bot.sendDocument(chatId, filePath, {
caption: caption,
parse_mode: 'Markdown'
});
// Elimina il messaggio dell'utente (il comando /logs)
try {
if (userMessageId) {
await bot.deleteMessage(chatId, parseInt(userMessageId));
}
} catch (e) {}
// Dopo 5 secondi, elimina il messaggio con il documento
setTimeout(async () => {
try {
await bot.deleteMessage(chatId, docMessage.message_id);
} catch (e) {}
}, 10000); //dopo 10 secondi
bot.answerCallbackQuery(query.id);
}
};

View File

@@ -1,51 +0,0 @@
const realtime = require('../../realtime/core.js');
const { config } = require('../../config.js');
module.exports = [
{
id: 'logs-refresh',
execute: async ({ bot, chatId, msg }) => {
const stats = realtime.getStats();
const consoleUrl = config.cloudUrl || 'https://console.mebboat.it';
let statusIcon = '🔴';
if (stats.status === 'connected') statusIcon = '🟢';
else if (stats.status === 'error') statusIcon = '🟡';
const now = new Date().toLocaleTimeString('it-IT');
let text = `📊 *Registrazione Dati Realtime*\n\n`;
text += `Stato: ${statusIcon} *${stats.status}*\n`;
text += `Sensore: \`${stats.sensorID}\`\n`;
text += `Messaggi inviati: *${stats.sent}*\n`;
text += `Frequenza: ogni *${stats.sentEveryMLS / 1000}s*\n`;
if (stats.buffered > 0) {
text += `⚠️ Messaggi in buffer: *${stats.buffered}*\n`;
}
if (stats.reconnections > 0) {
text += `Riconnessioni: ${stats.reconnections}\n`;
}
text += `\n_(Aggiornato: ${now})_`;
try {
await bot.editMessageText(text, {
chat_id: chatId,
message_id: msg.message_id,
parse_mode: 'Markdown',
reply_markup: {
inline_keyboard: [
[{ text: '📈 Apri Console Log', url: `${consoleUrl}/logs` }],
[{ text: '🔄 Aggiorna Stato', callback_data: 'logs-refresh' }]
]
}
});
} catch (e) {
if (!e.message.includes('message is not modified')) {
console.error("[Telegram] Errore refresh logs:", e);
}
}
}
}
];

View File

@@ -1,80 +0,0 @@
module.exports = [
{
id: 'set-meteo',
execute: async ({ bot, chatId, app }) => {
const config = app.mebConfig;
const currentFreqMin = config.forecast_current_frequency / 60000;
const hourlyFreqMin = config.forecast_hourly_frequency / 60000;
const msg = `*Configura Aggiornamenti Meteo*\n\n` +
`Aggiorno il meteo (attuale) ogni *${currentFreqMin} minuti*\n` +
`Registro le previsioni future (prossimi 7 giorni) ogni *${hourlyFreqMin} minuti*`;
await bot.sendMessage(chatId, msg, {
parse_mode: 'Markdown',
reply_markup: {
inline_keyboard: [
[
{ text: "1 sec", callback_data: 'set-meteo-curr-1' },
{ text: "10 sec", callback_data: 'set-meteo-curr-10' },
],
[
{ text: "1 min", callback_data: 'set-meteo-curr-60' },
{ text: "10 min", callback_data: 'set-meteo-curr-600' }
],
[
{ text: "30m", callback_data: 'set-meteo-hour-1800' }
],
[
{ text: "⬅️ Indietro", callback_data: 'session-refresh' }
]
]
}
});
}
},
{
match: (data) => data.startsWith('set-meteo-curr-'),
execute: async ({ bot, chatId, app, data, msg }) => {
const val = parseInt(data.replace('set-meteo-curr-', ''), 10);
if (app.mebPlugin && app.mebPlugin.setConfig) {
app.mebPlugin.setConfig('forecast_current_frequency', val);
await bot.editMessageText(`✅ Frequenza Aggiornamenti meteo aggiornata a *${val / 60} minuti*.\n_Ritorno al menu..._`, {
chat_id: chatId,
message_id: msg.message_id,
parse_mode: 'Markdown'
});
setTimeout(() => {
const sessionCmd = require('../commands/status.js');
bot.editMessageText("*Servizi*\n\n", {
chat_id: chatId, message_id: msg.message_id, parse_mode: 'Markdown', reply_markup: sessionCmd.createSessionMenu(app).reply_markup
}).catch(() => { });
}, 3000);
} else {
await bot.sendMessage(chatId, "Errore: il plugin non è accessibile per salvare la configurazione.");
}
}
},
{
match: (data) => data.startsWith('set-meteo-hour-'),
execute: async ({ bot, chatId, app, data, msg }) => {
const val = parseInt(data.replace('set-meteo-hour-', ''), 10);
if (app.mebPlugin && app.mebPlugin.setConfig) {
app.mebPlugin.setConfig('forecast_hourly_frequency', val);
await bot.editMessageText(`✅ Frequenza previsioni future aggiornata a *${val / 60} minuti*.\n_Ritorno al menu..._`, {
chat_id: chatId,
message_id: msg.message_id,
parse_mode: 'Markdown'
});
setTimeout(() => {
const sessionCmd = require('../commands/status.js');
bot.editMessageText("*Servizi*\n\n", {
chat_id: chatId, message_id: msg.message_id, parse_mode: 'Markdown', reply_markup: sessionCmd.createSessionMenu(app).reply_markup
}).catch(() => { });
}, 3000);
} else {
await bot.sendMessage(chatId, "Errore: il plugin non è accessibile per salvare la configurazione.");
}
}
}
];

View File

@@ -1,67 +0,0 @@
const realtime = require('../../realtime/core.js');
module.exports = [
{
id: 'session-weather-toggle',
execute: async ({ bot, chatId, app, msg }) => {
if (!app.mebPlugin) {
return bot.answerCallbackQuery(msg.id, { text: "Errore: Plugin Meteo non caricato" });
}
let isActive = app.mebPlugin.isPollingActive();
if (isActive) {
app.mebPlugin.stopPolling();
} else {
app.mebPlugin.startPolling();
}
const sessionCmd = require('../commands/status.js');
const newMarkup = sessionCmd.createSessionMenu(app);
await bot.editMessageReplyMarkup(newMarkup.reply_markup, {
chat_id: chatId,
message_id: msg.message_id
});
}
},
{
id: 'session-realtime-info',
execute: async ({ bot, chatId, msg }) => {
const stats = realtime.getStats();
let text = `📡 *Stato Realtime*\n\n`;
text += `Stato: *${stats.status}*\n`;
text += `Sensore: \`${stats.sensorID}\`\n`;
text += `Messaggi inviati: *${stats.sent}*\n`;
text += `Buffer: ${stats.buffered} msg\n`;
text += `Riconnessioni: ${stats.reconnections}\n`;
text += `\n_I dati vengono inviati automaticamente ogni ${stats.sentEveryMLS / 1000}s_`;
await bot.answerCallbackQuery(msg.id, { text: `Realtime: ${stats.status} | ${stats.sent} msg inviati` });
}
},
{
id: 'session-refresh',
execute: async ({ bot, chatId, app, msg }) => {
const sessionCmd = require('../commands/status.js');
const newMarkup = sessionCmd.createSessionMenu(app);
const now = new Date().toLocaleTimeString('it-IT');
const newText = `*Servizi*\n\n_(Ultimo aggiornamento: ${now})_`;
try {
await bot.editMessageText(newText, {
chat_id: chatId,
message_id: msg.message_id,
parse_mode: 'Markdown',
reply_markup: newMarkup.reply_markup
});
} catch (e) {
if (!e.message.includes('message is not modified')) {
console.error("Errore nel refresh session:", e);
}
}
}
}
];

View File

@@ -1,26 +0,0 @@
module.exports = [
{
id: 'weather-refresh',
execute: async ({ bot, chatId, msg }) => {
const weather = require('../commands/weather.js');
const newText = weather.formatWeatherData();
try {
await bot.editMessageText(newText, {
chat_id: chatId,
message_id: msg.message_id,
parse_mode: 'Markdown',
reply_markup: {
inline_keyboard: [
[{ text: 'Aggiorna', callback_data: 'weather-refresh' }]
]
}
});
} catch (e) {
if (!e.message.includes('message is not modified')) {
console.error('[Telegram Weather] Errore refresh:', e.message);
}
}
}
}
];

View File

@@ -0,0 +1,110 @@
const fs = require('fs').promises;
const fsSync = require('fs');
const pth = require('path');
const { closeButton } = require('../utility/close');
const dataDir = pth.join(__dirname, '../../../data/');
const PAGE_SIZE = 5;
/**
* Raccoglie ricorsivamente tutti i file nella cartella data/
*/
async function listDataFiles() {
const results = [];
async function scan(dir, prefix) {
const entries = await fs.readdir(dir, { withFileTypes: true });
for (const entry of entries) {
const fullPath = pth.join(dir, entry.name);
if (entry.isDirectory()) {
await scan(fullPath, prefix ? `${prefix}/${entry.name}` : entry.name);
} else {
const stat = await fs.stat(fullPath);
results.push({
name: prefix ? `${prefix}/${entry.name}` : entry.name,
path: fullPath,
size: stat.size,
modified: stat.mtime,
lines: null // calcolato on-demand
});
}
}
}
try {
await scan(dataDir, '');
} catch (e) {
console.error('[BACKUP] Errore scan:', e.message);
}
return results.sort((a, b) => b.modified - a.modified);
}
function formatSize(bytes) {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(2)} MB`;
}
function buildPage(files, page, userMessageId) {
const totalPages = Math.ceil(files.length / PAGE_SIZE);
const start = page * PAGE_SIZE;
const pageFiles = files.slice(start, start + PAGE_SIZE);
const keyboard = pageFiles.map(f => {
const date = new Date(f.modified).toLocaleDateString('it-IT', {
day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit'
});
const label = `${f.name} (${formatSize(f.size)}, ${date})`;
// Encode file name as base64-safe identifier (index in full list)
const fileIdx = files.indexOf(f);
return [{ text: label, callback_data: `bkfile:${fileIdx}:${userMessageId}` }];
});
// Navigation row
const navRow = [];
if (page > 0) {
navRow.push({ text: '<< Prec', callback_data: `bkpage:${page - 1}:${userMessageId}` });
}
navRow.push({ text: `${page + 1}/${totalPages}`, callback_data: `bknoop:0` });
if (page < totalPages - 1) {
navRow.push({ text: 'Succ >>', callback_data: `bkpage:${page + 1}:${userMessageId}` });
}
keyboard.push(navRow);
// Close button
keyboard.push([{ text: '<- Chiudi', callback_data: `close:${userMessageId}` }]);
return keyboard;
}
module.exports = {
command: 'backup',
handler: async (bot, msg) => {
const chatId = msg.chat.id;
const files = await listDataFiles();
if (files.length === 0) {
bot.sendMessage(chatId, 'Nessun file nella cartella data.', {
reply_to_message_id: msg.message_id,
reply_markup: closeButton(msg.message_id)
});
return;
}
const text = `*Backup & Logs*\n\n${files.length} file disponibili nella cartella data.\n_Seleziona un file per info e download._`;
const keyboard = buildPage(files, 0, msg.message_id);
bot.sendMessage(chatId, text, {
parse_mode: 'Markdown',
reply_to_message_id: msg.message_id,
reply_markup: { inline_keyboard: keyboard }
});
},
// Export utilities for callbacks
listDataFiles,
formatSize,
buildPage,
PAGE_SIZE
};

View File

@@ -1,57 +0,0 @@
const dataHub = require('../../tools/dataHub');
function formatSensorData() {
const sensorSnapshot = dataHub.getSensorData();
const data = { timestamp: new Date().toISOString(), ...(sensorSnapshot || {}) };
let output = `📊 *Dashboard Sensori*\n`;
output += `_Ultimo aggiornamento: ${new Date().toLocaleTimeString('it-IT')}_\n\n`;
let isDataEmpty = true;
for (const [key, value] of Object.entries(data)) {
if (key === 'timestamp') continue;
isDataEmpty = false;
let formattedKey = key.replace(/_/g, ' ');
// Prima lettera maiuscola
formattedKey = formattedKey.charAt(0).toUpperCase() + formattedKey.slice(1);
const formattedValue = (value !== null && value !== undefined)
? (typeof value === 'number' ? value.toFixed(2) : value)
: 'N/A';
output += `🔹 *${formattedKey}:* ${formattedValue}\n`;
}
if (isDataEmpty) {
output += `_Nessun dato configurato o letto. Controlla sensors.references.json_\n`;
}
return output;
}
module.exports = {
command: 'dashboard',
description: 'Mostra i sensori live (dal file references)',
pattern: /\/dashboard/,
execute: async (bot, msg) => {
const chatId = msg.chat.id;
const text = formatSensorData();
await bot.sendMessage(chatId, text, {
parse_mode: 'Markdown',
reply_markup: {
inline_keyboard: [
[
{ text: "⚡️ Avvia Live Tracker (2s)", callback_data: 'dashboard-live-start' }
],
[
{ text: "🔄 Ricarica Dati", callback_data: 'dashboard-refresh' }
]
]
}
});
},
formatSensorData // Esportato per riuso nel refresh e nel live
};

View File

@@ -1,58 +1,59 @@
const dataHub = require('../../tools/dataHub');
const skFlow = require('../../config/skFlow');
const { liveMarkup } = require('../utility/live');
/**
* Formatta i dati sensore in un messaggio Telegram leggibile.
* @returns {string} Testo formattato Markdown
*/
function formatSensorData() {
const sensors = dataHub.getSensorData();
if (!sensors) {
return 'Nessun dato sensore disponibile.\nI sensori potrebbero non essere ancora attivi.';
}
let text = '*Dati Sensori*\n';
text += `_${new Date().toLocaleTimeString('it-IT')}_\n\n`;
let hasData = false;
for (const [key, value] of Object.entries(sensors)) {
if (key.startsWith('_')) continue; // Skip campi interni
hasData = true;
let label = key.replace(/_/g, ' ');
label = label.charAt(0).toUpperCase() + label.slice(1);
const formatted = (value !== null && value !== undefined)
? (typeof value === 'number' ? value.toFixed(2) : String(value))
: 'N/A';
text += `*${label}:* ${formatted}\n`;
}
if (!hasData) {
text += '_Nessun dato configurato. Controlla sensors.references.json_\n';
}
return text;
}
const logsPaths = [
"navigation.position",
"navigation.headingTrue",
"navigation.speedOverGround",
"propulsion.p1.temperature"
];
module.exports = {
command: 'data',
description: 'Mostra i dati sensori attuali',
pattern: /\/data/,
execute: async (bot, msg) => {
handler: (bot, msg) => {
const chatId = msg.chat.id;
const text = formatSensorData();
let text = '';
await bot.sendMessage(chatId, text, {
parse_mode: 'Markdown',
reply_markup: {
inline_keyboard: [
[{ text: 'Aggiorna', callback_data: 'data-refresh' }]
]
// Telemetria
const logs = skFlow.getFrom(logsPaths);
text += '*Telemetria di Bordo*\n\n';
if (logs && Object.keys(logs).length > 0) {
for (const [path, value] of Object.entries(logs)) {
const displayValue = typeof value === 'object' ? JSON.stringify(value) : value;
text += `*${path}*: ${displayValue}\n`;
}
} else {
text += 'Nessun dato disponibile.\n';
}
// Meteo
const weather = skFlow.getWithFilter('meb.forecast');
text += '\n*Dati Meteo*\n\n';
if (weather && Object.keys(weather).length > 0) {
for (const [path, value] of Object.entries(weather)) {
const displayValue = typeof value === 'object' ? JSON.stringify(value) : value;
text += `*${path}*: ${displayValue}\n`;
}
} else {
text += 'Nessun dato disponibile.\n';
}
// Mare
const marine = skFlow.getWithFilter('meb.marine');
text += '\n*Dati Meteo del mare*\n\n';
if (marine && Object.keys(marine).length > 0) {
for (const [path, value] of Object.entries(marine)) {
const displayValue = typeof value === 'object' ? JSON.stringify(value) : value;
text += `*${path}*: ${displayValue}\n`;
}
} else {
text += 'Nessun dato disponibile.\n';
}
bot.sendMessage(chatId, text, {
parse_mode: 'Markdown',
reply_to_message_id: msg.message_id,
reply_markup: liveMarkup(msg.message_id, 'data')
});
},
formatSensorData
}
};

View File

@@ -1,84 +0,0 @@
const dataHub = require('../../tools/dataHub');
/**
* Formatta tutti i dati (sensori + meteo) per il live tracker.
* @returns {string} Testo formattato Markdown
*/
function formatLiveData() {
const sensors = dataHub.getSensorData();
const { forecast, sea } = dataHub.getWeatherData();
let text = '*LIVE - Dati Completi*\n';
text += `_${new Date().toLocaleTimeString('it-IT')}_\n\n`;
// Sezione sensori
if (sensors) {
text += '*Sensori:*\n';
for (const [key, value] of Object.entries(sensors)) {
if (key.startsWith('_')) continue;
let label = key.replace(/_/g, ' ');
label = label.charAt(0).toUpperCase() + label.slice(1);
const val = (value !== null && value !== undefined)
? (typeof value === 'number' ? value.toFixed(2) : String(value))
: 'N/A';
text += ` ${label}: ${val}\n`;
}
} else {
text += '_Nessun dato sensore disponibile_\n';
}
// Sezione meteo (compatta)
if (forecast) {
text += '\n*Meteo:*\n';
const parts = [];
if (forecast.temperature !== null && forecast.temperature !== undefined) {
parts.push(`Temp: ${forecast.temperature}C`);
}
if (forecast.humidity !== null && forecast.humidity !== undefined) {
parts.push(`Um: ${forecast.humidity}%`);
}
if (forecast.wind?.speed !== null && forecast.wind?.speed !== undefined) {
parts.push(`Vento: ${forecast.wind.speed}km/h`);
}
text += ` ${parts.join(' | ')}\n`;
}
if (sea?.waves) {
const seaParts = [];
if (sea.waves.height !== null && sea.waves.height !== undefined) {
seaParts.push(`Onde: ${sea.waves.height}m`);
}
if (sea.waves.period !== null && sea.waves.period !== undefined) {
seaParts.push(`Per: ${sea.waves.period}s`);
}
if (seaParts.length > 0) {
text += ` ${seaParts.join(' | ')}\n`;
}
}
return text;
}
module.exports = {
command: 'live',
description: 'Dati live (meteo + sensori) con aggiornamento automatico',
pattern: /\/live/,
execute: async (bot, msg) => {
const chatId = msg.chat.id;
const text = formatLiveData();
await bot.sendMessage(chatId, text, {
parse_mode: 'Markdown',
reply_markup: {
inline_keyboard: [
[{ text: 'Avvia Live (2s)', callback_data: 'live-start' }],
[{ text: 'Aggiorna', callback_data: 'live-refresh' }]
]
}
});
},
formatLiveData
};

View File

@@ -1,53 +1,53 @@
const realtime = require('../../realtime/core.js');
const { config } = require('../../config.js');
const recorder = require('../../cores/logs.local');
const { closeButton } = require('../utility/close');
module.exports = {
command: 'logs',
description: 'Mostra lo stato della registrazione dati in tempo reale',
pattern: /\/logs/,
execute: async (bot, msg, { app }) => {
handler: async (bot, msg) => {
const chatId = msg.chat.id;
try {
const stats = realtime.getStats();
const consoleUrl = config.cloudUrl || 'https://console.mebboat.it';
const logs = await recorder.listLogs();
let statusIcon = '🔴';
if (stats.status === 'connected') statusIcon = '🟢';
else if (stats.status === 'error') statusIcon = '🟡';
let text = `📊 *Registrazione Dati Realtime*\n\n`;
text += `Stato: ${statusIcon} *${stats.status}*\n`;
text += `Sensore: \`${stats.sensorID}\`\n`;
text += `Messaggi inviati: *${stats.sent}*\n`;
text += `Frequenza: ogni *${stats.sentEveryMLS / 1000}s*\n`;
if (stats.buffered > 0) {
text += `⚠️ Messaggi in buffer: *${stats.buffered}*\n`;
}
if (stats.reconnections > 0) {
text += `Riconnessioni: ${stats.reconnections}\n`;
}
if (stats.firstSent) {
text += `\nPrimo invio: ${stats.firstSent}\n`;
}
text += `\n_I dati vengono inviati automaticamente al server ogni secondo._`;
text += `\n_Consulta i log storici sulla console:_`;
await bot.sendMessage(chatId, text, {
parse_mode: 'Markdown',
reply_markup: {
inline_keyboard: [
[{ text: '📈 Apri Console Log', url: `${consoleUrl}/logs` }],
[{ text: '🔄 Aggiorna Stato', callback_data: 'logs-refresh' }]
]
}
if (!logs || logs.length === 0) {
bot.sendMessage(chatId, 'Nessun file di log disponibile.', {
reply_to_message_id: msg.message_id,
reply_markup: closeButton(msg.message_id)
});
} catch (error) {
console.error("[Telegram] Errore comando /logs:", error);
bot.sendMessage(chatId, `❌ Errore: ${error.message}`);
return;
}
const session = recorder.getSession();
let text = '*Registrazioni dei Log*\n\n';
if (session) {
text += `in corso: *${session.name}*\n`;
text += `${session.elements} dati raccolti ogni ${session.delay}s\n\n`;
}
text += `${logs.length} file disponibili:\n`;
text += '_Selezionane uno per scaricarlo_';
// Bottoni per ogni file
const keyboard = logs.map(log => {
const date = new Date(log.created).toLocaleDateString('it-IT', {
day: '2-digit', month: '2-digit', year: '2-digit',
hour: '2-digit', minute: '2-digit'
});
const isActive = session && session.name === log.name;
const label = isActive ? `🔴 ${log.name} *[IN CORSO, NON DISPONIBILE]*` : `${date})`;
const callback = isActive ? `logbusy:${log.name}` : `logfile:${log.name}:${msg.message_id}`;
return [{ text: label, callback_data: callback }];
});
// Aggiungi il bottone chiudi
keyboard.push([{ text: '<- Chiudi', callback_data: `close:${msg.message_id}` }]);
bot.sendMessage(chatId, text, {
parse_mode: 'Markdown',
reply_to_message_id: msg.message_id,
reply_markup: { inline_keyboard: keyboard }
});
}
};

View File

@@ -0,0 +1,30 @@
const skFlow = require('../../config/skFlow');
const { liveMarkup } = require('../utility/live');
module.exports = {
command: 'marine',
handler: (bot, msg) => {
const chatId = msg.chat.id;
const data = skFlow.getWithFilter('meb.marine');
let text = '';
if (!data || Object.keys(data).length === 0) {
text = 'Nessun dato sul mare disponibile.';
} else {
text = '*Dati Meteo del mare*\n\n';
for (const [path, value] of Object.entries(data)) {
const displayValue = typeof value === 'object' ? JSON.stringify(value) : value;
//TODO: ADD units
//TODO: Formattare meglio i path
text += `*${path}*: ${displayValue}\n`;
}
}
bot.sendMessage(chatId, text, {
parse_mode: 'Markdown',
reply_to_message_id: msg.message_id,
reply_markup: liveMarkup(msg.message_id, 'marine')
});
}
};

View File

@@ -1,24 +0,0 @@
const realtime = require('../../realtime/core.js');
module.exports = {
command: 'realtime',
description: 'Dettagli della connessione realtime',
pattern: /\/realtime/,
execute: async (bot, msg) => {
const stats = realtime.getStats();
const statusEmoji = stats.status === 'connected' ? '🟢' : '🔴';
let message = `*Connessione Realtime* ${statusEmoji}\n\n`;
message += `*ID Sensore:* ${stats.sensorID}\n`;
message += `*Stato:* ${stats.status}\n`;
message += `*Messaggi inviati:* ${stats.sent}\n`;
message += `*Riconnessioni:* ${stats.reconnections}\n`;
message += `*Frequenza:* ${stats.sentEveryMLS}ms\n`;
if (stats.firstSent) {
message += `*Primo invio:* ${new Date(stats.firstSent).toLocaleString()}\n`;
}
await bot.sendMessage(msg.chat.id, message, { parse_mode: 'Markdown' });
}
};

View File

@@ -1,20 +0,0 @@
module.exports = {
command: 'settings',
description: 'Mostra le impostazioni del Computer di Bordo',
pattern: /\/settings/,
execute: async (bot, msg) => {
const chatId = msg.chat.id;
await bot.sendMessage(chatId, "*Configurazione Computer di Bordo*\nScegli quali parametri modificare:", {
parse_mode: 'Markdown',
reply_markup: {
inline_keyboard: [
[
{ text: "Meteo", callback_data: 'set-meteo' },
{ text: "Batterie", callback_data: 'set-batteries' }
]
]
}
});
}
};

View File

@@ -0,0 +1,17 @@
module.exports = {
command: 'start',
handler: (bot, msg) => {
const chatId = msg.chat.id;
bot.setMyCommands([
{ command: 'data', description: 'Mostra tutti i dati' },
{ command: 'logs', description: 'Registrazioni logs' },
{ command: 'weather', description: 'Mostra i dati meteo' },
{ command: 'marine', description: 'Mostra i dati del mare' },
{ command: 'backup', description: 'Backup logs - lista file nella cartella data' },
// { command: 'start', description: 'Avvia il bot e configura i comandi' }
]);
bot.sendMessage(chatId, 'Benvenuto nel bot MEB!');
}
};

View File

@@ -1,40 +0,0 @@
const realtime = require('../../realtime/core.js');
function createSessionMenu(app) {
const weatherActive = app.mebPlugin && app.mebPlugin.isPollingActive ? app.mebPlugin.isPollingActive() : false;
const realtimeStats = realtime.getStats();
const realtimeConnected = realtimeStats.isConnected;
return {
reply_markup: {
inline_keyboard: [
[
{ text: weatherActive ? "Meteo: 🟢 ON (Premi per fermare)" : "Meteo: 🔴 OFF (Premi per avviare)", callback_data: 'session-weather-toggle' }
],
[
{ text: realtimeConnected ? "Realtime: 🟢 Connesso" : "Realtime: 🔴 Disconnesso", callback_data: 'session-realtime-info' }
],
[
{ text: "🔄", callback_data: 'session-refresh' },
{ text: "⚙️ ⛅️ (meteo)", callback_data: 'set-meteo' }
]
]
}
};
}
module.exports = {
command: 'session',
description: 'Verifica le attività di Meteo e Realtime',
pattern: /\/session/,
execute: async (bot, msg, { app }) => {
const chatId = msg.chat.id;
const msgText = `*Servizi*\n\n`;
await bot.sendMessage(chatId, msgText, {
parse_mode: 'Markdown',
...createSessionMenu(app)
});
},
createSessionMenu
};

View File

@@ -1,47 +0,0 @@
const realtime = require('../../realtime/core.js');
module.exports = {
command: 'structure',
description: 'Mostra la struttura dati del plugin',
pattern: /\/structure/,
execute: async (bot, msg) => {
const chatId = msg.chat.id;
const rules = realtime.getSensorRules();
if (!rules) {
return bot.sendMessage(chatId, 'Nessuna configurazione sensori caricata.');
}
let text = `*Struttura Dati Plugin*\n`;
text += `Versione: \`${rules.version}\`\n`;
text += `Attivo: ${rules.isActive ? 'Si' : 'No'}\n`;
text += `Collezioni: ${rules.items?.length || 0}\n\n`;
if (rules.items) {
for (const item of rules.items) {
text += `*${item.collection}*\n`;
text += ` Path: \`${item.main_path}\`\n`;
if (item.elements && Array.isArray(item.elements)) {
for (const element of item.elements) {
const { subelements, ...fields } = element;
const [name, subPath] = Object.entries(fields)[0];
text += ` - ${name} -> \`${item.main_path}.${subPath}\`\n`;
if (subelements && Array.isArray(subelements)) {
for (const sub of subelements) {
const [sName, sPath] = Object.entries(sub)[0];
text += ` - ${sName} -> \`${item.main_path}.${subPath}.${sPath}\`\n`;
}
}
}
} else {
text += ` (valore singolo)\n`;
}
text += `\n`;
}
}
await bot.sendMessage(chatId, text, { parse_mode: 'Markdown' });
}
};

View File

@@ -1,84 +1,29 @@
const dataHub = require('../../tools/dataHub');
/**
* Formatta i dati meteo in un messaggio Telegram leggibile.
* @returns {string} Testo formattato Markdown
*/
function formatWeatherData() {
const { forecast, sea } = dataHub.getWeatherData();
if (!forecast && !sea) {
return 'Nessun dato meteo disponibile.\nIl polling potrebbe non essere ancora partito.';
}
let text = '*Meteo Attuale*\n';
text += `_${new Date().toLocaleTimeString('it-IT')}_\n\n`;
if (forecast) {
if (forecast.temperature !== null && forecast.temperature !== undefined) {
text += `Temperatura: *${forecast.temperature}*C\n`;
}
if (forecast.humidity !== null && forecast.humidity !== undefined) {
text += `Umidita: *${forecast.humidity}*%\n`;
}
if (forecast.pressure !== null && forecast.pressure !== undefined) {
text += `Pressione: *${forecast.pressure}* hPa\n`;
}
if (forecast.rain !== null && forecast.rain !== undefined) {
text += `Pioggia: *${forecast.rain}* mm\n`;
}
if (forecast.wind) {
text += `\nVento:\n`;
if (forecast.wind.speed !== null && forecast.wind.speed !== undefined) {
text += ` Velocita: *${forecast.wind.speed}* km/h\n`;
}
if (forecast.wind.direction !== null && forecast.wind.direction !== undefined) {
text += ` Direzione: *${forecast.wind.direction}*\n`;
}
if (forecast.wind.gusts !== null && forecast.wind.gusts !== undefined) {
text += ` Raffiche: *${forecast.wind.gusts}* km/h\n`;
}
}
}
if (sea) {
text += `\nMare:\n`;
if (sea.waves) {
if (sea.waves.height !== null && sea.waves.height !== undefined) {
text += ` Altezza onde: *${sea.waves.height}* m\n`;
}
if (sea.waves.period !== null && sea.waves.period !== undefined) {
text += ` Periodo: *${sea.waves.period}* s\n`;
}
if (sea.waves.direction !== null && sea.waves.direction !== undefined) {
text += ` Direzione: *${sea.waves.direction}*\n`;
}
}
if (sea.temperature !== null && sea.temperature !== undefined) {
text += ` Temp. acqua: *${sea.temperature}*C\n`;
}
}
return text;
}
const skFlow = require('../../config/skFlow');
const { liveMarkup } = require('../utility/live');
module.exports = {
command: 'weather',
description: 'Mostra i dati meteo attuali',
pattern: /\/weather/,
execute: async (bot, msg) => {
handler: (bot, msg) => {
const chatId = msg.chat.id;
const text = formatWeatherData();
const data = skFlow.getWithFilter('meb.forecast');
await bot.sendMessage(chatId, text, {
parse_mode: 'Markdown',
reply_markup: {
inline_keyboard: [
[{ text: 'Aggiorna', callback_data: 'weather-refresh' }]
]
let text = '';
if (!data || Object.keys(data).length === 0) {
text = 'Nessun dato meteo disponibile.';
} else {
text = '*Dati Meteo*\n\n';
for (const [path, value] of Object.entries(data)) {
const displayValue = typeof value === 'object' ? JSON.stringify(value) : value;
//TODO: ADD units
text += `*${path}*: ${displayValue}\n`;
}
}
bot.sendMessage(chatId, text, {
parse_mode: 'Markdown',
reply_to_message_id: msg.message_id,
reply_markup: liveMarkup(msg.message_id, 'weather')
});
},
formatWeatherData
}
};

121
plugin/telegram/core.js Normal file
View File

@@ -0,0 +1,121 @@
const TelegramBot = require('node-telegram-bot-api');
const fs = require('fs');
const path = require('path');
const configManager = require('../config/configManager.js');
let bot = null;
/**
* Inizializza il bot Telegram con il token dalle configurazioni del plugin.
* Carica automaticamente i comandi dalla cartella commands/ e i callback dalla cartella callbacks/.
* @returns {TelegramBot|null} L'istanza del bot o null se il token non è disponibile.
*/
function init() {
const token = configManager.getTelegramToken();
if (!token) {
console.error('[TELEGRAM] TELEGRAM_BOT_TOKEN non trovato nelle configurazioni del plugin');
return null;
}
bot = new TelegramBot(token, { polling: true });
loadCommands();
loadCallbacks();
bot.on('polling_error', (error) => {
console.error('[TELEGRAM] Errore polling:', error.message);
});
return bot;
}
/**
* Carica tutti i file dalla cartella commands/ e li registra come handler per i comandi.
* Ogni file deve esportare un oggetto con { command: string, handler: function(bot, msg, match) }
*/
function loadCommands() {
const commandsDir = path.join(__dirname, 'commands');
if (!fs.existsSync(commandsDir)) return;
const files = fs.readdirSync(commandsDir).filter(f => f.endsWith('.js'));
for (const file of files) {
try {
const cmd = require(path.join(commandsDir, file));
if (cmd.command && cmd.handler) {
bot.onText(new RegExp(`^/${cmd.command}`), (msg, match) => {
cmd.handler(bot, msg, match);
});
}
} catch (error) {
console.error(`[TELEGRAM] Errore caricamento comando ${file}:`, error.message);
}
}
}
/**
* Carica tutti i file dalla cartella callbacks/ e li registra come handler per le callback query.
* Ogni file deve esportare un oggetto con { prefix: string, handler: function(bot, query) }
*/
function loadCallbacks() {
const callbacksDir = path.join(__dirname, 'callbacks');
if (!fs.existsSync(callbacksDir)) return;
const files = fs.readdirSync(callbacksDir).filter(f => f.endsWith('.js'));
const handlers = [];
for (const file of files) {
try {
const cb = require(path.join(callbacksDir, file));
if (cb.prefix && cb.handler) {
handlers.push(cb);
}
} catch (error) {
console.error(`[TELEGRAM] Errore caricamento callback ${file}:`, error.message);
}
}
if (handlers.length > 0) {
bot.on('callback_query', (query) => {
const matched = handlers.find(h => query.data.startsWith(h.prefix));
if (matched) {
matched.handler(bot, query);
}
});
}
}
/**
* Restituisce l'istanza del bot (se inizializzato)
* @returns {TelegramBot|null}
*/
function getBot() {
return bot;
}
/**
* Invia un messaggio ad un chatId specifico
* @param {Number|String} chatId
* @param {String} text
* @param {Object} options - opzioni aggiuntive (parse_mode, reply_markup, ecc.)
*/
async function send(chatId, text, options = {}) {
if (!bot) {
console.error('[TELEGRAM] Bot non inizializzato');
return;
}
try {
await bot.sendMessage(chatId, text, options);
} catch (error) {
console.error(`[TELEGRAM] Errore invio messaggio: ${error.message}`);
}
}
module.exports = {
init,
getBot,
send
};

View File

@@ -1,269 +0,0 @@
const TelegramBot = require('node-telegram-bot-api');
const fs = require('fs');
const path = require('path');
const BOT_TOKEN = process.env.TELEGRAM_BOT_TOKEN;
let bot = null;
let app = null;
let pollingRetryCount = 0;
const MAX_POLLING_RETRIES = 10;
const POLLING_BASE_DELAY_MS = 5000;
// Registry per i comandi, callback e query inline in formato { pattern: Regex, execute: Function }
let commandsRegistry = [];
let callbackHandlers = [];
let inlineQueriesRegistry = [];
let isMessageListenerRegistered = false;
// Inizializzazione del bot.
function initBot() {
if (!BOT_TOKEN) {
console.warn("[Telegram] BOT_TOKEN not set: bot disabled");
return null;
}
if (global.__meb_telegram_bot) {
bot = global.__meb_telegram_bot;
console.log("[Telegram] Già avviato. Riavvio del bot.");
} else {
bot = new TelegramBot(BOT_TOKEN, { polling: true });
// Gestione errori di polling: intercetta EFATAL (DNS/Rete) e riavvia con backoff esponenziale
bot.on('polling_error', (error) => {
const isNetworkError = error.code === 'EFATAL' || (error.message && (error.message.includes('EAI_AGAIN') || error.message.includes('ECONNREFUSED') || error.message.includes('ETIMEDOUT')));
if (isNetworkError) {
if (pollingRetryCount >= MAX_POLLING_RETRIES) {
console.error(`[Telegram] Polling fallito dopo ${MAX_POLLING_RETRIES} tentativi. Bot disattivato. Riavviare il plugin per riprovare.`);
return;
}
pollingRetryCount++;
const delay = Math.min(POLLING_BASE_DELAY_MS * Math.pow(2, pollingRetryCount - 1), 300000); // max 5 min
console.warn(`[Telegram] Errore Polling Critico (${error.code}), tentativo ${pollingRetryCount}/${MAX_POLLING_RETRIES}. Riavvio tra ${delay / 1000}s...`);
setTimeout(() => {
bot.startPolling({ restart: true })
.then(() => { pollingRetryCount = 0; })
.catch(err => console.error("[Telegram] Errore riavvio polling:", err.message));
}, delay);
} else {
console.error(`[Telegram] Polling error: ${error.message}`);
}
});
global.__meb_telegram_bot = bot;
console.log("[Telegram] Avvio del bot.");
}
// Caricamento dei comandi e dei callback.
if (!global.__meb_telegram_handlers) {
global.__meb_telegram_handlers = true;
loadCommands();
loadCallbacks();
loadInlineQueries();
setupMessageListener(); // Registra il listener generale dei messaggi
}
return bot;
}
/**
* Registra il listener centrale per tutti i messaggi.
*/
function setupMessageListener() {
if (!bot || isMessageListenerRegistered) return;
bot.on('message', async (msg) => {
if (!msg.text) return;
// Cicla i comandi registrati e vedi se il testo corrisponde a un pattern
for (const cmd of commandsRegistry) {
if (cmd.pattern && cmd.pattern.test(msg.text)) {
try {
await cmd.execute(bot, msg, { app, getSK });
} catch (error) {
console.error(`[Telegram] Error executing command ${msg.text}:`, error);
bot.sendMessage(msg.chat.id, "⚠️ Errore interno durante l'esecuzione del comando.");
}
return; // Trovato ed eseguito
}
}
});
bot.on('callback_query', async (query) => {
const chatId = query.message.chat.id;
const data = query.data;
await bot.answerCallbackQuery(query.id);
const context = { bot, app, getSK, chatId, data, msg: query.message };
// Find matching handler
const handler = callbackHandlers.find(h => {
if (h.id) return h.id === data;
if (h.match) return h.match(data);
return false;
});
if (handler) {
try {
await handler.execute(context);
} catch (err) {
const msgErr = err.message || (err.response && err.response.body && err.response.body.description) || String(err);
if (msgErr.includes("message is not modified") || msgErr.includes("message to edit not found")) {
// Silently ignore unmodified edit or deleted message
} else {
console.error(`[Telegram] Error executing callback ${data}:`, err);
await bot.sendMessage(chatId, `Errore nella chimata dell'api, ${msgErr}.`);
}
}
} else {
console.warn(`[Telegram] Unknown callback action: ${data}`);
await bot.sendMessage(chatId, `Azione sconosciuta: ${data}`);
}
});
bot.on('inline_query', async (query) => {
const text = query.query;
// Cerca una query inline corrispondente
for (const handler of inlineQueriesRegistry) {
if (handler.pattern && handler.pattern.test(text)) {
try {
await handler.execute(bot, query, { app, getSK });
} catch (err) {
console.error(`[Telegram] Error executing inline query ${text}:`, err);
}
return;
}
}
});
isMessageListenerRegistered = true;
}
/**
* Ottiene il valore di una chiave dal DataBrowser di SignalK.
* @param {*} skPath Nome della chiave (path completo, come ad esempio "navigation.position.latitude").
* @returns Valore della chiave.
*/
function getSK(skPath) {
if (!app) return null;
const v = app.getSelfPath(skPath);
return v && v.value !== undefined && v.value !== null ? v.value : null;
}
/**
* Carica o ricarica i comandi del bot. Pulisce la cache di module_require per implementare l'hot reload.
* @returns {void}
*/
function loadCommands() {
if (!bot) return;
const commandsDir = path.join(__dirname, 'commands');
if (fs.existsSync(commandsDir)) {
commandsRegistry = []; // Svuota i vecchi comandi
const menuCommands = []; // Per il menu di Telegram
// Legge solo i file .js dalla cartella /commands.
const commandFiles = fs.readdirSync(commandsDir).filter(file => file.endsWith('.js'));
// Per ogni file, importa il comando
for (const file of commandFiles) {
const fullPath = path.resolve(commandsDir, file);
//Importa i comandi da module.exports all'interno del file
const command = require(fullPath);
//Registra il comando nel registry interno.
if (command.pattern && command.execute) {
commandsRegistry.push(command);
// Se ha una descrizione e un nome comando, lo aggiungiamo al menu
if (command.command && command.description) {
menuCommands.push({
command: command.command.toLowerCase(),
description: command.description
});
}
}
}
// Invia la lista dei comandi a Telegram per il menu a sinistra
if (menuCommands.length > 0) {
bot.setMyCommands(menuCommands).catch(err => {
console.error("[Telegram] Errore nel setMyCommands:", err);
});
}
}
}
/**
* Carica o ricarica i callback del bot.
* @returns {void}
*/
function loadCallbacks() {
if (!bot) return;
const callbacksDir = path.join(__dirname, 'callbacks');
callbackHandlers = [];
if (fs.existsSync(callbacksDir)) {
// Legge solo i file .js dalla cartella /callbacks.
const callbackFiles = fs.readdirSync(callbacksDir).filter(file => file.endsWith('.js'));
// Per ogni file, importa i callback e li aggiunge all'array callbackHandlers.
for (const file of callbackFiles) {
const fullPath = path.resolve(callbacksDir, file);
//Importa i callback da module.exports all'interno del file
const handlers = require(fullPath);
if (Array.isArray(handlers)) {
callbackHandlers.push(...handlers);
}
}
}
}
/**
* Carica o ricarica le query inline del bot.
* @returns {void}
*/
function loadInlineQueries() {
if (!bot) return;
const inlineDir = path.join(__dirname, 'inline');
inlineQueriesRegistry = [];
if (fs.existsSync(inlineDir)) {
const inlineFiles = fs.readdirSync(inlineDir).filter(file => file.endsWith('.js'));
for (const file of inlineFiles) {
const fullPath = path.resolve(inlineDir, file);
const handler = require(fullPath);
if (handler.pattern && handler.execute) {
inlineQueriesRegistry.push(handler);
}
}
}
}
/**
* Collega il bot all'app.
* @param {*} mebApp L'app di SignalK.
* @returns {TelegramBot} Il bot.
*/
function linkBotToApp(mebApp) {
app = mebApp;
bot = initBot();
return bot;
}
/**
* Invia un messaggio ad un utente tramite il bot.
* @param {*} chatId L'ID della chat.
* @param {*} text Il testo del messaggio.
* @param {*} options Le opzioni del messaggio.
* @returns {Promise<TelegramBot>} Il bot.
*/
function send(chatId, text, options = {}) {
if (!bot) return Promise.reject("Bot not initialized");
return bot.sendMessage(chatId, text, options);
}
module.exports = {
linkBotToApp,
send
};

View File

@@ -0,0 +1,17 @@
/**
* Restituisce l'oggetto reply_markup per il bottone "Chiudi", che elimina
* il messaggio del bot e il messaggio originale dell'utente.
* @param {Number} userMessageId - L'ID del messaggio originale dell'utente a cui si sta rispondendo.
* @returns {Object} Oggetto compatibile con reply_markup.
*/
function addCloseAction(userMessageId) {
return {
inline_keyboard: [
[{ text: '<- Chiudi', callback_data: `close:${userMessageId}` }]
]
};
}
module.exports = {
closeButton: addCloseAction
}

View File

@@ -0,0 +1,130 @@
const MAX_LIVE_DURATION = 2 * 60 * 1000; // 2 minuti
const UPDATE_INTERVAL = 2000; // 2 secondi
// Mappa delle sessioni live attive: chiave = `chatId:botMessageId`
const activeSessions = new Map();
/**
* Restituisce il markup con i bottoni "Live" e "Chiudi"
* @param {Number} userMessageId
* @param {String} dataType - tipo di dati (logs, marine, weather, data)
*/
function liveMarkup(userMessageId, dataType) {
return {
inline_keyboard: [
[
{ text: 'Live', callback_data: `live:${dataType}:${userMessageId}` },
{ text: '<- Chiudi', callback_data: `close:${userMessageId}` }
]
]
};
}
/**
* Restituisce il markup con il bottone "Stop"
* @param {Number} userMessageId
*/
function stopMarkup(userMessageId) {
return {
inline_keyboard: [
[{ text: 'Stop', callback_data: `livestop:${userMessageId}` }]
]
};
}
/**
* Formatta il tempo rimanente in MM:SS
*/
function formatTime(ms) {
const totalSec = Math.ceil(ms / 1000);
const min = Math.floor(totalSec / 60);
const sec = totalSec % 60;
return `${min}:${sec.toString().padStart(2, '0')}`;
}
/**
* Avvia una sessione live
* @param {Object} bot - istanza del bot
* @param {Number} chatId
* @param {Number} botMessageId - ID del messaggio del bot da aggiornare
* @param {Number} userMessageId - ID del messaggio dell'utente
* @param {Function} getTextFn - funzione che restituisce il testo aggiornato (senza timer)
*/
function startSession(bot, chatId, botMessageId, userMessageId, getTextFn) {
const key = `${chatId}:${botMessageId}`;
// Se esiste già una sessione, non avviarne una nuova
if (activeSessions.has(key)) return;
const startTime = Date.now();
// Pinna il messaggio
bot.pinChatMessage(chatId, botMessageId, { disable_notification: true }).catch(() => {});
const interval = setInterval(async () => {
const elapsed = Date.now() - startTime;
const remaining = MAX_LIVE_DURATION - elapsed;
if (remaining <= 0) {
stopSession(bot, chatId, botMessageId, userMessageId);
return;
}
try {
const freshText = getTextFn();
const textWithTimer = freshText + `\n_Live: ${formatTime(remaining)} rimanenti_`;
await bot.editMessageText(textWithTimer, {
chat_id: chatId,
message_id: botMessageId,
parse_mode: 'Markdown',
reply_markup: stopMarkup(userMessageId)
});
} catch (error) {
// Ignora errori di "message not modified"
if (!error.message?.includes('message is not modified')) {
console.error('[LIVE] Errore aggiornamento:', error.message);
}
}
}, UPDATE_INTERVAL);
activeSessions.set(key, { interval, userMessageId });
}
/**
* Ferma una sessione live, toglie il pin, elimina i messaggi
*/
async function stopSession(bot, chatId, botMessageId, userMessageId) {
const key = `${chatId}:${botMessageId}`;
const session = activeSessions.get(key);
if (session) {
clearInterval(session.interval);
activeSessions.delete(key);
}
try {
await bot.unpinChatMessage(chatId, { message_id: botMessageId }).catch(() => {});
await bot.deleteMessage(chatId, botMessageId);
if (userMessageId) {
await bot.deleteMessage(chatId, parseInt(userMessageId));
}
} catch (error) {
console.error('[LIVE] Errore chiusura sessione:', error.message);
}
}
/**
* Cerca una sessione attiva dal botMessageId
*/
function getSession(chatId, botMessageId) {
return activeSessions.get(`${chatId}:${botMessageId}`);
}
module.exports = {
liveMarkup,
stopMarkup,
startSession,
stopSession,
getSession
};

View File

@@ -1,78 +0,0 @@
/**
* dataHub.js - Cache centralizzata dei dati del plugin.
*
* Tutti i moduli (realtime, telegram, ecc.) leggono da qui.
* I dati vengono scritti da:
* - realtime/core.js → updateSensorData() (ogni 500ms)
* - index.cjs → updateWeatherData() (ogni 5min)
*
* Nessuna duplicazione: i dati vengono raccolti UNA volta e condivisi.
*/
let latestSensorData = null;
let latestWeatherData = { forecast: null, sea: null };
let lastSensorUpdate = 0;
let lastWeatherUpdate = 0;
/**
* Aggiorna lo snapshot dei dati sensore.
* Chiamato da sendData() in core.js ogni 500ms.
* @param {Object} data - Dati sensore flat (es. { wind_direction: 180, temperature: 22.5 })
*/
function updateSensorData(data) {
latestSensorData = data ? { ...data } : null;
lastSensorUpdate = Date.now();
}
/**
* Aggiorna lo snapshot dei dati meteo.
* Chiamato da fetchAndPublishWeather() in index.cjs.
* @param {Object|null} forecast - Dati previsioni (temperatura, vento, ecc.)
* @param {Object|null} sea - Dati condizioni marine (onde, ecc.)
*/
function updateWeatherData(forecast, sea) {
latestWeatherData = {
forecast: forecast || null,
sea: sea || null
};
lastWeatherUpdate = Date.now();
}
/**
* Legge l'ultimo snapshot dei dati sensore.
* @returns {Object|null} Dati sensore o null se non ancora disponibili
*/
function getSensorData() {
return latestSensorData;
}
/**
* Legge l'ultimo snapshot dei dati meteo.
* @returns {{ forecast: Object|null, sea: Object|null }}
*/
function getWeatherData() {
return latestWeatherData;
}
/**
* Legge tutti i dati disponibili (sensori + meteo) con timestamps.
* @returns {{ sensors: Object|null, weather: Object, timestamps: Object }}
*/
function getAllData() {
return {
sensors: latestSensorData,
weather: latestWeatherData,
timestamps: {
sensorUpdate: lastSensorUpdate,
weatherUpdate: lastWeatherUpdate
}
};
}
module.exports = {
updateSensorData,
updateWeatherData,
getSensorData,
getWeatherData,
getAllData
};

View File

@@ -1,50 +0,0 @@
const SERVICES = {
api: 'https://api.mebboat.it/health',
storage: 'https://storage.mebboat.it/health',
auth: 'https://auth.mebboat.it/health',
realtime: 'https://realtime.mebboat.it/health',
};
/**
* Checks the health of a single service.
* @param {string} url - Health endpoint URL
* @returns {Promise<{ok: boolean, status: string}>}
*/
async function checkService(url) {
try {
const response = await fetch(url, {
headers: { Accept: "application/json" }
});
if (!response.ok) {
return { ok: false, status: `HTTP ${response.status}` };
}
const data = await response.json().catch(() => null);
const isOk = data?.status === "ok";
return { ok: isOk, status: isOk ? 'online' : 'offline' };
} catch (err) {
return { ok: false, status: `error: ${err.message}` };
}
}
/**
* Checks all MEB cloud services in parallel.
* @returns {Promise<Object>} Map of service name -> health result
*/
async function checkAllServices() {
const entries = Object.entries(SERVICES);
const results = await Promise.all(
entries.map(async ([name, url]) => {
const result = await checkService(url);
return [name, result];
})
);
return Object.fromEntries(results);
}
module.exports = {
SERVICES,
checkService,
checkAllServices
};

View File

@@ -0,0 +1,336 @@
const COLS = 24, ROWS = 18;
const SNAP = 0.5;
const SNAP_MAG = 0.3;
const MIN_GW = 2, MIN_GH = 1.5;
const MAX_GW = 20, MAX_GH = 16;
const DEF_GW = 6, DEF_GH = 5;
const canvasEl = document.getElementById('canvas');
const tooltipEl = document.getElementById('tooltip');
const emptyState = document.getElementById('emptyState');
const cardCountEl = document.getElementById('cardCount');
const unitBadge = document.getElementById('unitBadge');
const modalOvl = document.getElementById('modalOverlay');
const modalTitle = document.getElementById('modalTitle');
const importArea = document.getElementById('importArea');
const modalApply = document.getElementById('modalApply');
const toastEl = document.getElementById('toast');
let cards = [], cardIdCounter = 0, selectedCard = null, zCounter = 1;
let snapGuidesH = [], snapGuidesV = [];
let editMode = false;
const uw = () => canvasEl.clientWidth / COLS;
const uh = () => canvasEl.clientHeight / ROWS;
const gSnap = v => Math.round(v / SNAP) * SNAP;
const clamp = (v, lo, hi) => Math.max(lo, Math.min(hi, v));
function screenToGrid(cx, cy) {
const r = canvasEl.getBoundingClientRect();
return { gx: (cx - r.left) / uw(), gy: (cy - r.top) / uh() };
}
function renderCard(c) {
const u = uw(), h = uh();
c.el.style.left = (c.gx * u) + 'px';
c.el.style.top = (c.gy * h) + 'px';
c.el.style.width = (c.gw * u) + 'px';
c.el.style.height = (c.gh * h) + 'px';
if (editMode) c.el.classList.add('editable');
else c.el.classList.remove('editable', 'selected');
}
function renderAll() {
cards.forEach(renderCard);
unitBadge.textContent = `1u = ${Math.round(uw())}px`;
}
function updateCount() {
const n = cards.length;
cardCountEl.textContent = `${n} card${n !== 1 ? 's' : ''}`;
emptyState.classList.toggle('hidden', n > 0);
}
// Responsive re-render
let rafId = null;
window.addEventListener('resize', () => {
if (rafId) cancelAnimationFrame(rafId);
rafId = requestAnimationFrame(renderAll);
});
// Toast
let toastT = null;
function toast(msg) {
toastEl.textContent = msg;
toastEl.classList.add('show');
clearTimeout(toastT);
toastT = setTimeout(() => toastEl.classList.remove('show'), 2200);
}
// Guides
function ensureGuides() {
if (snapGuidesH.length) return;
for (let i = 0; i < 2; i++) {
let g = document.createElement('div'); g.className = 'guide snap-guide horizontal'; canvasEl.appendChild(g); snapGuidesH.push(g);
g = document.createElement('div'); g.className = 'guide snap-guide vertical'; canvasEl.appendChild(g); snapGuidesV.push(g);
}
}
function hideGuides() {
snapGuidesH.forEach(g => g.classList.remove('visible')); snapGuidesV.forEach(g => g.classList.remove('visible'));
}
function showGuide(type, gp, idx = 0) {
if (type === 'h' && idx < snapGuidesH.length) { snapGuidesH[idx].style.top = (gp * uh()) + 'px'; snapGuidesH[idx].classList.add('visible'); }
else if (type === 'v' && idx < snapGuidesV.length) { snapGuidesV[idx].style.left = (gp * uw()) + 'px'; snapGuidesV[idx].classList.add('visible'); }
}
// Magnetic snap
function magSnap(el, gx, gy, gw, gh) {
let sx = gx, sy = gy, gH = [], gV = [];
const others = cards.filter(c => c.el !== el);
let bH = SNAP_MAG + 1;
for (const o of others)
for (const [f, t] of [[gy, o.gy], [gy, o.gy + o.gh], [gy + gh, o.gy], [gy + gh, o.gy + o.gh]]) {
const d = Math.abs(f - t);
if (d < bH) { bH = d; sy = gy + (t - f); gH = [t]; }
}
let bV = SNAP_MAG + 1;
for (const o of others)
for (const [f, t] of [[gx, o.gx], [gx, o.gx + o.gw], [gx + gw, o.gx], [gx + gw, o.gx + o.gw]]) {
const d = Math.abs(f - t);
if (d < bV) { bV = d; sx = gx + (t - f); gV = [t]; }
}
if (bH > SNAP_MAG) sy = gSnap(gy);
if (bV > SNAP_MAG) sx = gSnap(gx);
return { gx: sx, gy: sy, guidesH: gH, guidesV: gV };
}
// Signal K data handling
function updateData(path, value) {
cards.filter(c => c.path === path).forEach(c => {
const body = c.el.querySelector('.card-body');
if (body) {
let displayVal = value;
if (path.includes('speed')) displayVal = (value * 1.94384).toFixed(1) + ' kn';
else if (path.includes('depth')) displayVal = value.toFixed(1) + ' m';
body.textContent = displayVal;
}
});
}
window.updateKioskData = updateData;
// Create card
function createCard(gx, gy, gw = DEF_GW, gh = DEF_GH, forceId = null, gz = null, type = 'widget', path = null) {
const id = forceId || (++cardIdCounter);
if (forceId && forceId >= cardIdCounter) cardIdCounter = forceId;
if (!forceId && id > cardIdCounter) cardIdCounter = id;
const skPaths = window.skPaths || [];
const finalPath = path || (type === 'widget' && skPaths.length ? skPaths[(id - 1) % skPaths.length] : null);
const el = document.createElement('div');
el.className = 'card spawning' + (editMode ? ' editable' : '');
el.dataset.id = id;
el.dataset.type = type;
const z = gz || (++zCounter);
el.style.zIndex = z;
if (gz && gz >= zCounter) zCounter = gz;
let headerHtml = `<span class="card-label">${type === 'map' ? 'Mappa' : (finalPath ? finalPath.split('.').pop() : 'Widget')}</span>`;
// Suggerimento menu path se widget
if (type === 'widget') {
let menuHtml = `<div class="path-menu">`;
skPaths.forEach(p => {
menuHtml += `<div class="path-option" data-path="${p}">${p.split('.').pop()}</div>`;
});
menuHtml += `</div>`;
headerHtml = `<div class="label-wrapper">${headerHtml}${menuHtml}</div>`;
}
el.innerHTML = `
<div class="card-header">
${headerHtml}
<button class="card-close" title="Rimuovi">ELIMINA</button>
</div>
<div class="card-body"></div>
<div class="rh corner nw"></div><div class="rh corner ne"></div>
<div class="rh corner se"></div><div class="rh corner sw"></div>
<div class="rh edge n"></div><div class="rh edge s"></div>
<div class="rh edge e"></div><div class="rh edge w"></div>`;
canvasEl.appendChild(el);
const c = { id, el, gx, gy, gw, gh, type, path: finalPath };
cards.push(c);
renderCard(c);
if (type === 'map') {
const mapDiv = document.createElement('div');
mapDiv.id = `map-container-${id}`;
mapDiv.className = 'card-map-canvas';
el.querySelector('.card-body').appendChild(mapDiv);
if (window.initMapInstance) window.initMapInstance(mapDiv.id);
} else {
updateBody(c);
// Listener per il cambio path
el.querySelectorAll('.path-option').forEach(opt => {
opt.addEventListener('click', (e) => {
e.stopPropagation();
c.path = opt.dataset.path;
el.querySelector('.card-label').textContent = c.path.split('.').pop();
toast(`Path aggiornato: ${c.path}`);
});
});
}
el.addEventListener('animationend', () => el.classList.remove('spawning'), { once: true });
el.querySelector('.card-close').addEventListener('click', ev => { ev.stopPropagation(); removeCard(c); });
el.addEventListener('mousedown', () => { if (editMode) selectCard(c); });
setupDrag(c);
setupResize(c);
updateCount();
return c;
}
function removeCard(c) {
c.el.classList.add('removing');
c.el.addEventListener('animationend', () => {
c.el.remove();
cards = cards.filter(x => x.id !== c.id);
if (selectedCard === c) selectedCard = null;
updateCount();
}, { once: true });
}
function selectCard(c) {
if (selectedCard?.el) selectedCard.el.classList.remove('selected');
selectedCard = c; c.el.classList.add('selected'); c.el.style.zIndex = ++zCounter;
}
function updateBody(c) {
if (c.type === 'map') {
if (window.resizeMapInstance) window.resizeMapInstance();
} else {
const b = c.el.querySelector('.card-body');
if (b && !b.textContent.trim()) {
b.textContent = `${c.gw.toFixed(1)} × ${c.gh.toFixed(1)} u`;
}
}
}
// Drag
function setupDrag(c) {
c.el.addEventListener('mousedown', e => {
if (!editMode) return;
if (e.target.classList.contains('rh') || e.target.classList.contains('card-close')) return;
e.preventDefault(); ensureGuides(); c.el.classList.add('dragging');
const start = screenToGrid(e.clientX, e.clientY);
const oGx = c.gx, oGy = c.gy;
const onMove = ev => {
const now = screenToGrid(ev.clientX, ev.clientY);
let nx = oGx + (now.gx - start.gx), ny = oGy + (now.gy - start.gy);
nx = clamp(nx, 0, COLS - c.gw); ny = clamp(ny, 0, ROWS - c.gh);
const s = magSnap(c.el, nx, ny, c.gw, c.gh);
c.gx = clamp(s.gx, 0, COLS - c.gw); c.gy = clamp(s.gy, 0, ROWS - c.gh);
hideGuides();
s.guidesH.forEach((p, i) => showGuide('h', p, i));
s.guidesV.forEach((p, i) => showGuide('v', p, i));
renderCard(c);
tooltipEl.textContent = `${c.gx.toFixed(1)}, ${c.gy.toFixed(1)}`;
tooltipEl.style.left = (ev.clientX + 14) + 'px'; tooltipEl.style.top = (ev.clientY + 14) + 'px';
tooltipEl.classList.add('visible');
};
const onUp = () => {
c.el.classList.remove('dragging'); hideGuides(); tooltipEl.classList.remove('visible');
document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onUp);
if (c.type === 'map' && window.resizeMapInstance) window.resizeMapInstance();
};
document.addEventListener('mousemove', onMove); document.addEventListener('mouseup', onUp);
});
}
// Resize
function setupResize(c) {
c.el.querySelectorAll('.rh').forEach(h => {
h.addEventListener('mousedown', e => {
if (!editMode) return;
e.preventDefault(); e.stopPropagation(); ensureGuides();
c.el.classList.add('resizing'); selectCard(c);
const start = screenToGrid(e.clientX, e.clientY);
const oGx = c.gx, oGy = c.gy, oGw = c.gw, oGh = c.gh;
const isN = h.classList.contains('nw') || h.classList.contains('ne') || h.classList.contains('n');
const isS = h.classList.contains('sw') || h.classList.contains('se') || h.classList.contains('s');
const isW = h.classList.contains('nw') || h.classList.contains('sw') || h.classList.contains('w');
const isE = h.classList.contains('ne') || h.classList.contains('se') || h.classList.contains('e');
const onMove = ev => {
const now = screenToGrid(ev.clientX, ev.clientY);
const dx = now.gx - start.gx, dy = now.gy - start.gy;
let nw = oGw, nh = oGh, nx = oGx, ny = oGy;
if (isE) nw = oGw + dx; if (isS) nh = oGh + dy;
if (isW) { nw = oGw - dx; nx = oGx + dx; }
if (isN) { nh = oGh - dy; ny = oGy + dy; }
nw = gSnap(clamp(nw, MIN_GW, MAX_GW)); nh = gSnap(clamp(nh, MIN_GH, MAX_GH));
if (isW) nx = oGx + oGw - nw; if (isN) ny = oGy + oGh - nh;
nx = gSnap(clamp(nx, 0, COLS - nw)); ny = gSnap(clamp(ny, 0, ROWS - nh));
c.gx = nx; c.gy = ny; c.gw = nw; c.gh = nh;
renderCard(c); updateBody(c);
tooltipEl.textContent = `${nw.toFixed(1)} × ${nh.toFixed(1)} u`;
tooltipEl.style.left = (ev.clientX + 14) + 'px'; tooltipEl.style.top = (ev.clientY + 14) + 'px';
tooltipEl.classList.add('visible');
};
const onUp = () => {
c.el.classList.remove('resizing'); tooltipEl.classList.remove('visible'); hideGuides();
document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onUp);
};
document.addEventListener('mousemove', onMove); document.addEventListener('mouseup', onUp);
});
});
}
// ═══════════════════════════════════════════════════════
// EXPORT / IMPORT
// ═══════════════════════════════════════════════════════
function exportConfig() {
return JSON.stringify({
canvas: { cols: COLS, rows: ROWS },
cards: cards.map(c => ({
id: c.id, type: c.type,
dimensions: {
x: Math.round(c.gx * 100) / 100, y: Math.round(c.gy * 100) / 100,
z: parseInt(c.el.style.zIndex) || 1,
width: Math.round(c.gw * 100) / 100, height: Math.round(c.gh * 100) / 100
}
}))
}, null, 2);
}
function importConfig(json) {
let data;
try { data = JSON.parse(json); } catch { toast('JSON non valido'); return false; }
if (!data.cards || !Array.isArray(data.cards)) { toast('Formato non valido: serve "cards" array'); return false; }
cards.forEach(c => c.el.remove());
cards = []; selectedCard = null; cardIdCounter = 0; zCounter = 1;
for (const entry of data.cards) {
const d = entry.dimensions || {};
createCard(
clamp(d.x ?? 0, 0, COLS - MIN_GW), clamp(d.y ?? 0, 0, ROWS - MIN_GH),
clamp(d.width ?? DEF_GW, MIN_GW, MAX_GW), clamp(d.height ?? DEF_GH, MIN_GH, MAX_GH),
entry.id ?? null, d.z ?? 1, entry.type ?? 'widget'
);
}
updateCount(); renderAll();
toast(`Importate ${data.cards.length} card`);
return true;
}
updateCount(); renderAll();

View File

@@ -0,0 +1,90 @@
/**
* Kiosk plugin page bootstrap:
* 1) legge config dal <meta> iniettato dal server del plugin
* 2) carica template attivo via API
* 3) apre WS locale SignalK per i valori live
* 4) apre WS "leggero" al realtime server per comandi dalla console
*/
(async function () {
function cfg(name, fallback) {
const m = document.querySelector(`meta[name="${name}"]`);
return (m && m.content) || fallback;
}
const apiUrl = cfg('api-url', 'https://api.mebboat.it');
const realtimeUrl = cfg('realtime-url', 'https://realtime.mebboat.it');
const realtimeWsUrl = cfg('realtime-ws-url', 'wss://realtime.mebboat.it');
const sensorCode = cfg('sensor-code', '');
const sensorName = cfg('sensor-name', '');
window.kiosk.init({ apiUrl, sensorCode, sensorName });
// 1) template iniziale
await window.kiosk.loadTemplate();
// 2) WS locale SignalK
try {
const skWs = new WebSocket(`ws://${location.host}/signalk/v1/stream?subscribe=all`);
skWs.onmessage = (ev) => {
let msg; try { msg = JSON.parse(ev.data); } catch { return; }
for (const u of msg.updates || []) for (const v of u.values || []) {
window.kiosk.updateValue(v.path, v.value);
}
};
skWs.onclose = () => setTimeout(() => location.reload(), 5000);
} catch (e) { console.error('[kiosk] signalk ws error', e); }
// 3) WS controllo verso realtime server
if (!sensorCode || !sensorName) { window.kiosk.setStatus('no sensor config', true); return; }
let controlWs = null;
let reconnectTm = null;
async function fetchSocketToken() {
const r = await fetch(`${realtimeUrl}/connect`, {
method: 'POST', headers: { 'Content-Type':'application/json' },
body: JSON.stringify({ name: sensorName, code: sensorCode })
});
if (!r.ok) return null;
const j = await r.json();
return j.s === 'ok' ? j.t : null;
}
async function connectControl() {
clearTimeout(reconnectTm);
const token = await fetchSocketToken();
if (!token) { reconnectTm = setTimeout(connectControl, 5000); return; }
const url = `${realtimeWsUrl}/kiosk?role=device&sensor=${encodeURIComponent(sensorName)}&token=${encodeURIComponent(token)}`;
controlWs = new WebSocket(url);
controlWs.onopen = () => {
controlWs.send(JSON.stringify({ t:'hello', templateId: window.kiosk.currentTemplateId() }));
};
controlWs.onmessage = async (ev) => {
let m; try { m = JSON.parse(ev.data); } catch { return; }
let ok = true, err = null;
try {
switch (m.t) {
case 'patch_box': ok = window.kiosk.patchBox(m.boxId, m.patch || {}); break;
case 'add_box': ok = window.kiosk.addBox(m.box); break;
case 'remove_box': ok = window.kiosk.removeBox(m.boxId); break;
case 'load_template': {
const tpl = await window.kiosk.loadTemplate(m.templateId);
ok = !!tpl;
break;
}
case 'apply_inline': ok = window.kiosk.applyInline(m.content); break;
case 'persist': ok = true; break; // no-op locale, la persistenza è server-side
case 'reload': location.reload(); return;
default: ok = false; err = 'unknown cmd';
}
} catch (e) { ok = false; err = e.message; }
if (m.cmdId) controlWs.send(JSON.stringify({ t:'ack', cmdId: m.cmdId, ok, err }));
};
controlWs.onclose = () => { reconnectTm = setTimeout(connectControl, 5000); };
controlWs.onerror = () => { try { controlWs.close(); } catch {} };
setInterval(() => { if (controlWs && controlWs.readyState === 1) controlWs.send(JSON.stringify({ t:'heartbeat' })); }, 25000);
}
connectControl();
})();

100
plugin/tools/kiosk/core.js Normal file
View File

@@ -0,0 +1,100 @@
const paths = [
"navigation.speedOverGround",
"environment.depth.belowTransducer",
]
window.skPaths = paths;
mapboxgl.accessToken = 'pk.eyJ1Ijoic2VzZWUzIiwiYSI6ImNtZ2dydndkMDBsNjUya3NjeW91dW41MzcifQ.M2qxj0wL1W7plRzIataojQ';
let map = null;
let boatMark = null;
let followBoat = true;
window.initMapInstance = (containerId) => {
map = new mapboxgl.Map({
container: containerId,
style: {
"version": 8,
"sources": {
"osm": { "type": "raster", "tiles": ["https://a.tile.openstreetmap.org/{z}/{x}/{y}.png"], "tileSize": 256 },
"openseamap": { "type": "raster", "tiles": ["https://tiles.openseamap.org/seamark/{z}/{x}/{y}.png"], "tileSize": 256 }
},
"layers": [
{ "id": "osm-layer", "type": "raster", "source": "osm", "minzoom": 0, "maxzoom": 22 },
{ "id": "openseamap-layer", "type": "raster", "source": "openseamap", "minzoom": 0, "maxzoom": 18 }
]
}
});
map.on('dragstart', () => {
followBoat = false;
});
boatMark = new mapboxgl.Marker({ color: 'red' })
.setLngLat([9, 9])
.addTo(map);
map.on('load', () => {
// Area Protetta mock
map.addSource('area-protetta', {
'type': 'geojson',
'data': {
'type': 'Feature',
'geometry': {
'type': 'Polygon',
'coordinates': [[[9.05, 45.05], [9.15, 45.05], [9.15, 45.15], [9.05, 45.15], [9.05, 45.05]]]
}
}
});
map.addLayer({
'id': 'area-layer',
'type': 'fill',
'source': 'area-protetta',
'paint': { 'fill-color': '#0080ff', 'fill-opacity': 0.3 }
});
});
};
window.resizeMapInstance = () => {
if (map) map.resize();
};
function movePosition(lng, lat) {
if (!followBoat || !map) return;
map.flyTo({ center: [lng, lat], zoom: 14, speed: 1.2 });
}
const host = window.location.host;
const connection = `ws://${host}/signalk/v1/stream?subscribe=all`;
const ws = new WebSocket(connection);
ws.onmessage = (event) => {
const msg = JSON.parse(event.data);
if (msg.updates) {
msg.updates.forEach(update => {
if (update.values) {
update.values.forEach(v => {
// Aggiorna le card nel dashboard tramite canvas.js
if (window.updateKioskData) {
window.updateKioskData(v.path, v.value);
}
if (v.path === "navigation.position" && boatMark) {
const lng = v.value.longitude;
const lat = v.value.latitude;
boatMark.setLngLat([lng, lat]);
movePosition(lng, lat);
}
});
}
});
}
};
ws.onerror = (err) => console.error("Errore WebSocket:", err);
ws.onclose = () => {
console.log("WebSocket chiuso. Riconnessione tra 5s...");
setTimeout(() => location.reload(), 5000);
};
// style: 'mapbox://styles/sesee3/cmn9767jg003l01qsbpmace1t/draft'

View File

@@ -0,0 +1,26 @@
<!DOCTYPE html>
<html lang="it">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Kiosk Dashboard</title>
<script src='https://api.mapbox.com/mapbox-gl-js/v2.14.1/mapbox-gl.js'></script>
<link href='https://api.mapbox.com/mapbox-gl-js/v2.14.1/mapbox-gl.css' rel='stylesheet' />
<link rel="stylesheet" href="style.css">
</head>
<body>
<!-- Main Grid Canvas -->
<div id="canvas" class="canvas">
<div id="emptyState" class="empty-state">Caricamento dei tile in corso</div>
</div>
<!-- UI Feedback & Overlays -->
<div id="tooltip" class="tooltip"></div>
<div id="toast" class="toast"></div>
<script src="canvas.js"></script>
<script src="core.js"></script>
</body>
</html>

Binary file not shown.

Binary file not shown.

View File

@@ -0,0 +1,24 @@
<!DOCTYPE html>
<html lang="it">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Kiosk</title>
<style>
html,body { margin:0; padding:0; height:100%; background:#0b1220; color:#fff; font-family:-apple-system,sans-serif; overflow:hidden; }
#canvas { position:relative; width:100vw; height:100vh; }
.box { position:absolute; border-radius:10px; padding:14px; box-sizing:border-box; display:flex; flex-direction:column; overflow:hidden; transition: background .25s, color .25s, left .25s, top .25s, width .25s, height .25s; }
.box .title { font-size:.9rem; opacity:.65; letter-spacing:.06em; text-transform:uppercase; }
.box .val { flex:1; display:flex; align-items:center; justify-content:center; font-weight:800; font-variant-numeric: tabular-nums; line-height:1; }
.box .unit { opacity:.7; font-size:.6em; margin-left:.25em; }
#statusChip { position:fixed; right:10px; bottom:10px; background:#111827aa; padding:4px 8px; border-radius:999px; font-size:11px; opacity:.6; }
#statusChip.err { background:#dc2626cc; opacity:1; }
</style>
</head>
<body>
<div id="canvas"></div>
<div id="statusChip">boot…</div>
<script src="template-loader.js"></script>
<script src="control-socket.js"></script>
</body>
</html>

View File

@@ -0,0 +1,472 @@
:root {
--card-bg: #101415;
--card-border: #2a2d2e;
--card-border-active: #3a9bff;
--danger: #ff4d4d;
--success: #34d399;
--grid-dot: rgba(255, 255, 255, 0.04);
--snap-line: rgba(50, 152, 255, 0.25);
--cols: 24;
--rows: 18
}
@font-face {
font-family: 'hyperlegible';
src: url('./fonts/atkinson-regular.ttf');
font-weight: normal;
font-style: normal;
}
@font-face {
font-family: 'hyperlegible';
src: url('./fonts/atkinson-bold.ttf');
font-weight: bold;
font-style: normal;
}
body {
font-family: 'hyperlegible', sans-serif;
background-color: black;
color: white
}
.data-card {
border: 1px solid #ccc;
padding: 15px;
margin-bottom: 10px;
border-radius: 8px;
width: 300px;
}
.value {
font-size: 24px;
font-weight: bold;
color: #007bff;
}
/** CAMVAS!!! */
.canvas {
width: 100%;
height: 100%;
position: absolute;
background:
radial-gradient(circle 1px, #ffffff2c 0.8px, transparent 0.4px);
background-size: calc(100% / var(--cols)) calc(100% / var(--rows));
}
/* CARDS */
.card {
position: absolute;
background-color: #101415;
border: 2px dashed var(--card-border);
border-radius: 15px;
cursor: grab;
transition: box-shadow 0.25s cubic-bezier(0.25, 0.46, 0.45, 0.94), border-color 0.25s ease;
will-change: left, top, width, height;
overflow: visible;
/* Necessario per vedere i manigli di resize fuori bordo se necessario */
}
/* Stili Header Card */
.card-header {
height: 32px;
padding: 0 12px;
display: flex;
align-items: center;
justify-content: space-between;
font-size: 11px;
user-select: none;
}
.card-label {
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
}
/* ── Path Picker Menu ────────────────────────────── */
.label-wrapper {
position: relative;
height: 100%;
display: flex;
align-items: center;
}
.path-menu {
display: none;
position: absolute;
top: 28px;
left: -8px;
background: rgba(26, 30, 31, 0.95);
backdrop-filter: blur(10px);
border: 1px solid var(--card-border-active);
border-radius: 8px;
z-index: 2000;
min-width: 180px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5);
padding: 5px 0;
overflow: hidden;
}
.card.editable .label-wrapper:hover .path-menu {
display: block;
animation: spawnIn 0.2s ease-out;
}
.path-option {
padding: 10px 15px;
color: #aaa;
cursor: pointer;
font-size: 10px;
font-weight: 600;
text-transform: uppercase;
transition: all 0.2s;
border-bottom: 1px solid rgba(255, 255, 255, 0.03);
}
.path-option:last-child {
border-bottom: none;
}
.path-option:hover {
background: var(--card-border-active);
color: white;
}
.card-close {
background: none;
border: none;
color: rgba(255, 0, 0, 0.404);
font-size: 10px;
font-weight: 700;
cursor: pointer;
line-height: 1;
padding: 0 4px;
transition: all 0.2s;
}
.card-close:hover {
color: var(--danger);
text-decoration: underline;
}
.card:not(.editable) .card-close {
display: none;
}
.card-body {
padding: 12px;
font-size: 70px;
font-weight: bold;
color: #ffffff;
height: calc(100% - 33px);
display: flex;
align-items: center;
justify-content: center;
}
@keyframes cardSpawn {
0% {
opacity: 0;
transform: scale(0.92);
}
100% {
opacity: 1;
transform: scale(1);
}
}
.card.spawning {
animation: cardSpawn 0.3s cubic-bezier(0.34, 1.56, 0.64, 1) forwards;
}
@keyframes cardRemove {
0% {
opacity: 1;
transform: scale(1);
}
100% {
opacity: 0;
transform: scale(0.88);
}
}
.card.removing {
animation: cardRemove 0.2s ease forwards;
pointer-events: none;
}
/* Stili per le classi dinamiche delle card */
.card.selected {
border-color: var(--card-border-active);
box-shadow: 0 0 15px rgba(50, 152, 255, 0.5);
}
.card.dragging,
.card.resizing {
cursor: grabbing;
opacity: 0.8;
}
/* Stili per gli elementi aggiunti da canvas.js */
.tooltip {
position: fixed;
background-color: rgba(0, 0, 0, 0.7);
color: white;
padding: 5px 10px;
border-radius: 5px;
font-size: 12px;
pointer-events: none;
opacity: 0;
transition: opacity 0.1s ease;
z-index: 2000;
}
.tooltip.visible {
opacity: 1;
}
.empty-state {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
color: #aaa;
font-size: 1.2em;
text-align: center;
pointer-events: none;
z-index: 1;
}
.empty-state.hidden {
display: none;
}
.unit-badge {
position: fixed;
top: 10px;
right: 10px;
background-color: rgba(0, 0, 0, 0.7);
color: white;
padding: 5px 10px;
border-radius: 5px;
font-size: 12px;
z-index: 1000;
}
.toast {
position: fixed;
bottom: 60px;
/* Regola in base all'altezza della toolbar */
left: 50%;
transform: translateX(-50%);
background-color: rgba(0, 0, 0, 0.8);
color: white;
padding: 10px 20px;
border-radius: 8px;
opacity: 0;
visibility: hidden;
transition: opacity 0.3s ease, visibility 0.3s ease;
z-index: 2000;
}
.toast.show {
opacity: 1;
visibility: visible;
}
.modal-overlay {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: rgba(0, 0, 0, 0.7);
display: flex;
justify-content: center;
align-items: center;
z-index: 3000;
opacity: 0;
visibility: hidden;
transition: opacity 0.3s ease, visibility 0.3s ease;
}
.modal-overlay.open {
opacity: 1;
visibility: visible;
}
.modal-content {
background-color: var(--card-bg);
border: 1px solid var(--card-border);
border-radius: 15px;
padding: 20px;
width: 90%;
max-width: 600px;
box-shadow: 0 5px 15px rgba(0, 0, 0, 0.3);
}
.modal-content h2 {
margin-top: 0;
color: white;
}
.modal-content textarea {
width: calc(100% - 20px);
min-height: 200px;
background-color: #2a2d2e;
border: 1px solid #3a3d3e;
color: white;
padding: 10px;
border-radius: 8px;
margin-bottom: 15px;
resize: vertical;
}
.modal-actions {
display: flex;
justify-content: flex-end;
gap: 10px;
}
.modal-actions button {
height: 32px;
padding: 0 13px;
border: none;
border-radius: 7px;
font-family: var(--font);
font-size: 12px;
font-weight: 600;
cursor: pointer;
display: flex;
align-items: center;
gap: 5px;
transition: all 0.2s cubic-bezier(0.25, 0.46, 0.45, 0.94);
white-space: nowrap;
background-color: rgba(255, 255, 255, 0.103);
color: white;
}
.modal-actions button.primary {
background-color: #4da8ff;
}
.modal-actions button:hover {
transform: translateY(-1px);
box-shadow: 0 4px 16px rgba(50, 152, 255, 0.3);
}
/* ── Edit Mode & Animations ──────────────────────── */
@keyframes cardSpawn {
0% { opacity: 0; transform: scale(0.92); }
100% { opacity: 1; transform: scale(1); }
}
@keyframes cardRemove {
0% { opacity: 1; transform: scale(1); }
100% { opacity: 0; transform: scale(0.88); }
}
.card.spawning {
animation: cardSpawn 0.3s cubic-bezier(0.34, 1.56, 0.64, 1) forwards;
}
.card.removing {
animation: cardRemove 0.2s ease forwards;
pointer-events: none;
}
/* Canvas state during editing */
.canvas.edit-active {
outline: 2px dashed rgba(58, 155, 255, 0.3);
outline-offset: -10px;
background-color: rgba(58, 155, 255, 0.02);
}
.card.editable {
border-style: dashed;
}
.card.editable:not(.selected) {
border-color: rgba(255, 255, 255, 0.1);
}
/* Hide handlers when not editing */
.card:not(.editable) .rh {
display: none !important;
}
.card:not(.editable) {
cursor: default;
border-style: solid;
}
/* Rimuovi padding se la card contiene la mappa per farla aderire ai bordi */
.card[data-type="map"] .card-body {
padding: 0;
overflow: hidden;
}
.card-map-canvas {
width: 100%;
height: 100%;
border-radius: 0 0 15px 15px;
overflow: hidden;
}
/* Fix per Mapbox canvas che a volte non prende il 100% se il container è flex */
.card-map-canvas .mapboxgl-canvas-container,
.card-map-canvas .mapboxgl-canvas {
width: 100% !important;
height: 100% !important;
}
.hidden {
display: none !important;
}
.toolbar button.primary {
background-color: var(--card-border-active) !important;
color: white;
}
/* ── Global Edit Mode Overrides ──────────────────── */
body.edit-mode {
background-color: #0a0e0f;
transition: background-color 0.4s ease;
}
body.edit-mode .toolbar {
background: rgba(58, 155, 255, 0.15) !important;
backdrop-filter: blur(25px);
border: 2px dashed var(--card-border-active) !important;
box-shadow: 0 0 20px rgba(58, 155, 255, 0.2);
}
body.edit-mode .toolbar p#cardCount {
color: var(--card-border-active);
}
@keyframes editPulse {
0% { opacity: 0.6; }
50% { opacity: 1; }
100% { opacity: 0.6; }
}
body.edit-mode .canvas.edit-active::after {
content: "DASHBOARD EDITING";
position: fixed;
top: 20px;
right: 20px;
font-size: 10px;
font-weight: 800;
color: var(--card-border-active);
letter-spacing: 2px;
animation: editPulse 2s infinite ease-in-out;
pointer-events: none;
}

View File

@@ -0,0 +1,166 @@
/**
* Kiosk template loader + renderer (display-only).
* Espone window.kiosk con: loadTemplate, applyInline, patchBox, addBox, removeBox, updateValue.
*/
(function () {
const COLS = 24, ROWS = 18;
const canvasEl = document.getElementById('canvas');
const chip = document.getElementById('statusChip');
const state = {
template: null,
boxes: [], // array con .el attaccato
byPath: new Map(), // path → Set<box>
sensorCode: null,
sensorName: null,
apiUrl: null,
};
function setStatus(msg, err) {
chip.textContent = msg;
chip.classList.toggle('err', !!err);
}
function indexPaths() {
state.byPath.clear();
for (const b of state.boxes) {
if (!b.path) continue;
if (!state.byPath.has(b.path)) state.byPath.set(b.path, new Set());
state.byPath.get(b.path).add(b);
}
}
function renderBox(b) {
const uw = canvasEl.clientWidth / COLS;
const uh = canvasEl.clientHeight / ROWS;
b.el.style.left = (b.x * uw) + 'px';
b.el.style.top = (b.y * uh) + 'px';
b.el.style.width = (b.w * uw) + 'px';
b.el.style.height = (b.h * uh) + 'px';
b.el.style.background = b.color || '#1e293b';
b.el.style.color = b.textColor || '#fff';
const titleEl = b.el.querySelector('.title');
const valEl = b.el.querySelector('.val');
titleEl.textContent = b.title || (b.path ? b.path.split('.').pop() : '');
// adatta font-size al box
valEl.style.fontSize = Math.min(b.w * uw, b.h * uh) * 0.35 + 'px';
if (b._lastVal !== undefined) renderValue(b, b._lastVal);
else valEl.innerHTML = '<span style="opacity:.4">—</span>';
}
function renderValue(b, value) {
const valEl = b.el.querySelector('.val');
if (value == null || (typeof value === 'object' && !('longitude' in value))) {
valEl.innerHTML = '<span style="opacity:.4">—</span>';
return;
}
let v = value;
if (typeof v === 'number') {
const mul = typeof b.multiplier === 'number' ? b.multiplier : 1;
const dec = typeof b.decimals === 'number' ? b.decimals : 1;
v = (v * mul).toFixed(dec);
} else if (v && typeof v === 'object' && 'longitude' in v) {
v = v.latitude.toFixed(3) + ', ' + v.longitude.toFixed(3);
}
valEl.innerHTML = String(v) + (b.unit ? `<span class="unit">${b.unit}</span>` : '');
}
function createBoxEl() {
const el = document.createElement('div');
el.className = 'box';
el.innerHTML = '<div class="title"></div><div class="val"></div>';
canvasEl.appendChild(el);
return el;
}
function clearAll() {
for (const b of state.boxes) b.el.remove();
state.boxes = [];
state.byPath.clear();
}
function applyContent(content) {
clearAll();
if (content?.background) document.body.style.background = content.background;
for (const raw of content?.boxes || []) {
const b = { ...raw, el: createBoxEl() };
state.boxes.push(b);
renderBox(b);
}
indexPaths();
}
async function loadTemplate(templateId) {
try {
const url = templateId
? `${state.apiUrl}/kiosk/templates/${templateId}`
: `${state.apiUrl}/kiosk/template/active`;
const r = await fetch(url);
if (!r.ok) { setStatus('no template (' + r.status + ')', true); return null; }
const tpl = await r.json();
state.template = tpl;
applyContent(tpl.content);
setStatus('template ' + tpl.name);
return tpl;
} catch (e) {
setStatus('fetch error', true);
return null;
}
}
function patchBox(boxId, patch) {
const b = state.boxes.find(x => x.id === boxId);
if (!b) return false;
Object.assign(b, patch);
indexPaths();
renderBox(b);
return true;
}
function addBox(boxDef) {
const b = { ...boxDef, el: createBoxEl() };
state.boxes.push(b);
renderBox(b);
indexPaths();
return true;
}
function removeBox(boxId) {
const i = state.boxes.findIndex(b => b.id === boxId);
if (i < 0) return false;
state.boxes[i].el.remove();
state.boxes.splice(i, 1);
indexPaths();
return true;
}
function applyInline(content) {
applyContent(content);
return true;
}
function updateValue(path, value) {
const set = state.byPath.get(path);
if (!set) return;
for (const b of set) {
b._lastVal = value;
renderValue(b, value);
}
}
function init({ apiUrl, sensorCode, sensorName }) {
state.apiUrl = apiUrl;
state.sensorCode = sensorCode;
state.sensorName = sensorName;
}
function currentTemplateId() { return state.template?.id || null; }
let resizeRaf;
window.addEventListener('resize', () => {
cancelAnimationFrame(resizeRaf);
resizeRaf = requestAnimationFrame(() => state.boxes.forEach(renderBox));
});
window.kiosk = { init, loadTemplate, patchBox, addBox, removeBox, applyInline, updateValue, currentTemplateId, setStatus };
})();

View File

@@ -1,72 +0,0 @@
/**
* 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 = "") {
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.length > 0 ? [...pathParts, key] : [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 },
});
}
}
}
const initialPath = prefix ? [prefix] : [];
traverse(data, initialPath);
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) {
return;
}
try {
app.handleMessage("meb", {
updates: [{ values }],
});
} catch (error) {
console.error('[Publisher] Errore pubblicazione:', error.message);
}
}
module.exports = { publish: publishWeatherData };

104
sensors.references.json Normal file
View File

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