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/README.md +26 -18
- package/ais-encoder.js +543 -66
- package/img/OpenCpn1.png +0 -0
- package/img/OpenCpn2.png +0 -0
- package/index.js +337 -155
- package/package.json +3 -13
- package/public/src_components_PluginConfigurationPanel_jsx.js +1 -1
- package/src/components/PluginConfigurationPanel.jsx +158 -80
package/ais-encoder.js
CHANGED
|
@@ -1,15 +1,26 @@
|
|
|
1
1
|
class AISEncoder {
|
|
2
|
-
|
|
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
|
-
|
|
8
|
-
|
|
9
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
286
|
+
const rawState = nav.state?.value;
|
|
63
287
|
let navStatus = 15;
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
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
|
-
|
|
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
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
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
|
-
|
|
85
|
-
const
|
|
86
|
-
const
|
|
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
|
-
|
|
89
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
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.
|
|
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
|
-
|
|
592
|
+
this.app.error('Error creating type5:', err);
|
|
209
593
|
return null;
|
|
210
594
|
}
|
|
211
595
|
}
|
|
212
596
|
|
|
213
|
-
|
|
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}`;
|