signalk-ais-navionics-converter 1.0.2 → 1.0.3

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,229 @@ 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
+ let headingInt = 511;
177
+
178
+ // Heading nur gültig, wenn SOG > 0 UND COG gültig
179
+ if (sogKn >= minAlarmSOG && cog10 !== 3600 && Number.isFinite(headingValue)) {
180
+
181
+ let headingDeg;
182
+
183
+ if (headingUnits) {
184
+ const u = headingUnits.toLowerCase();
185
+ if (u.includes("rad")) {
186
+ headingDeg = headingValue * 180 / Math.PI;
187
+ } else if (u.includes("deg")) {
188
+ headingDeg = headingValue;
189
+ } else {
190
+ headingDeg = Math.abs(headingValue) <= 2 * Math.PI
191
+ ? headingValue * 180 / Math.PI
192
+ : headingValue;
193
+ }
194
+ } else {
195
+ headingDeg = Math.abs(headingValue) <= 2 * Math.PI
196
+ ? headingValue * 180 / Math.PI
197
+ : headingValue;
198
+ }
199
+
200
+ headingDeg = ((headingDeg % 360) + 360) % 360;
201
+
202
+ headingInt = Math.round(headingDeg);
203
+ if (headingInt > 359) headingInt = 359;
204
+ }
205
+
206
+ return { sog10, cog10, headingInt };
207
+ }
208
+
209
+
210
+ computeAisRot(rateValue, rateUnits) {
211
+ // AIS default: ROT not available
212
+ let rot = -128; // standard -128 (unavailable)
213
+
214
+ // Kein Wert → fertig
215
+ if (typeof rateValue !== "number" || !Number.isFinite(rateValue)) {
216
+ return rot;
217
+ }
218
+
219
+ let rate = rateValue;
220
+
221
+ //
222
+ // 1. Einheit erkennen und nach °/min umrechnen
223
+ //
224
+ if (rateUnits) {
225
+ const u = rateUnits.toLowerCase();
226
+
227
+ if (u.includes("rad/s")) {
228
+ // rad/s → deg/min
229
+ rate = rate * (180 / Math.PI) * 60;
230
+
231
+ } else if (u.includes("deg/s")) {
232
+ // deg/s → deg/min
233
+ rate = rate * 60;
234
+
235
+ } else if (u.includes("deg/min")) {
236
+ // schon korrekt
237
+
238
+ } else {
239
+ // unbekannte Einheit → heuristisch rad/s
240
+ if (Math.abs(rate) < 10) {
241
+ rate = rate * (180 / Math.PI) * 60;
242
+ }
243
+ }
244
+
245
+ } else {
246
+ // Keine Units → heuristisch rad/s
247
+ if (Math.abs(rate) < 10) {
248
+ rate = rate * (180 / Math.PI) * 60;
249
+ }
250
+ }
251
+
252
+ //
253
+ // 2. Physikalische Begrenzung nach AIS (±708°/min)
254
+ //
255
+ if (rate > 708) rate = 708;
256
+ if (rate < -708) rate = -708;
257
+
258
+ //
259
+ // 3. AIS‑ROT‑Formel (ITU‑R M.1371)
260
+ //
261
+ if (rate !== 0) {
262
+ const sign = rate < 0 ? -1 : 1;
263
+ rot = Math.round(sign * 4.733 * Math.sqrt(Math.abs(rate)));
264
+
265
+ // 4. Begrenzung auf AIS‑Integer‑Range
266
+ if (rot > 126) rot = 126;
267
+ if (rot < -126) rot = -126;
268
+ }
269
+
270
+ return rot;
271
+ }
272
+
273
+
274
+ createPositionReportType1(vessel, config) {
275
+ // Type 1 - Position Report Class A
52
276
  try {
53
277
  const mmsi = parseInt(vessel.mmsi);
54
- if (!mmsi || mmsi === 0) return null;
278
+ if (!mmsi || mmsi === 0) return null;
55
279
 
56
280
  const nav = vessel.navigation || {};
57
281
  const pos = nav.position?.value || nav.position || {};
@@ -59,54 +283,59 @@ class AISEncoder {
59
283
  const longitude = pos.longitude;
60
284
  if (latitude === undefined || longitude === undefined) return null;
61
285
 
62
- const state = nav.state?.value || '';
286
+ const rawState = nav.state?.value;
63
287
  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];
288
+ if (typeof rawState === "number" && Number.isFinite(rawState)) {
289
+ navStatus = rawState;
290
+ } else if (typeof rawState === "string") {
291
+ const key = rawState.toLowerCase();
292
+ if (this.stateMap[key] !== undefined) {
293
+ navStatus = this.stateMap[key];
294
+ } else if (key !== "" && key !== "undefined" && key !== "default") {
295
+ this.app.error(`Unknown navigation status: ${rawState}`);
296
+ }
72
297
 
73
- const timestamp = 60;
298
+ } else if (rawState !== undefined && rawState !== null) {
299
+ this.app.error(`Invalid navigation state type: ${rawState}`);
300
+ }
301
+
302
+ // const timestamp = 60;
303
+ const isoTime = pos.timestamp;
304
+ let timestamp = 60; // default "not available"
305
+ if (isoTime && nav?.speedOverGround) {
306
+ // die Berechnung nur machen, wenn SOG > 0 ist, weil Navionics sonst TCPA berechnet
307
+ const date = new Date(isoTime);
308
+ const ageMs = Date.now() - date.getTime();
309
+ if (ageMs <= 60000) {
310
+ // nur wenn die Meldung jünger als 60 Sekunden ist
311
+ timestamp = date.getUTCSeconds(); // 0–59
312
+ }
313
+ }
314
+
74
315
  const raim = 0;
75
316
  const maneuver = 0;
76
317
 
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
- }
318
+ const rot = this.computeAisRot(nav.rateOfTurn?.value, nav.rateOfTurn?.meta?.units)
319
+
320
+ const lon = Math.round(longitude * 600000);
321
+ const lat = Math.round(latitude * 600000);
83
322
 
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;
323
+ // --- SOG + COG --- (sog10/10===SOG in kn, cog10/10===COG in °)
324
+ const sogField = nav?.speedOverGround;
325
+ const cogField = nav?.courseOverGroundTrue;
326
+ const headingField = nav?.headingTrue;
87
327
 
88
- let sogValue = typeof sog === 'number' ? sog : 0;
89
- if (sogValue < config.minAlarmSOG) sogValue = 0;
328
+ const { sog10, cog10, headingInt } = this.computeAisSogCog(
329
+ sogField?.value ?? 0,
330
+ sogField?.meta?.units ?? null,
331
+ cogField?.value ?? null,
332
+ cogField?.meta?.units ?? null,
333
+ headingField?.value ?? null,
334
+ headingField?.meta?.units ?? null,
335
+ config.minAlarmSOG
336
+ );
90
337
 
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
338
 
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
339
 
111
340
  let bits = '';
112
341
  bits += (1).toString(2).padStart(6, '0');
@@ -128,12 +357,154 @@ class AISEncoder {
128
357
 
129
358
  return this.bitsToPayload(bits);
130
359
  } catch (error) {
131
- console.error('Error creating position report:', error);
360
+ this.app.error('Error creating position report:', error);
361
+ return null;
362
+ }
363
+ }
364
+
365
+ createPositionReportType19(vessel, config) {
366
+ // Type 19 - Extended Class B Equipment Position Report
367
+ try {
368
+ const mmsi = parseInt(vessel.mmsi, 10);
369
+ if (!mmsi || mmsi === 0) return null;
370
+
371
+ const nav = vessel.navigation || {};
372
+ const posObj = nav.position || {};
373
+ const pos = posObj.value || posObj || {};
374
+ const latitude = pos.latitude;
375
+ const longitude = pos.longitude;
376
+ if (typeof latitude !== "number" || typeof longitude !== "number") return null;
377
+
378
+ // Zeitstempel (UTC-Sekunden, nur wenn Position <= 60s alt)
379
+ const isoTime = posObj.timestamp || pos.timestamp;
380
+ let timestamp = 60; // 60 = not available
381
+ if (isoTime && nav?.speedOverGround) {
382
+ // die Berechnung nur machen, wenn SOG > 0 ist, weil Navionics sonst TCPA berechnet
383
+ const date = new Date(isoTime);
384
+ const ageMs = Date.now() - date.getTime();
385
+ if (ageMs <= 60000) {
386
+ timestamp = date.getUTCSeconds(); // 0–59
387
+ }
388
+ }
389
+
390
+ // RAIM
391
+ const raimFlag = 0; // 0 = RAIM not in use
392
+
393
+ // --- SOG + COG --- (sog10/10===SOG in kn, cog10/10===COG in °)
394
+ const sogField = nav?.speedOverGround;
395
+ const cogField = nav?.courseOverGroundTrue;
396
+ const headingField = nav?.headingTrue;
397
+
398
+ const { sog10, cog10, headingInt } = this.computeAisSogCog(
399
+ sogField?.value ?? 0,
400
+ sogField?.meta?.units ?? null,
401
+ cogField?.value ?? null,
402
+ cogField?.meta?.units ?? null,
403
+ headingField?.value ?? null,
404
+ headingField?.meta?.units ?? null,
405
+ config.minAlarmSOG
406
+ );
407
+
408
+
409
+
410
+
411
+ // --- Position in 1/10000 Minuten ---
412
+ let lon = Math.round(longitude * 600000); // deg → 1/10000'
413
+ let lat = Math.round(latitude * 600000);
414
+
415
+ // Gültigkeitsbereiche (AIS spezifiziert):
416
+ // Lon: -180..180 → -108000000..108000000
417
+ // Lat: -90..90 → -54000000..54000000
418
+ const lonUnavailable = (lon < -108000000 || lon > 108000000);
419
+ const latUnavailable = (lat < -54000000 || lat > 54000000);
420
+ if (lonUnavailable) lon = 0x6791AC0; // 181° * 600000 → not available (eigentlich: 0x6791AC0)
421
+ if (latUnavailable) lat = 0x3412140; // 91° * 600000 → not available
422
+
423
+ // --- Design / Dimensionen ---
424
+ const design = vessel.design || {};
425
+ const length = design.length?.value?.overall || 0;
426
+ const beam = design.beam?.value || 0;
427
+
428
+ const ais = vessel.sensors?.ais || {};
429
+ const fromBow = ais.fromBow?.value || 0;
430
+ const fromCenter = ais.fromCenter?.value || 0;
431
+
432
+ const toBow = Math.max(0, Math.round(fromBow));
433
+ const toStern = Math.max(0, Math.round(Math.max(0, length - fromBow)));
434
+ const toPort = Math.max(0, Math.round(Math.max(0, beam / 2 - fromCenter)));
435
+ const toStarboard = Math.max(0, Math.round(Math.max(0, beam / 2 + fromCenter)));
436
+
437
+ // --- Ship type ---
438
+ const shipType = design.aisShipType?.value?.id || 0;
439
+
440
+ // --- EPFD ---
441
+ let epfd = 0; // 0 = undefined
442
+ const positionSource = posObj.$source || "";
443
+ const srcLower = positionSource.toLowerCase();
444
+ if (srcLower.includes("gps")) epfd = 1;
445
+ else if (srcLower.includes("glonass")) epfd = 2;
446
+ else if (srcLower.includes("galileo")) epfd = 3;
447
+
448
+ // --- Name ---
449
+ const name = vessel.name || "";
450
+ const nameBits = this.encodeNameTo6bit(name); // 120 Bit
451
+
452
+ // --- DTE & Assigned Mode ---
453
+ const dte = 0; // 0 = available
454
+ const assignedMode = 0; // 0 = autonomous/continuous
455
+
456
+ // --- Jetzt Bitstring exakt nach Type-19-Spezifikation aufbauen ---
457
+
458
+ let bits = "";
459
+
460
+ bits += (19).toString(2).padStart(6, "0"); // 01 Message ID
461
+ bits += (0).toString(2).padStart(2, "0"); // 02 Repeat
462
+ bits += mmsi.toString(2).padStart(30, "0"); // 03 MMSI
463
+
464
+ bits += (0).toString(2).padStart(8, "0"); // 04 Reserved (8 Bit)
465
+
466
+ bits += sog10.toString(2).padStart(10, "0"); // 05 SOG
467
+ bits += "0"; // 06 Position Accuracy (0 = low)
468
+
469
+ bits += this.toTwosComplement(lon, 28).toString(2).padStart(28, "0"); // 07 Longitude
470
+ bits += this.toTwosComplement(lat, 27).toString(2).padStart(27, "0"); // 08 Latitude
471
+
472
+ bits += cog10.toString(2).padStart(12, "0"); // 09 COG
473
+ bits += headingInt.toString(2).padStart(9, "0"); // 10 True Heading
474
+ bits += timestamp.toString(2).padStart(6, "0"); // 11 Timestamp
475
+
476
+ bits += (0).toString(2).padStart(4, "0"); // 12 Reserved (Regional)
477
+
478
+ bits += nameBits; // 13 Name (120 Bit)
479
+
480
+ bits += shipType.toString(2).padStart(8, "0"); // 14 Ship Type
481
+
482
+ bits += toBow.toString(2).padStart(9, "0"); // 15 A
483
+ bits += toStern.toString(2).padStart(9, "0"); // 15 B
484
+ bits += toPort.toString(2).padStart(6, "0"); // 15 C
485
+ bits += toStarboard.toString(2).padStart(6, "0");// 15 D
486
+
487
+ bits += epfd.toString(2).padStart(4, "0"); // 16 EPFD
488
+
489
+ bits += (raimFlag ? "1" : "0"); // 17 RAIM
490
+ bits += dte.toString(2).padStart(1, "0"); // 18 DTE
491
+ bits += assignedMode.toString(2).padStart(1, "0"); // 18 Mode flag
492
+
493
+ bits += (0).toString(2).padStart(4, "0"); // 19 Spare
494
+
495
+ if (bits.length !== 312) {
496
+ this.app.warn("AIS Type 19 bit length is not 312:", bits.length);
497
+ return null;
498
+ }
499
+
500
+ return this.bitsToPayload(bits);
501
+ } catch (err) {
502
+ this.app.error("Error creating type 19:", err);
132
503
  return null;
133
504
  }
134
505
  }
