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