@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.
- package/app.js +232 -194
- package/charts.js +7 -4
- package/index.js +697 -642
- package/package.json +1 -1
package/index.js
CHANGED
|
@@ -1,174 +1,199 @@
|
|
|
1
1
|
/**
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
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
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
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
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
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
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
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
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
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
|
-
|
|
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
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
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
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
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
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
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
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
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
|
-
|
|
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
|
-
|
|
212
|
+
manageHistory('stw', smoothedVal * 1.94384);
|
|
188
213
|
}
|
|
189
214
|
else if (path === 'navigation.speedOverGround') {
|
|
190
|
-
|
|
215
|
+
manageHistory('sog', smoothedVal * 1.94384);
|
|
191
216
|
}
|
|
192
217
|
else if (path === 'environment.depth.belowTransducer') {
|
|
193
|
-
|
|
218
|
+
manageHistory('depth', smoothedVal);
|
|
194
219
|
}
|
|
195
220
|
else if (path === 'environment.wind.speedApparent') {
|
|
196
|
-
|
|
221
|
+
manageHistory('aws', smoothedVal * 1.94384);
|
|
197
222
|
}
|
|
198
223
|
else if (path === 'environment.wind.angleApparent') {
|
|
199
|
-
|
|
224
|
+
// Già gestito e normalizzato dal filtro passa-basso superiore
|
|
200
225
|
}
|
|
201
226
|
else if (path === 'environment.wind.speedTrue') {
|
|
202
|
-
|
|
203
|
-
|
|
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
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
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
|
-
|
|
215
|
-
|
|
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
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
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
|
-
|
|
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
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
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
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
306
|
-
|
|
307
|
-
|
|
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
|
-
//
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
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
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
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
|
-
|
|
329
|
-
|
|
362
|
+
const minDiff = Math.min(...diffs);
|
|
363
|
+
const maxDiff = Math.max(...diffs);
|
|
330
364
|
|
|
331
|
-
|
|
332
|
-
|
|
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
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
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
|
-
|
|
370
|
-
|
|
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
|
-
|
|
411
|
+
emitDelta('environment.wind.speedTrue', finalValue / 1.94384); // Converte nodi in m/s
|
|
378
412
|
}
|
|
379
|
-
|
|
413
|
+
} else {
|
|
380
414
|
// Salvataggio specifico del TWD contenente l'oggetto { val, min, max, time }
|
|
381
415
|
histories['twd'].push({
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
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
|
-
|
|
431
|
+
lastFrozen30mSlot = current30mSlot; // Inizializzazione al primo avvio
|
|
398
432
|
} else if (current30mSlot > lastFrozen30mSlot) {
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
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
|
-
|
|
407
|
-
|
|
408
|
-
|
|
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
|
-
|
|
413
|
-
|
|
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
|
-
|
|
417
|
-
|
|
418
|
-
|
|
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: '
|
|
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
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
type: '
|
|
542
|
-
title: '
|
|
543
|
-
description: "
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
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
|
-
|
|
586
|
-
|
|
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
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
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
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
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
|
-
|
|
652
|
-
|
|
653
|
-
|
|
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
|
-
|
|
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
|
-
|
|
661
|
-
|
|
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
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
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
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
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
|
-
|
|
717
|
-
|
|
774
|
+
let s1 = null;
|
|
775
|
+
let s2 = null;
|
|
718
776
|
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
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
|
-
|
|
756
|
-
|
|
789
|
+
if (s1 && s2) {
|
|
790
|
+
const ratio = (targetTime - s1.time) / (s2.time - s1.time);
|
|
757
791
|
|
|
758
|
-
|
|
759
|
-
|
|
792
|
+
// 1. Interpolazione Lineare Velocità (TWS)
|
|
793
|
+
const interpolatedTws = s1.tws + (s2.tws - s1.tws) * ratio;
|
|
760
794
|
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
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
|
-
|
|
766
|
-
|
|
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
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
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
|
-
|
|
777
|
-
|
|
778
|
-
|
|
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
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
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
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
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
|
-
|
|
861
|
+
return plugin;
|
|
807
862
|
};
|