signalk-ais-navionics-converter 1.0.2 → 1.0.4

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/ais-encoder.js CHANGED
@@ -1,15 +1,26 @@
1
1
  class AISEncoder {
2
- static encode6bit(val) {
2
+ constructor(app) { this.app = app; }
3
+
4
+ stateMap = {
5
+ 'motoring': 0, 'anchored': 1, 'not under command': 2, 'restricted manouverability': 3,
6
+ 'constrained by draft': 4, 'moored': 5, 'aground': 6, 'fishing': 7,
7
+ 'sailing': 8, 'hazardous material high speed': 9, 'hazardous material wing in ground': 10,
8
+ 'power-driven vessel towing astern': 11, 'power-driven vessel pushing ahead': 12,
9
+ 'reserved': 13, 'ais-sart': 14, 'undefined': 15, 'default':15
10
+ };
11
+
12
+ encode6bit(val) {
3
13
  if (val < 0 || val > 63) throw new Error("6-bit out of range: " + val);
4
14
  return val <= 39 ? String.fromCharCode(val + 48) : String.fromCharCode(val + 56);
5
15
  }
6
16
 
7
- static toTwosComplement(value, bits) {
8
- if (value < 0) value = (1 << bits) + value;
9
- return value;
17
+ toTwosComplement(value, bits) {
18
+ let max = 1 << bits;
19
+ if (value < 0) value = max + value;
20
+ return value.toString(2).padStart(bits, "0");
10
21
  }
11
22
 
12
- static textToSixBit(str, length) {
23
+ textToSixBit(str, length) {
13
24
  const table = '@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\\]^_ !"#$%&\'()*+,-./0123456789:;<=>?';
14
25
  let bits = '';
15
26
  str = str || '';
@@ -22,13 +33,13 @@ class AISEncoder {
22
33
  return bits;
23
34
  }
24
35
 
25
- static callsignToSixBit(callsign) {
36
+ callsignToSixBit(callsign) {
26
37
  callsign = (callsign || '').trim().toUpperCase();
27
38
  const padded = callsign.padEnd(7, '@').substring(0, 7);
28
39
  return this.textToSixBit(padded, 7);
29
40
  }
30
41
 
31
- static bitsToPayload(bits) {
42
+ bitsToPayload(bits) {
32
43
  let payload = '';
33
44
  while (bits.length % 6 !== 0) {
34
45
  bits += '0';
@@ -42,16 +53,242 @@ class AISEncoder {
42
53
  return payload;
43
54
  }
44
55
 
45
- static calculateChecksum(nmea) {
56
+ calculateChecksum(nmea) {
46
57
  let cs = 0;
47
58
  for (let i = 1; i < nmea.length; i++) cs ^= nmea.charCodeAt(i);
48
59
  return cs.toString(16).toUpperCase().padStart(2, '0');
49
60
  }
50
61
 
51
- static createPositionReport(vessel, config) {
62
+ parseETAToUTC(etaString) {
63
+ if (!etaString) return null;
64
+
65
+ // Ungültige Platzhalter abfangen
66
+ if (
67
+ etaString.startsWith("00-00") ||
68
+ etaString.startsWith("0000-00-00")
69
+ ) {
70
+ return null;
71
+ }
72
+
73
+ // 1️⃣ Vollständiges ISO-8601
74
+ if (/^\d{4}-\d{2}-\d{2}T/.test(etaString)) {
75
+ const d = new Date(etaString);
76
+ return isNaN(d) ? null : d;
77
+ }
78
+
79
+ // 2️⃣ AIS-Kurzformat: MM-DDTHH:mmZ
80
+ const m = etaString.match(/^(\d{2})-(\d{2})T(\d{2}):(\d{2})Z$/);
81
+ if (m) {
82
+ const [, month, day, hour, minute] = m.map(Number);
83
+
84
+ const now = new Date();
85
+ let year = now.getUTCFullYear();
86
+
87
+ let candidate = new Date(Date.UTC(year, month - 1, day, hour, minute));
88
+
89
+ // ETA ist immer zukünftig → ggf. nächstes Jahr
90
+ if (candidate < now) {
91
+ candidate = new Date(Date.UTC(year + 1, month - 1, day, hour, minute));
92
+ }
93
+
94
+ return candidate;
95
+ }
96
+
97
+ return null;
98
+ }
99
+
100
+ encodeNameTo6bit(name) {
101
+ // Offizielle AIS 6-Bit Tabelle nach ITU-R M.1371:
102
+ // 0=@, 1=A, ... 26=Z, 27=[, 28=\, 29=], 30=^, 31=_,
103
+ // 32=Space, 33=!, 34=", ..., 48-57=0-9, 63=?
104
+ const AIS_CHARS = "@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\\]^_ !\"#$%&'()*+,-./0123456789:;<=>?";
105
+
106
+ let n = (name || "").toUpperCase().slice(0, 20).padEnd(20, " ");
107
+
108
+ let bits = "";
109
+ for (let i = 0; i < n.length; i++) {
110
+ const ch = n[i];
111
+ let idx = AIS_CHARS.indexOf(ch);
112
+ if (idx < 0) idx = 32; // unbekannt → Space (nicht '@')
113
+ bits += idx.toString(2).padStart(6, "0");
114
+ }
115
+ return bits; // 20 * 6 = 120 Bit
116
+ }
117
+
118
+ computeAisSogCog(sogValue, sogUnits, cogValue, cogUnits, headingValue, headingUnits, minAlarmSOG = 0.2) {
119
+
120
+ //
121
+ // --- SOG ---
122
+ //
123
+ let sogKn = Number(sogValue) || 0;
124
+
125
+ if (!(sogUnits && sogUnits.toLowerCase().includes("kn"))) {
126
+ sogKn = sogKn * 1.94384;
127
+ }
128
+
129
+ if (sogKn < minAlarmSOG) {
130
+ sogKn = 0;
131
+ }
132
+
133
+ let sog10;
134
+ if (!Number.isFinite(sogKn) || sogKn <= 0) {
135
+ sog10 = 1023; // 1023 wäre "not available", aber du nutzt 0 → bleibt so
136
+ } else {
137
+ sog10 = Math.round(sogKn * 10);
138
+ if (sog10 > 1022) sog10 = 1022;
139
+ }
140
+
141
+ //
142
+ // --- COG ---
143
+ //
144
+ let cog10 = 3600; // AIS: 3600 = not available
145
+
146
+ if (sogKn >= minAlarmSOG && Number.isFinite(cogValue)) {
147
+ let cogDeg;
148
+
149
+ if (cogUnits) {
150
+ const u = cogUnits.toLowerCase();
151
+ if (u.includes("rad")) {
152
+ cogDeg = cogValue * 180 / Math.PI;
153
+ } else if (u.includes("deg")) {
154
+ cogDeg = cogValue;
155
+ } else {
156
+ cogDeg = Math.abs(cogValue) <= 2 * Math.PI
157
+ ? cogValue * 180 / Math.PI
158
+ : cogValue;
159
+ }
160
+ } else {
161
+ cogDeg = Math.abs(cogValue) <= 2 * Math.PI
162
+ ? cogValue * 180 / Math.PI
163
+ : cogValue;
164
+ }
165
+
166
+ cogDeg = ((cogDeg % 360) + 360) % 360;
167
+
168
+ cog10 = Math.round(cogDeg * 10);
169
+ if (cog10 > 3599) cog10 = 3599;
170
+ }
171
+
172
+ //
173
+ // --- HEADING ---
174
+ //
175
+ // AIS: 511 = not available
176
+ const HEADING_UNAVALABLE=511
177
+ let headingInt = HEADING_UNAVALABLE;
178
+
179
+ // Heading nur gültig, wenn SOG > 0 UND COG gültig
180
+ if (sogKn >= minAlarmSOG && cog10 !== 3600 && Number.isFinite(headingValue)) {
181
+ let headingDeg;
182
+
183
+ if (headingUnits) {
184
+ const u = headingUnits.toLowerCase();
185
+ if (u.includes("rad")) {
186
+ if (units === "rad" && value > 2 * Math.PI) {
187
+ // Wert ist definitiv falsch bwz. bei 8.91863247972741 genau gleich 511 Grad === unavailable
188
+ headingInt = HEADING_UNAVALABLE;
189
+ }else{
190
+ headingDeg = headingValue * 180 / Math.PI;
191
+ }
192
+ } else if (u.includes("deg")) {
193
+ headingDeg = headingValue;
194
+ } else {
195
+ headingDeg = Math.abs(headingValue) <= 2 * Math.PI
196
+ ? headingValue * 180 / Math.PI
197
+ : headingValue;
198
+ }
199
+ } else {
200
+ headingDeg = Math.abs(headingValue) <= 2 * Math.PI
201
+ ? headingValue * 180 / Math.PI
202
+ : headingValue;
203
+ }
204
+ // Filter für ungültige Werte
205
+ if (headingDeg >= 360 && headingDeg <= HEADING_UNAVALABLE) {
206
+ // Wert ist im "ungültig"-Bereich (360-511)
207
+ headingInt = HEADING_UNAVALABLE;
208
+ } else {
209
+ headingDeg = ((headingDeg % 360) + 360) % 360;
210
+ headingInt = Math.round(headingDeg);
211
+ if (headingInt > 359) headingInt = 359;
212
+ }
213
+ }
214
+
215
+ return { sog10, cog10, headingInt };
216
+ }
217
+
218
+
219
+ computeAisRot(rateValue, rateUnits) {
220
+ // AIS default: ROT not available
221
+ const ROT_UNAVAILABLE = -2.23402144306284;
222
+ let rot = -128; // standard -128 (unavailable)
223
+
224
+ // Kein Wert → fertig
225
+ if (typeof rateValue !== "number" || !Number.isFinite(rateValue)) {
226
+ return rot;
227
+ }
228
+
229
+ let rate = rateValue;
230
+
231
+ //
232
+ // 1. Einheit erkennen und nach °/min umrechnen
233
+ //
234
+ if (rateUnits) {
235
+ const u = rateUnits.toLowerCase();
236
+
237
+ if (u.includes("rad/s")) {
238
+ // rad/s → deg/min
239
+ if (Math.abs(rate - ROT_UNAVAILABLE) < 1e-6) {
240
+ return rot; // -128
241
+ }
242
+ rate = rate * (180 / Math.PI) * 60;
243
+
244
+ } else if (u.includes("deg/s")) {
245
+ // deg/s → deg/min
246
+ rate = rate * 60;
247
+
248
+ } else if (u.includes("deg/min")) {
249
+ // schon korrekt
250
+
251
+ } else {
252
+ // unbekannte Einheit → heuristisch rad/s
253
+ if (Math.abs(rate) < 10) {
254
+ rate = rate * (180 / Math.PI) * 60;
255
+ }
256
+ }
257
+
258
+ } else {
259
+ // Keine Units → heuristisch rad/s
260
+ if (Math.abs(rate) < 10) {
261
+ rate = rate * (180 / Math.PI) * 60;
262
+ }
263
+ }
264
+
265
+ //
266
+ // 2. Physikalische Begrenzung nach AIS (±708°/min)
267
+ //
268
+ if (rate > 708) rate = 708;
269
+ if (rate < -708) rate = -708;
270
+
271
+ //
272
+ // 3. AIS‑ROT‑Formel (ITU‑R M.1371)
273
+ //
274
+ if (rate !== 0) {
275
+ const sign = rate < 0 ? -1 : 1;
276
+ rot = Math.round(sign * 4.733 * Math.sqrt(Math.abs(rate)));
277
+
278
+ // 4. Begrenzung auf AIS‑Integer‑Range
279
+ if (rot > 126) rot = 126;
280
+ if (rot < -126) rot = -126;
281
+ }
282
+
283
+ return rot;
284
+ }
285
+
286
+
287
+ createPositionReportType1(vessel, config) {
288
+ // Type 1 - Position Report Class A
52
289
  try {
53
290
  const mmsi = parseInt(vessel.mmsi);
54
- if (!mmsi || mmsi === 0) return null;
291
+ if (!mmsi || mmsi === 0) return null;
55
292
 
56
293
  const nav = vessel.navigation || {};
57
294
  const pos = nav.position?.value || nav.position || {};
@@ -59,54 +296,59 @@ class AISEncoder {
59
296
  const longitude = pos.longitude;
60
297
  if (latitude === undefined || longitude === undefined) return null;
61
298
 
62
- const state = nav.state?.value || '';
299
+ const rawState = nav.state?.value;
63
300
  let navStatus = 15;
64
- const stateMap = {
65
- 'motoring': 0, 'anchored': 1, 'not under command': 2, 'restricted maneuverability': 3,
66
- 'constrained by draft': 4, 'moored': 5, 'aground': 6, 'fishing': 7,
67
- 'sailing': 8, 'hazardous material high speed': 9, 'hazardous material wing in ground': 10,
68
- 'power-driven vessel towing astern': 11, 'power-driven vessel pushing ahead': 12,
69
- 'reserved': 13, 'ais-sart': 14, 'undefined': 15
70
- };
71
- if (state && stateMap[state] !== undefined) navStatus = stateMap[state];
301
+ if (typeof rawState === "number" && Number.isFinite(rawState)) {
302
+ navStatus = rawState;
303
+ } else if (typeof rawState === "string") {
304
+ const key = rawState.toLowerCase();
305
+ if (this.stateMap[key] !== undefined) {
306
+ navStatus = this.stateMap[key];
307
+ } else if (key !== "" && key !== "undefined" && key !== "default") {
308
+ this.app.error(`Unknown navigation status: ${rawState}`);
309
+ }
72
310
 
73
- const timestamp = 60;
311
+ } else if (rawState !== undefined && rawState !== null) {
312
+ this.app.error(`Invalid navigation state type: ${rawState}`);
313
+ }
314
+
315
+ // const timestamp = 60;
316
+ const isoTime = pos.timestamp;
317
+ let timestamp = 60; // default "not available"
318
+ if (isoTime && nav?.speedOverGround) {
319
+ // die Berechnung nur machen, wenn SOG > 0 ist, weil Navionics sonst TCPA berechnet
320
+ const date = new Date(isoTime);
321
+ const ageMs = Date.now() - date.getTime();
322
+ if (ageMs <= 60000) {
323
+ // nur wenn die Meldung jünger als 60 Sekunden ist
324
+ timestamp = date.getUTCSeconds(); // 0–59
325
+ }
326
+ }
327
+
74
328
  const raim = 0;
75
329
  const maneuver = 0;
76
330
 
77
- const rateOfTurn = nav.rateOfTurn?.value || 0;
78
- let rot = -128;
79
- if (rateOfTurn !== 0) {
80
- rot = Math.round(rateOfTurn * 4.733 * Math.sqrt(Math.abs(rateOfTurn)));
81
- rot = Math.max(-126, Math.min(126, rot));
82
- }
331
+ const rot = this.computeAisRot(nav.rateOfTurn?.value, nav.rateOfTurn?.meta?.units)
332
+
333
+ const lon = Math.round(longitude * 600000);
334
+ const lat = Math.round(latitude * 600000);
83
335
 
84
- const sog = nav.speedOverGround?.value || nav.speedOverGround || 0;
85
- const cog = nav.courseOverGroundTrue?.value || nav.courseOverGroundTrue || 0;
86
- const heading = nav.headingTrue?.value || nav.headingTrue || 0;
336
+ // --- SOG + COG --- (sog10/10===SOG in kn, cog10/10===COG in °)
337
+ const sogField = nav?.speedOverGround;
338
+ const cogField = nav?.courseOverGroundTrue;
339
+ const headingField = nav?.headingTrue;
87
340
 
88
- let sogValue = typeof sog === 'number' ? sog : 0;
89
- if (sogValue < config.minAlarmSOG) sogValue = 0;
341
+ const { sog10, cog10, headingInt } = this.computeAisSogCog(
342
+ sogField?.value ?? 0,
343
+ sogField?.meta?.units ?? null,
344
+ cogField?.value ?? null,
345
+ cogField?.meta?.units ?? null,
346
+ headingField?.value ?? null,
347
+ headingField?.meta?.units ?? null,
348
+ config.minAlarmSOG
349
+ );
90
350
 
91
- const cogValue = typeof cog === 'object' ? 0 : (typeof cog === 'number' ? cog : 0);
92
- const headingValue = typeof heading === 'object' ? 0 : (typeof heading === 'number' ? heading : 0);
93
351
 
94
- const lon = Math.round(longitude * 600000);
95
- const lat = Math.round(latitude * 600000);
96
-
97
- const sogKnots = sogValue * 1.94384;
98
- const sog10 = Math.round(sogKnots * 10);
99
-
100
- const cogDegrees = cogValue * 180 / Math.PI;
101
- let cog10;
102
- if (sogKnots < config.minAlarmSOG) {
103
- cog10 = 0; // or 3600 to indicate not available
104
- } else {
105
- cog10 = Math.round(cogDegrees * 10);
106
- }
107
-
108
- const headingDegrees = headingValue * 180 / Math.PI;
109
- const headingInt = Math.round(headingDegrees);
110
352
 
111
353
  let bits = '';
112
354
  bits += (1).toString(2).padStart(6, '0');
@@ -128,12 +370,154 @@ class AISEncoder {
128
370
 
129
371
  return this.bitsToPayload(bits);
130
372
  } catch (error) {
131
- console.error('Error creating position report:', error);
373
+ this.app.error('Error creating position report:', error);
374
+ return null;
375
+ }
376
+ }
377
+
378
+ createPositionReportType19(vessel, config) {
379
+ // Type 19 - Extended Class B Equipment Position Report
380
+ try {
381
+ const mmsi = parseInt(vessel.mmsi, 10);
382
+ if (!mmsi || mmsi === 0) return null;
383
+
384
+ const nav = vessel.navigation || {};
385
+ const posObj = nav.position || {};
386
+ const pos = posObj.value || posObj || {};
387
+ const latitude = pos.latitude;
388
+ const longitude = pos.longitude;
389
+ if (typeof latitude !== "number" || typeof longitude !== "number") return null;
390
+
391
+ // Zeitstempel (UTC-Sekunden, nur wenn Position <= 60s alt)
392
+ const isoTime = posObj.timestamp || pos.timestamp;
393
+ let timestamp = 60; // 60 = not available
394
+ if (isoTime && nav?.speedOverGround) {
395
+ // die Berechnung nur machen, wenn SOG > 0 ist, weil Navionics sonst TCPA berechnet
396
+ const date = new Date(isoTime);
397
+ const ageMs = Date.now() - date.getTime();
398
+ if (ageMs <= 60000) {
399
+ timestamp = date.getUTCSeconds(); // 0–59
400
+ }
401
+ }
402
+
403
+ // RAIM
404
+ const raimFlag = 0; // 0 = RAIM not in use
405
+
406
+ // --- SOG + COG --- (sog10/10===SOG in kn, cog10/10===COG in °)
407
+ const sogField = nav?.speedOverGround;
408
+ const cogField = nav?.courseOverGroundTrue;
409
+ const headingField = nav?.headingTrue;
410
+
411
+ const { sog10, cog10, headingInt } = this.computeAisSogCog(
412
+ sogField?.value ?? 0,
413
+ sogField?.meta?.units ?? null,
414
+ cogField?.value ?? null,
415
+ cogField?.meta?.units ?? null,
416
+ headingField?.value ?? null,
417
+ headingField?.meta?.units ?? null,
418
+ config.minAlarmSOG
419
+ );
420
+
421
+
422
+
423
+
424
+ // --- Position in 1/10000 Minuten ---
425
+ let lon = Math.round(longitude * 600000); // deg → 1/10000'
426
+ let lat = Math.round(latitude * 600000);
427
+
428
+ // Gültigkeitsbereiche (AIS spezifiziert):
429
+ // Lon: -180..180 → -108000000..108000000
430
+ // Lat: -90..90 → -54000000..54000000
431
+ const lonUnavailable = (lon < -108000000 || lon > 108000000);
432
+ const latUnavailable = (lat < -54000000 || lat > 54000000);
433
+ if (lonUnavailable) lon = 0x6791AC0; // 181° * 600000 → not available (eigentlich: 0x6791AC0)
434
+ if (latUnavailable) lat = 0x3412140; // 91° * 600000 → not available
435
+
436
+ // --- Design / Dimensionen ---
437
+ const design = vessel.design || {};
438
+ const length = design.length?.value?.overall || 0;
439
+ const beam = design.beam?.value || 0;
440
+
441
+ const ais = vessel.sensors?.ais || {};
442
+ const fromBow = ais.fromBow?.value || 0;
443
+ const fromCenter = ais.fromCenter?.value || 0;
444
+
445
+ const toBow = Math.max(0, Math.round(fromBow));
446
+ const toStern = Math.max(0, Math.round(Math.max(0, length - fromBow)));
447
+ const toPort = Math.max(0, Math.round(Math.max(0, beam / 2 - fromCenter)));
448
+ const toStarboard = Math.max(0, Math.round(Math.max(0, beam / 2 + fromCenter)));
449
+
450
+ // --- Ship type ---
451
+ const shipType = design.aisShipType?.value?.id || 0;
452
+
453
+ // --- EPFD ---
454
+ let epfd = 0; // 0 = undefined
455
+ const positionSource = posObj.$source || "";
456
+ const srcLower = positionSource.toLowerCase();
457
+ if (srcLower.includes("gps")) epfd = 1;
458
+ else if (srcLower.includes("glonass")) epfd = 2;
459
+ else if (srcLower.includes("galileo")) epfd = 3;
460
+
461
+ // --- Name ---
462
+ const name = vessel.name || "";
463
+ const nameBits = this.encodeNameTo6bit(name); // 120 Bit
464
+
465
+ // --- DTE & Assigned Mode ---
466
+ const dte = 0; // 0 = available
467
+ const assignedMode = 0; // 0 = autonomous/continuous
468
+
469
+ // --- Jetzt Bitstring exakt nach Type-19-Spezifikation aufbauen ---
470
+
471
+ let bits = "";
472
+
473
+ bits += (19).toString(2).padStart(6, "0"); // 01 Message ID
474
+ bits += (0).toString(2).padStart(2, "0"); // 02 Repeat
475
+ bits += mmsi.toString(2).padStart(30, "0"); // 03 MMSI
476
+
477
+ bits += (0).toString(2).padStart(8, "0"); // 04 Reserved (8 Bit)
478
+
479
+ bits += sog10.toString(2).padStart(10, "0"); // 05 SOG
480
+ bits += "0"; // 06 Position Accuracy (0 = low)
481
+
482
+ bits += this.toTwosComplement(lon, 28).toString(2).padStart(28, "0"); // 07 Longitude
483
+ bits += this.toTwosComplement(lat, 27).toString(2).padStart(27, "0"); // 08 Latitude
484
+
485
+ bits += cog10.toString(2).padStart(12, "0"); // 09 COG
486
+ bits += headingInt.toString(2).padStart(9, "0"); // 10 True Heading
487
+ bits += timestamp.toString(2).padStart(6, "0"); // 11 Timestamp
488
+
489
+ bits += (0).toString(2).padStart(4, "0"); // 12 Reserved (Regional)
490
+
491
+ bits += nameBits; // 13 Name (120 Bit)
492
+
493
+ bits += shipType.toString(2).padStart(8, "0"); // 14 Ship Type
494
+
495
+ bits += toBow.toString(2).padStart(9, "0"); // 15 A
496
+ bits += toStern.toString(2).padStart(9, "0"); // 15 B
497
+ bits += toPort.toString(2).padStart(6, "0"); // 15 C
498
+ bits += toStarboard.toString(2).padStart(6, "0");// 15 D
499
+
500
+ bits += epfd.toString(2).padStart(4, "0"); // 16 EPFD
501
+
502
+ bits += (raimFlag ? "1" : "0"); // 17 RAIM
503
+ bits += dte.toString(2).padStart(1, "0"); // 18 DTE
504
+ bits += assignedMode.toString(2).padStart(1, "0"); // 18 Mode flag
505
+
506
+ bits += (0).toString(2).padStart(4, "0"); // 19 Spare
507
+
508
+ if (bits.length !== 312) {
509
+ this.app.warn("AIS Type 19 bit length is not 312:", bits.length);
510
+ return null;
511
+ }
512
+
513
+ return this.bitsToPayload(bits);
514
+ } catch (err) {
515
+ this.app.error("Error creating type 19:", err);
132
516
  return null;
133
517
  }
134
518
  }
135
519
 
136
- static createStaticVoyage(vessel) {
520
+ createStaticVoyage(vessel) {
137
521
  try {
138
522
  const mmsi = parseInt(vessel.mmsi);
139
523
  if (!mmsi || mmsi === 0) return null;
@@ -144,7 +528,18 @@ class AISEncoder {
144
528
  const draft = design.draft?.value?.maximum || 0;
145
529
  const shipType = design.aisShipType?.value?.id || 0;
146
530
 
147
- const imo = parseInt(vessel.imo) || 0;
531
+ // IMO-Nummer extrahieren - verschiedene mögliche Quellen
532
+ let imo = 0;
533
+ if (vessel.registrations?.imo) {
534
+ // IMO aus registrations
535
+ const imoStr = vessel.registrations.imo.toString().replace(/[^\d]/g, '');
536
+ imo = parseInt(imoStr) || 0;
537
+ } else if (vessel.imo) {
538
+ // Direkt als vessel.imo
539
+ const imoStr = vessel.imo.toString().replace(/[^\d]/g, '');
540
+ imo = parseInt(imoStr) || 0;
541
+ }
542
+
148
543
  const aisVersion = 0;
149
544
 
150
545
  const ais = vessel.sensors?.ais || {};
@@ -164,17 +559,20 @@ class AISEncoder {
164
559
  else if (positionSource.includes('galileo')) epfd = 3;
165
560
 
166
561
  const destination = vessel.navigation?.destination?.commonName?.value || '';
167
- const etaString = vessel.navigation?.courseGreatCircle?.activeRoute?.estimatedTimeOfArrival?.value || '';
168
-
562
+ const etaString =
563
+ vessel.navigation?.courseGreatCircle?.activeRoute?.estimatedTimeOfArrival?.value ??
564
+ vessel.navigation?.destination?.eta?.value ??
565
+ '';
566
+
169
567
  let etaMonth = 0, etaDay = 0, etaHour = 24, etaMinute = 60;
170
- if (etaString && etaString !== '00-00T00:00Z' && etaString !== '00-00T24:60Z') {
171
- const etaMatch = etaString.match(/(\d+)-(\d+)T(\d+):(\d+)/);
172
- if (etaMatch) {
173
- etaMonth = parseInt(etaMatch[1]) || 0;
174
- etaDay = parseInt(etaMatch[2]) || 0;
175
- etaHour = parseInt(etaMatch[3]) || 24;
176
- etaMinute = parseInt(etaMatch[4]) || 60;
177
- }
568
+
569
+ const etaDate = this.parseETAToUTC(etaString);
570
+
571
+ if (etaDate) {
572
+ etaMonth = etaDate.getUTCMonth() + 1;
573
+ etaDay = etaDate.getUTCDate();
574
+ etaHour = etaDate.getUTCHours();
575
+ etaMinute = etaDate.getUTCMinutes();
178
576
  }
179
577
 
180
578
  const draughtDecimeters = Math.round(draft * 10);
@@ -186,7 +584,7 @@ class AISEncoder {
186
584
  bits += mmsi.toString(2).padStart(30,'0');
187
585
  bits += aisVersion.toString(2).padStart(2,'0');
188
586
  bits += imo.toString(2).padStart(30,'0');
189
- bits += this.callsignToSixBit(vessel.callSign ?? '');
587
+ bits += this.callsignToSixBit(vessel.callsign ?? '');
190
588
  bits += this.textToSixBit(vessel.name ?? '', 20);
191
589
  bits += shipType.toString(2).padStart(8,'0');
192
590
  bits += toBow.toString(2).padStart(9,'0');
@@ -202,15 +600,107 @@ class AISEncoder {
202
600
  bits += this.textToSixBit(destination, 20);
203
601
  bits += dte.toString(2);
204
602
  bits += '0';
205
-
206
603
  return this.bitsToPayload(bits);
207
604
  } catch(err) {
208
- console.error('Error creating type5:', err);
605
+ this.app.error('Error creating type5:', err);
209
606
  return null;
210
607
  }
211
608
  }
212
609
 
213
- static createNMEASentence(payload, fragmentCount=1, fragmentNum=1, messageId=null, channel='B') {
610
+ createStaticVoyageType24(vessel) {
611
+ try {
612
+ const mmsi = parseInt(vessel.mmsi);
613
+ if (!mmsi || mmsi === 0) return null;
614
+
615
+ const design = vessel.design || {};
616
+ const length = design.length?.value?.overall || 0;
617
+ const beam = design.beam?.value || 0;
618
+
619
+ const ais = vessel.sensors?.ais || {};
620
+ const fromBow = ais.fromBow?.value || 0;
621
+ const fromCenter = ais.fromCenter?.value || 0;
622
+
623
+ const toBow = Math.round(fromBow);
624
+ const toStern = Math.round(Math.max(0, length - fromBow));
625
+ const toPort = Math.round(Math.max(0, beam / 2 - fromCenter));
626
+ const toStarboard = Math.round(Math.max(0, beam / 2 + fromCenter));
627
+
628
+ const shipType = design.aisShipType?.value?.id || 0;
629
+
630
+ //
631
+ // -------------------------
632
+ // PART A (Name)
633
+ // -------------------------
634
+ //
635
+ let bitsA = "";
636
+ bitsA += (24).toString(2).padStart(6, "0"); // type
637
+ bitsA += (0).toString(2).padStart(2, "0"); // repeat
638
+ bitsA += mmsi.toString(2).padStart(30, "0"); // mmsi
639
+ bitsA += (0).toString(2).padStart(2, "0"); // part A
640
+
641
+ // 20 six-bit chars = 120 bits
642
+ bitsA += this.textToSixBit(vessel.name ?? "", 20);
643
+
644
+ // Spare (optional, many devices omit it)
645
+ bitsA = bitsA.padEnd(168, "0"); // valid length for Part A
646
+
647
+ //
648
+ // -------------------------
649
+ // PART B (ShipType, Vendor, Callsign, Dimensions)
650
+ // -------------------------
651
+ //
652
+ let bitsB = "";
653
+ bitsB += (24).toString(2).padStart(6, "0");
654
+ bitsB += (0).toString(2).padStart(2, "0");
655
+ bitsB += mmsi.toString(2).padStart(30, "0");
656
+ bitsB += (1).toString(2).padStart(2, "0"); // part B
657
+
658
+ // 40–47: Ship Type
659
+ bitsB += shipType.toString(2).padStart(8, "0");
660
+
661
+ // 48–65: Vendor ID (3 × 6-bit chars)
662
+ bitsB += this.textToSixBit("", 3); // leave empty
663
+
664
+ // 66–69: Unit Model Code (4 bits)
665
+ bitsB += "0000";
666
+
667
+ // 70–89: Serial Number (20 bits)
668
+ bitsB += "00000000000000000000";
669
+
670
+ // 90–131: Callsign (7 × 6-bit chars = 42 bits)
671
+ bitsB += this.textToSixBit(vessel.callsign ?? "", 7);
672
+
673
+ // 132–140: To Bow (9 bits)
674
+ bitsB += toBow.toString(2).padStart(9, "0");
675
+
676
+ // 141–149: To Stern (9 bits)
677
+ bitsB += toStern.toString(2).padStart(9, "0");
678
+
679
+ // 150–155: To Port (6 bits)
680
+ bitsB += toPort.toString(2).padStart(6, "0");
681
+
682
+ // 156–161: To Starboard (6 bits)
683
+ bitsB += toStarboard.toString(2).padStart(6, "0");
684
+
685
+ // 162–167: Spare (6 bits)
686
+ bitsB += "000000";
687
+
688
+ // Ensure correct length
689
+ bitsB = bitsB.padEnd(168, "0");
690
+
691
+ return {
692
+ partA: this.bitsToPayload(bitsA),
693
+ partB: this.bitsToPayload(bitsB)
694
+ };
695
+
696
+ } catch (err) {
697
+ this.app.error("Error creating type24:", err);
698
+ return null;
699
+ }
700
+ }
701
+
702
+
703
+ createNMEASentence(payload, fragmentCount=1, fragmentNum=1, messageId=null, channel='B') {
214
704
  const msgId = messageId !== null ? messageId.toString() : '';
215
705
  const fillBits = (6 - (payload.length*6)%6)%6;
216
706
  const sentence = `AIVDM,${fragmentCount},${fragmentNum},${msgId},${channel},${payload},${fillBits}`;