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

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

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

View File

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

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

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

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

View File

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

View File

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

View File

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

View File