signalk-vessels-to-ais 1.6.1 → 2.0.0-beta.1
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/.github/dependabot.yml +11 -11
- package/.mocharc.json +5 -0
- package/README.md +1 -0
- package/index.js +163 -382
- package/lib/helpers.js +357 -0
- package/package.json +8 -7
- package/test/helpers.test.js +609 -0
- package/test/plugin.test.js +479 -0
package/lib/helpers.js
ADDED
|
@@ -0,0 +1,357 @@
|
|
|
1
|
+
/* eslint-disable no-bitwise */
|
|
2
|
+
/**
|
|
3
|
+
* Helper functions for signalk-vessels-to-ais-ws plugin
|
|
4
|
+
* Extracted for testability
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
// State Mapping - AIS navigation status codes
|
|
8
|
+
const stateMapping = {
|
|
9
|
+
motoring: 0,
|
|
10
|
+
UnderWayUsingEngine: 0,
|
|
11
|
+
'under way using engine': 0,
|
|
12
|
+
'underway using engine': 0,
|
|
13
|
+
anchored: 1,
|
|
14
|
+
AtAnchor: 1,
|
|
15
|
+
'at anchor': 1,
|
|
16
|
+
'not under command': 2,
|
|
17
|
+
'restricted manouverability': 3,
|
|
18
|
+
'constrained by draft': 4,
|
|
19
|
+
'constrained by her draught': 4,
|
|
20
|
+
moored: 5,
|
|
21
|
+
Moored: 5,
|
|
22
|
+
aground: 6,
|
|
23
|
+
fishing: 7,
|
|
24
|
+
'engaged in fishing': 7,
|
|
25
|
+
sailing: 8,
|
|
26
|
+
UnderWaySailing: 8,
|
|
27
|
+
'under way sailing': 8,
|
|
28
|
+
'underway sailing': 8,
|
|
29
|
+
'hazardous material high speed': 9,
|
|
30
|
+
'hazardous material wing in ground': 10,
|
|
31
|
+
'reserved for future use': 13,
|
|
32
|
+
'ais-sart': 14,
|
|
33
|
+
default: 15,
|
|
34
|
+
UnDefined: 15,
|
|
35
|
+
undefined: 15,
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Extract value from SignalK data structure
|
|
40
|
+
* Handles both { value: X } objects and direct values
|
|
41
|
+
* @param {object} obj - The object to extract from
|
|
42
|
+
* @param {string} path - Dot-separated path (e.g., 'navigation.position.latitude')
|
|
43
|
+
* @returns {*} The extracted value or null
|
|
44
|
+
*/
|
|
45
|
+
function getValue(obj, path) {
|
|
46
|
+
if (obj === undefined || obj === null) return null;
|
|
47
|
+
|
|
48
|
+
const parts = path.split('.');
|
|
49
|
+
let current = obj;
|
|
50
|
+
|
|
51
|
+
for (const part of parts) {
|
|
52
|
+
if (current === undefined || current === null) return null;
|
|
53
|
+
current = current[part];
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
if (current === undefined || current === null) return null;
|
|
57
|
+
|
|
58
|
+
// Handle SignalK value wrapper
|
|
59
|
+
if (typeof current === 'object' && 'value' in current) {
|
|
60
|
+
return current.value;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
return current;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Get timestamp from SignalK data
|
|
68
|
+
* @param {object} obj - The object to extract from
|
|
69
|
+
* @param {string} path - Dot-separated path
|
|
70
|
+
* @returns {string|null} ISO timestamp or null
|
|
71
|
+
*/
|
|
72
|
+
function getTimestamp(obj, path) {
|
|
73
|
+
if (obj === undefined || obj === null) return null;
|
|
74
|
+
|
|
75
|
+
const parts = path.split('.');
|
|
76
|
+
let current = obj;
|
|
77
|
+
|
|
78
|
+
for (const part of parts) {
|
|
79
|
+
if (current === undefined || current === null) return null;
|
|
80
|
+
current = current[part];
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
if (current === undefined || current === null) return null;
|
|
84
|
+
|
|
85
|
+
if (typeof current === 'object' && 'timestamp' in current) {
|
|
86
|
+
return current.timestamp;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
return null;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Convert radians to degrees
|
|
94
|
+
* @param {number|null} radians - Value in radians
|
|
95
|
+
* @returns {number|null} Value in degrees or null
|
|
96
|
+
*/
|
|
97
|
+
function radToDegrees(radians) {
|
|
98
|
+
if (radians === null || radians === undefined) return null;
|
|
99
|
+
return (radians * 180) / Math.PI;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Convert meters per second to knots
|
|
104
|
+
* @param {number|null} speed - Speed in m/s
|
|
105
|
+
* @returns {number|null} Speed in knots or null
|
|
106
|
+
*/
|
|
107
|
+
function msToKnots(speed) {
|
|
108
|
+
if (speed === null || speed === undefined) return null;
|
|
109
|
+
return (speed * 3.6) / 1.852;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// NMEA checksum hex conversion
|
|
113
|
+
const mHex = ['0', '1', '2', '3', '4', '5', '6', '7', '8', '9', 'A', 'B', 'C', 'D', 'E', 'F'];
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Convert byte to hex string
|
|
117
|
+
* @param {number} v - Byte value
|
|
118
|
+
* @returns {string} Two-character hex string
|
|
119
|
+
*/
|
|
120
|
+
function toHexString(v) {
|
|
121
|
+
const msn = (v >> 4) & 0x0f;
|
|
122
|
+
const lsn = (v >> 0) & 0x0f;
|
|
123
|
+
return mHex[msn] + mHex[lsn];
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Create NMEA tag block with checksum
|
|
128
|
+
* @param {number} [timestamp] - Optional timestamp (defaults to Date.now())
|
|
129
|
+
* @returns {string} Tag block string
|
|
130
|
+
*/
|
|
131
|
+
function createTagBlock(timestamp) {
|
|
132
|
+
let tagBlock = '';
|
|
133
|
+
tagBlock += 's:SK0001,';
|
|
134
|
+
tagBlock += `c:${timestamp || Date.now()},`;
|
|
135
|
+
tagBlock = tagBlock.slice(0, -1);
|
|
136
|
+
let tagBlockChecksum = 0;
|
|
137
|
+
for (let i = 0; i < tagBlock.length; i++) {
|
|
138
|
+
tagBlockChecksum ^= tagBlock.charCodeAt(i);
|
|
139
|
+
}
|
|
140
|
+
return `\\${tagBlock}*${toHexString(tagBlockChecksum)}\\`;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Get navigation status code from state string
|
|
145
|
+
* @param {string} state - Navigation state string
|
|
146
|
+
* @returns {number|string} AIS navigation status code or empty string
|
|
147
|
+
*/
|
|
148
|
+
function getNavStatus(state) {
|
|
149
|
+
if (state === null || state === undefined) return '';
|
|
150
|
+
return stateMapping[state] !== undefined ? stateMapping[state] : '';
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Extract vessel data from SignalK vessel object
|
|
155
|
+
* @param {object} vessel - SignalK vessel object
|
|
156
|
+
* @returns {object} Extracted vessel data
|
|
157
|
+
*/
|
|
158
|
+
function extractVesselData(vessel) {
|
|
159
|
+
const mmsi = getValue(vessel, 'mmsi');
|
|
160
|
+
let shipName = getValue(vessel, 'name');
|
|
161
|
+
if (typeof shipName === 'number') shipName = '';
|
|
162
|
+
|
|
163
|
+
// Handle SignalK position structure: navigation.position.value = {latitude, longitude}
|
|
164
|
+
const position = getValue(vessel, 'navigation.position');
|
|
165
|
+
const lat = position && typeof position === 'object' ? position.latitude : null;
|
|
166
|
+
const lon = position && typeof position === 'object' ? position.longitude : null;
|
|
167
|
+
|
|
168
|
+
const sog = msToKnots(getValue(vessel, 'navigation.speedOverGround'));
|
|
169
|
+
const cog = radToDegrees(getValue(vessel, 'navigation.courseOverGroundTrue'));
|
|
170
|
+
const rot = radToDegrees(getValue(vessel, 'navigation.rateOfTurn'));
|
|
171
|
+
const hdg = radToDegrees(getValue(vessel, 'navigation.headingTrue'));
|
|
172
|
+
|
|
173
|
+
const navStateValue = getValue(vessel, 'navigation.state');
|
|
174
|
+
const navStat = getNavStatus(navStateValue);
|
|
175
|
+
|
|
176
|
+
let dst = getValue(vessel, 'navigation.destination.commonName');
|
|
177
|
+
if (typeof dst === 'number') dst = '';
|
|
178
|
+
|
|
179
|
+
let callSign = getValue(vessel, 'communication.callsignVhf');
|
|
180
|
+
if (typeof callSign === 'number') callSign = '';
|
|
181
|
+
|
|
182
|
+
let imo = getValue(vessel, 'registrations.imo');
|
|
183
|
+
if (imo && typeof imo === 'string' && imo.startsWith('IMO ')) {
|
|
184
|
+
imo = imo.substring(4);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
const id = getValue(vessel, 'design.aisShipType.id');
|
|
188
|
+
let type = getValue(vessel, 'design.aisShipType.name');
|
|
189
|
+
if (typeof type === 'number') type = '';
|
|
190
|
+
|
|
191
|
+
// Draft in meters - ggencoder handles conversion to 0.1m units internally
|
|
192
|
+
const draftCur = getValue(vessel, 'design.draft.current');
|
|
193
|
+
|
|
194
|
+
const length = getValue(vessel, 'design.length.overall');
|
|
195
|
+
let beam = getValue(vessel, 'design.beam');
|
|
196
|
+
if (beam !== null) beam /= 2;
|
|
197
|
+
|
|
198
|
+
const aisClass = getValue(vessel, 'sensors.ais.class');
|
|
199
|
+
|
|
200
|
+
return {
|
|
201
|
+
mmsi,
|
|
202
|
+
shipName,
|
|
203
|
+
lat,
|
|
204
|
+
lon,
|
|
205
|
+
sog,
|
|
206
|
+
cog,
|
|
207
|
+
rot,
|
|
208
|
+
hdg,
|
|
209
|
+
navStat,
|
|
210
|
+
dst,
|
|
211
|
+
callSign,
|
|
212
|
+
imo,
|
|
213
|
+
id,
|
|
214
|
+
type,
|
|
215
|
+
draftCur,
|
|
216
|
+
length,
|
|
217
|
+
beam,
|
|
218
|
+
aisClass,
|
|
219
|
+
};
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* Build AIS message type 3 (Class A position report)
|
|
224
|
+
* @param {object} data - Vessel data
|
|
225
|
+
* @param {boolean} isOwn - Whether this is own vessel
|
|
226
|
+
* @returns {object} AIS message object
|
|
227
|
+
*/
|
|
228
|
+
function buildAisMessage3(data, isOwn = false) {
|
|
229
|
+
return {
|
|
230
|
+
own: isOwn,
|
|
231
|
+
aistype: 3,
|
|
232
|
+
repeat: 0,
|
|
233
|
+
mmsi: data.mmsi,
|
|
234
|
+
navstatus: data.navStat,
|
|
235
|
+
sog: data.sog,
|
|
236
|
+
lon: data.lon,
|
|
237
|
+
lat: data.lat,
|
|
238
|
+
cog: data.cog,
|
|
239
|
+
hdg: data.hdg,
|
|
240
|
+
rot: data.rot,
|
|
241
|
+
};
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* Build AIS message type 5 (Class A static data)
|
|
246
|
+
* @param {object} data - Vessel data
|
|
247
|
+
* @param {boolean} isOwn - Whether this is own vessel
|
|
248
|
+
* @returns {object} AIS message object
|
|
249
|
+
*/
|
|
250
|
+
function buildAisMessage5(data, isOwn = false) {
|
|
251
|
+
return {
|
|
252
|
+
own: isOwn,
|
|
253
|
+
aistype: 5,
|
|
254
|
+
repeat: 0,
|
|
255
|
+
mmsi: data.mmsi,
|
|
256
|
+
imo: data.imo,
|
|
257
|
+
cargo: data.id,
|
|
258
|
+
callsign: data.callSign,
|
|
259
|
+
shipname: data.shipName,
|
|
260
|
+
draught: data.draftCur,
|
|
261
|
+
destination: data.dst,
|
|
262
|
+
dimA: 0,
|
|
263
|
+
dimB: data.length,
|
|
264
|
+
dimC: data.beam,
|
|
265
|
+
dimD: data.beam,
|
|
266
|
+
};
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* Build AIS message type 18 (Class B position report)
|
|
271
|
+
* @param {object} data - Vessel data
|
|
272
|
+
* @param {boolean} isOwn - Whether this is own vessel
|
|
273
|
+
* @returns {object} AIS message object
|
|
274
|
+
*/
|
|
275
|
+
function buildAisMessage18(data, isOwn = false) {
|
|
276
|
+
return {
|
|
277
|
+
own: isOwn,
|
|
278
|
+
aistype: 18,
|
|
279
|
+
repeat: 0,
|
|
280
|
+
mmsi: data.mmsi,
|
|
281
|
+
sog: data.sog,
|
|
282
|
+
accuracy: 0,
|
|
283
|
+
lon: data.lon,
|
|
284
|
+
lat: data.lat,
|
|
285
|
+
cog: data.cog,
|
|
286
|
+
hdg: data.hdg,
|
|
287
|
+
};
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/**
|
|
291
|
+
* Build AIS message type 24 part A (Class B static - ship name)
|
|
292
|
+
* @param {object} data - Vessel data
|
|
293
|
+
* @param {boolean} isOwn - Whether this is own vessel
|
|
294
|
+
* @returns {object} AIS message object
|
|
295
|
+
*/
|
|
296
|
+
function buildAisMessage24A(data, isOwn = false) {
|
|
297
|
+
return {
|
|
298
|
+
own: isOwn,
|
|
299
|
+
aistype: 24,
|
|
300
|
+
repeat: 0,
|
|
301
|
+
part: 0,
|
|
302
|
+
mmsi: data.mmsi,
|
|
303
|
+
shipname: data.shipName,
|
|
304
|
+
};
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/**
|
|
308
|
+
* Build AIS message type 24 part B (Class B static - call sign, dimensions)
|
|
309
|
+
* @param {object} data - Vessel data
|
|
310
|
+
* @param {boolean} isOwn - Whether this is own vessel
|
|
311
|
+
* @returns {object} AIS message object
|
|
312
|
+
*/
|
|
313
|
+
function buildAisMessage24B(data, isOwn = false) {
|
|
314
|
+
return {
|
|
315
|
+
own: isOwn,
|
|
316
|
+
aistype: 24,
|
|
317
|
+
repeat: 0,
|
|
318
|
+
part: 1,
|
|
319
|
+
mmsi: data.mmsi,
|
|
320
|
+
cargo: data.id,
|
|
321
|
+
callsign: data.callSign,
|
|
322
|
+
dimA: 0,
|
|
323
|
+
dimB: data.length,
|
|
324
|
+
dimC: data.beam,
|
|
325
|
+
dimD: data.beam,
|
|
326
|
+
};
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/**
|
|
330
|
+
* Check if AIS data is fresh (within update interval)
|
|
331
|
+
* @param {string} aisTime - ISO timestamp
|
|
332
|
+
* @param {number} maxAgeSeconds - Maximum age in seconds
|
|
333
|
+
* @returns {boolean} True if data is fresh
|
|
334
|
+
*/
|
|
335
|
+
function isDataFresh(aisTime, maxAgeSeconds) {
|
|
336
|
+
if (!aisTime) return false;
|
|
337
|
+
const ageSeconds = (Date.now() - new Date(aisTime).getTime()) / 1000;
|
|
338
|
+
return ageSeconds < maxAgeSeconds;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
module.exports = {
|
|
342
|
+
stateMapping,
|
|
343
|
+
getValue,
|
|
344
|
+
getTimestamp,
|
|
345
|
+
radToDegrees,
|
|
346
|
+
msToKnots,
|
|
347
|
+
toHexString,
|
|
348
|
+
createTagBlock,
|
|
349
|
+
getNavStatus,
|
|
350
|
+
extractVesselData,
|
|
351
|
+
buildAisMessage3,
|
|
352
|
+
buildAisMessage5,
|
|
353
|
+
buildAisMessage18,
|
|
354
|
+
buildAisMessage24A,
|
|
355
|
+
buildAisMessage24B,
|
|
356
|
+
isDataFresh,
|
|
357
|
+
};
|
package/package.json
CHANGED
|
@@ -1,26 +1,27 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "signalk-vessels-to-ais",
|
|
3
|
-
"version": "
|
|
4
|
-
"description": "SignalK server plugin to convert other vessel data to NMEA0183 AIS format
|
|
3
|
+
"version": "2.0.0-beta.1",
|
|
4
|
+
"description": "SignalK server plugin to convert other vessel data to NMEA0183 AIS format using direct data access (no REST API)",
|
|
5
5
|
"main": "index.js",
|
|
6
6
|
"scripts": {
|
|
7
|
-
"test": "
|
|
7
|
+
"test": "mocha 'test/**/*.test.js'",
|
|
8
|
+
"lint": "eslint index.js lib/"
|
|
8
9
|
},
|
|
9
10
|
"keywords": [
|
|
10
|
-
"signalk-node-server-plugin"
|
|
11
|
+
"signalk-node-server-plugin",
|
|
12
|
+
"signalk-category-ais"
|
|
11
13
|
],
|
|
12
14
|
"repository": "https://github.com/KEGustafsson/signalk-vessels-to-ais",
|
|
13
15
|
"author": "Karl-Erik Gustafsson",
|
|
14
16
|
"license": "MIT",
|
|
15
17
|
"dependencies": {
|
|
16
18
|
"haversine-distance": "^1.2.1",
|
|
17
|
-
"moment": "^2.29.1",
|
|
18
|
-
"node-fetch": "^3.1.1",
|
|
19
19
|
"ggencoder": "^1.0.9"
|
|
20
20
|
},
|
|
21
21
|
"devDependencies": {
|
|
22
22
|
"eslint": "^7.17.0",
|
|
23
23
|
"eslint-config-airbnb-base": "^14.2.1",
|
|
24
|
-
"eslint-plugin-import": "^2.22.1"
|
|
24
|
+
"eslint-plugin-import": "^2.22.1",
|
|
25
|
+
"mocha": "^10.2.0"
|
|
25
26
|
}
|
|
26
27
|
}
|