135
506
 
136
- static createStaticVoyage(vessel) {
507
+ createStaticVoyage(vessel) {
137
508
  try {
138
509
  const mmsi = parseInt(vessel.mmsi);
139
510
  if (!mmsi || mmsi === 0) return null;
@@ -144,7 +515,18 @@ class AISEncoder {
144
515
  const draft = design.draft?.value?.maximum || 0;
145
516
  const shipType = design.aisShipType?.value?.id || 0;
146
517
 
147
- const imo = parseInt(vessel.imo) || 0;
518
+ // IMO-Nummer extrahieren - verschiedene mögliche Quellen
519
+ let imo = 0;
520
+ if (vessel.registrations?.imo) {
521
+ // IMO aus registrations
522
+ const imoStr = vessel.registrations.imo.toString().replace(/[^\d]/g, '');
523
+ imo = parseInt(imoStr) || 0;
524
+ } else if (vessel.imo) {
525
+ // Direkt als vessel.imo
526
+ const imoStr = vessel.imo.toString().replace(/[^\d]/g, '');
527
+ imo = parseInt(imoStr) || 0;
528
+ }
529
+
148
530
  const aisVersion = 0;
149
531
 
150
532
  const ais = vessel.sensors?.ais || {};
@@ -164,17 +546,20 @@ class AISEncoder {
164
546
  else if (positionSource.includes('galileo')) epfd = 3;
165
547
 
166
548
  const destination = vessel.navigation?.destination?.commonName?.value || '';
167
- const etaString = vessel.navigation?.courseGreatCircle?.activeRoute?.estimatedTimeOfArrival?.value || '';
168
-
549
+ const etaString =
550
+ vessel.navigation?.courseGreatCircle?.activeRoute?.estimatedTimeOfArrival?.value ??
551
+ vessel.navigation?.destination?.eta?.value ??
552
+ '';
553
+
169
554
  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
- }
555
+
556
+ const etaDate = this.parseETAToUTC(etaString);
557
+
558
+ if (etaDate) {
559
+ etaMonth = etaDate.getUTCMonth() + 1;
560
+ etaDay = etaDate.getUTCDate();
561
+ etaHour = etaDate.getUTCHours();
562
+ etaMinute = etaDate.getUTCMinutes();
178
563
  }
179
564
 
180
565
  const draughtDecimeters = Math.round(draft * 10);
@@ -186,7 +571,7 @@ class AISEncoder {
186
571
  bits += mmsi.toString(2).padStart(30,'0');
187
572
  bits += aisVersion.toString(2).padStart(2,'0');
188
573
  bits += imo.toString(2).padStart(30,'0');
189
- bits += this.callsignToSixBit(vessel.callSign ?? '');
574
+ bits += this.callsignToSixBit(vessel.callsign ?? '');
190
575
  bits += this.textToSixBit(vessel.name ?? '', 20);
191
576
  bits += shipType.toString(2).padStart(8,'0');
192
577
  bits += toBow.toString(2).padStart(9,'0');
@@ -202,15 +587,107 @@ class AISEncoder {
202
587
  bits += this.textToSixBit(destination, 20);
203
588
  bits += dte.toString(2);
204
589
  bits += '0';
205
-
206
590
  return this.bitsToPayload(bits);
207
591
  } catch(err) {
208
- console.error('Error creating type5:', err);
592
+ this.app.error('Error creating type5:', err);
209
593
  return null;
210
594
  }
211
595
  }
212
596
 
213
- static createNMEASentence(payload, fragmentCount=1, fragmentNum=1, messageId=null, channel='B') {
597
+ createStaticVoyageType24(vessel) {
598
+ try {
599
+ const mmsi = parseInt(vessel.mmsi);
600
+ if (!mmsi || mmsi === 0) return null;
601
+
602
+ const design = vessel.design || {};
603
+ const length = design.length?.value?.overall || 0;
604
+ const beam = design.beam?.value || 0;
605
+
606
+ const ais = vessel.sensors?.ais || {};
607
+ const fromBow = ais.fromBow?.value || 0;
608
+ const fromCenter = ais.fromCenter?.value || 0;
609
+
610
+ const toBow = Math.round(fromBow);
611
+ const toStern = Math.round(Math.max(0, length - fromBow));
612
+ const toPort = Math.round(Math.max(0, beam / 2 - fromCenter));
613
+ const toStarboard = Math.round(Math.max(0, beam / 2 + fromCenter));
614
+
615
+ const shipType = design.aisShipType?.value?.id || 0;
616
+
617
+ //
618
+ // -------------------------
619
+ // PART A (Name)
620
+ // -------------------------
621
+ //
622
+ let bitsA = "";
623
+ bitsA += (24).toString(2).padStart(6, "0"); // type
624
+ bitsA += (0).toString(2).padStart(2, "0"); // repeat
625
+ bitsA += mmsi.toString(2).padStart(30, "0"); // mmsi
626
+ bitsA += (0).toString(2).padStart(2, "0"); // part A
627
+
628
+ // 20 six-bit chars = 120 bits
629
+ bitsA += this.textToSixBit(vessel.name ?? "", 20);
630
+
631
+ // Spare (optional, many devices omit it)
632
+ bitsA = bitsA.padEnd(168, "0"); // valid length for Part A
633
+
634
+ //
635
+ // -------------------------
636
+ // PART B (ShipType, Vendor, Callsign, Dimensions)
637
+ // -------------------------
638
+ //
639
+ let bitsB = "";
640
+ bitsB += (24).toString(2).padStart(6, "0");
641
+ bitsB += (0).toString(2).padStart(2, "0");
642
+ bitsB += mmsi.toString(2).padStart(30, "0");
643
+ bitsB += (1).toString(2).padStart(2, "0"); // part B
644
+
645
+ // 40–47: Ship Type
646
+ bitsB += shipType.toString(2).padStart(8, "0");
647
+
648
+ // 48–65: Vendor ID (3 × 6-bit chars)
649
+ bitsB += this.textToSixBit("", 3); // leave empty
650
+
651
+ // 66–69: Unit Model Code (4 bits)
652
+ bitsB += "0000";
653
+
654
+ // 70–89: Serial Number (20 bits)
655
+ bitsB += "00000000000000000000";
656
+
657
+ // 90–131: Callsign (7 × 6-bit chars = 42 bits)
658
+ bitsB += this.textToSixBit(vessel.callsign ?? "", 7);
659
+
660
+ // 132–140: To Bow (9 bits)
661
+ bitsB += toBow.toString(2).padStart(9, "0");
662
+
663
+ // 141–149: To Stern (9 bits)
664
+ bitsB += toStern.toString(2).padStart(9, "0");
665
+
666
+ // 150–155: To Port (6 bits)
667
+ bitsB += toPort.toString(2).padStart(6, "0");
668
+
669
+ // 156–161: To Starboard (6 bits)
670
+ bitsB += toStarboard.toString(2).padStart(6, "0");
671
+
672
+ // 162–167: Spare (6 bits)
673
+ bitsB += "000000";
674
+
675
+ // Ensure correct length
676
+ bitsB = bitsB.padEnd(168, "0");
677
+
678
+ return {
679
+ partA: this.bitsToPayload(bitsA),
680
+ partB: this.bitsToPayload(bitsB)
681
+ };
682
+
683
+ } catch (err) {
684
+ this.app.error("Error creating type24:", err);
685
+ return null;
686
+ }
687
+ }
688
+
689
+
690
+ createNMEASentence(payload, fragmentCount=1, fragmentNum=1, messageId=null, channel='B') {
214
691
  const msgId = messageId !== null ? messageId.toString() : '';
215
692
  const fillBits = (6 - (payload.length*6)%6)%6;
216
693
  const sentence = `AIVDM,${fragmentCount},${fragmentNum},${msgId},${channel},${payload},${fillBits}`;