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.
@@ -1,11 +1,11 @@
1
- # To get started with Dependabot version updates, you'll need to specify which
2
- # package ecosystems to update and where the package manifests are located.
3
- # Please see the documentation for all configuration options:
4
- # https://docs.github.com/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file
5
-
6
- version: 2
7
- updates:
8
- - package-ecosystem: "npm" # See documentation for possible values
9
- directory: "/" # Location of package manifests
10
- schedule:
11
- interval: "weekly"
1
+ # To get started with Dependabot version updates, you'll need to specify which
2
+ # package ecosystems to update and where the package manifests are located.
3
+ # Please see the documentation for all configuration options:
4
+ # https://docs.github.com/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file
5
+
6
+ version: 2
7
+ updates:
8
+ - package-ecosystem: "npm" # See documentation for possible values
9
+ directory: "/" # Location of package manifests
10
+ schedule:
11
+ interval: "weekly"
package/.mocharc.json ADDED
@@ -0,0 +1,5 @@
1
+ {
2
+ "spec": "test/**/*.test.js",
3
+ "timeout": 5000,
4
+ "reporter": "spec"
5
+ }
package/README.md CHANGED
@@ -9,6 +9,7 @@ User can configure:
9
9
  - Own data can be added to AIS sending
10
10
 
11
11
  New:
12
+ - v2.0.0, refactor: use direct data access (app.getPath) instead of REST API, removed node-fetch and moment dependencies, added unit tests
12
13
  - v1.6.1, fix: ggencoder ^1.0.9 is use
13
14
  - v1.6.0, fix: enhance error handling for AIS timestamp retrieval
14
15
  - v1.5.1, fix: fix: improve shipName type checking
package/index.js CHANGED
@@ -23,399 +23,189 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
23
23
  SOFTWARE.
24
24
  */
25
25
 
26
- const fetchNew = (...args) => import('node-fetch').then(({ default: fetch }) => fetch(...args));
27
- const https = require('https');
26
+ /**
27
+ * Modified version using app.getPath() instead of REST API
28
+ * - No HTTP/HTTPS configuration needed
29
+ * - No node-fetch dependency
30
+ * - Direct access to SignalK data model
31
+ */
32
+
28
33
  const AisEncode = require('ggencoder').AisEncode;
29
- const moment = require('moment');
30
34
  const haversine = require('haversine-distance');
35
+ const {
36
+ getValue,
37
+ getTimestamp,
38
+ createTagBlock,
39
+ extractVesselData,
40
+ buildAisMessage3,
41
+ buildAisMessage5,
42
+ buildAisMessage18,
43
+ buildAisMessage24A,
44
+ buildAisMessage24B,
45
+ isDataFresh,
46
+ } = require('./lib/helpers');
31
47
 
