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/README.md +28 -18
- package/ais-encoder.js +556 -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,242 @@ 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
|
+
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
|
-
|
|
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
|
|
299
|
+
const rawState = nav.state?.value;
|
|
63
300
|
let navStatus = 15;
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
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
|
-
|
|
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
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
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
|
-
|
|
85
|
-
const
|
|
86
|
-
const
|
|
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
|
-
|
|
89
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
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.
|
|
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
|
-
|
|
605
|
+
this.app.error('Error creating type5:', err);
|
|
209
606
|
return null;
|
|
210
607
|
}
|
|
211
608
|
}
|
|
212
609
|
|
|
213
|
-
|
|
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}`;
|