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