signalk-ais-navionics-converter 1.0.0
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/LICENSE +21 -0
- package/README.md +318 -0
- package/ais-encoder.js +222 -0
- package/img/OpenCpn1.png +0 -0
- package/img/OpenCpn2.png +0 -0
- package/index.js +1005 -0
- package/package.json +21 -0
package/index.js
ADDED
|
@@ -0,0 +1,1005 @@
|
|
|
1
|
+
const net = require('net');
|
|
2
|
+
const dgram = require('dgram');
|
|
3
|
+
const axios = require('axios');
|
|
4
|
+
const AISEncoder = require('./ais-encoder');
|
|
5
|
+
|
|
6
|
+
module.exports = function(app) {
|
|
7
|
+
let plugin = {
|
|
8
|
+
id: 'signalk-ais-navionics-converter',
|
|
9
|
+
name: 'AIS to NMEA 0183 converter for TPC clients (e.g. Navionics, OpenCpn)',
|
|
10
|
+
description: 'SignalK plugin to convert AIS data to NMEA 0183 sentences to TCP clients (e.g. Navionics boating app, OpenCpn) and optional to vesselfinder.com'
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
let tcpServer = null;
|
|
14
|
+
let udpClient = null;
|
|
15
|
+
let updateInterval = null;
|
|
16
|
+
let tcpClients = [];
|
|
17
|
+
let newClients = [];
|
|
18
|
+
let previousVesselsState = new Map();
|
|
19
|
+
let lastTCPBroadcast = new Map();
|
|
20
|
+
let messageIdCounter = 0;
|
|
21
|
+
let ownMMSI = null;
|
|
22
|
+
let vesselFinderLastUpdate = 0;
|
|
23
|
+
let signalkApiUrl = null;
|
|
24
|
+
|
|
25
|
+
plugin.schema = {
|
|
26
|
+
type: 'object',
|
|
27
|
+
required: ['tcpPort'],
|
|
28
|
+
properties: {
|
|
29
|
+
tcpPort: {
|
|
30
|
+
type: 'number',
|
|
31
|
+
title: 'TCP Port',
|
|
32
|
+
description: 'Port for NMEA TCP server',
|
|
33
|
+
default: 10113
|
|
34
|
+
},
|
|
35
|
+
updateInterval: {
|
|
36
|
+
type: 'number',
|
|
37
|
+
title: 'Update interval for changed vessels (seconds, default: 15)',
|
|
38
|
+
description: 'How often to send updates to TCP clients (only changed vessels)',
|
|
39
|
+
default: 15
|
|
40
|
+
},
|
|
41
|
+
tcpResendInterval: {
|
|
42
|
+
type: 'number',
|
|
43
|
+
title: 'Update interval for unchanged vessels (seconds, default: 60)',
|
|
44
|
+
description: 'How often to resend unchanged vessels to TCP clients (0=disabled) - if 0 or too high vessels might disappear in Navionics boating app',
|
|
45
|
+
default: 60
|
|
46
|
+
},
|
|
47
|
+
skipWithoutCallsign: {
|
|
48
|
+
type: 'boolean',
|
|
49
|
+
title: 'Skip vessels without callsign',
|
|
50
|
+
description: 'Vessels without callsign will not be send',
|
|
51
|
+
default: false
|
|
52
|
+
},
|
|
53
|
+
skipStaleData: {
|
|
54
|
+
type: 'boolean',
|
|
55
|
+
title: 'Skip vessels with stale data',
|
|
56
|
+
description: 'Do not send vessels without unchanged data (yes/no, default: yes)',
|
|
57
|
+
default: true
|
|
58
|
+
},
|
|
59
|
+
staleDataThresholdMinutes: {
|
|
60
|
+
type: 'number',
|
|
61
|
+
title: 'Stale data threshold (minutes)',
|
|
62
|
+
description: 'Data where the position timestamp is older then n minutes will not be send',
|
|
63
|
+
default: 60
|
|
64
|
+
},
|
|
65
|
+
staleDataShipnameAddTime: {
|
|
66
|
+
type: 'number',
|
|
67
|
+
title: 'Timestamp last update of position added to ship name (minutes, 0=disabled)',
|
|
68
|
+
description: 'The timestamp of the last position update will be added to the ship name, if the data is older then actual time - minutes',
|
|
69
|
+
default: 5
|
|
70
|
+
},
|
|
71
|
+
minAlarmSOG: {
|
|
72
|
+
type: 'number',
|
|
73
|
+
title: 'Minimum SOG for alarm (m/s)',
|
|
74
|
+
description: 'SOG below this value will be set to 0',
|
|
75
|
+
default: 0.2
|
|
76
|
+
},
|
|
77
|
+
maxMinutesSOGToZero: {
|
|
78
|
+
type: 'number',
|
|
79
|
+
title: 'Maximum minutes before SOG is set to 0 (0=no correction of SOG)',
|
|
80
|
+
description: 'SOG will be set to 0 if last position timestamp is older then current time - minutes',
|
|
81
|
+
default: 0
|
|
82
|
+
},
|
|
83
|
+
logDebugDetails: {
|
|
84
|
+
type: 'boolean',
|
|
85
|
+
title: 'Debug all vessel details',
|
|
86
|
+
description: 'Detailed debug output in server log for all vessels - only visible if plugin is in debug mode',
|
|
87
|
+
default: false
|
|
88
|
+
},
|
|
89
|
+
logMMSI: {
|
|
90
|
+
type: 'string',
|
|
91
|
+
title: 'Debug MMSI',
|
|
92
|
+
description: 'MMSI for detailed debug output in server log - only visible if plugin is in debug mode',
|
|
93
|
+
default: ''
|
|
94
|
+
},
|
|
95
|
+
logDebugStale: {
|
|
96
|
+
type: 'boolean',
|
|
97
|
+
title: 'Debug all vessel stale vessels',
|
|
98
|
+
description: 'Detailed debug output in server log for all stale vessels - only visible if plugin is in debug mode and debug all vessel details is enabled',
|
|
99
|
+
default: false
|
|
100
|
+
},
|
|
101
|
+
logDebugJSON: {
|
|
102
|
+
type: 'boolean',
|
|
103
|
+
title: 'Debug all JSON data for vessels',
|
|
104
|
+
description: 'Detailed debug JSON output in server log for all vessels - only visible if plugin is in debug mode and debug all vessel details is enabled',
|
|
105
|
+
default: false
|
|
106
|
+
},
|
|
107
|
+
logDebugAIS: {
|
|
108
|
+
type: 'boolean',
|
|
109
|
+
title: 'Debug all AIS data for vessels',
|
|
110
|
+
description: 'Detailed debug AIS data output in server log for all vessels - only visible if plugin is in debug mode and debug all vessel details is enabled',
|
|
111
|
+
default: false
|
|
112
|
+
},
|
|
113
|
+
logDebugSOG: {
|
|
114
|
+
type: 'boolean',
|
|
115
|
+
title: 'Debug all vessels with corrected SOG',
|
|
116
|
+
description: 'Detailed debug output in server log for all vessels with corrected SOG - only visible if plugin is in debug mode and debug all vessel details is enabled',
|
|
117
|
+
default: false
|
|
118
|
+
},
|
|
119
|
+
vesselFinderEnabled: {
|
|
120
|
+
type: 'boolean',
|
|
121
|
+
title: 'Enable VesselFinder forwarding',
|
|
122
|
+
description: 'AIS type 1 messages (position) will be send to vesselfinder.com via UDP',
|
|
123
|
+
default: false
|
|
124
|
+
},
|
|
125
|
+
vesselFinderHost: {
|
|
126
|
+
type: 'string',
|
|
127
|
+
title: 'VesselFinder Host (default: ais.vesselfinder.com)',
|
|
128
|
+
default: 'ais.vesselfinder.com'
|
|
129
|
+
},
|
|
130
|
+
vesselFinderPort: {
|
|
131
|
+
type: 'number',
|
|
132
|
+
title: 'VesselFinder UDP Port (default: 5500)',
|
|
133
|
+
default: 5500
|
|
134
|
+
},
|
|
135
|
+
vesselFinderUpdateRate: {
|
|
136
|
+
type: 'number',
|
|
137
|
+
title: 'VesselFinder Update Rate (seconds)',
|
|
138
|
+
default: 60
|
|
139
|
+
},
|
|
140
|
+
cloudVesselsEnabled: {
|
|
141
|
+
type: 'boolean',
|
|
142
|
+
title: 'Include vessels received from AISFleet.com',
|
|
143
|
+
description: 'Beside vessels available in SignalK vessels from aisfleet.com will taken into account',
|
|
144
|
+
default: true
|
|
145
|
+
},
|
|
146
|
+
cloudVesselsRadius: {
|
|
147
|
+
type: 'number',
|
|
148
|
+
title: 'Radius (from own vessel) to include vessels from AISFleet.com (nautical miles)',
|
|
149
|
+
default: 10
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
};
|
|
153
|
+
|
|
154
|
+
plugin.start = function(options) {
|
|
155
|
+
app.debug('AIS to NMEA Converter plugin will start in 5 seconds...');
|
|
156
|
+
|
|
157
|
+
setTimeout(() => {
|
|
158
|
+
app.debug('Starting AIS to NMEA Converter plugin');
|
|
159
|
+
|
|
160
|
+
// Ermittle SignalK API URL
|
|
161
|
+
const port = app.config.settings.port || 3000;
|
|
162
|
+
let hostname = app.config.settings.hostname || '0.0.0.0';
|
|
163
|
+
|
|
164
|
+
// Wenn 0.0.0.0, verwende 127.0.0.1 für lokale Aufrufe
|
|
165
|
+
if (hostname === '0.0.0.0' || hostname === '::') {
|
|
166
|
+
hostname = '127.0.0.1';
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
signalkApiUrl = `http://${hostname}:${port}/signalk/v1/api`;
|
|
170
|
+
|
|
171
|
+
// Hole eigene MMSI
|
|
172
|
+
getOwnMMSI().then(() => {
|
|
173
|
+
startTCPServer(options);
|
|
174
|
+
startUpdateLoop(options);
|
|
175
|
+
|
|
176
|
+
if (options.vesselFinderEnabled && options.vesselFinderHost) {
|
|
177
|
+
startVesselFinderForwarding(options);
|
|
178
|
+
}
|
|
179
|
+
});
|
|
180
|
+
}, 5000);
|
|
181
|
+
};
|
|
182
|
+
|
|
183
|
+
plugin.stop = function() {
|
|
184
|
+
app.debug('Stopping AIS to NMEA Converter plugin');
|
|
185
|
+
|
|
186
|
+
if (updateInterval) clearInterval(updateInterval);
|
|
187
|
+
|
|
188
|
+
if (tcpServer) {
|
|
189
|
+
tcpClients.forEach(client => client.destroy());
|
|
190
|
+
tcpClients = [];
|
|
191
|
+
tcpServer.close();
|
|
192
|
+
tcpServer = null;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
if (udpClient) {
|
|
196
|
+
udpClient.close();
|
|
197
|
+
udpClient = null;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
previousVesselsState.clear();
|
|
201
|
+
lastTCPBroadcast.clear();
|
|
202
|
+
};
|
|
203
|
+
|
|
204
|
+
function getOwnMMSI() {
|
|
205
|
+
return new Promise((resolve) => {
|
|
206
|
+
if (ownMMSI) {
|
|
207
|
+
resolve(ownMMSI);
|
|
208
|
+
return;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
const selfData = app.getSelfPath('mmsi');
|
|
212
|
+
if (selfData) {
|
|
213
|
+
ownMMSI = selfData.toString();
|
|
214
|
+
app.debug(`Own MMSI detected: ${ownMMSI}`);
|
|
215
|
+
}
|
|
216
|
+
resolve(ownMMSI);
|
|
217
|
+
});
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function startTCPServer(options) {
|
|
221
|
+
tcpServer = net.createServer((socket) => {
|
|
222
|
+
app.debug(`TCP client connected: ${socket.remoteAddress}:${socket.remotePort}`);
|
|
223
|
+
tcpClients.push(socket);
|
|
224
|
+
newClients.push(socket);
|
|
225
|
+
|
|
226
|
+
socket.on('end', () => {
|
|
227
|
+
app.debug(`TCP client disconnected`);
|
|
228
|
+
tcpClients = tcpClients.filter(c => c !== socket);
|
|
229
|
+
newClients = newClients.filter(c => c !== socket);
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
socket.on('error', (err) => {
|
|
233
|
+
app.error(`TCP socket error: ${err}`);
|
|
234
|
+
tcpClients = tcpClients.filter(c => c !== socket);
|
|
235
|
+
newClients = newClients.filter(c => c !== socket);
|
|
236
|
+
});
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
tcpServer.listen(options.tcpPort, () => {
|
|
240
|
+
app.debug(`NMEA TCP Server listening on port ${options.tcpPort}`);
|
|
241
|
+
app.setPluginStatus(`TCP Server running on port ${options.tcpPort}`);
|
|
242
|
+
});
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function startVesselFinderForwarding(options) {
|
|
246
|
+
udpClient = dgram.createSocket('udp4');
|
|
247
|
+
app.debug(`VesselFinder UDP forwarding enabled: ${options.vesselFinderHost}:${options.vesselFinderPort}`);
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
function broadcastTCP(message) {
|
|
251
|
+
tcpClients.forEach(client => {
|
|
252
|
+
try {
|
|
253
|
+
client.write(message + '\r\n');
|
|
254
|
+
} catch (err) {
|
|
255
|
+
app.error(`Error broadcasting to TCP client: ${err}`);
|
|
256
|
+
}
|
|
257
|
+
});
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
function sendToVesselFinder(message, options) {
|
|
261
|
+
if (!udpClient || !options.vesselFinderEnabled) return;
|
|
262
|
+
|
|
263
|
+
try {
|
|
264
|
+
const buffer = Buffer.from(message + '\r\n');
|
|
265
|
+
udpClient.send(buffer, 0, buffer.length, options.vesselFinderPort, options.vesselFinderHost, (err) => {
|
|
266
|
+
if (err) {
|
|
267
|
+
app.error(`Error sending to VesselFinder: ${err}`);
|
|
268
|
+
}
|
|
269
|
+
});
|
|
270
|
+
} catch (error) {
|
|
271
|
+
app.error(`VesselFinder send error: ${error}`);
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
function startUpdateLoop(options) {
|
|
276
|
+
// Initiales Update
|
|
277
|
+
processVessels(options, 'Startup');
|
|
278
|
+
|
|
279
|
+
// Regelmäßige Updates
|
|
280
|
+
updateInterval = setInterval(() => {
|
|
281
|
+
processVessels(options, 'Scheduled');
|
|
282
|
+
}, options.updateInterval * 1000);
|
|
283
|
+
|
|
284
|
+
// Memory-Monitoring alle 5 Minuten wenn Debug aktiv
|
|
285
|
+
if (options.logDebugDetails) {
|
|
286
|
+
setInterval(() => {
|
|
287
|
+
logMemoryUsage();
|
|
288
|
+
}, 5 * 60 * 1000);
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
function logMemoryUsage() {
|
|
293
|
+
const usage = process.memoryUsage();
|
|
294
|
+
app.debug('=== Memory Usage ===');
|
|
295
|
+
app.debug(`RSS: ${(usage.rss / 1024 / 1024).toFixed(2)} MB`);
|
|
296
|
+
app.debug(`Heap Used: ${(usage.heapUsed / 1024 / 1024).toFixed(2)} MB`);
|
|
297
|
+
app.debug(`Heap Total: ${(usage.heapTotal / 1024 / 1024).toFixed(2)} MB`);
|
|
298
|
+
app.debug(`External: ${(usage.external / 1024 / 1024).toFixed(2)} MB`);
|
|
299
|
+
app.debug(`Map sizes - previousVesselsState: ${previousVesselsState.size}, lastTCPBroadcast: ${lastTCPBroadcast.size}`);
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
function fetchFromURL(url) {
|
|
303
|
+
return new Promise((resolve, reject) => {
|
|
304
|
+
app.debug(`Fetching SignalK vessels from URL: ${url}`);
|
|
305
|
+
|
|
306
|
+
const http = require('http');
|
|
307
|
+
|
|
308
|
+
http.get(url, (res) => {
|
|
309
|
+
let data = '';
|
|
310
|
+
|
|
311
|
+
// Prüfe HTTP Status Code
|
|
312
|
+
if (res.statusCode !== 200) {
|
|
313
|
+
app.error(`HTTP ${res.statusCode} from ${url}`);
|
|
314
|
+
reject(new Error(`HTTP ${res.statusCode}`));
|
|
315
|
+
return;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
res.on('data', (chunk) => {
|
|
319
|
+
data += chunk;
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
res.on('end', () => {
|
|
323
|
+
try {
|
|
324
|
+
const result = JSON.parse(data);
|
|
325
|
+
resolve(result);
|
|
326
|
+
} catch (err) {
|
|
327
|
+
app.error(`Invalid JSON from ${url}: ${err.message}`);
|
|
328
|
+
app.error(`Data preview: ${data.substring(0, 200)}`);
|
|
329
|
+
reject(err);
|
|
330
|
+
}
|
|
331
|
+
});
|
|
332
|
+
}).on('error', (err) => {
|
|
333
|
+
app.error(`HTTP request error for ${url}: ${err.message}`);
|
|
334
|
+
reject(err);
|
|
335
|
+
});
|
|
336
|
+
});
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
function fetchVesselsFromAPI() {
|
|
340
|
+
return fetchFromURL(`${signalkApiUrl}/vessels`);
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
function fetchCloudVessels(options) {
|
|
344
|
+
if (!options.cloudVesselsEnabled) {
|
|
345
|
+
return Promise.resolve(null);
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
try {
|
|
349
|
+
// Hole eigene Position
|
|
350
|
+
const position = app.getSelfPath('navigation.position');
|
|
351
|
+
if (!position || !position.value || !position.value.latitude || !position.value.longitude) {
|
|
352
|
+
app.error('No self position available for cloud vessels fetch');
|
|
353
|
+
return Promise.resolve(null);
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
const lat = position.value.latitude;
|
|
357
|
+
const lng = position.value.longitude;
|
|
358
|
+
const radius = options.cloudVesselsRadius || 10;
|
|
359
|
+
|
|
360
|
+
if (!ownMMSI) {
|
|
361
|
+
app.error('No own MMSI available for cloud vessels fetch');
|
|
362
|
+
return Promise.resolve(null);
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
const url = `https://aisfleet.com/api/vessels/nearby?lat=${lat}&lng=${lng}&radius=${radius}&mmsi=${ownMMSI}`;
|
|
366
|
+
app.debug(`Fetching cloud vessels from AISFleet API (radius: ${radius}nm)`);
|
|
367
|
+
|
|
368
|
+
const requestConfig = {
|
|
369
|
+
method: 'get',
|
|
370
|
+
maxBodyLength: Infinity,
|
|
371
|
+
url: url,
|
|
372
|
+
headers: {},
|
|
373
|
+
timeout: 15000 // Reduziert auf 15 Sekunden
|
|
374
|
+
};
|
|
375
|
+
|
|
376
|
+
const startTime = Date.now();
|
|
377
|
+
|
|
378
|
+
return axios.request(requestConfig)
|
|
379
|
+
.then(response => {
|
|
380
|
+
const duration = Date.now() - startTime;
|
|
381
|
+
app.debug(`AISFleet API response time: ${duration}ms`);
|
|
382
|
+
|
|
383
|
+
if (duration > 10000) {
|
|
384
|
+
app.error(`AISFleet API slow response: ${duration}ms - consider reducing radius`);
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
const data = response.data;
|
|
388
|
+
|
|
389
|
+
if (data.vessels && Array.isArray(data.vessels)) {
|
|
390
|
+
app.debug(`Retrieved ${data.vessels.length} vessels from AISFleet cloud API`);
|
|
391
|
+
|
|
392
|
+
// Konvertiere zu SignalK-Format
|
|
393
|
+
const cloudVessels = {};
|
|
394
|
+
data.vessels.forEach(vessel => {
|
|
395
|
+
if (!vessel.mmsi || vessel.mmsi === ownMMSI) return;
|
|
396
|
+
|
|
397
|
+
const vesselId = `urn:mrn:imo:mmsi:${vessel.mmsi}`;
|
|
398
|
+
const vesselData = {};
|
|
399
|
+
|
|
400
|
+
// Basis-Informationen
|
|
401
|
+
if (vessel.mmsi) {
|
|
402
|
+
vesselData.mmsi = vessel.mmsi;
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
if (vessel.name) {
|
|
406
|
+
vesselData.name = vessel.name;
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
if (vessel.call_sign) {
|
|
410
|
+
vesselData.communication = {
|
|
411
|
+
callsignVhf: vessel.call_sign
|
|
412
|
+
};
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
if (vessel.imo_number) {
|
|
416
|
+
vesselData.imo = vessel.imo_number;
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
// Design-Daten - Format kompatibel zu SignalK
|
|
420
|
+
const design = {};
|
|
421
|
+
if (vessel.design_length) {
|
|
422
|
+
design.length = {
|
|
423
|
+
value: {
|
|
424
|
+
overall: vessel.design_length
|
|
425
|
+
}
|
|
426
|
+
};
|
|
427
|
+
}
|
|
428
|
+
if (vessel.design_beam) {
|
|
429
|
+
design.beam = {
|
|
430
|
+
value: vessel.design_beam
|
|
431
|
+
};
|
|
432
|
+
}
|
|
433
|
+
if (vessel.design_draft) {
|
|
434
|
+
design.draft = {
|
|
435
|
+
value: {
|
|
436
|
+
maximum: vessel.design_draft
|
|
437
|
+
}
|
|
438
|
+
};
|
|
439
|
+
}
|
|
440
|
+
if (vessel.ais_ship_type) {
|
|
441
|
+
design.aisShipType = {
|
|
442
|
+
value: {
|
|
443
|
+
id: vessel.ais_ship_type
|
|
444
|
+
}
|
|
445
|
+
};
|
|
446
|
+
}
|
|
447
|
+
if (Object.keys(design).length > 0) {
|
|
448
|
+
vesselData.design = design;
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
// Navigation
|
|
452
|
+
const navigation = {};
|
|
453
|
+
|
|
454
|
+
if (vessel.last_position) {
|
|
455
|
+
navigation.position = {
|
|
456
|
+
value: {
|
|
457
|
+
latitude: vessel.last_position.latitude,
|
|
458
|
+
longitude: vessel.last_position.longitude
|
|
459
|
+
},
|
|
460
|
+
timestamp: vessel.last_position.timestamp
|
|
461
|
+
};
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
if (vessel.latest_navigation) {
|
|
465
|
+
const nav = vessel.latest_navigation;
|
|
466
|
+
const navTimestamp = nav.timestamp;
|
|
467
|
+
|
|
468
|
+
if (nav.course_over_ground !== null && nav.course_over_ground !== undefined) {
|
|
469
|
+
// COG 360° ist ungültig, setze auf 0°
|
|
470
|
+
let cog = nav.course_over_ground;
|
|
471
|
+
if (cog >= 360) {
|
|
472
|
+
cog = 0;
|
|
473
|
+
}
|
|
474
|
+
navigation.courseOverGroundTrue = {
|
|
475
|
+
value: cog,
|
|
476
|
+
timestamp: navTimestamp
|
|
477
|
+
};
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
if (nav.speed_over_ground !== null && nav.speed_over_ground !== undefined) {
|
|
481
|
+
navigation.speedOverGround = {
|
|
482
|
+
value: nav.speed_over_ground * 0.514444, // knots to m/s
|
|
483
|
+
timestamp: navTimestamp
|
|
484
|
+
};
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
if (nav.heading !== null && nav.heading !== undefined) {
|
|
488
|
+
// Heading 360° ist ungültig, setze auf 0°
|
|
489
|
+
let heading = nav.heading;
|
|
490
|
+
if (heading >= 360) {
|
|
491
|
+
heading = 0;
|
|
492
|
+
}
|
|
493
|
+
navigation.headingTrue = {
|
|
494
|
+
value: heading * Math.PI / 180, // degrees to radians
|
|
495
|
+
timestamp: navTimestamp
|
|
496
|
+
};
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
if (nav.rate_of_turn !== null && nav.rate_of_turn !== undefined) {
|
|
500
|
+
navigation.rateOfTurn = {
|
|
501
|
+
value: nav.rate_of_turn * Math.PI / 180, // degrees/s to radians/s
|
|
502
|
+
timestamp: navTimestamp
|
|
503
|
+
};
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
if (nav.navigation_status !== null && nav.navigation_status !== undefined) {
|
|
507
|
+
navigation.state = {
|
|
508
|
+
value: nav.navigation_status,
|
|
509
|
+
timestamp: navTimestamp
|
|
510
|
+
};
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
if (Object.keys(navigation).length > 0) {
|
|
515
|
+
vesselData.navigation = navigation;
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
cloudVessels[vesselId] = vesselData;
|
|
519
|
+
});
|
|
520
|
+
|
|
521
|
+
return cloudVessels;
|
|
522
|
+
} else {
|
|
523
|
+
app.debug('No vessels array in AISFleet API response');
|
|
524
|
+
return null;
|
|
525
|
+
}
|
|
526
|
+
})
|
|
527
|
+
.catch(error => {
|
|
528
|
+
if (error.code === 'ECONNABORTED' || error.code === 'ETIMEDOUT') {
|
|
529
|
+
app.error(`AISFleet API timeout after 15s - consider reducing radius or check internet connection`);
|
|
530
|
+
} else if (error.response?.status >= 500) {
|
|
531
|
+
app.error(`AISFleet API fetch failed: server error ${error.response.status}`);
|
|
532
|
+
} else if (error.response?.status === 403) {
|
|
533
|
+
app.error('AISFleet API fetch failed: access denied');
|
|
534
|
+
} else if (error.response?.status) {
|
|
535
|
+
app.error(`AISFleet API fetch failed: HTTP ${error.response.status}`);
|
|
536
|
+
} else if (error.code) {
|
|
537
|
+
app.error(`AISFleet API fetch failed: ${error.code} - ${error.message}`);
|
|
538
|
+
} else {
|
|
539
|
+
app.error(`AISFleet API fetch failed: ${error.message || 'Unknown error'}`);
|
|
540
|
+
}
|
|
541
|
+
return null;
|
|
542
|
+
});
|
|
543
|
+
|
|
544
|
+
} catch (error) {
|
|
545
|
+
app.error(`Error in fetchCloudVessels: ${error.message}`);
|
|
546
|
+
return Promise.resolve(null);
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
function mergeVesselData(vessel1, vessel2) {
|
|
551
|
+
// Merge zwei Vessel-Objekte, neuere Timestamps haben Vorrang
|
|
552
|
+
const merged = JSON.parse(JSON.stringify(vessel1)); // Deep copy
|
|
553
|
+
|
|
554
|
+
if (!vessel2) return merged;
|
|
555
|
+
|
|
556
|
+
// Spezialbehandlung für name und callsign: Gefüllte Werte haben immer Vorrang
|
|
557
|
+
const vessel1Name = vessel1.name;
|
|
558
|
+
const vessel2Name = vessel2.name;
|
|
559
|
+
const vessel1Callsign = vessel1.communication?.callsignVhf || vessel1.callsign || vessel1.callSign;
|
|
560
|
+
const vessel2Callsign = vessel2.communication?.callsignVhf || vessel2.callsign || vessel2.callSign;
|
|
561
|
+
|
|
562
|
+
// Name: Bevorzuge gefüllte Werte über "Unknown" oder leere Werte
|
|
563
|
+
if (vessel2Name && vessel2Name !== 'Unknown' && vessel2Name !== '') {
|
|
564
|
+
if (!vessel1Name || vessel1Name === 'Unknown' || vessel1Name === '') {
|
|
565
|
+
merged.name = vessel2Name;
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
// Callsign: Bevorzuge gefüllte Werte über leere Werte
|
|
570
|
+
if (vessel2Callsign && vessel2Callsign !== '') {
|
|
571
|
+
if (!vessel1Callsign || vessel1Callsign === '') {
|
|
572
|
+
if (!merged.communication) merged.communication = {};
|
|
573
|
+
merged.communication.callsignVhf = vessel2Callsign;
|
|
574
|
+
// Setze auch die anderen Varianten
|
|
575
|
+
merged.callsign = vessel2Callsign;
|
|
576
|
+
merged.callSign = vessel2Callsign;
|
|
577
|
+
}
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
// Funktion zum Vergleichen und Mergen von Objekten mit Timestamps
|
|
581
|
+
const mergeWithTimestamp = (target, source, path = '') => {
|
|
582
|
+
if (!source) return;
|
|
583
|
+
|
|
584
|
+
for (const key in source) {
|
|
585
|
+
const sourcePath = path ? `${path}.${key}` : key;
|
|
586
|
+
|
|
587
|
+
// Überspringe name und callsign-Felder, die bereits behandelt wurden
|
|
588
|
+
if (key === 'name' || key === 'callsign' || key === 'callSign') {
|
|
589
|
+
continue;
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
// Überspringe communication.callsignVhf, wurde bereits behandelt
|
|
593
|
+
if (path === 'communication' && key === 'callsignVhf') {
|
|
594
|
+
continue;
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
if (source[key] && typeof source[key] === 'object' && !Array.isArray(source[key])) {
|
|
598
|
+
// Prüfe ob Objekt einen Timestamp hat
|
|
599
|
+
if (source[key].timestamp && target[key]?.timestamp) {
|
|
600
|
+
const sourceTime = new Date(source[key].timestamp);
|
|
601
|
+
const targetTime = new Date(target[key].timestamp);
|
|
602
|
+
|
|
603
|
+
if (sourceTime > targetTime) {
|
|
604
|
+
target[key] = source[key];
|
|
605
|
+
}
|
|
606
|
+
} else if (source[key].timestamp && !target[key]?.timestamp) {
|
|
607
|
+
// Source hat Timestamp, Target nicht - nehme Source
|
|
608
|
+
target[key] = source[key];
|
|
609
|
+
} else if (!source[key].timestamp && target[key]?.timestamp) {
|
|
610
|
+
// Target hat Timestamp, Source nicht - behalte Target
|
|
611
|
+
// Nichts tun
|
|
612
|
+
} else {
|
|
613
|
+
// Kein Timestamp in beiden - rekursiv mergen
|
|
614
|
+
if (!target[key]) target[key] = {};
|
|
615
|
+
mergeWithTimestamp(target[key], source[key], sourcePath);
|
|
616
|
+
}
|
|
617
|
+
} else if (!target[key] && source[key]) {
|
|
618
|
+
// Target hat keinen Wert, übernehme von Source
|
|
619
|
+
target[key] = source[key];
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
};
|
|
623
|
+
|
|
624
|
+
mergeWithTimestamp(merged, vessel2);
|
|
625
|
+
return merged;
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
function mergeVesselSources(signalkVessels, cloudVessels, options) {
|
|
629
|
+
const merged = {};
|
|
630
|
+
|
|
631
|
+
app.debug(`Merging vessels - SignalK: ${signalkVessels ? Object.keys(signalkVessels).length : 0}, Cloud: ${cloudVessels ? Object.keys(cloudVessels).length : 0}`);
|
|
632
|
+
|
|
633
|
+
// Füge alle SignalK Vessels hinzu
|
|
634
|
+
if (signalkVessels) {
|
|
635
|
+
for (const [vesselId, vessel] of Object.entries(signalkVessels)) {
|
|
636
|
+
merged[vesselId] = vessel;
|
|
637
|
+
}
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
// Merge Cloud Vessels
|
|
641
|
+
if (cloudVessels) {
|
|
642
|
+
for (const [vesselId, cloudVessel] of Object.entries(cloudVessels)) {
|
|
643
|
+
const mmsiMatch = vesselId.match(/mmsi:(\d+)/);
|
|
644
|
+
const mmsi = mmsiMatch ? mmsiMatch[1] : null;
|
|
645
|
+
const logMMSI = options.logMMSI || '';
|
|
646
|
+
const shouldLog = options.logDebugDetails || (logMMSI && logMMSI !== '' && mmsi === logMMSI);
|
|
647
|
+
|
|
648
|
+
if (merged[vesselId]) {
|
|
649
|
+
// Vessel existiert in beiden Quellen - merge mit Timestamp-Vergleich
|
|
650
|
+
merged[vesselId] = mergeVesselData(merged[vesselId], cloudVessel);
|
|
651
|
+
|
|
652
|
+
if (shouldLog) {
|
|
653
|
+
// app.debug(`Merged vessel ${vesselId} (${mmsi}):`);
|
|
654
|
+
if ((options.logDebugJSON && options.logDebugDetails) || (logMMSI && logMMSI !== '' && mmsi === logMMSI)){
|
|
655
|
+
app.debug(JSON.stringify(merged[vesselId], null, 2));
|
|
656
|
+
}
|
|
657
|
+
}
|
|
658
|
+
} else {
|
|
659
|
+
// Vessel nur in Cloud - direkt hinzufügen
|
|
660
|
+
merged[vesselId] = cloudVessel;
|
|
661
|
+
|
|
662
|
+
if (shouldLog) {
|
|
663
|
+
// app.debug(`Cloud-only vessel ${vesselId} (${mmsi}):`);
|
|
664
|
+
if ((options.logDebugJSON && options.logDebugDetails) || (logMMSI && logMMSI !== '' && mmsi === logMMSI)){
|
|
665
|
+
app.debug(JSON.stringify(cloudVessel, null, 2));
|
|
666
|
+
}
|
|
667
|
+
}
|
|
668
|
+
if (merged[vesselId].name && merged[vesselId].navigation.position.timestamp && options.staleDataShipnameAddTime > 0) {
|
|
669
|
+
const timestamp = new Date(merged[vesselId].navigation.position.timestamp); // UTC-Zeit
|
|
670
|
+
const nowUTC = new Date(); // aktuelle Zeit (intern ebenfalls UTC-basiert)
|
|
671
|
+
const diffMs = nowUTC - timestamp; // Differenz in Millisekunden
|
|
672
|
+
const diffMinutes = Math.floor(diffMs / (1000 * 60)); // Umrechnung in Minuten
|
|
673
|
+
if (diffMinutes >= options.staleDataShipnameAddTime) {
|
|
674
|
+
// Füge Zeitstempel zum Schiffsnamen hinzu und kürze auf 20 Zeichen
|
|
675
|
+
let suffix = "";
|
|
676
|
+
if (diffMinutes > 1439) { // mehr als 23:59 Minuten → Tage
|
|
677
|
+
const days = Math.ceil(diffMinutes / (60 * 24));
|
|
678
|
+
suffix = ` DAY${days}`;
|
|
679
|
+
} else if (diffMinutes > 59) { // mehr als 59 Minuten → Stunden
|
|
680
|
+
const hours = Math.ceil(diffMinutes / 60);
|
|
681
|
+
suffix = ` HOUR${hours}`;
|
|
682
|
+
} else { // sonst → Minuten
|
|
683
|
+
suffix = ` MIN${diffMinutes}`;
|
|
684
|
+
}
|
|
685
|
+
// Füge Zeitstempel zum Schiffsnamen hinzu und kürze auf 20 Zeichen
|
|
686
|
+
merged[vesselId].name = `${merged[vesselId].name}${suffix}`.substring(0, 20);
|
|
687
|
+
}
|
|
688
|
+
}
|
|
689
|
+
}
|
|
690
|
+
}
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
app.debug(`Total merged vessels: ${Object.keys(merged).length}`);
|
|
694
|
+
return merged;
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
function getVessels(options) {
|
|
698
|
+
return Promise.all([
|
|
699
|
+
fetchVesselsFromAPI(),
|
|
700
|
+
fetchCloudVessels(options)
|
|
701
|
+
]).then(([signalkVessels, cloudVessels]) => {
|
|
702
|
+
const vessels = [];
|
|
703
|
+
|
|
704
|
+
// Merge beide Datenquellen
|
|
705
|
+
const allVessels = mergeVesselSources(signalkVessels, cloudVessels, options);
|
|
706
|
+
|
|
707
|
+
if (!allVessels) return vessels;
|
|
708
|
+
|
|
709
|
+
for (const [vesselId, vessel] of Object.entries(allVessels)) {
|
|
710
|
+
if (vesselId === 'self') continue;
|
|
711
|
+
|
|
712
|
+
const mmsiMatch = vesselId.match(/mmsi:(\d+)/);
|
|
713
|
+
if (!mmsiMatch) continue;
|
|
714
|
+
|
|
715
|
+
const mmsi = mmsiMatch[1];
|
|
716
|
+
if (ownMMSI && mmsi === ownMMSI) continue;
|
|
717
|
+
|
|
718
|
+
vessels.push({
|
|
719
|
+
mmsi: mmsi,
|
|
720
|
+
name: vessel.name || 'Unknown',
|
|
721
|
+
callsign: vessel.callsign || vessel.callSign || vessel.communication?.callsignVhf || '',
|
|
722
|
+
navigation: vessel.navigation || {},
|
|
723
|
+
design: vessel.design || {},
|
|
724
|
+
sensors: vessel.sensors || {},
|
|
725
|
+
destination: vessel.navigation?.destination?.commonName?.value || null,
|
|
726
|
+
imo: vessel.imo || vessel.registrations?.imo || '0'
|
|
727
|
+
});
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
return vessels;
|
|
731
|
+
}).catch(err => {
|
|
732
|
+
app.error(`Error in getVessels: ${err}`);
|
|
733
|
+
return [];
|
|
734
|
+
});
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
function filterVessels(vessels, options) {
|
|
738
|
+
const now = new Date();
|
|
739
|
+
const filtered = [];
|
|
740
|
+
|
|
741
|
+
for (const vessel of vessels) {
|
|
742
|
+
let callSign = vessel.callsign || '';
|
|
743
|
+
const hasRealCallsign = callSign && callSign.length > 0;
|
|
744
|
+
|
|
745
|
+
if (!hasRealCallsign) {
|
|
746
|
+
callSign = 'UNKNOWN';
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
// Hole Position Timestamp für mehrere Checks
|
|
750
|
+
let posTimestamp = vessel.navigation?.position?.timestamp;
|
|
751
|
+
|
|
752
|
+
// Fallback für verschachtelte Strukturen (z.B. nach Merge)
|
|
753
|
+
if (!posTimestamp && vessel.navigation?.position?.value) {
|
|
754
|
+
posTimestamp = vessel.navigation.position.value.timestamp;
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
// Stale data check
|
|
758
|
+
if (options.skipStaleData && posTimestamp) {
|
|
759
|
+
const lastUpdate = new Date(posTimestamp);
|
|
760
|
+
const ageMs = now - lastUpdate;
|
|
761
|
+
const thresholdMs = options.staleDataThresholdMinutes * 60 * 1000;
|
|
762
|
+
|
|
763
|
+
if (ageMs > thresholdMs) {
|
|
764
|
+
const ageSec = Math.floor(ageMs / 1000);
|
|
765
|
+
const days = Math.floor(ageSec / 86400);
|
|
766
|
+
const hours = Math.floor((ageSec % 86400) / 3600);
|
|
767
|
+
const minutes = Math.floor((ageSec % 3600) / 60);
|
|
768
|
+
|
|
769
|
+
let ageStr = '';
|
|
770
|
+
if (days > 0) ageStr += `${days}d `;
|
|
771
|
+
if (hours > 0) ageStr += `${hours}h `;
|
|
772
|
+
if (minutes > 0) ageStr += `${minutes}m`;
|
|
773
|
+
|
|
774
|
+
if ((options.logDebugDetails && options.logDebugStale) || (options.logMMSI && vessel.mmsi === options.logMMSI)) {
|
|
775
|
+
app.debug(`Skipped (stale): ${vessel.mmsi} ${vessel.name} - ${ageStr.trim()} ago`);
|
|
776
|
+
}
|
|
777
|
+
continue;
|
|
778
|
+
}
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
// SOG Korrektur basierend auf Position Timestamp Alter
|
|
782
|
+
if (options.maxMinutesSOGToZero > 0 && posTimestamp) {
|
|
783
|
+
const lastUpdate = new Date(posTimestamp);
|
|
784
|
+
const ageMs = now - lastUpdate;
|
|
785
|
+
const sogThresholdMs = options.maxMinutesSOGToZero * 60 * 1000;
|
|
786
|
+
if (ageMs > sogThresholdMs) {
|
|
787
|
+
// Position ist zu alt, setze SOG auf 0
|
|
788
|
+
if (vessel.navigation && vessel.navigation.speedOverGround) {
|
|
789
|
+
let originalSOG = vessel.navigation.speedOverGround.value !== undefined
|
|
790
|
+
? vessel.navigation.speedOverGround.value
|
|
791
|
+
: vessel.navigation.speedOverGround;
|
|
792
|
+
|
|
793
|
+
// Stelle sicher, dass originalSOG eine Zahl ist
|
|
794
|
+
if (typeof originalSOG !== 'number') {
|
|
795
|
+
originalSOG = 0;
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
if (vessel.navigation.speedOverGround.value !== undefined) {
|
|
799
|
+
vessel.navigation.speedOverGround.value = 0;
|
|
800
|
+
} else {
|
|
801
|
+
vessel.navigation.speedOverGround = 0;
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
if ((options.logDebugDetails && options.logDebugSOG) || (options.logMMSI && vessel.mmsi === options.logMMSI)) {
|
|
805
|
+
const ageMinutes = Math.floor(ageMs / 60000)
|
|
806
|
+
app.debug(`SOG corrected to 0 for ${vessel.mmsi} ${vessel.name} - position age: ${ageMinutes}min (was: ${originalSOG.toFixed(2)} m/s)`);
|
|
807
|
+
}
|
|
808
|
+
}
|
|
809
|
+
}
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
// Callsign check
|
|
813
|
+
if (options.skipWithoutCallsign && !hasRealCallsign) {
|
|
814
|
+
if (options.logDebugDetails || (!options.logMMSI || vessel.mmsi === options.logMMSI)) {
|
|
815
|
+
app.debug(`Skipped (no callsign): ${vessel.mmsi} ${vessel.name}`);
|
|
816
|
+
}
|
|
817
|
+
continue;
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
vessel.callSign = callSign;
|
|
821
|
+
filtered.push(vessel);
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
return filtered;
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
function processVessels(options, reason) {
|
|
828
|
+
getVessels(options).then(vessels => {
|
|
829
|
+
try {
|
|
830
|
+
const filtered = filterVessels(vessels, options);
|
|
831
|
+
|
|
832
|
+
// Erstelle Set der aktuellen MMSIs
|
|
833
|
+
const currentMMSIs = new Set(filtered.map(v => v.mmsi));
|
|
834
|
+
|
|
835
|
+
// Bereinige Maps von Schiffen die nicht mehr existieren
|
|
836
|
+
cleanupMaps(currentMMSIs, options);
|
|
837
|
+
|
|
838
|
+
messageIdCounter = (messageIdCounter + 1) % 10;
|
|
839
|
+
const hasNewClients = newClients.length > 0;
|
|
840
|
+
const nowTimestamp = Date.now();
|
|
841
|
+
const vesselFinderUpdateDue = options.vesselFinderEnabled &&
|
|
842
|
+
(nowTimestamp - vesselFinderLastUpdate) >= (options.vesselFinderUpdateRate * 1000);
|
|
843
|
+
|
|
844
|
+
let sentCount = 0;
|
|
845
|
+
let unchangedCount = 0;
|
|
846
|
+
let vesselFinderCount = 0;
|
|
847
|
+
|
|
848
|
+
filtered.forEach(vessel => {
|
|
849
|
+
const currentState = JSON.stringify({
|
|
850
|
+
position: vessel.navigation?.position,
|
|
851
|
+
speedOverGround: vessel.navigation?.speedOverGround,
|
|
852
|
+
courseOverGroundTrue: vessel.navigation?.courseOverGroundTrue,
|
|
853
|
+
headingTrue: vessel.navigation?.headingTrue,
|
|
854
|
+
state: vessel.navigation?.state,
|
|
855
|
+
name: vessel.name,
|
|
856
|
+
callSign: vessel.callSign
|
|
857
|
+
});
|
|
858
|
+
|
|
859
|
+
const previousState = previousVesselsState.get(vessel.mmsi);
|
|
860
|
+
const hasChanged = !previousState || previousState !== currentState || hasNewClients;
|
|
861
|
+
|
|
862
|
+
// TCP Resend Check
|
|
863
|
+
const lastBroadcast = lastTCPBroadcast.get(vessel.mmsi) || 0;
|
|
864
|
+
const timeSinceLastBroadcast = nowTimestamp - lastBroadcast;
|
|
865
|
+
const needsTCPResend = timeSinceLastBroadcast >= (options.tcpResendInterval*1000);
|
|
866
|
+
|
|
867
|
+
if (!hasChanged && !vesselFinderUpdateDue && !needsTCPResend) {
|
|
868
|
+
unchangedCount++;
|
|
869
|
+
return;
|
|
870
|
+
}
|
|
871
|
+
|
|
872
|
+
// Filtere Schiffe ohne Name und Callsign aus
|
|
873
|
+
const hasValidName = vessel.name && vessel.name.toLowerCase() !== 'unknown';
|
|
874
|
+
const hasValidCallsign = vessel.callSign && vessel.callSign.toLowerCase() !== 'unknown' && vessel.callSign !== '';
|
|
875
|
+
|
|
876
|
+
if (!hasValidName && !hasValidCallsign) {
|
|
877
|
+
if (options.logDebugDetails || (!options.logMMSI || vessel.mmsi === options.logMMSI)) {
|
|
878
|
+
app.debug(`Skipped (no valid name and no valid callsign): ${vessel.mmsi} - Name: "${vessel.name}", Callsign: "${vessel.callSign}"`);
|
|
879
|
+
}
|
|
880
|
+
unchangedCount++;
|
|
881
|
+
return;
|
|
882
|
+
}
|
|
883
|
+
|
|
884
|
+
previousVesselsState.set(vessel.mmsi, currentState);
|
|
885
|
+
|
|
886
|
+
// Bestimme ob an TCP gesendet werden soll
|
|
887
|
+
const sendToTCP = hasChanged || needsTCPResend;
|
|
888
|
+
|
|
889
|
+
// Debug-Logging für spezifische MMSI
|
|
890
|
+
const shouldLogDebug = (options.logDebugDetails && options.logDebugAIS) || (options.logMMSI && vessel.mmsi === options.logMMSI);
|
|
891
|
+
|
|
892
|
+
// Type 1
|
|
893
|
+
const payload1 = AISEncoder.createPositionReport(vessel, options);
|
|
894
|
+
if (payload1) {
|
|
895
|
+
const sentence1 = AISEncoder.createNMEASentence(payload1, 1, 1, messageIdCounter, 'B');
|
|
896
|
+
|
|
897
|
+
if (shouldLogDebug) {
|
|
898
|
+
app.debug(`[${vessel.mmsi}] Type 1: ${sentence1}`);
|
|
899
|
+
}
|
|
900
|
+
|
|
901
|
+
if (sendToTCP) {
|
|
902
|
+
broadcastTCP(sentence1);
|
|
903
|
+
sentCount++;
|
|
904
|
+
lastTCPBroadcast.set(vessel.mmsi, nowTimestamp);
|
|
905
|
+
}
|
|
906
|
+
|
|
907
|
+
// VesselFinder: nur Type 1 Nachrichten und nur wenn Position nicht älter als 5 Minuten
|
|
908
|
+
if (vesselFinderUpdateDue) {
|
|
909
|
+
let posTimestamp = vessel.navigation?.position?.timestamp;
|
|
910
|
+
if (!posTimestamp && vessel.navigation?.position?.value) {
|
|
911
|
+
posTimestamp = vessel.navigation.position.value.timestamp;
|
|
912
|
+
}
|
|
913
|
+
|
|
914
|
+
if (posTimestamp) {
|
|
915
|
+
const posAge = nowTimestamp - new Date(posTimestamp).getTime();
|
|
916
|
+
const fiveMinutes = 5 * 60 * 1000;
|
|
917
|
+
|
|
918
|
+
if (posAge <= fiveMinutes) {
|
|
919
|
+
sendToVesselFinder(sentence1, options);
|
|
920
|
+
vesselFinderCount++;
|
|
921
|
+
} else if (shouldLogDebug) {
|
|
922
|
+
app.debug(`[${vessel.mmsi}] Skipped VesselFinder (position age: ${Math.floor(posAge/60000)}min)`);
|
|
923
|
+
}
|
|
924
|
+
}
|
|
925
|
+
}
|
|
926
|
+
}
|
|
927
|
+
|
|
928
|
+
// Type 5 - nur an TCP Clients, NICHT an VesselFinder
|
|
929
|
+
const shouldSendType5 = vessel.callSign && vessel.callSign.length > 0 &&
|
|
930
|
+
(vessel.callSign !== 'UNKNOWN' || !options.skipWithoutCallsign);
|
|
931
|
+
|
|
932
|
+
if (shouldSendType5 && sendToTCP) {
|
|
933
|
+
const payload5 = AISEncoder.createStaticVoyage(vessel);
|
|
934
|
+
if (payload5) {
|
|
935
|
+
if (payload5.length <= 62) {
|
|
936
|
+
const sentence5 = AISEncoder.createNMEASentence(payload5, 1, 1, messageIdCounter, 'B');
|
|
937
|
+
|
|
938
|
+
if (shouldLogDebug) {
|
|
939
|
+
app.debug(`[${vessel.mmsi}] Type 5: ${sentence5}`);
|
|
940
|
+
}
|
|
941
|
+
|
|
942
|
+
broadcastTCP(sentence5);
|
|
943
|
+
} else {
|
|
944
|
+
const fragment1 = payload5.substring(0, 62);
|
|
945
|
+
const fragment2 = payload5.substring(62);
|
|
946
|
+
const sentence5_1 = AISEncoder.createNMEASentence(fragment1, 2, 1, messageIdCounter, 'B');
|
|
947
|
+
const sentence5_2 = AISEncoder.createNMEASentence(fragment2, 2, 2, messageIdCounter, 'B');
|
|
948
|
+
|
|
949
|
+
if (shouldLogDebug) {
|
|
950
|
+
app.debug(`[${vessel.mmsi}] Type 5 (1/2): ${sentence5_1}`);
|
|
951
|
+
app.debug(`[${vessel.mmsi}] Type 5 (2/2): ${sentence5_2}`);
|
|
952
|
+
}
|
|
953
|
+
|
|
954
|
+
broadcastTCP(sentence5_1);
|
|
955
|
+
broadcastTCP(sentence5_2);
|
|
956
|
+
}
|
|
957
|
+
}
|
|
958
|
+
}
|
|
959
|
+
});
|
|
960
|
+
|
|
961
|
+
if (hasNewClients) {
|
|
962
|
+
newClients = [];
|
|
963
|
+
}
|
|
964
|
+
|
|
965
|
+
if (vesselFinderUpdateDue) {
|
|
966
|
+
vesselFinderLastUpdate = nowTimestamp;
|
|
967
|
+
app.debug(`VesselFinder: sent ${vesselFinderCount} vessels`);
|
|
968
|
+
}
|
|
969
|
+
|
|
970
|
+
app.debug(`${reason}: sent ${sentCount}, unchanged ${unchangedCount}, clients ${tcpClients.length}`);
|
|
971
|
+
|
|
972
|
+
} catch (err) {
|
|
973
|
+
app.error(`Error processing vessels: ${err}`);
|
|
974
|
+
}
|
|
975
|
+
}).catch(err => {
|
|
976
|
+
app.error(`Error in processVessels: ${err}`);
|
|
977
|
+
});
|
|
978
|
+
}
|
|
979
|
+
|
|
980
|
+
function cleanupMaps(currentMMSIs, options) {
|
|
981
|
+
// Entferne Einträge aus previousVesselsState
|
|
982
|
+
let removedFromState = 0;
|
|
983
|
+
for (const mmsi of previousVesselsState.keys()) {
|
|
984
|
+
if (!currentMMSIs.has(mmsi)) {
|
|
985
|
+
previousVesselsState.delete(mmsi);
|
|
986
|
+
removedFromState++;
|
|
987
|
+
}
|
|
988
|
+
}
|
|
989
|
+
|
|
990
|
+
// Entferne Einträge aus lastTCPBroadcast
|
|
991
|
+
let removedFromBroadcast = 0;
|
|
992
|
+
for (const mmsi of lastTCPBroadcast.keys()) {
|
|
993
|
+
if (!currentMMSIs.has(mmsi)) {
|
|
994
|
+
lastTCPBroadcast.delete(mmsi);
|
|
995
|
+
removedFromBroadcast++;
|
|
996
|
+
}
|
|
997
|
+
}
|
|
998
|
+
|
|
999
|
+
if (options.logDebugDetails && (removedFromState > 0 || removedFromBroadcast > 0)) {
|
|
1000
|
+
app.debug(`Map cleanup: removed ${removedFromState} from previousVesselsState, ${removedFromBroadcast} from lastTCPBroadcast`);
|
|
1001
|
+
}
|
|
1002
|
+
}
|
|
1003
|
+
|
|
1004
|
+
return plugin;
|
|
1005
|
+
};
|