@sailingrotevista/rotevista-dash 7.0.8 → 7.0.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (4) hide show
  1. package/app.js +232 -194
  2. package/charts.js +7 -4
  3. package/index.js +706 -658
  4. package/package.json +1 -1
package/index.js CHANGED
@@ -1,181 +1,199 @@
1
1
  /**
2
- * ==========================================================================
3
- * Rotevista Dash Configuration & History Plugin (Pro v6.0)
4
- * ==========================================================================
5
- * Definisce l'interfaccia di configurazione in Signal K Admin e crea
6
- * gli endpoint pubblici per la Dashboard, mantenendo lo storico in RAM.
7
- * file index.js
8
- */
2
+ * ==========================================================================
3
+ * Rotevista Dash Configuration & History Plugin (Pro v6.0)
4
+ * ==========================================================================
5
+ * Definisce l'interfaccia di configurazione in Signal K Admin e crea
6
+ * gli endpoint pubblici per la Dashboard, mantenendo lo storico in RAM.
7
+ * file index.js
8
+ */
9
9
  const https = require('https'); // Importazione del modulo HTTPS nativo di Node.js
10
10
 
11
11
  module.exports = function (app) {
12
- const plugin = {};
13
- plugin.id = 'rotevista-dash';
14
- plugin.name = 'Rotevista Dash Configuration';
15
- plugin.description = 'Configure boat-specific tactical and safety parameters for the Dashboard';
16
-
17
- let currentConfig = {};
18
- let routeRegistered = false;
19
- let unsubscribes = [];
20
-
21
- // ==========================================================================
22
- // COSTANTI DI SVILUPPO (DEVELOPER CONFIG)
23
- // ==========================================================================
24
- const CALM_THRESHOLD_KTS = 1.5; // Soglia di calma piatta (anello a 360°)
25
- const PRESSURE_FILTER_RATIO = 0.40; // Filtro di pressione dinamico (40% del picco per ignorare i cali)
26
-
27
- // Database dello storico in RAM sul server (Sintonizzato Pro v6.0)
28
- let histories = { stw: [], sog: [], depth: [], tws: [], vmg: [], aws: [], twd: [] };
29
- let graphTempBuf = { stw: [], sog: [], depth: [], tws: [], vmg: [], aws: [], twd: [] };
30
- let lastUpdates = { stw: 0, sog: 0, depth: 0, tws: 0, vmg: 0, aws: 0, twd: 0 };
31
- let raw = {};
32
- let lastPathProcessTimes = {}; // Registro dei timestamp per limitazione di frequenza a 1Hz
33
-
34
- // Nuovo database dedicato per gli archi storici della bussola (6 ore = 12 slot)
35
- let windRadarSlots = [];
36
-
37
- // Memoria temporale per rilevare la presenza di sensori nativi sul Cerbo
38
- let lastNativeTwsTime = 0;
39
- let lastNativeTwdTime = 0;
40
-
41
- // Monitoraggio dello scorrere dei blocchi da 30 minuti
42
- let lastFrozen30mSlot = 0;
43
-
44
- // Variabili dedicate al recupero e calcolo previsioni meteo future
45
- let futureForecast = null; // Memorizza la previsione futura { timestamp, tws, twd }
46
- let lastForecast30mSlot = 0; // Orario dell'ultimo blocco orologio scaricato con successo (es. 15:30)
47
-
48
- /**
49
- * plugin.start: Inizializza il plugin.
50
- * Viene chiamato all'avvio e OGNI VOLTA che si salva nella configurazione.
51
- */
52
- plugin.start = function (options) {
53
- // 1. Aggiorna la configurazione in memoria
54
- currentConfig = options;
55
- app.debug(`${plugin.name} started/updated with new options`);
56
-
57
- // Reset dello storico al riavvio del plugin per evitare incoerenze (Sintonizzato Pro v6.0)
58
- histories = { stw: [], sog: [], depth: [], tws: [], vmg: [], aws: [], twd: [] };
59
- graphTempBuf = { stw: [], sog: [], depth: [], tws: [], vmg: [], aws: [], twd: [] };
60
- lastUpdates = { stw: 0, sog: 0, depth: 0, tws: 0, vmg: 0, aws: 0, twd: 0 };
61
- raw = {};
62
- windRadarSlots = []; // Reset degli archi storici della bussola
63
- lastFrozen30mSlot = 0; // Reset del monitor temporale della bussola
64
- futureForecast = null; // Reset della previsione meteo futura
65
- lastForecast30mSlot = 0; // Reset del monitor temporale di scaricamento meteo
66
-
67
- // 2. Registra le rotte API solo la prima volta (Abilitate per CORS remoto)
68
- if (!routeRegistered) {
69
- app.get('/rotevista-config', (req, res) => {
70
- res.header("Access-Control-Allow-Origin", "*"); // Sblocca la Dashboard locale su Mac
71
- res.json(currentConfig);
72
- });
73
- app.get('/rotevista-history', (req, res) => {
74
- res.header("Access-Control-Allow-Origin", "*"); // Sblocca la Dashboard locale su Mac
75
- // Costruiamo un pacchetto unificato che unisce i grafici classici, i dati radar e il futuro
76
- const responseData = {
77
- ...histories,
78
- windRadarSlots: windRadarSlots,
79
- futureForecast: futureForecast,
80
- 'navigation.position': raw['navigation.position'] // Chirurgico: Espone le coordinate GPS correnti per la diagnostica e il radar
12
+ const plugin = {};
13
+ plugin.id = 'rotevista-dash';
14
+ plugin.name = 'Rotevista Dash Configuration';
15
+ plugin.description = 'Configure boat-specific tactical and safety parameters for the Dashboard';
16
+
17
+ let currentConfig = {};
18
+ let routeRegistered = false;
19
+ let unsubscribes = [];
20
+ let pruneInterval = null; // Timer in background per la potatura automatica dei sensori spenti
21
+
22
+ // ==========================================================================
23
+ // COSTANTI DI SVILUPPO (DEVELOPER CONFIG)
24
+ // ==========================================================================
25
+ const CALM_THRESHOLD_KTS = 1.5; // Soglia di calma piatta (anello a 360°)
26
+ const PRESSURE_FILTER_RATIO = 0.40; // Filtro di pressione dinamico (40% del picco per ignorare i cali)
27
+
28
+ // Database dello storico in RAM sul server (Sintonizzato Pro v6.0)
29
+ let histories = { stw: [], sog: [], depth: [], tws: [], vmg: [], aws: [], twd: [] };
30
+ let graphTempBuf = { stw: [], sog: [], depth: [], tws: [], vmg: [], aws: [], twd: [] };
31
+ let lastUpdates = { stw: 0, sog: 0, depth: 0, tws: 0, vmg: 0, aws: 0, twd: 0 };
32
+ let raw = {};
33
+ let lastPathProcessTimes = {}; // Registro dei timestamp per limitazione di frequenza a 1Hz
34
+
35
+ // Nuovo database dedicato per gli archi storici della bussola (6 ore = 12 slot)
36
+ let windRadarSlots = [];
37
+
38
+ // Memoria temporale per rilevare la presenza di sensori nativi sul Cerbo
39
+ let lastNativeTwsTime = 0;
40
+ let lastNativeTwdTime = 0;
41
+
42
+ // Monitoraggio dello scorrere dei blocchi da 30 minuti
43
+ let lastFrozen30mSlot = 0;
44
+
45
+ // Variabili dedicate al recupero e calcolo previsioni meteo future
46
+ let futureForecast = null; // Memorizza la previsione futura { timestamp, tws, twd }
47
+ let lastForecast30mSlot = 0; // Orario dell'ultimo blocco orologio scaricato con successo (es. 15:30)
48
+
49
+ /**
50
+ * plugin.start: Inizializza il plugin.
51
+ * Viene chiamato all'avvio e OGNI VOLTA che si salva nella configurazione.
52
+ */
53
+ plugin.start = function (options) {
54
+ // 1. Aggiorna la configurazione in memoria
55
+ currentConfig = options;
56
+ app.debug(`${plugin.name} started/updated with new options`);
57
+
58
+ // Rimosso il reset distruttivo per garantire la conservazione dei dati in RAM
59
+ // durante il salvataggio dei parametri o il cambio delle calibrazioni delle scale.
60
+
61
+ // 2. Registra le rotte API solo la prima volta (Abilitate per CORS remoto)
62
+ if (!routeRegistered) {
63
+ app.get('/rotevista-config', (req, res) => {
64
+ res.header("Access-Control-Allow-Origin", "*"); // Sblocca la Dashboard locale su Mac
65
+ res.json(currentConfig);
66
+ });
67
+ app.get('/rotevista-history', (req, res) => {
68
+ res.header("Access-Control-Allow-Origin", "*"); // Sblocca la Dashboard locale su Mac
69
+ // Costruiamo un pacchetto unificato che unisce i grafici classici, i dati radar e il futuro
70
+ const responseData = {
71
+ ...histories,
72
+ windRadarSlots: windRadarSlots,
73
+ futureForecast: futureForecast,
74
+ 'navigation.position': raw['navigation.position'] // Chirurgico: Espone le coordinate GPS correnti per la diagnostica e il radar
75
+ };
76
+ res.json(responseData);
77
+ });
78
+ routeRegistered = true;
79
+ app.debug('Public API endpoints registered at /rotevista-config and /rotevista-history');
80
+ }
81
+
82
+ // 3. Iscrizione ai dati dei sensori di bordo tramite Signal K (Ottimizzata a 1Hz)
83
+ const localSubscription = {
84
+ context: 'vessels.self',
85
+ subscribe: [
86
+ { path: 'navigation.position', minPeriod: 1000 },
87
+ { path: 'navigation.speedThroughWater', minPeriod: 1000 },
88
+ { path: 'navigation.speedOverGround', minPeriod: 1000 },
89
+ { path: 'environment.depth.belowTransducer', minPeriod: 1000 },
90
+ { path: 'environment.wind.speedApparent', minPeriod: 1000 },
91
+ { path: 'environment.wind.angleApparent', minPeriod: 1000 },
92
+ { path: 'environment.wind.speedTrue', minPeriod: 1000 },
93
+ { path: 'environment.wind.directionTrue', minPeriod: 1000 },
94
+ { path: 'navigation.headingTrue', minPeriod: 1000 },
95
+ { path: 'navigation.headingMagnetic', minPeriod: 1000 },
96
+ { path: 'navigation.magneticVariation', minPeriod: 1000 },
97
+ { path: 'navigation.courseOverGroundTrue', minPeriod: 1000 }
98
+ ]
81
99
  };
82
- res.json(responseData);
83
- });
84
- routeRegistered = true;
85
- app.debug('Public API endpoints registered at /rotevista-config and /rotevista-history');
86
- }
87
100
 
88
- // 3. Iscrizione ai dati dei sensori di bordo tramite Signal K (Ottimizzata a 1Hz)
89
- const localSubscription = {
90
- context: 'vessels.self',
91
- subscribe: [
92
- { path: 'navigation.position', minPeriod: 1000 },
93
- { path: 'navigation.speedThroughWater', minPeriod: 1000 },
94
- { path: 'navigation.speedOverGround', minPeriod: 1000 },
95
- { path: 'environment.depth.belowTransducer', minPeriod: 1000 },
96
- { path: 'environment.wind.speedApparent', minPeriod: 1000 },
97
- { path: 'environment.wind.angleApparent', minPeriod: 1000 },
98
- { path: 'environment.wind.speedTrue', minPeriod: 1000 },
99
- { path: 'environment.wind.directionTrue', minPeriod: 1000 },
100
- { path: 'navigation.headingTrue', minPeriod: 1000 },
101
- { path: 'navigation.headingMagnetic', minPeriod: 1000 },
102
- { path: 'navigation.magneticVariation', minPeriod: 1000 },
103
- { path: 'navigation.courseOverGroundTrue', minPeriod: 1000 }
104
- ]
105
- };
106
-
107
- app.subscriptionmanager.subscribe(
108
- localSubscription,
109
- unsubscribes,
110
- subscriptionError => {
111
- app.error('Subscription error: ' + subscriptionError);
112
- },
113
- delta => {
114
- if (delta.updates) {
115
- delta.updates.forEach(update => {
116
- if (update.values) {
117
- update.values.forEach(v => {
118
- processIncomingDelta(v.path, v.value);
119
- });
101
+ app.subscriptionmanager.subscribe(
102
+ localSubscription,
103
+ unsubscribes,
104
+ subscriptionError => {
105
+ app.error('Subscription error: ' + subscriptionError);
106
+ },
107
+ delta => {
108
+ if (delta.updates) {
109
+ delta.updates.forEach(update => {
110
+ if (update.values) {
111
+ update.values.forEach(v => {
112
+ processIncomingDelta(v.path, v.value);
113
+ });
114
+ }
115
+ });
116
+ }
120
117
  }
121
- });
118
+ );
119
+
120
+ // Avvia il timer di background per la potatura della RAM ogni 60 secondi
121
+ if (pruneInterval) {
122
+ clearInterval(pruneInterval);
122
123
  }
123
- }
124
- );
125
- };
126
-
127
- /**
128
- * plugin.stop: Chiamato quando il plugin viene disattivato.
129
- */
130
- plugin.stop = function () {
131
- unsubscribes.forEach(f => f());
132
- unsubscribes = [];
133
- app.debug(`${plugin.name} stopped`);
134
- };
135
-
136
- plugin.debug = function(msg) {
137
- app.debug(msg);
138
- };
124
+ pruneInterval = setInterval(pruneStaleHistories, 60000);
125
+ };
126
+
127
+ /**
128
+ * plugin.stop: Chiamato quando il plugin viene disattivato.
129
+ */
130
+ plugin.stop = function () {
131
+ unsubscribes.forEach(f => f());
132
+ unsubscribes = [];
133
+ if (pruneInterval) {
134
+ clearInterval(pruneInterval);
135
+ pruneInterval = null;
136
+ }
137
+ app.debug(`${plugin.name} stopped`);
138
+ };
139
+
140
+ plugin.debug = function(msg) {
141
+ app.debug(msg);
142
+ };
139
143
 
140
144
  /**
141
- * processIncomingDelta: Decodifica i dati dei sensori in Knots/Meters ed esegue l'aggregazione
142
- * Applica un filtro passa-basso continuo a ogni pacchetto e storicizza a 1Hz con dati stabilizzati.
143
- */
144
- function processIncomingDelta(path, val) {
145
+ * processIncomingDelta: Decodifica i dati dei sensori in Knots/Meters ed esegue l'aggregazione
146
+ * Applica un filtro passa-basso continuo a ogni pacchetto e storicizza a 1Hz con dati stabilizzati.
147
+ */
148
+ function processIncomingDelta(path, val) {
145
149
  if (val === null || val === undefined) return;
146
150
 
151
+ // 1. Filtro anti-spike vento (> 100 nodi / 51.44 m/s)
152
+ if ((path === 'environment.wind.speedApparent' || path === 'environment.wind.speedTrue') && val > 51.44) {
153
+ return;
154
+ }
155
+
156
+ // 2. Filtro anti-spike velocità barca STW/SOG (> 50 nodi / 25.72 m/s)
157
+ if ((path === 'navigation.speedThroughWater' || path === 'navigation.speedOverGround') && val > 25.72) {
158
+ return;
159
+ }
160
+
161
+ // 3. Filtro validità profondità (ignora errori negativi e letture > 500m per lost-echo)
162
+ if (path === 'environment.depth.belowTransducer' && (val < -2.0 || val > 500)) {
163
+ return;
164
+ }
147
165
  const now = Date.now();
148
166
  const alpha = 1.0; // Passa-tutto istantaneo (i sensori di bordo ST60+ sono già calibrati con damping hardware a 12)
149
167
 
150
168
  // FILTRO PASSA-BASSO CONTINUO IN TEMPO REALE (Previene gli Spike prima della storicizzazione)
151
169
  if (path === 'navigation.position') {
152
- raw[path] = val; // Le coordinate GPS non sono soggette a filtri di smorzamento o ritardo
153
-
154
- // Chiamata periodica Open-Meteo
155
- if (val.latitude !== undefined && val.longitude !== undefined) {
156
- const current30mSlot = Math.floor(now / 1800000) * 1800000;
157
- if (current30mSlot > lastForecast30mSlot) {
158
- fetchOpenMeteoForecast(val, current30mSlot);
170
+ raw[path] = val; // Le coordinate GPS non sono soggette a filtri di smorzamento o ritardo
171
+
172
+ // Chiamata periodica Open-Meteo
173
+ if (val.latitude !== undefined && val.longitude !== undefined) {
174
+ const current30mSlot = Math.floor(now / 1800000) * 1800000;
175
+ if (current30mSlot > lastForecast30mSlot) {
176
+ fetchOpenMeteoForecast(val, current30mSlot);
177
+ }
159
178
  }
160
- }
161
- return; // Esce subito
179
+ return; // Esce subito
162
180
  }
163
181
 
164
182
  if (path.includes('angle') || path.includes('heading') || path.includes('course') || path.includes('direction') || path.includes('twd')) {
165
- // 1. Caso Angolare (Radianti): Calcolo differenziale circolare per gestire l'oltrepasso dello 0/360 gradi
166
- if (raw[path] !== undefined) {
167
- let diff = Math.atan2(Math.sin(val - raw[path]), Math.cos(val - raw[path]));
168
- raw[path] = (raw[path] + diff * alpha + Math.PI * 2) % (Math.PI * 2);
169
- } else {
170
- raw[path] = val;
171
- }
183
+ // 1. Caso Angolare (Radianti): Calcolo differenziale circolare per gestire l'oltrepasso dello 0/360 gradi
184
+ if (raw[path] !== undefined) {
185
+ let diff = Math.atan2(Math.sin(val - raw[path]), Math.cos(val - raw[path]));
186
+ raw[path] = (raw[path] + diff * alpha + Math.PI * 2) % (Math.PI * 2);
187
+ } else {
188
+ raw[path] = val;
189
+ }
172
190
  } else {
173
- // 2. Caso Lineare (Velocità e Profondità): Smorzamento continuo
174
- if (raw[path] !== undefined) {
175
- raw[path] = (val * alpha) + (raw[path] * (1 - alpha));
176
- } else {
177
- raw[path] = val;
178
- }
191
+ // 2. Caso Lineare (Velocità e Profondità): Smorzamento continuo
192
+ if (raw[path] !== undefined) {
193
+ raw[path] = (val * alpha) + (raw[path] * (1 - alpha));
194
+ } else {
195
+ raw[path] = val;
196
+ }
179
197
  }
180
198
 
181
199
  // LIMITATORE DI FREQUENZA (RATE LIMITER) A 1HZ PER PERCORSO ATTIVO:
@@ -183,7 +201,7 @@ module.exports = function (app) {
183
201
  // leggendo il valore "raw[path]" stabilizzato continuamente dal filtro passa-basso superiore.
184
202
  if (!lastPathProcessTimes[path]) lastPathProcessTimes[path] = 0;
185
203
  if (now - lastPathProcessTimes[path] < 1000) {
186
- return; // Esce subito risparmiando la CPU se il sensore ha già aggiornato nell'ultimo secondo
204
+ return; // Esce subito risparmiando la CPU se il sensore ha già aggiornato nell'ultimo secondo
187
205
  }
188
206
  lastPathProcessTimes[path] = now;
189
207
 
@@ -191,35 +209,44 @@ module.exports = function (app) {
191
209
  const smoothedVal = raw[path];
192
210
 
193
211
  if (path === 'navigation.speedThroughWater') {
194
- manageHistory('stw', smoothedVal * 1.94384);
212
+ manageHistory('stw', smoothedVal * 1.94384);
195
213
  }
196
214
  else if (path === 'navigation.speedOverGround') {
197
- manageHistory('sog', smoothedVal * 1.94384);
215
+ manageHistory('sog', smoothedVal * 1.94384);
198
216
  }
199
217
  else if (path === 'environment.depth.belowTransducer') {
200
- manageHistory('depth', smoothedVal);
218
+ manageHistory('depth', smoothedVal);
201
219
  }
202
220
  else if (path === 'environment.wind.speedApparent') {
203
- manageHistory('aws', smoothedVal * 1.94384);
221
+ manageHistory('aws', smoothedVal * 1.94384);
204
222
  }
205
223
  else if (path === 'environment.wind.angleApparent') {
206
- // Già gestito e normalizzato dal filtro passa-basso superiore
224
+ // Già gestito e normalizzato dal filtro passa-basso superiore
207
225
  }
208
226
  else if (path === 'environment.wind.speedTrue') {
209
- lastNativeTwsTime = now; // Rilevato TWS nativo della centralina!
210
- manageHistory('tws', smoothedVal * 1.94384);
227
+ lastNativeTwsTime = now; // Rilevato TWS nativo della centralina!
228
+ manageHistory('tws', smoothedVal * 1.94384);
211
229
  }
212
230
  // --- DECODIFICA PRUA MAGNETICA SERVER-SIDE ---
213
231
  else if (path === 'navigation.headingMagnetic') {
214
- const hasTrueHdg = raw['navigation.headingTrue'] !== undefined;
215
- if (!hasTrueHdg) {
216
- const variation = raw['navigation.magneticVariation'] || 0;
217
- raw['navigation.headingTrue'] = (smoothedVal + variation + 2 * Math.PI) % (2 * Math.PI);
218
- }
232
+ const hasTrueHdg = raw['navigation.headingTrue'] !== undefined;
233
+ if (!hasTrueHdg) {
234
+ const variation = raw['navigation.magneticVariation'] || 0;
235
+ raw['navigation.headingTrue'] = (smoothedVal + variation + 2 * Math.PI) % (2 * Math.PI);
236
+ }
219
237
  }
220
238
  else if (path === 'environment.wind.directionTrue') {
221
- lastNativeTwdTime = now; // Rilevato TWD nativo della centralina!
222
- manageHistory('twd', smoothedVal);
239
+ lastNativeTwdTime = now; // Rilevato TWD nativo della centralina!
240
+ manageHistory('twd', smoothedVal);
241
+ }
242
+ else if (path === 'navigation.courseOverGroundTrue') {
243
+ // Se non è installata alcuna bussola fisica sulla rete e la barca è in movimento stabile (> 1.5 nodi),
244
+ // emuliamo l'Heading usando il COG per consentire il calcolo del TWD e della bussola radar.
245
+ const hasCompass = raw['navigation.headingTrue'] !== undefined || raw['navigation.headingMagnetic'] !== undefined;
246
+ const sog = raw['navigation.speedOverGround'] || 0;
247
+ if (!hasCompass && sog > 0.77) { // 0.77 m/s = 1.5 nodi
248
+ raw['navigation.headingTrue'] = smoothedVal;
249
+ }
223
250
  }
224
251
 
225
252
  // 2. Calcolo combinato di FALLBACK (Si attiva solo se la centralina non invia TWS/TWD nativi)
@@ -231,165 +258,165 @@ module.exports = function (app) {
231
258
  const cog = raw["navigation.courseOverGroundTrue"] || 0;
232
259
 
233
260
  if (aws !== undefined && awa !== undefined) {
234
- const awsKts = aws * 1.94384;
235
- const stwKts = stw * 1.94384;
236
- const tw_water_x = awsKts * Math.cos(awa) - stwKts;
237
- const tw_water_y = awsKts * Math.sin(awa);
238
-
239
- // Calcoliamo il TWS di fallback solo se non abbiamo visto dati nativi negli ultimi 5 secondi
240
- if (now - lastNativeTwsTime > 5000) {
241
- const tws = Math.sqrt(tw_water_x * tw_water_x + tw_water_y * tw_water_y);
242
- manageHistory('tws', tws);
243
- }
244
-
245
- const twa = Math.atan2(tw_water_y, tw_water_x);
246
-
247
- // La VMG viene sempre calcolata a livello server poiché raramente è nativa
248
- const vmg = Math.abs(stwKts * Math.cos(twa));
249
- manageHistory('vmg', vmg);
250
-
251
- // Calcoliamo il TWD di fallback solo se non abbiamo visto dati nativi negli ultimi 5 secondi
252
- if (hdg !== undefined && (now - lastNativeTwdTime > 5000)) {
253
- const twd = (hdg + twa + 2 * Math.PI) % (2 * Math.PI);
254
- manageHistory('twd', twd);
255
- }
256
- }
257
- }
258
-
259
- /**
260
- * manageHistory: Versione Server-side dell'aggregatore matematico tattico
261
- */
262
- function manageHistory(type, value) {
263
- if (value === undefined || value === null || !isFinite(value)) return;
264
-
265
- const now = Date.now();
266
- const historyMinutes = currentConfig.graphs ? currentConfig.graphs.historyMinutes : 5;
267
- const samples = 60;
268
- const bucketIntervalMs = (historyMinutes * 60000) / samples;
269
-
270
- if (!graphTempBuf[type]) graphTempBuf[type] = [];
271
- if (!histories[type]) histories[type] = [];
272
-
273
- // SINTONIZZAZIONE DI FASE LATO SERVER (UTC Snap)
274
- if (lastUpdates[type] === undefined || lastUpdates[type] === 0) {
275
- lastUpdates[type] = Math.floor(now / bucketIntervalMs) * bucketIntervalMs;
276
- }
261
+ const awsKts = aws * 1.94384;
262
+ const stwKts = stw * 1.94384;
263
+ const tw_water_x = awsKts * Math.cos(awa) - stwKts;
264
+ const tw_water_y = awsKts * Math.sin(awa);
265
+
266
+ // Calcoliamo il TWS di fallback solo se non abbiamo visto dati nativi negli ultimi 5 secondi
267
+ if (now - lastNativeTwsTime > 5000) {
268
+ const tws = Math.sqrt(tw_water_x * tw_water_x + tw_water_y * tw_water_y);
269
+ manageHistory('tws', tws);
270
+ }
277
271
 
278
- const tempBuf = graphTempBuf[type];
272
+ const twa = Math.atan2(tw_water_y, tw_water_x);
273
+
274
+ // La VMG viene sempre calcolata a livello server poiché raramente è nativa
275
+ const vmg = Math.abs(stwKts * Math.cos(twa));
276
+ manageHistory('vmg', vmg);
279
277
 
280
- // Anti-dropout dinamico sul vento forte
281
- if ((type === 'tws' || type === 'aws') && value < 0.05 && tempBuf.length > 0) {
282
- const lastPoint = tempBuf[tempBuf.length - 1];
283
- const reef1 = currentConfig.graphs ? currentConfig.graphs.reef1 : 15;
284
- const glitchThreshold = reef1 * 0.5;
285
- if (lastPoint && lastPoint.val > glitchThreshold) return;
278
+ // Calcoliamo il TWD di fallback solo se non abbiamo visto dati nativi negli ultimi 5 secondi
279
+ if (hdg !== undefined && (now - lastNativeTwdTime > 5000)) {
280
+ const twd = (hdg + twa + 2 * Math.PI) % (2 * Math.PI);
281
+ manageHistory('twd', twd);
282
+ }
283
+ }
286
284
  }
285
+
286
+ /**
287
+ * manageHistory: Versione Server-side dell'aggregatore matematico tattico
288
+ */
289
+ function manageHistory(type, value) {
290
+ if (value === undefined || value === null || !isFinite(value)) return;
287
291
 
288
- // Cattura la velocità del vento corrente (in m/s) al momento della lettura
289
- const currentTws = raw['environment.wind.speedTrue'] || 0;
290
- tempBuf.push({ val: value, tws: currentTws, time: now });
291
-
292
- // Controllo avanzamento del secchiello temporale (Bucket)
293
- const bucketReady = (now - lastUpdates[type] > bucketIntervalMs) || histories[type].length === 0;
294
- if (!bucketReady) return;
295
-
296
- let finalValue = value;
297
- let isTwdType = (type === 'twd');
298
-
299
- if (tempBuf.length > 0) {
300
- // ==========================================================================
301
- // CASO PARTICOLARE: DIREZIONE VENTO (TWD) -> MEDIA PESATA E LIMITI MIN/MAX
302
- // ==========================================================================
303
- if (isTwdType) {
304
- // 1. Identifica il vento massimo del minuto (in m/s)
305
- const twsVals = tempBuf.map(p => p.tws || 0);
306
- const maxTwsInMinute = Math.max(...twsVals, 0);
292
+ const now = Date.now();
293
+ const historyMinutes = currentConfig.graphs ? currentConfig.graphs.historyMinutes : 5;
294
+ const samples = 60;
295
+ const bucketIntervalMs = (historyMinutes * 60000) / samples;
296
+
297
+ if (!graphTempBuf[type]) graphTempBuf[type] = [];
298
+ if (!histories[type]) histories[type] = [];
299
+
300
+ // SINTONIZZAZIONE DI FASE LATO SERVER (UTC Snap)
301
+ if (lastUpdates[type] === undefined || lastUpdates[type] === 0) {
302
+ lastUpdates[type] = Math.floor(now / bucketIntervalMs) * bucketIntervalMs;
303
+ }
307
304
 
308
- // 2. Calcola la soglia dinamica di pressione: max tra 40% del picco e soglia di calma piatta (in m/s)
309
- const calmThresholdMs = CALM_THRESHOLD_KTS / 1.94384;
310
- const pressureThreshold = Math.max(maxTwsInMinute * PRESSURE_FILTER_RATIO, calmThresholdMs);
305
+ const tempBuf = graphTempBuf[type];
311
306
 
312
- // 3. Esclude le direzioni registrate quando il vento era debole (sotto la soglia di pressione)
313
- const activePoints = tempBuf.filter(p => (p.tws || 0) >= pressureThreshold);
314
- const pointsToUse = activePoints.length > 0 ? activePoints : tempBuf; // Fallback se tutto è calma piatta
307
+ // Anti-dropout dinamico sul vento forte
308
+ if ((type === 'tws' || type === 'aws') && value < 0.05 && tempBuf.length > 0) {
309
+ const lastPoint = tempBuf[tempBuf.length - 1];
310
+ const reef1 = currentConfig.graphs ? currentConfig.graphs.reef1 : 15;
311
+ const glitchThreshold = reef1 * 0.5;
312
+ if (lastPoint && lastPoint.val > glitchThreshold) return;
313
+ }
315
314
 
316
- // 4. Media Vettoriale Pesata (TWS * sin, TWS * cos)
317
- let sumSin = 0;
318
- let sumCos = 0;
319
- let totalWeight = 0;
320
- pointsToUse.forEach(p => {
321
- const weight = Math.max(p.tws || 0.1, 0.05); // Evita pesi a zero
322
- sumSin += weight * Math.sin(p.val);
323
- sumCos += weight * Math.cos(p.val);
324
- totalWeight += weight;
325
- });
326
- const avgAngle = Math.atan2(sumSin, sumCos);
327
- const finalAvg = (avgAngle + Math.PI * 2) % (Math.PI * 2);
315
+ // Cattura la velocità del vento corrente (in m/s) al momento della lettura
316
+ const currentTws = raw['environment.wind.speedTrue'] || 0;
317
+ tempBuf.push({ val: value, tws: currentTws, time: now });
318
+
319
+ // Controllo avanzamento del secchiello temporale (Bucket)
320
+ const bucketReady = (now - lastUpdates[type] > bucketIntervalMs) || histories[type].length === 0;
321
+ if (!bucketReady) return;
322
+
323
+ let finalValue = value;
324
+ let isTwdType = (type === 'twd');
325
+
326
+ if (tempBuf.length > 0) {
327
+ // ==========================================================================
328
+ // CASO PARTICOLARE: DIREZIONE VENTO (TWD) -> MEDIA PESATA E LIMITI MIN/MAX
329
+ // ==========================================================================
330
+ if (isTwdType) {
331
+ // 1. Identifica il vento massimo del minuto (in m/s)
332
+ const twsVals = tempBuf.map(p => p.tws || 0);
333
+ const maxTwsInMinute = Math.max(...twsVals, 0);
334
+
335
+ // 2. Calcola la soglia dinamica di pressione: max tra 40% del picco e soglia di calma piatta (in m/s)
336
+ const calmThresholdMs = CALM_THRESHOLD_KTS / 1.94384;
337
+ const pressureThreshold = Math.max(maxTwsInMinute * PRESSURE_FILTER_RATIO, calmThresholdMs);
338
+
339
+ // 3. Esclude le direzioni registrate quando il vento era debole (sotto la soglia di pressione)
340
+ const activePoints = tempBuf.filter(p => (p.tws || 0) >= pressureThreshold);
341
+ const pointsToUse = activePoints.length > 0 ? activePoints : tempBuf; // Fallback se tutto è calma piatta
342
+
343
+ // 4. Media Vettoriale Pesata (TWS * sin, TWS * cos)
344
+ let sumSin = 0;
345
+ let sumCos = 0;
346
+ let totalWeight = 0;
347
+ pointsToUse.forEach(p => {
348
+ const weight = Math.max(p.tws || 0.1, 0.05); // Evita pesi a zero
349
+ sumSin += weight * Math.sin(p.val);
350
+ sumCos += weight * Math.cos(p.val);
351
+ totalWeight += weight;
352
+ });
353
+ const avgAngle = Math.atan2(sumSin, sumCos);
354
+ const finalAvg = (avgAngle + Math.PI * 2) % (Math.PI * 2);
328
355
 
329
- // 5. Calcolo di Min e Max angolare del minuto (rispetto alla media per gestire l'oltrepasso di 0/360°)
330
- let diffs = pointsToUse.map(p => {
331
- let diff = p.val - finalAvg;
332
- return Math.atan2(Math.sin(diff), Math.cos(diff)); // Srotolamento tra -PI e +PI
333
- });
356
+ // 5. Calcolo di Min e Max angolare del minuto (rispetto alla media per gestire l'oltrepasso di 0/360°)
357
+ let diffs = pointsToUse.map(p => {
358
+ let diff = p.val - finalAvg;
359
+ return Math.atan2(Math.sin(diff), Math.cos(diff)); // Srotolamento tra -PI e +PI
360
+ });
334
361
 
335
- const minDiff = Math.min(...diffs);
336
- const maxDiff = Math.max(...diffs);
362
+ const minDiff = Math.min(...diffs);
363
+ const maxDiff = Math.max(...diffs);
337
364
 
338
- const finalMin = (finalAvg + minDiff + Math.PI * 2) % (Math.PI * 2);
339
- const finalMax = (finalAvg + maxDiff + Math.PI * 2) % (Math.PI * 2);
365
+ const finalMin = (finalAvg + minDiff + Math.PI * 2) % (Math.PI * 2);
366
+ const finalMax = (finalAvg + maxDiff + Math.PI * 2) % (Math.PI * 2);
340
367
 
341
- finalValue = {
342
- val: finalAvg,
343
- min: finalMin,
344
- max: finalMax
345
- };
346
- }
347
- // A. VENTO VELOCITÀ -> SUSTAINED PEAK (EMA Time-Aware)
348
- else if (type === 'tws' || type === 'aws') {
349
- const tauMs = 2500;
350
- let ema = tempBuf[0].val;
351
- let maxSustained = ema;
352
-
353
- for (let i = 1; i < tempBuf.length; i++) {
354
- const dt = Math.max(1, tempBuf[i].time - tempBuf[i-1].time);
355
- const alpha = 1 - Math.exp(-dt / tauMs);
356
- ema = (tempBuf[i].val * alpha) + (ema * (1 - alpha));
357
- if (isFinite(ema) && ema > maxSustained) maxSustained = ema;
358
- }
359
- finalValue = maxSustained;
360
- }
361
- // B. PROFONDITÀ -> MINIMO
362
- else if (type === 'depth') {
363
- const vals = tempBuf.map(p => p.val).filter(v => isFinite(v));
364
- if (vals.length > 0) finalValue = Math.min(...vals);
365
- }
366
- // C. VELOCITÀ BARCA / ALTRO -> MEDIA
367
- else {
368
- const vals = tempBuf.map(p => p.val).filter(v => isFinite(v));
369
- if (vals.length > 0) {
370
- const sum = vals.reduce((a, b) => a + b, 0);
371
- finalValue = sum / tempBuf.length;
368
+ finalValue = {
369
+ val: finalAvg,
370
+ min: finalMin,
371
+ max: finalMax
372
+ };
373
+ }
374
+ // A. VENTO VELOCITÀ -> SUSTAINED PEAK (EMA Time-Aware)
375
+ else if (type === 'tws' || type === 'aws') {
376
+ const tauMs = 2500;
377
+ let ema = tempBuf[0].val;
378
+ let maxSustained = ema;
379
+
380
+ for (let i = 1; i < tempBuf.length; i++) {
381
+ const dt = Math.max(1, tempBuf[i].time - tempBuf[i-1].time);
382
+ const alpha = 1 - Math.exp(-dt / tauMs);
383
+ ema = (tempBuf[i].val * alpha) + (ema * (1 - alpha));
384
+ if (isFinite(ema) && ema > maxSustained) maxSustained = ema;
385
+ }
386
+ finalValue = maxSustained;
387
+ }
388
+ // B. PROFONDITÀ -> MINIMO
389
+ else if (type === 'depth') {
390
+ const vals = tempBuf.map(p => p.val).filter(v => isFinite(v));
391
+ if (vals.length > 0) finalValue = Math.min(...vals);
392
+ }
393
+ // C. VELOCITÀ BARCA / ALTRO -> MEDIA
394
+ else {
395
+ const vals = tempBuf.map(p => p.val).filter(v => isFinite(v));
396
+ if (vals.length > 0) {
397
+ const sum = vals.reduce((a, b) => a + b, 0);
398
+ finalValue = sum / tempBuf.length;
399
+ }
400
+ }
372
401
  }
373
- }
374
- }
375
402
 
376
- // Validazione e clamping di sicurezza (non si applica all'oggetto TWD)
377
- if (!isTwdType) {
403
+ // Validazione e clamping di sicurezza (non si applica all'oggetto TWD)
404
+ if (!isTwdType) {
378
405
  if (!isFinite(finalValue)) return;
379
406
  finalValue = Math.max(0, finalValue);
380
407
  histories[type].push({ val: finalValue, time: now });
381
408
 
382
409
  // EMISSIONE DEL DELTA: Se abbiamo calcolato il TWS di fallback, lo trasmettiamo a Signal K
383
410
  if (type === 'tws' && (now - lastNativeTwsTime > 5000)) {
384
- emitDelta('environment.wind.speedTrue', finalValue / 1.94384); // Converte nodi in m/s
411
+ emitDelta('environment.wind.speedTrue', finalValue / 1.94384); // Converte nodi in m/s
385
412
  }
386
- } else {
413
+ } else {
387
414
  // Salvataggio specifico del TWD contenente l'oggetto { val, min, max, time }
388
415
  histories['twd'].push({
389
- val: finalValue.val,
390
- min: finalValue.min,
391
- max: finalValue.max,
392
- time: now
416
+ val: finalValue.val,
417
+ min: finalValue.min,
418
+ max: finalValue.max,
419
+ time: now
393
420
  });
394
421
 
395
422
  // EMISSIONE DEL DELTA: Se abbiamo calcolato il TWD di fallback, lo trasmettiamo a Signal K
@@ -401,198 +428,198 @@ module.exports = function (app) {
401
428
  // --- TRIGGER DI CONGELAMENTO ARCO (Ogni :00 e :30 dell'orologio) ---
402
429
  const current30mSlot = Math.floor(now / 1800000) * 1800000;
403
430
  if (lastFrozen30mSlot === 0) {
404
- lastFrozen30mSlot = current30mSlot; // Inizializzazione al primo avvio
431
+ lastFrozen30mSlot = current30mSlot; // Inizializzazione al primo avvio
405
432
  } else if (current30mSlot > lastFrozen30mSlot) {
406
- // Lo slot da 30 minuti precedente (lastFrozen30mSlot) si è appena concluso!
407
- // Avviamo la procedura di compressione e congelamento dei dati di quel periodo
408
- freeze30mSlot(lastFrozen30mSlot);
409
- lastFrozen30mSlot = current30mSlot;
433
+ // Lo slot da 30 minuti precedente (lastFrozen30mSlot) si è appena concluso!
434
+ // Avviamo la procedura di compressione e congelamento dei dati di quel periodo
435
+ freeze30mSlot(lastFrozen30mSlot);
436
+ lastFrozen30mSlot = current30mSlot;
410
437
  }
411
- }
438
+ }
412
439
 
413
- // Pruning automatico basato sulle impostazioni di timeline
414
- // (Forziamo il server a conservare sempre almeno 60 minuti per il TWD!)
415
- const limitMinutes = (type === 'twd') ? 60 : historyMinutes;
416
- const maxViewportMinutes = limitMinutes * 2;
417
- const maxHistoryMs = (maxViewportMinutes * 60000) + 60000;
440
+ // Pruning automatico basato sulle impostazioni di timeline
441
+ // (Forziamo il server a conservare sempre almeno 60 minuti per il TWD!)
442
+ const limitMinutes = (type === 'twd') ? 60 : historyMinutes;
443
+ const maxViewportMinutes = limitMinutes * 2;
444
+ const maxHistoryMs = (maxViewportMinutes * 60000) + 60000;
445
+
446
+ while (histories[type].length > 0 && (now - histories[type][0].time) > maxHistoryMs) {
447
+ histories[type].shift();
448
+ }
418
449
 
419
- while (histories[type].length > 0 && (now - histories[type][0].time) > maxHistoryMs) {
420
- histories[type].shift();
450
+ graphTempBuf[type] = [];
451
+ // Spostiamo il timer esattamente al confine del secchiello assoluto appena concluso
452
+ lastUpdates[type] = Math.floor(now / bucketIntervalMs) * bucketIntervalMs;
421
453
  }
422
454
 
423
- graphTempBuf[type] = [];
424
- // Spostiamo il timer esattamente al confine del secchiello assoluto appena concluso
425
- lastUpdates[type] = Math.floor(now / bucketIntervalMs) * bucketIntervalMs;
426
- }
427
-
428
- /**
429
- * plugin.schema: Definisce l'interfaccia grafica in Signal K Admin.
430
- */
431
- plugin.schema = {
432
- type: 'object',
433
- title: 'Rotevista Dashboard Settings',
434
- properties: {
435
- // --- SEZIONE ALLARMI PROFONDITÀ ---
436
- alarms: {
437
- type: 'object',
438
- title: 'Depth Safety Alarms',
439
- description: "Configure safety thresholds for depth monitoring based on your boat's draft.",
440
- properties: {
441
- depthDanger: {
442
- type: 'number',
443
- title: 'Emergency Depth (Red + Sound)',
444
- description: "Critical depth level. Below this limit, the display turns RED and the audible 'Bing-Bing' alarm starts.",
445
- default: 2.5
446
- },
447
- depthWarning: {
448
- type: 'number',
449
- title: 'Safety Margin (Yellow)',
450
- description: "Shallow water warning. The depth value turns YELLOW below this threshold to alert you to pay attention.",
451
- default: 5.0
452
- }
453
- }
454
- },
455
- // --- SEZIONE GRAFICI E REEF ---
456
- graphs: {
457
- type: 'object',
458
- title: 'Performance History & Reef Alerts',
459
- description: "Settings for chart timelines and tactical wind alerts.",
460
- properties: {
461
- reef1: {
462
- type: 'number',
463
- title: '1st Reef Alert (Orange)',
464
- description: "Wind speed at which the graph turns orange. This threshold applies to the active mode: in TWS it indicates weather intensity, in AWS it indicates pressure on sails/rigging.",
465
- default: 15.0
466
- },
467
- reef2: {
468
- type: 'number',
469
- title: '2nd Reef Alert (Red)',
470
- description: "Critical wind speed at which the graph turns red. This threshold applies to the active mode: in TWS it warns of high sea state, in AWS it warns of excessive load on the mast/sails.",
471
- default: 20.0
472
- },
473
- historyMinutes: {
474
- type: 'number',
475
- title: 'Strategic Timeline (Minutes)',
476
- description: "Sets the time duration for all charts. It also defines the comparison window for the Strategic Weather Trend (the dot in the TWD compass) to detect long-term wind shifts.",
477
- default: 5,
478
- enum: [5, 10, 15, 30, 60]
479
- }
480
- }
481
- },
482
- // --- SEZIONE MEDIE E STABILITÀ ---
483
- averaging: {
484
- type: 'object',
485
- title: 'Tactical Brain & Stability',
486
- description: "Fine-tune how the dashboard reacts to boat movements and maneuvers.",
487
- properties: {
488
- longWindow: {
489
- type: 'number',
490
- title: 'Decision Stability Window (ms)',
491
- description: "The time range used to calculate MEAN values. A longer window (e.g. 30s) provides a solid base for strategy, while a shorter one reacts faster to every oscillation.",
492
- default: 30000
493
- },
494
- smoothWindow: {
495
- type: 'number',
496
- title: 'Needle Fluidity (ms)',
497
- description: "Controls how smoothly pointers move. It filters out sensor 'shaking' without delaying the real-time feel.",
498
- default: 2000
499
- },
500
- minSpeed: {
501
- type: 'number',
502
- title: 'Harbor Silence (knots)',
503
- description: "Minimum speed required to enable orange blinking alerts. This prevents the display from flashing due to GPS noise while docked or at anchor.",
504
- default: 0.5
505
- },
506
- stabilityThreshold: {
507
- type: 'number',
508
- title: 'Steering Precision (Sensitivity)',
509
- description: "How strictly the system judges data coherence (0.0 to 1.0). Due to internal smoothing, 0.97-0.98 requires racing precision; 0.93-0.95 is ideal for cruising in waves. Below this, the display rarely alerts for instability.",
510
- default: 0.95
511
- },
512
- stabilityBreakout: {
513
- type: 'number',
514
- title: 'Maneuver Detection Limit (degrees)',
515
- description: "If the boat or wind shifts more than these degrees, the display blinks orange to warn you that the current average is no longer reliable.",
516
- default: 15
517
- }
518
- }
519
- },
520
- // --- SEZIONE CALIBRAZIONE SCALE ---
521
- scales: {
455
+ /**
456
+ * plugin.schema: Definisce l'interfaccia grafica in Signal K Admin.
457
+ */
458
+ plugin.schema = {
522
459
  type: 'object',
523
- title: 'Chart Scale Calibration',
524
- description: "Customize how charts adapt to your boat's performance in both Standard and Hercules Zoom modes.",
460
+ title: 'Rotevista Dashboard Settings',
525
461
  properties: {
526
- stw: {
527
- type: 'object',
528
- title: 'STW (Speed Through Water)',
529
- properties: {
530
- stdMax: { type: 'number', title: 'Standard Max', description: "Default top limit of the graph.", default: 12 },
531
- step: { type: 'number', title: 'Scale Jump', description: "Amount the scale increases when you exceed the limit.", default: 2 },
532
- hercSpan: {
533
- type: 'number',
534
- title: 'Hercules Grid Step (Resolution)',
535
- description: "Select the multiplier step for the Hercules zoom. The scale boundaries will always snap to multiples of this value.",
536
- enum: [0.5, 1.0, 2.0, 3.0],
537
- default: 1.0
538
- }
539
- }
540
- },
541
- sog: {
542
- type: 'object',
543
- title: 'SOG (Speed Over Ground)',
544
- properties: {
545
- stdMax: { type: 'number', title: 'Standard Max', default: 12 },
546
- step: { type: 'number', title: 'Scale Jump', default: 2 },
547
- hercSpan: {
548
- type: 'number',
549
- title: 'Hercules Grid Step (Resolution)',
550
- description: "Select the multiplier step for the Hercules zoom. The scale boundaries will always snap to multiples of this value.",
551
- enum: [0.5, 1.0, 2.0, 3.0],
552
- default: 1.0
553
- }
554
- }
555
- },
556
- tws: {
557
- type: 'object',
558
- title: 'TWS (True Wind Speed)',
559
- properties: {
560
- stdMax: { type: 'number', title: 'Standard Max', default: 25 },
561
- step: { type: 'number', title: 'Scale Jump', default: 5 },
562
- hercSpan: {
563
- type: 'number',
564
- title: 'Hercules Grid Step (Resolution)',
565
- description: "Select the multiplier step for the Hercules zoom. The scale boundaries will always snap to multiples of this value.",
566
- enum: [1, 2, 3, 5, 10],
567
- default: 2
568
- }
569
- }
570
- },
571
- depth: {
572
- type: 'object',
573
- title: 'Depth',
574
- properties: {
575
- stdMax: { type: 'number', title: 'Standard Max', default: 20 },
576
- step: { type: 'number', title: 'Scale Jump', default: 10 },
577
- hercSpan: {
578
- type: 'number',
579
- title: 'Hercules Grid Step (Resolution)',
580
- description: "Select the multiplier step for the Hercules zoom. The scale boundaries will always snap to multiples of this value.",
581
- enum: [1, 2, 3, 5, 10],
582
- default: 2
583
- }
462
+ // --- SEZIONE ALLARMI PROFONDITÀ ---
463
+ alarms: {
464
+ type: 'object',
465
+ title: 'Depth Safety Alarms',
466
+ description: "Configure safety thresholds for depth monitoring based on your boat's draft.",
467
+ properties: {
468
+ depthDanger: {
469
+ type: 'number',
470
+ title: 'Emergency Depth (Red + Sound)',
471
+ description: "Critical depth level. Below this limit, the display turns RED and the audible 'Bing-Bing' alarm starts.",
472
+ default: 2.5
473
+ },
474
+ depthWarning: {
475
+ type: 'number',
476
+ title: 'Safety Margin (Yellow)',
477
+ description: "Shallow water warning. The depth value turns YELLOW below this threshold to alert you to pay attention.",
478
+ default: 5.0
479
+ }
480
+ }
481
+ },
482
+ // --- SEZIONE GRAFICI E REEF ---
483
+ graphs: {
484
+ type: 'object',
485
+ title: 'Performance History & Reef Alerts',
486
+ description: "Settings for chart timelines and tactical wind alerts.",
487
+ properties: {
488
+ reef1: {
489
+ type: 'number',
490
+ title: '1st Reef Alert (Orange)',
491
+ description: "Wind speed at which the graph turns orange. This threshold applies to the active mode: in TWS it indicates weather intensity, in AWS it indicates pressure on sails/rigging.",
492
+ default: 15.0
493
+ },
494
+ reef2: {
495
+ type: 'number',
496
+ title: '2nd Reef Alert (Red)',
497
+ description: "Critical wind speed at which the graph turns red. This threshold applies to the active mode: in TWS it warns of high sea state, in AWS it warns of excessive load on the mast/sails.",
498
+ default: 20.0
499
+ },
500
+ historyMinutes: {
501
+ type: 'number',
502
+ title: 'Strategic Timeline (Minutes)',
503
+ description: "Sets the time duration for all charts. It also defines the comparison window for the Strategic Weather Trend (the dot in the TWD compass) to detect long-term wind shifts.",
504
+ default: 5,
505
+ enum: [5, 10, 15, 30, 60]
506
+ }
507
+ }
508
+ },
509
+ // --- SEZIONE MEDIE E STABILITÀ ---
510
+ averaging: {
511
+ type: 'object',
512
+ title: 'Tactical Brain & Stability',
513
+ description: "Fine-tune how the dashboard reacts to boat movements and maneuvers.",
514
+ properties: {
515
+ longWindow: {
516
+ type: 'number',
517
+ title: 'Decision Stability Window (ms)',
518
+ description: "The time range used to calculate MEAN values. A longer window (e.g. 30s) provides a solid base for strategy, while a shorter one reacts faster to every oscillation.",
519
+ default: 30000
520
+ },
521
+ smoothWindow: {
522
+ type: 'number',
523
+ title: 'Needle Fluidity (ms)',
524
+ description: "Controls how smoothly pointers move. It filters out sensor 'shaking' without delaying the real-time feel.",
525
+ default: 2000
526
+ },
527
+ minSpeed: {
528
+ type: 'number',
529
+ title: 'Harbor Silence (knots)',
530
+ description: "Minimum speed required to enable orange blinking alerts. This prevents the display from flashing due to GPS noise while docked or at anchor.",
531
+ default: 0.5
532
+ },
533
+ stabilityThreshold: {
534
+ type: 'number',
535
+ title: 'Steering Precision (Sensitivity)',
536
+ description: "How strictly the system judges data coherence (0.0 to 1.0). Due to internal smoothing, 0.97-0.98 requires racing precision; 0.93-0.95 is ideal for cruising in waves. Below this, the display rarely alerts for instability.",
537
+ default: 0.95
538
+ },
539
+ stabilityBreakout: {
540
+ type: 'number',
541
+ title: 'Maneuver Detection Limit (degrees)',
542
+ description: "If the boat or wind shifts more than these degrees, the display blinks orange to warn you that the current average is no longer reliable.",
543
+ default: 15
544
+ }
545
+ }
546
+ },
547
+ // --- SEZIONE CALIBRAZIONE SCALE ---
548
+ scales: {
549
+ type: 'object',
550
+ title: 'Chart Scale Calibration',
551
+ description: "Customize how charts adapt to your boat's performance in both Standard and Hercules Zoom modes.",
552
+ properties: {
553
+ stw: {
554
+ type: 'object',
555
+ title: 'STW (Speed Through Water)',
556
+ properties: {
557
+ stdMax: { type: 'number', title: 'Standard Max', description: "Default top limit of the graph.", default: 12 },
558
+ step: { type: 'number', title: 'Scale Jump', description: "Amount the scale increases when you exceed the limit.", default: 2 },
559
+ hercSpan: {
560
+ type: 'number',
561
+ title: 'Hercules Grid Step (Resolution)',
562
+ description: "Select the multiplier step for the Hercules zoom. The scale boundaries will always snap to multiples of this value.",
563
+ enum: [0.5, 1.0, 2.0, 3.0],
564
+ default: 1.0
565
+ }
566
+ }
567
+ },
568
+ sog: {
569
+ type: 'object',
570
+ title: 'SOG (Speed Over Ground)',
571
+ properties: {
572
+ stdMax: { type: 'number', title: 'Standard Max', default: 12 },
573
+ step: { type: 'number', title: 'Scale Jump', default: 2 },
574
+ hercSpan: {
575
+ type: 'number',
576
+ title: 'Hercules Grid Step (Resolution)',
577
+ description: "Select the multiplier step for the Hercules zoom. The scale boundaries will always snap to multiples of this value.",
578
+ enum: [0.5, 1.0, 2.0, 3.0],
579
+ default: 1.0
580
+ }
581
+ }
582
+ },
583
+ tws: {
584
+ type: 'object',
585
+ title: 'TWS (True Wind Speed)',
586
+ properties: {
587
+ stdMax: { type: 'number', title: 'Standard Max', default: 25 },
588
+ step: { type: 'number', title: 'Scale Jump', default: 5 },
589
+ hercSpan: {
590
+ type: 'number',
591
+ title: 'Hercules Grid Step (Resolution)',
592
+ description: "Select the multiplier step for the Hercules zoom. The scale boundaries will always snap to multiples of this value.",
593
+ enum: [1, 2, 3, 5, 10],
594
+ default: 2
595
+ }
596
+ }
597
+ },
598
+ depth: {
599
+ type: 'object',
600
+ title: 'Depth',
601
+ properties: {
602
+ stdMax: { type: 'number', title: 'Standard Max', default: 20 },
603
+ step: { type: 'number', title: 'Scale Jump', default: 10 },
604
+ hercSpan: {
605
+ type: 'number',
606
+ title: 'Hercules Grid Step (Resolution)',
607
+ description: "Select the multiplier step for the Hercules zoom. The scale boundaries will always snap to multiples of this value.",
608
+ enum: [1, 2, 3, 5, 10],
609
+ default: 2
610
+ }
611
+ }
612
+ }
613
+ }
584
614
  }
585
- }
586
615
  }
587
- }
588
- }
589
- };
590
-
591
- /**
592
- * freeze30mSlot: Consolida e congela i dati di una specifica mezz'ora.
593
- * Raccoglie i 30 record da 1 minuto, estrae i picchi, unisce gli angoli
594
- * e applica la scrematura del percentile al 5% prima di salvare lo slot.
595
- */
616
+ };
617
+
618
+ /**
619
+ * freeze30mSlot: Consolida e congela i dati di una specifica mezz'ora.
620
+ * Raccoglie i 30 record da 1 minuto, estrae i picchi, unisce gli angoli
621
+ * e applica la scrematura del percentile al 5% prima di salvare lo slot.
622
+ */
596
623
  function freeze30mSlot(slotTimestamp) {
597
624
  const startTime = slotTimestamp;
598
625
  const endTime = slotTimestamp + 1800000; // 30 minuti in millisecondi
@@ -609,64 +636,64 @@ module.exports = function (app) {
609
636
  const minTws = twsVals.length > 0 ? Math.min(...twsVals) : 0; // Chirurgico: Calcoliamo il vento minimo del periodo
610
637
 
611
638
  // 3. Estrae tutti gli estremi angolari catturati minuto per minuto
612
- let allAngles = [];
613
- twdPoints.forEach(p => {
614
- allAngles.push(p.val);
615
- allAngles.push(p.min);
616
- allAngles.push(p.max);
617
- });
618
-
619
- // 4. Calcola la direzione media vettoriale complessiva della mezz'ora per srotolare gli angoli
620
- let sumSin = 0;
621
- let sumCos = 0;
622
- allAngles.forEach(a => {
623
- sumSin += Math.sin(a);
624
- sumCos += Math.cos(a);
625
- });
626
- const avgAngle = Math.atan2(sumSin, sumCos);
627
- const finalAvg = (avgAngle + Math.PI * 2) % (Math.PI * 2);
628
-
629
- // 5. Srotola gli angoli rispetto alla direzione media per gestire l'oltrepasso dello 0/360°
630
- let diffs = allAngles.map(a => {
631
- let diff = a - finalAvg;
632
- return Math.atan2(Math.sin(diff), Math.cos(diff)); // Mappa tra -PI e +PI
633
- });
634
-
635
- // 6. ORDINA LE DIFFERENZE ED APPLICA IL TAGLIO PERCENTILE DEL 5% PER LATO (TRIM)
636
- diffs.sort((a, b) => a - b);
637
- const trimCount = Math.floor(diffs.length * 0.05); // Scarta il 5% dei disturbi a sinistra e a destra
638
- const activeDiffs = diffs.slice(trimCount, diffs.length - trimCount);
639
-
640
- // Fallback se ci sono pochissimi campioni
641
- const finalDiffs = activeDiffs.length > 0 ? activeDiffs : diffs;
642
-
643
- const minDiff = Math.min(...finalDiffs);
644
- const maxDiff = Math.max(...finalDiffs);
645
-
646
- const finalMin = (finalAvg + minDiff + Math.PI * 2) % (Math.PI * 2);
647
- const finalMax = (finalAvg + maxDiff + Math.PI * 2) % (Math.PI * 2);
639
+ let allAngles = [];
640
+ twdPoints.forEach(p => {
641
+ allAngles.push(p.val);
642
+ allAngles.push(p.min);
643
+ allAngles.push(p.max);
644
+ });
645
+
646
+ // 4. Calcola la direzione media vettoriale complessiva della mezz'ora per srotolare gli angoli
647
+ let sumSin = 0;
648
+ let sumCos = 0;
649
+ allAngles.forEach(a => {
650
+ sumSin += Math.sin(a);
651
+ sumCos += Math.cos(a);
652
+ });
653
+ const avgAngle = Math.atan2(sumSin, sumCos);
654
+ const finalAvg = (avgAngle + Math.PI * 2) % (Math.PI * 2);
655
+
656
+ // 5. Srotola gli angoli rispetto alla direzione media per gestire l'oltrepasso dello 0/360°
657
+ let diffs = allAngles.map(a => {
658
+ let diff = a - finalAvg;
659
+ return Math.atan2(Math.sin(diff), Math.cos(diff)); // Mappa tra -PI e +PI
660
+ });
661
+
662
+ // 6. ORDINA LE DIFFERENZE ED APPLICA IL TAGLIO PERCENTILE DEL 5% PER LATO (TRIM)
663
+ diffs.sort((a, b) => a - b);
664
+ const trimCount = Math.floor(diffs.length * 0.05); // Scarta il 5% dei disturbi a sinistra e a destra
665
+ const activeDiffs = diffs.slice(trimCount, diffs.length - trimCount);
666
+
667
+ // Fallback se ci sono pochissimi campioni
668
+ const finalDiffs = activeDiffs.length > 0 ? activeDiffs : diffs;
669
+
670
+ const minDiff = Math.min(...finalDiffs);
671
+ const maxDiff = Math.max(...finalDiffs);
672
+
673
+ const finalMin = (finalAvg + minDiff + Math.PI * 2) % (Math.PI * 2);
674
+ const finalMax = (finalAvg + maxDiff + Math.PI * 2) % (Math.PI * 2);
648
675
 
649
676
  // 7. Salva l'arco compresso e pulito nello store dedicato
650
- windRadarSlots.push({
651
- timestamp: startTime,
652
- twdMin: finalMin,
653
- twdMax: finalMax,
654
- twsPeak: maxTws,
655
- twsMin: minTws // Chirurgico: Salviamo il vento minimo per poter tracciare la variabilità (Gust Factor)
656
- });
677
+ windRadarSlots.push({
678
+ timestamp: startTime,
679
+ twdMin: finalMin,
680
+ twdMax: finalMax,
681
+ twsPeak: maxTws,
682
+ twsMin: minTws // Chirurgico: Salviamo il vento minimo per poter tracciare la variabilità (Gust Factor)
683
+ });
657
684
 
658
- // Pruning: manteniamo in RAM solo le ultime 6 ore (12 slot)
659
- while (windRadarSlots.length > 12) {
660
- windRadarSlots.shift();
661
- }
685
+ // Pruning: manteniamo in RAM solo le ultime 6 ore (12 slot)
686
+ while (windRadarSlots.length > 12) {
687
+ windRadarSlots.shift();
688
+ }
662
689
 
663
- app.debug(`💨 [Wind Radar] Locked 30m slot at ${new Date(startTime).toLocaleTimeString()}: TWD ${Math.round(finalMin * 180 / Math.PI)}°-${Math.round(finalMax * 180 / Math.PI)}°, TWS Peak ${maxTws.toFixed(1)} kts`);
664
- }
690
+ app.debug(`💨 [Wind Radar] Locked 30m slot at ${new Date(startTime).toLocaleTimeString()}: TWD ${Math.round(finalMin * 180 / Math.PI)}°-${Math.round(finalMax * 180 / Math.PI)}°, TWS Peak ${maxTws.toFixed(1)} kts`);
691
+ }
665
692
 
666
- /**
667
- * fetchOpenMeteoForecast: Recupera le previsioni orarie accoppiate (Seamless) da Open-Meteo.
668
- * Utilizza il modello integrato /forecast per evitare zone d'ombra in rada, con tempo forzato in UTC.
669
- */
693
+ /**
694
+ * fetchOpenMeteoForecast: Recupera le previsioni orarie accoppiate (Seamless) da Open-Meteo.
695
+ * Utilizza il modello integrato /forecast per evitare zone d'ombra in rada, con tempo forzato in UTC.
696
+ */
670
697
  function fetchOpenMeteoForecast(position, current30mSlot) {
671
698
  if (!position || position.latitude === undefined || position.longitude === undefined) return;
672
699
 
@@ -679,136 +706,157 @@ module.exports = function (app) {
679
706
  lastForecast30mSlot = current30mSlot; // Aggiorna preventivamente lo slot per evitare chiamate simultanee in caso di rallentamento di rete
680
707
 
681
708
  https.get(url, (res) => {
682
- if (res.statusCode !== 200) {
683
- app.error(`[Open-Meteo] HTTP Error: ${res.statusCode}`);
684
- res.resume();
685
- lastForecast30mSlot = 0; // Reset in caso di errore per permettere un tentativo al prossimo pacchetto GPS
686
- return;
687
- }
688
-
689
- let data = '';
690
- res.on('data', (chunk) => { data += chunk; });
691
- res.on('end', () => {
692
- try {
693
- const parsed = JSON.parse(data);
694
- if (!parsed.hourly || !parsed.hourly.time) {
695
- app.error('[Open-Meteo] Invalid API response format');
696
- lastForecast30mSlot = 0;
709
+ if (res.statusCode !== 200) {
710
+ app.error(`[Open-Meteo] HTTP Error: ${res.statusCode}`);
711
+ res.resume();
712
+ lastForecast30mSlot = 0; // Reset in caso di errore per permettere un tentativo al prossimo pacchetto GPS
697
713
  return;
698
- }
699
-
700
- const times = parsed.hourly.time;
701
- const speeds = parsed.hourly.wind_speed_10m;
702
- const directions = parsed.hourly.wind_direction_10m;
703
- const gusts = parsed.hourly.wind_gusts_10m || []; // Chirurgico: Catturiamo le raffiche dal payload JSON
704
-
705
- // Costruiamo la serie storica delle previsioni in formato UTC
706
- const forecastList = [];
707
- for (let i = 0; i < times.length; i++) {
708
- // Forziamo il parsing UTC aggiungendo la dicitura 'Z' alla stringa ISO prodotta da Open-Meteo
709
- const epoch = Date.parse(times[i] + "Z");
710
- if (isNaN(epoch)) continue;
711
-
712
- // Convertiamo la direzione del vento da gradi (0-360) a radianti (0-2PI)
713
- const twdRad = (directions[i] * Math.PI) / 180;
714
-
715
- forecastList.push({
716
- time: epoch,
717
- tws: speeds[i], // Già in nodi grazie ai parametri della chiamata
718
- twd: twdRad,
719
- gust: gusts[i] !== undefined ? gusts[i] : speeds[i] // Chirurgico: Memorizziamo la raffica oraria (con fallback sulla velocità media)
720
- });
721
- }
714
+ }
715
+
716
+ let data = '';
717
+ res.on('data', (chunk) => { data += chunk; });
718
+ res.on('end', () => {
719
+ try {
720
+ const parsed = JSON.parse(data);
721
+ if (!parsed.hourly || !parsed.hourly.time) {
722
+ app.error('[Open-Meteo] Invalid API response format');
723
+ lastForecast30mSlot = 0;
724
+ return;
725
+ }
726
+
727
+ const times = parsed.hourly.time;
728
+ const speeds = parsed.hourly.wind_speed_10m;
729
+ const directions = parsed.hourly.wind_direction_10m;
730
+ const gusts = parsed.hourly.wind_gusts_10m || []; // Chirurgico: Catturiamo le raffiche dal payload JSON
731
+
732
+ // Costruiamo la serie storica delle previsioni in formato UTC
733
+ const forecastList = [];
734
+ for (let i = 0; i < times.length; i++) {
735
+ // Forziamo il parsing UTC aggiungendo la dicitura 'Z' alla stringa ISO prodotta da Open-Meteo
736
+ const epoch = Date.parse(times[i] + "Z");
737
+ if (isNaN(epoch)) continue;
738
+
739
+ // Convertiamo la direzione del vento da gradi (0-360) a radianti (0-2PI)
740
+ const twdRad = (directions[i] * Math.PI) / 180;
741
+
742
+ forecastList.push({
743
+ time: epoch,
744
+ tws: speeds[i], // Già in nodi grazie ai parametri della chiamata
745
+ twd: twdRad,
746
+ gust: gusts[i] !== undefined ? gusts[i] : speeds[i] // Chirurgico: Memorizziamo la raffica oraria (con fallback sulla velocità media)
747
+ });
748
+ }
749
+
750
+ // Calcola l'interpolazione per la mezz'ora futura basandosi sullo slot corrente dell'orologio
751
+ calculateInterpolatedFuture(forecastList);
752
+
753
+ } catch (err) {
754
+ app.error(`[Open-Meteo] Error parsing JSON: ${err.message}`);
755
+ lastForecast30mSlot = 0;
756
+ }
757
+ });
758
+ }).on('error', (err) => {
759
+ app.error(`[Open-Meteo] Network Error: ${err.message}`);
760
+ lastForecast30mSlot = 0;
761
+ });
762
+ }
763
+
764
+ /**
765
+ * calculateInterpolatedFuture: Esegue l'interpolazione lineare e vettoriale circolare
766
+ * per trovare la previsione del vento tra 30 minuti esatti.
767
+ */
768
+ function calculateInterpolatedFuture(forecastList) {
769
+ if (forecastList.length < 2) return;
770
+
771
+ const now = Date.now();
772
+ const targetTime = now + 1800000; // Calcoliamo la proiezione a +30 minuti nel futuro
722
773
 
723
- // Calcola l'interpolazione per la mezz'ora futura basandosi sullo slot corrente dell'orologio
724
- calculateInterpolatedFuture(forecastList);
774
+ let s1 = null;
775
+ let s2 = null;
725
776
 
726
- } catch (err) {
727
- app.error(`[Open-Meteo] Error parsing JSON: ${err.message}`);
728
- lastForecast30mSlot = 0;
777
+ // Individuiamo i due segmenti orari che racchiudono la nostra mezz'ora futura
778
+ for (let i = 0; i < forecastList.length; i++) {
779
+ const f = forecastList[i];
780
+ if (f.time <= targetTime) {
781
+ s1 = f;
782
+ }
783
+ if (f.time > targetTime) {
784
+ s2 = f;
785
+ break; // Trovato il limite superiore, usciamo
786
+ }
729
787
  }
730
- });
731
- }).on('error', (err) => {
732
- app.error(`[Open-Meteo] Network Error: ${err.message}`);
733
- lastForecast30mSlot = 0;
734
- });
735
- }
736
-
737
- /**
738
- * calculateInterpolatedFuture: Esegue l'interpolazione lineare e vettoriale circolare
739
- * per trovare la previsione del vento tra 30 minuti esatti.
740
- */
741
- function calculateInterpolatedFuture(forecastList) {
742
- if (forecastList.length < 2) return;
743
-
744
- const now = Date.now();
745
- const targetTime = now + 1800000; // Calcoliamo la proiezione a +30 minuti nel futuro
746
-
747
- let s1 = null;
748
- let s2 = null;
749
-
750
- // Individuiamo i due segmenti orari che racchiudono la nostra mezz'ora futura
751
- for (let i = 0; i < forecastList.length; i++) {
752
- const f = forecastList[i];
753
- if (f.time <= targetTime) {
754
- s1 = f;
755
- }
756
- if (f.time > targetTime) {
757
- s2 = f;
758
- break; // Trovato il limite superiore, usciamo
759
- }
760
- }
761
788
 
762
- if (s1 && s2) {
763
- const ratio = (targetTime - s1.time) / (s2.time - s1.time);
789
+ if (s1 && s2) {
790
+ const ratio = (targetTime - s1.time) / (s2.time - s1.time);
764
791
 
765
- // 1. Interpolazione Lineare Velocità (TWS)
766
- const interpolatedTws = s1.tws + (s2.tws - s1.tws) * ratio;
792
+ // 1. Interpolazione Lineare Velocità (TWS)
793
+ const interpolatedTws = s1.tws + (s2.tws - s1.tws) * ratio;
767
794
 
768
- // 2. Interpolazione Circolare Vettoriale Direzione (TWD) per evitare l'effetto sfasamento a 0/360°
769
- const diff = Math.atan2(Math.sin(s2.twd - s1.twd), Math.cos(s2.twd - s1.twd));
770
- const interpolatedTwd = (s1.twd + diff * ratio + Math.PI * 2) % (Math.PI * 2);
795
+ // 2. Interpolazione Circolare Vettoriale Direzione (TWD) per evitare l'effetto sfasamento a 0/360°
796
+ const diff = Math.atan2(Math.sin(s2.twd - s1.twd), Math.cos(s2.twd - s1.twd));
797
+ const interpolatedTwd = (s1.twd + diff * ratio + Math.PI * 2) % (Math.PI * 2);
771
798
 
772
- // 3. Interpolazione Lineare Raffiche (Gust)
773
- const interpolatedGust = s1.gust + (s2.gust - s1.gust) * ratio; // Chirurgico: Calcolo interpolato della raffica futura
799
+ // 3. Interpolazione Lineare Raffiche (Gust)
800
+ const interpolatedGust = s1.gust + (s2.gust - s1.gust) * ratio; // Chirurgico: Calcolo interpolato della raffica futura
774
801
 
775
- // Salviamo le previsioni future nel server
776
- futureForecast = {
777
- timestamp: targetTime,
778
- tws: interpolatedTws,
779
- twd: interpolatedTwd,
780
- gust: interpolatedGust // Chirurgico: Aggiunta la raffica nel pacchetto dati della previsione
781
- };
802
+ // Salviamo le previsioni future nel server
803
+ futureForecast = {
804
+ timestamp: targetTime,
805
+ tws: interpolatedTws,
806
+ twd: interpolatedTwd,
807
+ gust: interpolatedGust // Chirurgico: Aggiunta la raffica nel pacchetto dati della previsione
808
+ };
782
809
 
783
- app.debug(`🔮 [Open-Meteo] Target forecast interpolated for ${new Date(targetTime).toLocaleTimeString()}: TWD ${Math.round(interpolatedTwd * 180 / Math.PI)}°, TWS ${interpolatedTws.toFixed(1)} kts (Gust: ${interpolatedGust.toFixed(1)} kts)`);
784
- } else {
785
- app.error('[Open-Meteo] Forecast matching slots not found for target time');
810
+ app.debug(`🔮 [Open-Meteo] Target forecast interpolated for ${new Date(targetTime).toLocaleTimeString()}: TWD ${Math.round(interpolatedTwd * 180 / Math.PI)}°, TWS ${interpolatedTws.toFixed(1)} kts (Gust: ${interpolatedGust.toFixed(1)} kts)`);
811
+ } else {
812
+ app.error('[Open-Meteo] Forecast matching slots not found for target time');
813
+ }
786
814
  }
787
- }
788
815
 
789
816
  /**
790
- * emitDelta: Scrive ed emette un aggiornamento di rotta direttamente nel
791
- * server principale di Signal K per renderlo disponibile a tutti i client WebSocket.
792
- */
793
- function emitDelta(path, value) {
817
+ * emitDelta: Scrive ed emette un aggiornamento di rotta direttamente nel
818
+ * server principale di Signal K per renderlo disponibile a tutti i client WebSocket.
819
+ */
820
+ function emitDelta(path, value) {
794
821
  if (typeof app.handleMessage === 'function') {
795
- app.handleMessage(plugin.id, {
796
- context: 'vessels.self', // BUG RISOLTO: Inserito il contesto Signal K per evitare lo scarto del delta
797
- updates: [
798
- {
799
- source: { label: 'rotevista-dash-plugin' },
800
- timestamp: new Date().toISOString(),
801
- values: [
802
- {
803
- path: path,
804
- value: value
805
- }
822
+ app.handleMessage(plugin.id, {
823
+ context: 'vessels.self', // BUG RISOLTO: Inserito il contesto Signal K per evitare lo scarto del delta
824
+ updates: [
825
+ {
826
+ source: { label: 'rotevista-dash-plugin' },
827
+ timestamp: new Date().toISOString(),
828
+ values: [
829
+ {
830
+ path: path,
831
+ value: value
832
+ }
833
+ ]
834
+ }
806
835
  ]
807
- }
808
- ]
809
- });
836
+ });
810
837
  }
811
- }
838
+ }
839
+
840
+ /**
841
+ * pruneStaleHistories: Pota in background i punti storici obsoleti dei sensori spenti.
842
+ * Evita il congelamento dei grafici sul tablet e previene sprechi di RAM sul server.
843
+ */
844
+ function pruneStaleHistories() {
845
+ const now = Date.now();
846
+ const historyMinutes = currentConfig.graphs ? currentConfig.graphs.historyMinutes : 5;
847
+ const maxHistoryMs = (historyMinutes * 2 * 60000) + 60000;
848
+
849
+ for (let type in histories) {
850
+ let pruned = false;
851
+ while (histories[type].length > 0 && (now - histories[type][0].time) > maxHistoryMs) {
852
+ histories[type].shift();
853
+ pruned = true;
854
+ }
855
+ if (pruned) {
856
+ app.debug(`🧹 [Server RAM] Pruned stale points for "${type}" due to instrument silence.`);
857
+ }
858
+ }
859
+ }
812
860
 
813
- return plugin;
861
+ return plugin;
814
862
  };