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/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
+ };