@sailingrotevista/rotevista-dash 6.1.4 → 6.2.3

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.
package/index.js CHANGED
@@ -5,6 +5,7 @@
5
5
  * Definisce l'interfaccia di configurazione in Signal K Admin e crea
6
6
  * gli endpoint pubblici per la Dashboard, mantenendo lo storico in RAM.
7
7
  */
8
+ const https = require('https'); // Importazione del modulo HTTPS nativo di Node.js
8
9
 
9
10
  module.exports = function (app) {
10
11
  const plugin = {};
@@ -16,43 +17,70 @@ module.exports = function (app) {
16
17
  let routeRegistered = false;
17
18
  let unsubscribes = [];
18
19
 
19
- // Database dello storico in RAM sul server (Sintonizzato Pro v6.0)
20
- let histories = { stw: [], sog: [], depth: [], tws: [], vmg: [], aws: [], twd: [] };
21
- let graphTempBuf = { stw: [], sog: [], depth: [], tws: [], vmg: [], aws: [], twd: [] };
22
- let lastUpdates = { stw: 0, sog: 0, depth: 0, tws: 0, vmg: 0, aws: 0, twd: 0 };
23
- let raw = {};
24
- // Memoria temporale per rilevare la presenza di sensori nativi sul Cerbo
25
- let lastNativeTwsTime = 0;
26
- let lastNativeTwdTime = 0;
20
+ // ==========================================================================
21
+ // COSTANTI DI SVILUPPO (DEVELOPER CONFIG)
22
+ // ==========================================================================
23
+ const CALM_THRESHOLD_KTS = 1.5; // Soglia di calma piatta (anello a 360°)
24
+ const PRESSURE_FILTER_RATIO = 0.40; // Filtro di pressione dinamico (40% del picco per ignorare i cali)
25
+
26
+ // Database dello storico in RAM sul server (Sintonizzato Pro v6.0)
27
+ let histories = { stw: [], sog: [], depth: [], tws: [], vmg: [], aws: [], twd: [] };
28
+ let graphTempBuf = { stw: [], sog: [], depth: [], tws: [], vmg: [], aws: [], twd: [] };
29
+ let lastUpdates = { stw: 0, sog: 0, depth: 0, tws: 0, vmg: 0, aws: 0, twd: 0 };
30
+ let raw = {};
31
+
32
+ // Nuovo database dedicato per gli archi storici della bussola (6 ore = 12 slot)
33
+ let windRadarSlots = [];
34
+
35
+ // Memoria temporale per rilevare la presenza di sensori nativi sul Cerbo
36
+ let lastNativeTwsTime = 0;
37
+ let lastNativeTwdTime = 0;
38
+
39
+ // Monitoraggio dello scorrere dei blocchi da 30 minuti
40
+ let lastFrozen30mSlot = 0;
41
+
42
+ // Variabili dedicate al recupero e calcolo previsioni meteo future
43
+ let futureForecast = null; // Memorizza la previsione futura { timestamp, tws, twd }
44
+ let lastForecast30mSlot = 0; // Orario dell'ultimo blocco orologio scaricato con successo (es. 15:30)
27
45
 
28
46
  /**
29
47
  * plugin.start: Inizializza il plugin.
30
- * Viene chiamato all'avvio e OGNI VOLTA che clicchi "Save" nelle impostazioni.
48
+ * Viene chiamato all'avvio e OGNI VOLTA che si salva nella configurazione.
31
49
  */
32
50
  plugin.start = function (options) {
33
51
  // 1. Aggiorna la configurazione in memoria
34
52
  currentConfig = options;
35
53
  app.debug(`${plugin.name} started/updated with new options`);
36
54
 
37
- // Reset dello storico al riavvio del plugin per evitare incoerenze (Sintonizzato Pro v6.0)
38
- histories = { stw: [], sog: [], depth: [], tws: [], vmg: [], aws: [], twd: [] };
39
- graphTempBuf = { stw: [], sog: [], depth: [], tws: [], vmg: [], aws: [], twd: [] };
40
- lastUpdates = { stw: 0, sog: 0, depth: 0, tws: 0, vmg: 0, aws: 0, twd: 0 };
41
- raw = {};
42
-
43
- // 2. Registra le rotte API solo la prima volta (Abilitate per CORS remoto)
44
- if (!routeRegistered) {
45
- app.get('/rotevista-config', (req, res) => {
46
- res.header("Access-Control-Allow-Origin", "*"); // Sblocca la Dashboard locale su Mac
47
- res.json(currentConfig);
48
- });
49
- app.get('/rotevista-history', (req, res) => {
50
- res.header("Access-Control-Allow-Origin", "*"); // Sblocca la Dashboard locale su Mac
51
- res.json(histories);
52
- });
53
- routeRegistered = true;
54
- app.debug('Public API endpoints registered at /rotevista-config and /rotevista-history');
55
- }
55
+ // Reset dello storico al riavvio del plugin per evitare incoerenze (Sintonizzato Pro v6.0)
56
+ histories = { stw: [], sog: [], depth: [], tws: [], vmg: [], aws: [], twd: [] };
57
+ graphTempBuf = { stw: [], sog: [], depth: [], tws: [], vmg: [], aws: [], twd: [] };
58
+ lastUpdates = { stw: 0, sog: 0, depth: 0, tws: 0, vmg: 0, aws: 0, twd: 0 };
59
+ raw = {};
60
+ windRadarSlots = []; // Reset degli archi storici della bussola
61
+ lastFrozen30mSlot = 0; // Reset del monitor temporale della bussola
62
+ futureForecast = null; // Reset della previsione meteo futura
63
+ lastForecast30mSlot = 0; // Reset del monitor temporale di scaricamento meteo
64
+
65
+ // 2. Registra le rotte API solo la prima volta (Abilitate per CORS remoto)
66
+ if (!routeRegistered) {
67
+ app.get('/rotevista-config', (req, res) => {
68
+ res.header("Access-Control-Allow-Origin", "*"); // Sblocca la Dashboard locale su Mac
69
+ res.json(currentConfig);
70
+ });
71
+ app.get('/rotevista-history', (req, res) => {
72
+ res.header("Access-Control-Allow-Origin", "*"); // Sblocca la Dashboard locale su Mac
73
+ // Costruiamo un pacchetto unificato che unisce i grafici classici, i dati radar e il futuro
74
+ const responseData = {
75
+ ...histories,
76
+ windRadarSlots: windRadarSlots,
77
+ futureForecast: futureForecast
78
+ };
79
+ res.json(responseData);
80
+ });
81
+ routeRegistered = true;
82
+ app.debug('Public API endpoints registered at /rotevista-config and /rotevista-history');
83
+ }
56
84
 
57
85
  // 3. Iscrizione ai dati dei sensori di bordo tramite Signal K
58
86
  const localSubscription = {
@@ -63,6 +91,8 @@ module.exports = function (app) {
63
91
  { path: 'environment.depth.belowTransducer' },
64
92
  { path: 'environment.wind.speedApparent' },
65
93
  { path: 'environment.wind.angleApparent' },
94
+ { path: 'environment.wind.speedTrue' }, // Aggiunto chirurgicamente
95
+ { path: 'environment.wind.directionTrue' }, // Aggiunto chirurgicamente
66
96
  { path: 'navigation.headingTrue' },
67
97
  { path: 'navigation.headingMagnetic' },
68
98
  { path: 'navigation.magneticVariation' },
@@ -103,7 +133,7 @@ module.exports = function (app) {
103
133
  app.debug(msg);
104
134
  };
105
135
 
106
- /**
136
+ /**
107
137
  * processIncomingDelta: Decodifica i dati dei sensori in Knots/Meters ed esegue l'aggregazione
108
138
  * Gestisce l'architettura "Nativo Prima, Fallback Dopo" per il vento reale.
109
139
  */
@@ -114,7 +144,17 @@ module.exports = function (app) {
114
144
  const now = Date.now();
115
145
 
116
146
  // 1. Cattura dei dati nativi (Se presenti, li scrive direttamente nello storico)
117
- if (path === 'navigation.speedThroughWater') {
147
+ if (path === 'navigation.position') {
148
+ // Trigger allineato all'orologio: calcoliamo il confine della mezz'ora corrente dell'orologio
149
+ if (val && val.latitude !== undefined && val.longitude !== undefined) {
150
+ const current30mSlot = Math.floor(Date.now() / 1800000) * 1800000;
151
+ // Se siamo entrati in una nuova mezz'ora di orologio dall'ultimo download, avviamo il fetch
152
+ if (current30mSlot > lastForecast30mSlot) {
153
+ fetchOpenMeteoForecast(val, current30mSlot);
154
+ }
155
+ }
156
+ }
157
+ else if (path === 'navigation.speedThroughWater') {
118
158
  manageHistory('stw', val * 1.94384);
119
159
  }
120
160
  else if (path === 'navigation.speedOverGround') {
@@ -126,6 +166,9 @@ module.exports = function (app) {
126
166
  else if (path === 'environment.wind.speedApparent') {
127
167
  manageHistory('aws', val * 1.94384);
128
168
  }
169
+ else if (path === 'environment.wind.angleApparent') {
170
+ raw[path] = val; // BUG RISOLTO: Acquisizione dell'AWA mancante inserita!
171
+ }
129
172
  else if (path === 'environment.wind.speedTrue') {
130
173
  lastNativeTwsTime = now; // Rilevato TWS nativo della centralina!
131
174
  manageHistory('tws', val * 1.94384);
@@ -172,6 +215,7 @@ module.exports = function (app) {
172
215
  // Calcoliamo il TWD di fallback solo se non abbiamo visto dati nativi negli ultimi 5 secondi
173
216
  if (hdg !== undefined && (now - lastNativeTwdTime > 5000)) {
174
217
  const twd = (hdg + twa + 2 * Math.PI) % (2 * Math.PI);
218
+ twd = (twd + 2 * Math.PI) % (2 * Math.PI); // Sicurezza extra
175
219
  manageHistory('twd', twd);
176
220
  }
177
221
  }
@@ -188,15 +232,15 @@ module.exports = function (app) {
188
232
  const samples = 60;
189
233
  const bucketIntervalMs = (historyMinutes * 60000) / samples;
190
234
 
191
- if (!graphTempBuf[type]) graphTempBuf[type] = [];
192
- if (!histories[type]) histories[type] = [];
235
+ if (!graphTempBuf[type]) graphTempBuf[type] = [];
236
+ if (!histories[type]) histories[type] = [];
193
237
 
194
- // SINTONIZZAZIONE DI FASE LATO SERVER (UTC Snap)
195
- if (lastUpdates[type] === undefined || lastUpdates[type] === 0) {
196
- lastUpdates[type] = Math.floor(now / bucketIntervalMs) * bucketIntervalMs;
197
- }
238
+ // SINTONIZZAZIONE DI FASE LATO SERVER (UTC Snap)
239
+ if (lastUpdates[type] === undefined || lastUpdates[type] === 0) {
240
+ lastUpdates[type] = Math.floor(now / bucketIntervalMs) * bucketIntervalMs;
241
+ }
198
242
 
199
- const tempBuf = graphTempBuf[type];
243
+ const tempBuf = graphTempBuf[type];
200
244
 
201
245
  // Anti-dropout dinamico sul vento forte
202
246
  if ((type === 'tws' || type === 'aws') && value < 0.05 && tempBuf.length > 0) {
@@ -206,17 +250,67 @@ module.exports = function (app) {
206
250
  if (lastPoint && lastPoint.val > glitchThreshold) return;
207
251
  }
208
252
 
209
- tempBuf.push({ val: value, time: now });
253
+ // Cattura la velocità del vento corrente (in m/s) al momento della lettura
254
+ const currentTws = raw['environment.wind.speedTrue'] || 0;
255
+ tempBuf.push({ val: value, tws: currentTws, time: now });
210
256
 
211
257
  // Controllo avanzamento del secchiello temporale (Bucket)
212
258
  const bucketReady = (now - lastUpdates[type] > bucketIntervalMs) || histories[type].length === 0;
213
259
  if (!bucketReady) return;
214
260
 
215
261
  let finalValue = value;
262
+ let isTwdType = (type === 'twd');
216
263
 
217
264
  if (tempBuf.length > 0) {
218
- // A. VENTO -> SUSTAINED PEAK (EMA Time-Aware)
219
- if (type === 'tws' || type === 'aws') {
265
+ // ==========================================================================
266
+ // CASO PARTICOLARE: DIREZIONE VENTO (TWD) -> MEDIA PESATA E LIMITI MIN/MAX
267
+ // ==========================================================================
268
+ if (isTwdType) {
269
+ // 1. Identifica il vento massimo del minuto (in m/s)
270
+ const twsVals = tempBuf.map(p => p.tws || 0);
271
+ const maxTwsInMinute = Math.max(...twsVals, 0);
272
+
273
+ // 2. Calcola la soglia dinamica di pressione: max tra 40% del picco e soglia di calma piatta (in m/s)
274
+ const calmThresholdMs = CALM_THRESHOLD_KTS / 1.94384;
275
+ const pressureThreshold = Math.max(maxTwsInMinute * PRESSURE_FILTER_RATIO, calmThresholdMs);
276
+
277
+ // 3. Esclude le direzioni registrate quando il vento era debole (sotto la soglia di pressione)
278
+ const activePoints = tempBuf.filter(p => (p.tws || 0) >= pressureThreshold);
279
+ const pointsToUse = activePoints.length > 0 ? activePoints : tempBuf; // Fallback se tutto è calma piatta
280
+
281
+ // 4. Media Vettoriale Pesata (TWS * sin, TWS * cos)
282
+ let sumSin = 0;
283
+ let sumCos = 0;
284
+ let totalWeight = 0;
285
+ pointsToUse.forEach(p => {
286
+ const weight = Math.max(p.tws || 0.1, 0.05); // Evita pesi a zero
287
+ sumSin += weight * Math.sin(p.val);
288
+ sumCos += weight * Math.cos(p.val);
289
+ totalWeight += weight;
290
+ });
291
+ const avgAngle = Math.atan2(sumSin, sumCos);
292
+ const finalAvg = (avgAngle + Math.PI * 2) % (Math.PI * 2);
293
+
294
+ // 5. Calcolo di Min e Max angolare del minuto (rispetto alla media per gestire l'oltrepasso di 0/360°)
295
+ let diffs = pointsToUse.map(p => {
296
+ let diff = p.val - finalAvg;
297
+ return Math.atan2(Math.sin(diff), Math.cos(diff)); // Srotolamento tra -PI e +PI
298
+ });
299
+
300
+ const minDiff = Math.min(...diffs);
301
+ const maxDiff = Math.max(...diffs);
302
+
303
+ const finalMin = (finalAvg + minDiff + Math.PI * 2) % (Math.PI * 2);
304
+ const finalMax = (finalAvg + maxDiff + Math.PI * 2) % (Math.PI * 2);
305
+
306
+ finalValue = {
307
+ val: finalAvg,
308
+ min: finalMin,
309
+ max: finalMax
310
+ };
311
+ }
312
+ // A. VENTO VELOCITÀ -> SUSTAINED PEAK (EMA Time-Aware)
313
+ else if (type === 'tws' || type === 'aws') {
220
314
  const tauMs = 2500;
221
315
  let ema = tempBuf[0].val;
222
316
  let maxSustained = ema;
@@ -234,7 +328,7 @@ module.exports = function (app) {
234
328
  const vals = tempBuf.map(p => p.val).filter(v => isFinite(v));
235
329
  if (vals.length > 0) finalValue = Math.min(...vals);
236
330
  }
237
- // C. VELOCITÀ -> MEDIA
331
+ // C. VELOCITÀ BARCA / ALTRO -> MEDIA
238
332
  else {
239
333
  const vals = tempBuf.map(p => p.val).filter(v => isFinite(v));
240
334
  if (vals.length > 0) {
@@ -244,27 +338,61 @@ module.exports = function (app) {
244
338
  }
245
339
  }
246
340
 
247
- if (!isFinite(finalValue)) return;
248
- finalValue = Math.max(0, finalValue);
341
+ // Validazione e clamping di sicurezza (non si applica all'oggetto TWD)
342
+ if (!isTwdType) {
343
+ if (!isFinite(finalValue)) return;
344
+ finalValue = Math.max(0, finalValue);
345
+ histories[type].push({ val: finalValue, time: now });
249
346
 
250
- // Salvataggio nel ring buffer dello storico principale
251
- histories[type].push({ val: finalValue, time: now });
347
+ // EMISSIONE DEL DELTA: Se abbiamo calcolato il TWS di fallback, lo trasmettiamo a Signal K
348
+ if (type === 'tws' && (now - lastNativeTwsTime > 5000)) {
349
+ emitDelta('environment.wind.speedTrue', finalValue / 1.94384); // Converte nodi in m/s
350
+ }
351
+ } else {
352
+ // Salvataggio specifico del TWD contenente l'oggetto { val, min, max, time }
353
+ histories['twd'].push({
354
+ val: finalValue.val,
355
+ min: finalValue.min,
356
+ max: finalValue.max,
357
+ time: now
358
+ });
359
+
360
+ // EMISSIONE DEL DELTA: Se abbiamo calcolato il TWD di fallback, lo trasmettiamo a Signal K
361
+ if (now - lastNativeTwdTime > 5000) {
362
+ emitDelta('environment.wind.directionTrue', {
363
+ val: finalValue.val,
364
+ min: finalValue.min,
365
+ max: finalValue.max
366
+ });
367
+ }
252
368
 
253
- // Pruning automatico basato sulle impostazioni di timeline
254
- // (Forziamo il server a conservare sempre almeno 60 minuti per il TWD!)
255
- const limitMinutes = (type === 'twd') ? 60 : historyMinutes;
256
- const maxViewportMinutes = limitMinutes * 2;
257
- const maxHistoryMs = (maxViewportMinutes * 60000) + 60000;
369
+ // --- TRIGGER DI CONGELAMENTO ARCO (Ogni :00 e :30 dell'orologio) ---
370
+ const current30mSlot = Math.floor(now / 1800000) * 1800000;
371
+ if (lastFrozen30mSlot === 0) {
372
+ lastFrozen30mSlot = current30mSlot; // Inizializzazione al primo avvio
373
+ } else if (current30mSlot > lastFrozen30mSlot) {
374
+ // Lo slot da 30 minuti precedente (lastFrozen30mSlot) si è appena concluso!
375
+ // Avviamo la procedura di compressione e congelamento dei dati di quel periodo
376
+ freeze30mSlot(lastFrozen30mSlot);
377
+ lastFrozen30mSlot = current30mSlot;
378
+ }
379
+ }
258
380
 
259
- while (histories[type].length > 0 && (now - histories[type][0].time) > maxHistoryMs) {
260
- histories[type].shift();
261
- }
381
+ // Pruning automatico basato sulle impostazioni di timeline
382
+ // (Forziamo il server a conservare sempre almeno 60 minuti per il TWD!)
383
+ const limitMinutes = (type === 'twd') ? 60 : historyMinutes;
384
+ const maxViewportMinutes = limitMinutes * 2;
385
+ const maxHistoryMs = (maxViewportMinutes * 60000) + 60000;
262
386
 
263
- graphTempBuf[type] = [];
264
- // Spostiamo il timer esattamente al confine del secchiello assoluto appena concluso
265
- lastUpdates[type] = Math.floor(now / bucketIntervalMs) * bucketIntervalMs;
387
+ while (histories[type].length > 0 && (now - histories[type][0].time) > maxHistoryMs) {
388
+ histories[type].shift();
266
389
  }
267
390
 
391
+ graphTempBuf[type] = [];
392
+ // Spostiamo il timer esattamente al confine del secchiello assoluto appena concluso
393
+ lastUpdates[type] = Math.floor(now / bucketIntervalMs) * bucketIntervalMs;
394
+ }
395
+
268
396
  /**
269
397
  * plugin.schema: Definisce l'interfaccia grafica in Signal K Admin.
270
398
  */
@@ -427,6 +555,218 @@ module.exports = function (app) {
427
555
  }
428
556
  }
429
557
  };
558
+
559
+ /**
560
+ * freeze30mSlot: Consolida e congela i dati di una specifica mezz'ora.
561
+ * Raccoglie i 30 record da 1 minuto, estrae i picchi, unisce gli angoli
562
+ * e applica la scrematura del percentile al 5% prima di salvare lo slot.
563
+ */
564
+ function freeze30mSlot(slotTimestamp) {
565
+ const startTime = slotTimestamp;
566
+ const endTime = slotTimestamp + 1800000; // 30 minuti in millisecondi
567
+
568
+ // 1. Estrae i record storici del TWD e del TWS che ricadono in quella mezz'ora
569
+ const twdPoints = histories['twd'].filter(p => p.time >= startTime && p.time < endTime);
570
+ const twsPoints = histories['tws'].filter(p => p.time >= startTime && p.time < endTime);
571
+
572
+ if (twdPoints.length === 0) return; // Se non ci sono dati, salta lo slot
573
+
574
+ // 2. Calcola il vento massimo sostenuto (in nodi) registrato nel periodo
575
+ const twsVals = twsPoints.map(p => p.val).filter(v => isFinite(v));
576
+ const maxTws = twsVals.length > 0 ? Math.max(...twsVals) : 0;
577
+
578
+ // 3. Estrae tutti gli estremi angolari catturati minuto per minuto
579
+ let allAngles = [];
580
+ twdPoints.forEach(p => {
581
+ allAngles.push(p.val);
582
+ allAngles.push(p.min);
583
+ allAngles.push(p.max);
584
+ });
585
+
586
+ // 4. Calcola la direzione media vettoriale complessiva della mezz'ora per srotolare gli angoli
587
+ let sumSin = 0;
588
+ let sumCos = 0;
589
+ allAngles.forEach(a => {
590
+ sumSin += Math.sin(a);
591
+ sumCos += Math.cos(a);
592
+ });
593
+ const avgAngle = Math.atan2(sumSin, sumCos);
594
+ const finalAvg = (avgAngle + Math.PI * 2) % (Math.PI * 2);
595
+
596
+ // 5. Srotola gli angoli rispetto alla direzione media per gestire l'oltrepasso dello 0/360°
597
+ let diffs = allAngles.map(a => {
598
+ let diff = a - finalAvg;
599
+ return Math.atan2(Math.sin(diff), Math.cos(diff)); // Mappa tra -PI e +PI
600
+ });
601
+
602
+ // 6. ORDINA LE DIFFERENZE ED APPLICA IL TAGLIO PERCENTILE DEL 5% PER LATO (TRIM)
603
+ diffs.sort((a, b) => a - b);
604
+ const trimCount = Math.floor(diffs.length * 0.05); // Scarta il 5% dei disturbi a sinistra e a destra
605
+ const activeDiffs = diffs.slice(trimCount, diffs.length - trimCount);
606
+
607
+ // Fallback se ci sono pochissimi campioni
608
+ const finalDiffs = activeDiffs.length > 0 ? activeDiffs : diffs;
609
+
610
+ const minDiff = Math.min(...finalDiffs);
611
+ const maxDiff = Math.max(...finalDiffs);
612
+
613
+ const finalMin = (finalAvg + minDiff + Math.PI * 2) % (Math.PI * 2);
614
+ const finalMax = (finalAvg + maxDiff + Math.PI * 2) % (Math.PI * 2);
615
+
616
+ // 7. Salva l'arco compresso e pulito nello store dedicato
617
+ windRadarSlots.push({
618
+ timestamp: startTime,
619
+ twdMin: finalMin,
620
+ twdMax: finalMax,
621
+ twsPeak: maxTws
622
+ });
623
+
624
+ // Pruning: manteniamo in RAM solo le ultime 6 ore (12 slot)
625
+ while (windRadarSlots.length > 12) {
626
+ windRadarSlots.shift();
627
+ }
430
628
 
629
+ 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`);
630
+ }
631
+
632
+ /**
633
+ * fetchOpenMeteoForecast: Recupera le previsioni orarie accoppiate (Seamless) da Open-Meteo.
634
+ * Utilizza il modello integrato /forecast per evitare zone d'ombra in rada, con tempo forzato in UTC.
635
+ */
636
+ function fetchOpenMeteoForecast(position, current30mSlot) {
637
+ if (!position || position.latitude === undefined || position.longitude === undefined) return;
638
+
639
+ const lat = position.latitude;
640
+ const lon = position.longitude;
641
+
642
+ // Modello accoppiato Seamless basato su forecast, con vento espresso in nodi e orario in UTC (GMT)
643
+ const url = `https://api.open-meteo.com/v1/forecast?latitude=${lat}&longitude=${lon}&hourly=wind_speed_10m,wind_direction_10m&wind_speed_unit=kn&timezone=GMT&forecast_days=2`;
644
+
645
+ lastForecast30mSlot = current30mSlot; // Aggiorna preventivamente lo slot per evitare chiamate simultanee in caso di rallentamento di rete
646
+
647
+ https.get(url, (res) => {
648
+ if (res.statusCode !== 200) {
649
+ app.error(`[Open-Meteo] HTTP Error: ${res.statusCode}`);
650
+ lastForecast30mSlot = 0; // Reset in caso di errore per permettere un tentativo al prossimo pacchetto GPS
651
+ return;
652
+ }
653
+
654
+ let data = '';
655
+ res.on('data', (chunk) => { data += chunk; });
656
+ res.on('end', () => {
657
+ try {
658
+ const parsed = JSON.parse(data);
659
+ if (!parsed.hourly || !parsed.hourly.time) {
660
+ app.error('[Open-Meteo] Invalid API response format');
661
+ lastForecast30mSlot = 0;
662
+ return;
663
+ }
664
+
665
+ const times = parsed.hourly.time;
666
+ const speeds = parsed.hourly.wind_speed_10m;
667
+ const directions = parsed.hourly.wind_direction_10m;
668
+
669
+ // Costruiamo la serie storica delle previsioni in formato UTC
670
+ const forecastList = [];
671
+ for (let i = 0; i < times.length; i++) {
672
+ // Forziamo il parsing UTC aggiungendo la dicitura 'Z' alla stringa ISO prodotta da Open-Meteo
673
+ const epoch = Date.parse(times[i] + "Z");
674
+ if (isNaN(epoch)) continue;
675
+
676
+ // Convertiamo la direzione del vento da gradi (0-360) a radianti (0-2PI)
677
+ const twdRad = (directions[i] * Math.PI) / 180;
678
+
679
+ forecastList.push({
680
+ time: epoch,
681
+ tws: speeds[i], // Già in nodi grazie ai parametri della chiamata
682
+ twd: twdRad
683
+ });
684
+ }
685
+
686
+ // Calcola l'interpolazione per la mezz'ora futura basandosi sullo slot corrente dell'orologio
687
+ calculateInterpolatedFuture(forecastList);
688
+
689
+ } catch (err) {
690
+ app.error(`[Open-Meteo] Error parsing JSON: ${err.message}`);
691
+ lastForecast30mSlot = 0;
692
+ }
693
+ });
694
+ }).on('error', (err) => {
695
+ app.error(`[Open-Meteo] Network Error: ${err.message}`);
696
+ lastForecast30mSlot = 0;
697
+ });
698
+ }
699
+
700
+ /**
701
+ * calculateInterpolatedFuture: Esegue l'interpolazione lineare e vettoriale circolare
702
+ * per trovare la previsione del vento tra 30 minuti esatti.
703
+ */
704
+ function calculateInterpolatedFuture(forecastList) {
705
+ if (forecastList.length < 2) return;
706
+
707
+ const now = Date.now();
708
+ const targetTime = now + 1800000; // Calcoliamo la proiezione a +30 minuti nel futuro
709
+
710
+ let s1 = null;
711
+ let s2 = null;
712
+
713
+ // Individuiamo i due segmenti orari che racchiudono la nostra mezz'ora futura
714
+ for (let i = 0; i < forecastList.length; i++) {
715
+ const f = forecastList[i];
716
+ if (f.time <= targetTime) {
717
+ s1 = f;
718
+ }
719
+ if (f.time > targetTime) {
720
+ s2 = f;
721
+ break; // Trovato il limite superiore, usciamo
722
+ }
723
+ }
724
+
725
+ if (s1 && s2) {
726
+ const ratio = (targetTime - s1.time) / (s2.time - s1.time);
727
+
728
+ // 1. Interpolazione Lineare Velocità (TWS)
729
+ const interpolatedTws = s1.tws + (s2.tws - s1.tws) * ratio;
730
+
731
+ // 2. Interpolazione Circolare Vettoriale Direzione (TWD) per evitare l'effetto sfasamento a 0/360°
732
+ const diff = Math.atan2(Math.sin(s2.twd - s1.twd), Math.cos(s2.twd - s1.twd));
733
+ const interpolatedTwd = (s1.twd + diff * ratio + Math.PI * 2) % (Math.PI * 2);
734
+
735
+ // Salviamo le previsioni future nel server
736
+ futureForecast = {
737
+ timestamp: targetTime,
738
+ tws: interpolatedTws,
739
+ twd: interpolatedTwd
740
+ };
741
+
742
+ app.debug(`🔮 [Open-Meteo] Target forecast interpolated for ${new Date(targetTime).toLocaleTimeString()}: TWD ${Math.round(interpolatedTwd * 180 / Math.PI)}°, TWS ${interpolatedTws.toFixed(1)} kts`);
743
+ } else {
744
+ app.error('[Open-Meteo] Forecast matching slots not found for target time');
745
+ }
746
+ }
747
+ /**
748
+ * emitDelta: Scrive ed emette un aggiornamento di rotta direttamente nel
749
+ * server principale di Signal K per renderlo disponibile a tutti i client WebSocket.
750
+ */
751
+ function emitDelta(path, value) {
752
+ if (typeof app.handleMessage === 'function') {
753
+ app.handleMessage(plugin.id, {
754
+ updates: [
755
+ {
756
+ source: { label: 'rotevista-dash-plugin' },
757
+ timestamp: new Date().toISOString(),
758
+ values: [
759
+ {
760
+ path: path,
761
+ value: value
762
+ }
763
+ ]
764
+ }
765
+ ]
766
+ });
767
+ }
768
+ }
769
+
770
+
431
771
  return plugin;
432
772
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sailingrotevista/rotevista-dash",
3
- "version": "6.1.4",
3
+ "version": "6.2.3",
4
4
  "description": "Wind Dashboard with navigation and course aids",
5
5
  "main": "index.js",
6
6
  "publishConfig": {