32
48
  module.exports = function createPlugin(app) {
33
49
  const plugin = {};
34
- plugin.id = 'signalk-vessels-to-ais';
35
- plugin.name = 'Other vessels data to AIS NMEA0183';
36
- plugin.description = 'SignalK server plugin to convert other vessel data to NMEA0183 AIS format and forward it out to 3rd party applications';
37
-
38
- let positionUpdate = null;
39
- let distance;
40
- let sendOwn;
41
- let url;
42
- let intervalRun;
43
- const setStatus = app.setPluginStatus || app.setProviderStatus;
44
-
45
- let useTag;
46
- let eventName
50
+ plugin.id = 'signalk-vessels-to-ais-ws';
51
+ plugin.name = 'Other vessels data to AIS NMEA0183 (WebSocket)';
52
+ plugin.description = 'SignalK server plugin to convert other vessel data to NMEA0183 AIS format using direct data access (no REST API)';
47
53
 
48
- const httpsAgent = new https.Agent({
49
- rejectUnauthorized: false,
50
- });
54
+ let intervalId = null;
55
+ let positionUpdate = 60;
56
+ let distance = 100;
57
+ let sendOwn = true;
58
+ let useTag = false;
59
+ let eventName = 'nmea0183out';
51
60
 
52
- let getParam;
53
-
54
- plugin.start = function (options) {
55
- useTag = options.useTag;
56
- eventName = options.eventName;
57
-
58
- positionUpdate = options.position_update * 60;
59
- distance = options.distance;
60
- sendOwn = options.sendOwn;
61
-
62
- const port = options.port || 3000;
63
- const portSec = options.portSec || 3443;
64
-
65
- url = `https://localhost:${portSec}/signalk/v1/api/vessels`;
66
- getParam = { method: 'GET', agent: httpsAgent };
67
- fetchNew(url, getParam)
68
- .then((res) => {
69
- console.log(`${plugin.id}: SSL enabled, using https`);
70
- if (!res.ok) {
71
- console.error(`${plugin.id}: SSL enabled, but error accessing server. Check 'Allow Readonly Access' and enable it.`);
72
- setStatus("Error accessing server. Check 'Allow Readonly Access' and enable it");
73
- }
74
- })
75
- .catch(() => {
76
- url = `http://localhost:${port}/signalk/v1/api/vessels`;
77
- getParam = { method: 'GET' };
78
- fetchNew(url, getParam)
79
- .then((res) => {
80
- console.log(`${plugin.id}: SSL disabled, using http`);
81
- if (!res.ok) {
82
- console.error(`${plugin.id}: SSL disabled, but error accessing server. Check 'Allow Readonly Access' and enable it.`);
83
- setStatus("Error accessing server. Check 'Allow Readonly Access' and enable it");
84
- }
85
- });
86
- })
87
- .finally(() => {
88
- // eslint-disable-next-line no-use-before-define
89
- intervalRun = setInterval(readData, (positionUpdate * 1000), getParam);
90
- });
91
-
92
- app.debug('Plugin started');
93
- };
94
-
95
- //----------------------------------------------------------------------------
96
- // State Mapping
97
-
98
- const stateMapping = {
99
- motoring: 0,
100
- 'UnderWayUsingEngine': 0,
101
- 'under way using engine': 0,
102
- 'underway using engine': 0,
103
- anchored: 1,
104
- 'AtAnchor': 1,
105
- 'at anchor': 1,
106
- 'not under command': 2,
107
- 'restricted manouverability': 3,
108
- 'constrained by draft': 4,
109
- 'constrained by her draught': 4,
110
- moored: 5,
111
- 'Moored': 5,
112
- aground: 6,
113
- fishing: 7,
114
- 'engaged in fishing': 7,
115
- sailing: 8,
116
- 'UnderWaySailing': 8,
117
- 'under way sailing': 8,
118
- 'underway sailing': 8,
119
- 'hazardous material high speed': 9,
120
- 'hazardous material wing in ground': 10,
121
- 'reserved for future use': 13,
122
- 'ais-sart': 14,
123
- default: 15,
124
- 'UnDefined': 15,
125
- 'undefined': 15,
126
- };
127
-
128
- //----------------------------------------------------------------------------
129
- // Rad to Deg
130
- function radToDegrees(radians) {
131
- const pi = Math.PI;
132
- return ((radians * 180) / pi);
133
- }
134
-
135
- //----------------------------------------------------------------------------
136
- // m/s to knots
137
- function msToKnots(speed) {
138
- return ((speed * 3.6) / 1.852);
139
- }
140
-
141
- //----------------------------------------------------------------------------
142
- // nmea out
61
+ const setStatus = app.setPluginStatus || app.setProviderStatus;
143
62
 
144
- function aisOut(encMsg, aisTime) {
63
+ // NMEA output
64
+ function aisOut(encMsg) {
145
65
  const enc = new AisEncode(encMsg);
146
66
  const sentence = enc.nmea;
147
- let taggString = '';
67
+ let tagString = '';
148
68
  if (useTag) {
149
- // eslint-disable-next-line no-use-before-define
150
- taggString = createTagBlock(aisTime);
69
+ tagString = createTagBlock();
151
70
  }
152
71
  if (sentence && sentence.length > 0) {
153
- app.debug(taggString + sentence);
154
- app.emit(eventName, taggString + sentence);
72
+ app.debug(tagString + sentence);
73
+ app.emit(eventName, tagString + sentence);
155
74
  }
156
75
  }
157
76
 
158
- const mHex = [
159
- '0',
160
- '1',
161
- '2',
162
- '3',
163
- '4',
164
- '5',
165
- '6',
166
- '7',
167
- '8',
168
- '9',
169
- 'A',
170
- 'B',
171
- 'C',
172
- 'D',
173
- 'E',
174
- 'F',
175
- ];
176
-
177
- function toHexString(v) {
178
- const msn = (v >> 4) & 0x0f;
179
- const lsn = (v >> 0) & 0x0f;
180
- return mHex[msn] + mHex[lsn];
181
- }
77
+ /**
78
+ * Main data processing function
79
+ * Uses app.getPath() instead of REST API
80
+ */
81
+ function processVessels() {
82
+ // Get own position for distance calculation
83
+ const ownPosition = app.getSelfPath('navigation.position.value');
84
+ if (!ownPosition || ownPosition.latitude === undefined || ownPosition.longitude === undefined) {
85
+ app.debug('Own position not available, skipping');
86
+ return;
87
+ }
182
88
 
183
- function createTagBlock(aisTime) {
184
- let tagBlock = '';
185
- tagBlock += 's:SK0001,';
186
- // tagBlock += 'c:' + aisTime + ','
187
- tagBlock += `c:${Date.now(aisTime)},`;
188
- tagBlock = tagBlock.slice(0, -1);
189
- let tagBlockChecksum = 0;
190
- for (let i = 0; i < tagBlock.length; i++) {
191
- tagBlockChecksum ^= tagBlock.charCodeAt(i);
89
+ const ownLat = ownPosition.latitude;
90
+ const ownLon = ownPosition.longitude;
91
+
92
+ // Get all vessels using direct data access - NO REST API!
93
+ const vessels = app.getPath('vessels');
94
+ if (!vessels) {
95
+ app.debug('No vessels data available');
96
+ return;
192
97
  }
193
- return `\\${tagBlock}*${toHexString(tagBlockChecksum)}\\`;
194
- }
195
98
 
196
- //----------------------------------------------------------------------------
197
- // Read and parse AIS data
198
-
199
- // eslint-disable-next-line no-shadow
200
- function readData(getParam) {
201
- let i, mmsi, aisTime, aisDelay, shipName, lat, lon, sog, cog, rot;
202
- let navStat, hdg, dst, callSign, imo, id, type;
203
- let draftCur, length, beam, ais, encMsg3, encMsg5, encMsg18, encMsg240, encMsg241, own;
204
- const ownLat = app.getSelfPath('navigation.position.value.latitude');
205
- const ownLon = app.getSelfPath('navigation.position.value.longitude');
206
- if (typeof ownLat !== "undefined" && typeof ownLon !== "undefined") {
207
- fetchNew(url, getParam)
208
- .then((res) => res.json())
209
- .then((json) => {
210
- const jsonContent = JSON.parse(JSON.stringify(json));
211
- const numberAIS = Object.keys(jsonContent).length;
212
- for (i = 0; i < numberAIS; i++) {
213
- const jsonKey = Object.keys(jsonContent)[i];
214
- try {
215
- aisTime = jsonContent[jsonKey].sensors.ais.class.timestamp;
216
- } catch (error) {
217
- try {
218
- if (i === 0) {
219
- aisTime = jsonContent[jsonKey].navigation.position.timestamp;
220
- } else {
221
- aisTime = null;
222
- }
223
- } catch (error) {
224
- aisTime = null;
225
- }
226
- }
227
-
228
- if (aisTime) {
229
- aisDelay = (parseFloat((moment(new Date(Date.now()))
230
- .diff(aisTime) / 1000).toFixed(3))) < positionUpdate;
231
- } else {
232
- aisDelay = false;
233
- }
234
-
235
-
236
- try {
237
- mmsi = jsonContent[jsonKey].mmsi;
238
- } catch (error) { mmsi = null; }
239
- try {
240
- shipName = typeof jsonContent[jsonKey]?.name === 'string'
241
- ? jsonContent[jsonKey].name
242
- : jsonContent[jsonKey]?.name?.value || '';
243
- } catch (error) { shipName = ''; }
244
- try {
245
- lat = jsonContent[jsonKey].navigation.position.value.latitude;
246
- } catch (error) { lat = null; }
247
- try {
248
- lon = jsonContent[jsonKey].navigation.position.value.longitude;
249
- } catch (error) { lon = null; }
250
- try {
251
- sog = msToKnots(jsonContent[jsonKey].navigation.speedOverGround.value);
252
- } catch (error) { sog = null; }
253
- try {
254
- cog = radToDegrees(jsonContent[jsonKey].navigation.courseOverGroundTrue.value);
255
- } catch (error) { cog = null; }
256
- try {
257
- rot = radToDegrees(jsonContent[jsonKey].navigation.rateOfTurn.value);
258
- } catch (error) { rot = null; }
259
- try {
260
- navStat = stateMapping[jsonContent[jsonKey].navigation.state.value];
261
- } catch (error) { navStat = ''; }
262
- try {
263
- hdg = radToDegrees(jsonContent[jsonKey].navigation.headingTrue.value);
264
- } catch (error) { hdg = null; }
265
- try {
266
- dst = jsonContent[jsonKey].navigation.destination.commonName.value;
267
- } catch (error) { dst = ''; }
268
- try {
269
- callSign = jsonContent[jsonKey].communication.callsignVhf.value || jsonContent[jsonKey].communication.callsignVhf;
270
- } catch (error) { callSign = ''; }
271
- try {
272
- imo = (jsonContent[jsonKey].registrations.value.imo).substring(4, 20);
273
- } catch (error) { imo = null; }
274
- try {
275
- id = jsonContent[jsonKey].design.aisShipType.value.id;
276
- } catch (error) { id = null; }
277
- try {
278
- type = jsonContent[jsonKey].design.aisShipType.value.name;
279
- } catch (error) { type = ''; }
280
- try {
281
- draftCur = (jsonContent[jsonKey].design.draft.value.current) / 10;
282
- } catch (error) { draftCur = null; }
283
- try {
284
- length = jsonContent[jsonKey].design.length.value.overall;
285
- } catch (error) { length = null; }
286
- try {
287
- beam = (jsonContent[jsonKey].design.beam.value) / 2;
288
- } catch (error) { beam = null; }
289
- try {
290
- ais = jsonContent[jsonKey].sensors.ais.class.value;
291
- } catch (error) { ais = null; }
292
-
293
- if (shipName % 1 === 0) {
294
- shipName = '';
295
- }
296
- if (dst % 1 === 0) {
297
- dst = '';
298
- }
299
- if (callSign % 1 === 0) {
300
- callSign = '';
301
- }
302
- if (type % 1 === 0) {
303
- type = '';
304
- }
305
-
306
- if (i === 0) {
307
- own = true;
308
- } else {
309
- own = false;
310
- }
311
-
312
- const a = { lat: ownLat, lon: ownLon };
313
- const b = { lat, lon };
314
- const dist = (haversine(a, b) / 1000).toFixed(2);
315
-
316
- if (dist <= distance) {
317
- encMsg3 = {
318
- own,
319
- aistype: 3, // class A position report
320
- repeat: 0,
321
- mmsi,
322
- navstatus: navStat,
323
- sog,
324
- lon,
325
- lat,
326
- cog,
327
- hdg,
328
- rot,
329
- };
330
-
331
- encMsg5 = {
332
- own,
333
- aistype: 5, // class A static
334
- repeat: 0,
335
- mmsi,
336
- imo,
337
- cargo: id,
338
- callsign: callSign,
339
- shipname: shipName,
340
- draught: draftCur,
341
- destination: dst,
342
- dimA: 0,
343
- dimB: length,
344
- dimC: beam,
345
- dimD: beam,
346
- };
347
-
348
- encMsg18 = {
349
- own,
350
- aistype: 18, // class B position report
351
- repeat: 0,
352
- mmsi,
353
- sog,
354
- accuracy: 0,
355
- lon,
356
- lat,
357
- cog,
358
- hdg,
359
- };
360
-
361
- encMsg240 = {
362
- own,
363
- aistype: 24, // class B static
364
- repeat: 0,
365
- part: 0,
366
- mmsi,
367
- shipname: shipName,
368
- };
369
-
370
- encMsg241 = {
371
- own,
372
- aistype: 24, // class B static
373
- repeat: 0,
374
- part: 1,
375
- mmsi,
376
- cargo: id,
377
- callsign: callSign,
378
- dimA: 0,
379
- dimB: length,
380
- dimC: beam,
381
- dimD: beam,
382
- };
383
-
384
- if (aisDelay && (ais === 'A' || ais === 'B' || ais === 'BASE')) {
385
- // eslint-disable-next-line no-useless-concat
386
- app.debug(`Distance range: ${distance}km, AIS target distance: ${dist}km` + `, Class ${ais} Vessel` + `, MMSI:${mmsi}`);
387
- if (ais === 'A') {
388
- app.debug(`class A, ${i}, time: ${aisTime}`);
389
- aisOut(encMsg3, aisTime);
390
- aisOut(encMsg5, aisTime);
391
- }
392
- if (ais === 'B') {
393
- app.debug(`class ${ais}, ${i}, time: ${aisTime}`);
394
- aisOut(encMsg18, aisTime);
395
- aisOut(encMsg240, aisTime);
396
- aisOut(encMsg241, aisTime);
397
- }
398
- if (ais === 'BASE') {
399
- app.debug(`class ${ais}, ${i}, time: ${aisTime}`);
400
- aisOut(encMsg3, aisTime);
401
- }
402
- app.debug('--------------------------------------------------------');
403
- }
404
- }
405
- }
406
- const dateobj = new Date(Date.now());
407
- const date = dateobj.toISOString();
408
- setStatus(`AIS NMEA message sent: ${date}`);
409
- })
410
- .catch((err) => console.error(err));
99
+ const vesselIds = Object.keys(vessels);
100
+ let processedCount = 0;
101
+
102
+ // Get own vessel identifier for comparison
103
+ const selfId = app.selfId || 'self';
104
+
105
+ for (const vesselId of vesselIds) {
106
+ const vessel = vessels[vesselId];
107
+
108
+ // Determine if this is own vessel by checking 'self' key or matching selfId
109
+ const isOwn = vesselId === 'self' || vesselId === selfId;
110
+
111
+ // Skip own vessel if not configured to send
112
+ if (isOwn && !sendOwn) {
113
+ continue;
114
+ }
115
+
116
+ // Extract AIS timestamp for freshness check
117
+ let aisTime = getValue(vessel, 'sensors.ais.class.timestamp');
118
+ if (!aisTime) {
119
+ aisTime = getTimestamp(vessel, 'navigation.position');
120
+ }
121
+
122
+ // Check data freshness
123
+ const aisDelay = isDataFresh(aisTime, positionUpdate);
124
+
125
+ // Extract vessel data using helper
126
+ const data = extractVesselData(vessel);
127
+
128
+ // Skip if no position
129
+ if (data.lat === null || data.lon === null) {
130
+ continue;
131
+ }
132
+
133
+ // Calculate distance from own vessel
134
+ const a = { lat: ownLat, lon: ownLon };
135
+ const b = { lat: data.lat, lon: data.lon };
136
+ const dist = (haversine(a, b) / 1000).toFixed(2);
137
+
138
+ // Check if within distance range
139
+ if (parseFloat(dist) > distance) {
140
+ continue;
141
+ }
142
+
143
+ // Send AIS messages based on class
144
+ if (aisDelay && (data.aisClass === 'A' || data.aisClass === 'B' || data.aisClass === 'BASE')) {
145
+ app.debug(
146
+ `Distance range: ${distance}km, AIS target distance: ${dist}km, Class ${data.aisClass} Vessel, MMSI:${data.mmsi}`,
147
+ );
148
+
149
+ if (data.aisClass === 'A') {
150
+ app.debug(`Class A, MMSI: ${data.mmsi}, Name: ${data.shipName || 'Unknown'}`);
151
+ aisOut(buildAisMessage3(data, isOwn));
152
+ aisOut(buildAisMessage5(data, isOwn));
153
+ processedCount++;
154
+ }
155
+
156
+ if (data.aisClass === 'B') {
157
+ app.debug(`Class B, MMSI: ${data.mmsi}, Name: ${data.shipName || 'Unknown'}`);
158
+ aisOut(buildAisMessage18(data, isOwn));
159
+ aisOut(buildAisMessage24A(data, isOwn));
160
+ aisOut(buildAisMessage24B(data, isOwn));
161
+ processedCount++;
162
+ }
163
+
164
+ if (data.aisClass === 'BASE') {
165
+ app.debug(`Base Station, MMSI: ${data.mmsi}`);
166
+ aisOut(buildAisMessage3(data, isOwn));
167
+ processedCount++;
168
+ }
169
+
170
+ app.debug('--------------------------------------------------------');
171
+ }
172
+ }
173
+
174
+ const dateObj = new Date(Date.now());
175
+ const date = dateObj.toISOString();
176
+ setStatus(`${processedCount} AIS targets sent: ${date}`);
177
+
178
+ if (processedCount > 0) {
179
+ app.reportOutputMessages(processedCount);
411
180
  }
412
181
  }
413
182
 
414
- //----------------------------------------------------------------------------
183
+ plugin.start = function (options) {
184
+ positionUpdate = (options.position_update || 1) * 60;
185
+ distance = options.distance || 100;
186
+ sendOwn = options.sendOwn !== false;
187
+ useTag = options.useTag || false;
188
+ eventName = options.eventName || 'nmea0183out';
189
+
190
+ app.debug('Plugin starting with direct data access (no REST API)');
191
+ app.debug(`Update interval: ${positionUpdate}s, Distance: ${distance}km`);
415
192
 
416
- plugin.stop = function stop() {
417
- clearInterval(intervalRun);
418
- app.debug('Stopped');
193
+ // Initial run
194
+ processVessels();
195
+
196
+ // Set up periodic processing
197
+ intervalId = setInterval(processVessels, positionUpdate * 1000);
198
+
199
+ setStatus('Running');
200
+ app.debug('Plugin started');
201
+ };
202
+
203
+ plugin.stop = function () {
204
+ if (intervalId) {
205
+ clearInterval(intervalId);
206
+ intervalId = null;
207
+ }
208
+ app.debug('Plugin stopped');
419
209
  };
420
210
 
421
211
  plugin.schema = {
@@ -424,21 +214,12 @@ module.exports = function createPlugin(app) {
424
214
  position_update: {
425
215
  type: 'number',
426
216
  default: 1,
427
- title: 'How often AIS data is sent to NMEA0183 out (in minutes). E.g. 0.5 = 30s, 1 = 1min',
428
- },
429
- port: {
430
- type: 'number',
431
- title: 'HTTP port',
432
- default: 3000,
433
- },
434
- portSec: {
435
- type: 'number',
436
- title: 'HTTPS port',
437
- default: 3443,
217
+ title: 'How often AIS data is sent to NMEA0183 out (in minutes)',
218
+ description: 'E.g. 0.5 = 30s, 1 = 1min',
438
219
  },
439
220
  sendOwn: {
440
221
  type: 'boolean',
441
- title: 'Send own AIS data, VDO',
222
+ title: 'Send own AIS data (VDO)',
442
223
  default: true,
443
224
  },
444
225
  useTag: {