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:
7
.gitignore
vendored
7
.gitignore
vendored
@@ -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
|
||||
46
README.md
46
README.md
@@ -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=
|
||||
@@ -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
1442
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
19
package.json
19
package.json
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
138
plugin/config.js
138
plugin/config.js
@@ -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 };
|
||||
73
plugin/config/configManager.js
Normal file
73
plugin/config/configManager.js
Normal 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
171
plugin/config/skFlow.js
Normal 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
|
||||
};
|
||||
53
plugin/config/skSettings.js
Normal file
53
plugin/config/skSettings.js
Normal 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
250
plugin/cores/logs.local.js
Normal 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
261
plugin/cores/openmeteo.js
Normal 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}¤t=${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}¤t=${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
|
||||
};
|
||||
50
plugin/cores/realtime/auth.js
Normal file
50
plugin/cores/realtime/auth.js
Normal 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 };
|
||||
106
plugin/cores/realtime/core.js
Normal file
106
plugin/cores/realtime/core.js
Normal 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
|
||||
};
|
||||
114
plugin/cores/realtime/socket.js
Normal file
114
plugin/cores/realtime/socket.js
Normal 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 };
|
||||
0
plugin/cores/weatherkit.js
Normal file
0
plugin/cores/weatherkit.js
Normal file
276
plugin/index.cjs
276
plugin/index.cjs
@@ -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
118
plugin/index.js
Normal 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;
|
||||
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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">0°</div>
|
||||
<div class="stat-label">Progresso</div>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<div class="stat-value" id="stat-arc">0°</div>
|
||||
<div class="stat-label">Arco attuale</div>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<div class="stat-value" id="stat-remaining">100%</div>
|
||||
<div class="stat-label">Rimanente</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="container"
|
||||
style="max-width:500px; background:white; padding:30px; border-radius:10px; box-shadow:0 4px 15px rgba(0,0,0,0.1);">
|
||||
<h2>Controllo Trasformazioni Freccia</h2>
|
||||
<div class="controls">
|
||||
<div class="control-group">
|
||||
<label for="rotate-input">Rotazione (°)</label>
|
||||
<input type="number" id="rotate-input" value="70" step="1">
|
||||
<div class="value-display" id="rotate-display">70°</div>
|
||||
</div>
|
||||
|
||||
<div class="control-group">
|
||||
<label for="rotate-x">Centro Rot. X</label>
|
||||
<input type="number" id="rotate-x" value="0" step="1">
|
||||
<div class="value-display" id="rotate-x-display">0</div>
|
||||
</div>
|
||||
|
||||
<div class="control-group">
|
||||
<label for="rotate-y">Centro Rot. Y</label>
|
||||
<input type="number" id="rotate-y" value="0" step="1">
|
||||
<div class="value-display" id="rotate-y-display">0</div>
|
||||
</div>
|
||||
|
||||
<div class="control-group">
|
||||
<label for="scale-input">Scala</label>
|
||||
<input type="number" id="scale-input" value="1" step="0.1" min="0.1" max="5">
|
||||
<div class="value-display" id="scale-display">1x</div>
|
||||
</div>
|
||||
|
||||
<div class="control-group">
|
||||
<label for="translate-x">Posizione X</label>
|
||||
<input type="number" id="translate-x" value="80" step="1">
|
||||
<div class="value-display" id="translate-x-display">80</div>
|
||||
</div>
|
||||
|
||||
<div class="control-group">
|
||||
<label for="translate-y">Posizione Y</label>
|
||||
<input type="number" id="translate-y" value="-150" step="1">
|
||||
<div class="value-display" id="translate-y-display">-150</div>
|
||||
</div>
|
||||
|
||||
<div class="control-group full-width">
|
||||
<label>Transform String</label>
|
||||
<textarea id="transform-output" readonly></textarea>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const slider = document.getElementById('mio-slider');
|
||||
const progressCircle = document.getElementById('progress-circle-fill');
|
||||
const progressText = document.getElementById('progress-text');
|
||||
const startAngleInput = document.getElementById('start-angle');
|
||||
const maxAngleInput = document.getElementById('max-angle');
|
||||
const directionToggle = document.getElementById('direction-toggle');
|
||||
const directionText = document.getElementById('direction-text');
|
||||
const directionArrow = document.getElementById('direction-arrow');
|
||||
const fineFreccia = document.getElementById('fine-freccia');
|
||||
const statDegrees = document.getElementById('stat-degrees');
|
||||
const statArc = document.getElementById('stat-arc');
|
||||
const statRemaining = document.getElementById('stat-remaining');
|
||||
|
||||
// Calcoliamo la circonferenza del cerchio
|
||||
const raggio = 90;
|
||||
const circonferenza = 2 * Math.PI * raggio;
|
||||
|
||||
function aggiornaProgresso() {
|
||||
const valore = parseFloat(slider.value);
|
||||
const startAngle = parseFloat(startAngleInput.value) || 0;
|
||||
const maxAngle = parseFloat(maxAngleInput.value) || 40;
|
||||
const isReverse = directionToggle.checked;
|
||||
|
||||
// Calcoliamo l'angolo effettivo del progresso
|
||||
const angoloProgressoEffettivo = (valore / 100) * maxAngle;
|
||||
|
||||
// Calcoliamo la lunghezza dell'arco
|
||||
const lunghezzaArcoAttuale = (angoloProgressoEffettivo / 360) * circonferenza;
|
||||
|
||||
// Impostiamo stroke-dasharray
|
||||
progressCircle.style.strokeDasharray = `${lunghezzaArcoAttuale} ${circonferenza}`;
|
||||
|
||||
// Calcoliamo la rotazione in base alla direzione
|
||||
let rotazione;
|
||||
let rotazioneFreccia;
|
||||
|
||||
if (isReverse) {
|
||||
// Direzione antioraria
|
||||
rotazione = startAngle - 90;
|
||||
progressCircle.style.transform = `rotate(${rotazione}deg) scale(-1, 1)`;
|
||||
// La freccia deve ruotare al contrario e compensare lo scale
|
||||
rotazioneFreccia = -startAngle + 90;
|
||||
|
||||
} else {
|
||||
// Direzione oraria
|
||||
rotazione = startAngle - 90;
|
||||
progressCircle.style.transform = `rotate(${rotazione}deg)`;
|
||||
|
||||
rotazioneFreccia = startAngle - 90;
|
||||
}
|
||||
|
||||
progressCircle.style.transformOrigin = 'center';
|
||||
|
||||
// Ruotiamo la freccia per allinearla al punto iniziale
|
||||
fineFreccia.style.transform = `rotate(${rotazioneFreccia}deg)`;
|
||||
fineFreccia.style.transformOrigin = '100px 100px'; // Centro del cerchio
|
||||
|
||||
// Aggiorniamo il testo
|
||||
progressText.textContent = valore.toFixed(1) + '%';
|
||||
|
||||
// Aggiorniamo le statistiche
|
||||
statDegrees.textContent = angoloProgressoEffettivo.toFixed(1) + '°';
|
||||
statArc.textContent = maxAngle + '°';
|
||||
statRemaining.textContent = (100 - valore).toFixed(1) + '%';
|
||||
|
||||
// Aggiorniamo il colore dello slider
|
||||
slider.style.background = `linear-gradient(to right, #007aff 0%, #007aff ${valore}%, #e0e0e0 ${valore}%, #e0e0e0 100%)`;
|
||||
}
|
||||
|
||||
function aggiornaIndicatoreDirection() {
|
||||
if (directionToggle.checked) {
|
||||
directionText.textContent = 'Antioraria';
|
||||
directionArrow.textContent = '↺';
|
||||
} else {
|
||||
directionText.textContent = 'Oraria';
|
||||
directionArrow.textContent = '↻';
|
||||
}
|
||||
}
|
||||
|
||||
// Event listeners
|
||||
slider.addEventListener('input', aggiornaProgresso);
|
||||
startAngleInput.addEventListener('input', aggiornaProgresso);
|
||||
maxAngleInput.addEventListener('input', aggiornaProgresso);
|
||||
directionToggle.addEventListener('change', () => {
|
||||
aggiornaIndicatoreDirection();
|
||||
aggiornaProgresso();
|
||||
});
|
||||
|
||||
const elementoSvg = document.getElementById('elemento-svg');
|
||||
const rotateInput = document.getElementById('rotate-input');
|
||||
const rotateX = document.getElementById('rotate-x');
|
||||
const rotateY = document.getElementById('rotate-y');
|
||||
const scaleInput = document.getElementById('scale-input');
|
||||
const translateX = document.getElementById('translate-x');
|
||||
const translateY = document.getElementById('translate-y');
|
||||
const transformOutput = document.getElementById('transform-output');
|
||||
|
||||
const rotateDisplay = document.getElementById('rotate-display');
|
||||
const rotateXDisplay = document.getElementById('rotate-x-display');
|
||||
const rotateYDisplay = document.getElementById('rotate-y-display');
|
||||
const scaleDisplay = document.getElementById('scale-display');
|
||||
const translateXDisplay = document.getElementById('translate-x-display');
|
||||
const translateYDisplay = document.getElementById('translate-y-display');
|
||||
|
||||
function aggiornaTransform() {
|
||||
const rotate = parseFloat(rotateInput.value) || 0;
|
||||
const rotX = parseFloat(rotateX.value) || 0;
|
||||
const rotY = parseFloat(rotateY.value) || 0;
|
||||
const scale = parseFloat(scaleInput.value) || 1;
|
||||
const transX = parseFloat(translateX.value) || 0;
|
||||
const transY = parseFloat(translateY.value) || 0;
|
||||
|
||||
const transformString = `rotate(${rotate} ${rotX} ${rotY}) translate(${transX} ${transY}) scale(${scale})`;
|
||||
elementoSvg.setAttribute('transform', transformString);
|
||||
|
||||
rotateDisplay.textContent = `${rotate}°`;
|
||||
rotateXDisplay.textContent = rotX;
|
||||
rotateYDisplay.textContent = rotY;
|
||||
scaleDisplay.textContent = `${scale}x`;
|
||||
translateXDisplay.textContent = transX;
|
||||
translateYDisplay.textContent = transY;
|
||||
|
||||
transformOutput.value = transformString;
|
||||
}
|
||||
|
||||
[rotateInput, rotateX, rotateY, scaleInput, translateX, translateY].forEach(el =>
|
||||
el.addEventListener('input', aggiornaTransform)
|
||||
);
|
||||
|
||||
aggiornaTransform();
|
||||
|
||||
|
||||
// Inizializzazione
|
||||
aggiornaIndicatoreDirection();
|
||||
aggiornaProgresso();
|
||||
|
||||
// --- NEW PROGRESS CIRCLE LOGIC ---
|
||||
const newSlider = document.getElementById('new-slider');
|
||||
const newProgressCircle = document.getElementById('new-progress-circle-fill');
|
||||
const newProgressText = document.getElementById('new-progress-text');
|
||||
const newRaggio = 90;
|
||||
const newCirconferenza = 2 * Math.PI * newRaggio;
|
||||
|
||||
function aggiornaNuovoProgresso() {
|
||||
const valore = parseFloat(newSlider.value);
|
||||
const offset = newCirconferenza - (valore / 100) * newCirconferenza;
|
||||
|
||||
newProgressCircle.style.strokeDasharray = `${newCirconferenza} ${newCirconferenza}`;
|
||||
newProgressCircle.style.strokeDashoffset = offset;
|
||||
|
||||
newProgressText.textContent = valore.toFixed(0) + '%';
|
||||
|
||||
// Update slider background
|
||||
newSlider.style.background = `linear-gradient(to right, #007aff 0%, #007aff ${valore}%, #e0e0e0 ${valore}%, #e0e0e0 100%)`;
|
||||
}
|
||||
|
||||
newSlider.addEventListener('input', aggiornaNuovoProgresso);
|
||||
// Initialize
|
||||
aggiornaNuovoProgresso();
|
||||
</script>
|
||||
|
||||
</body>
|
||||
|
||||
</html>
|
||||
@@ -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
|
||||
};
|
||||
27
plugin/routes/collection/cloud.js
Normal file
27
plugin/routes/collection/cloud.js
Normal 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;
|
||||
16
plugin/routes/collection/dashboard.js
Normal file
16
plugin/routes/collection/dashboard.js
Normal 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;
|
||||
38
plugin/routes/collection/data.js
Normal file
38
plugin/routes/collection/data.js
Normal 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;
|
||||
34
plugin/routes/collection/kiosk.js
Normal file
34
plugin/routes/collection/kiosk.js
Normal 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, '"');
|
||||
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;
|
||||
9
plugin/routes/collection/map.js
Normal file
9
plugin/routes/collection/map.js
Normal 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;
|
||||
55
plugin/routes/collection/rec.js
Normal file
55
plugin/routes/collection/rec.js
Normal 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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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
18
plugin/routes/main.js
Normal 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
|
||||
@@ -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;
|
||||
@@ -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
72
plugin/rules.js
Normal 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
|
||||
};
|
||||
29
plugin/telegram/callbacks/backupback.js
Normal file
29
plugin/telegram/callbacks/backupback.js
Normal 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);
|
||||
}
|
||||
};
|
||||
33
plugin/telegram/callbacks/backupdownload.js
Normal file
33
plugin/telegram/callbacks/backupdownload.js
Normal 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' });
|
||||
}
|
||||
};
|
||||
73
plugin/telegram/callbacks/backupfile.js
Normal file
73
plugin/telegram/callbacks/backupfile.js
Normal 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);
|
||||
}
|
||||
};
|
||||
6
plugin/telegram/callbacks/backupnoop.js
Normal file
6
plugin/telegram/callbacks/backupnoop.js
Normal file
@@ -0,0 +1,6 @@
|
||||
module.exports = {
|
||||
prefix: 'bknoop:',
|
||||
handler: async (bot, query) => {
|
||||
bot.answerCallbackQuery(query.id);
|
||||
}
|
||||
};
|
||||
29
plugin/telegram/callbacks/backuppage.js
Normal file
29
plugin/telegram/callbacks/backuppage.js
Normal 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);
|
||||
}
|
||||
};
|
||||
25
plugin/telegram/callbacks/close.js
Normal file
25
plugin/telegram/callbacks/close.js
Normal 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);
|
||||
}
|
||||
};
|
||||
@@ -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) { }
|
||||
}
|
||||
}
|
||||
];
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
];
|
||||
@@ -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
|
||||
};
|
||||
|
||||
15
plugin/telegram/callbacks/livestop.js
Normal file
15
plugin/telegram/callbacks/livestop.js
Normal 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);
|
||||
}
|
||||
};
|
||||
12
plugin/telegram/callbacks/logbusy.js
Normal file
12
plugin/telegram/callbacks/logbusy.js
Normal 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
|
||||
});
|
||||
}
|
||||
};
|
||||
69
plugin/telegram/callbacks/logfile.js
Normal file
69
plugin/telegram/callbacks/logfile.js
Normal 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);
|
||||
}
|
||||
};
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
];
|
||||
@@ -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.");
|
||||
}
|
||||
}
|
||||
}
|
||||
];
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
];
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
];
|
||||
110
plugin/telegram/commands/backuplogs.js
Normal file
110
plugin/telegram/commands/backuplogs.js
Normal 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
|
||||
};
|
||||
@@ -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
|
||||
};
|
||||
@@ -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
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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
|
||||
};
|
||||
@@ -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 }
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
30
plugin/telegram/commands/marine.js
Normal file
30
plugin/telegram/commands/marine.js
Normal 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')
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -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' });
|
||||
}
|
||||
};
|
||||
@@ -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' }
|
||||
]
|
||||
]
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
17
plugin/telegram/commands/start.js
Normal file
17
plugin/telegram/commands/start.js
Normal 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!');
|
||||
}
|
||||
};
|
||||
@@ -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
|
||||
};
|
||||
@@ -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' });
|
||||
}
|
||||
};
|
||||
@@ -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
121
plugin/telegram/core.js
Normal 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
|
||||
};
|
||||
@@ -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
|
||||
};
|
||||
17
plugin/telegram/utility/close.js
Normal file
17
plugin/telegram/utility/close.js
Normal 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
|
||||
}
|
||||
130
plugin/telegram/utility/live.js
Normal file
130
plugin/telegram/utility/live.js
Normal 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
|
||||
};
|
||||
@@ -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
|
||||
};
|
||||
@@ -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
|
||||
};
|
||||
336
plugin/tools/kiosk/canvas.js
Normal file
336
plugin/tools/kiosk/canvas.js
Normal 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();
|
||||
90
plugin/tools/kiosk/control-socket.js
Normal file
90
plugin/tools/kiosk/control-socket.js
Normal 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
100
plugin/tools/kiosk/core.js
Normal 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'
|
||||
26
plugin/tools/kiosk/dashboard.html
Normal file
26
plugin/tools/kiosk/dashboard.html
Normal 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>
|
||||
BIN
plugin/tools/kiosk/fonts/atkinson-bold.ttf
Normal file
BIN
plugin/tools/kiosk/fonts/atkinson-bold.ttf
Normal file
Binary file not shown.
BIN
plugin/tools/kiosk/fonts/atkinson-regular.ttf
Normal file
BIN
plugin/tools/kiosk/fonts/atkinson-regular.ttf
Normal file
Binary file not shown.
24
plugin/tools/kiosk/kiosk.html
Normal file
24
plugin/tools/kiosk/kiosk.html
Normal 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>
|
||||
472
plugin/tools/kiosk/style.css
Normal file
472
plugin/tools/kiosk/style.css
Normal 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;
|
||||
}
|
||||
166
plugin/tools/kiosk/template-loader.js
Normal file
166
plugin/tools/kiosk/template-loader.js
Normal 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 };
|
||||
})();
|
||||
@@ -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
104
sensors.references.json
Normal 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"
|
||||
}
|
||||
]
|
||||
Reference in New Issue
Block a user