signalk-to-noforeignland 0.1.21 → 0.1.24

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/CHANGELOG.md ADDED
@@ -0,0 +1,29 @@
1
+ 0.1.24
2
+ * CHANGE: testInternet only uses ipv4 now, some users don't have ipv6 configured properly and where unable to reach the API, when testInternet returned false
3
+ * NEW: PluginStatus on SK dashboard now shows last savePoint and last API transfer, so a user has more feedback what the app is doing without enabling the debug log and crawling though it.
4
+ * CHANGE: Renamed CHANGELOG to CHANGELOG.md
5
+ * CHANGE: CHANGELOG ORDER - newest on top.
6
+ * Final version after successful testing SV MOIN and SV KIAPA NUI
7
+
8
+ 0.1.23
9
+ * Final version after successful testing SV MOIN and SV KIAPA NUI
10
+
11
+ 0.1.23-beta.1
12
+ * Renamed branch to follow the release versions.
13
+ * CLEANUP - More debug info for the SK dashboard using this.app.setPluginError
14
+ * CLEANUP - Removed CreateGPX, was only used for removed Email transmission of the track
15
+
16
+ 0.1.22-beta.2
17
+
18
+ * CLEANUP and move to Object Oriented Javascript
19
+
20
+ 0.1.22-beta.1
21
+
22
+ * CONFIG: Attempt sending location while moving - Default changed from false to true
23
+ * CONFIG: Ping added for 24h ping if boat is not moved. - Default: true
24
+ * Package.json - Nodemailer dependency removed
25
+ * Marked for removal - Depricated "sendEmailData" function.
26
+ * REMOVED - sendEmail.js
27
+ * CLEANUP - Renamed emaiCron to apiCron
28
+ * NEW: 24h api ping, when enabled, even if boat didn't move.
29
+
package/README.md CHANGED
@@ -1,6 +1,12 @@
1
1
  # SignalK To NFL
2
2
  Effortlessly log your boat's movement to **noforeignland.com**
3
3
 
4
+ ## Testing Notes
5
+ - This version includes experimental features
6
+ - Report issues on GitHub
7
+ - Not recommended for production use
8
+
9
+
4
10
  ## Features
5
11
  * Automatically log your position to NFL
6
12
  * Send detailed tracks to log your entire trip and not just your final position
package/index.js CHANGED
@@ -1,4 +1,3 @@
1
-
2
1
  const { EOL } = require('os');
3
2
  const internetTestAddress = 'google.com';
4
3
  const internetTestTimeout = 1000;
@@ -8,578 +7,486 @@ const CronJob = require('cron').CronJob;
8
7
  const readline = require('readline');
9
8
  const fetch = require('node-fetch');
10
9
  const isReachable = require('is-reachable');
11
- const sendEmail = require('./sendEmail');
12
- const createGPX = require('./createGPX');
10
+
11
+ const apiUrl = 'https://www.noforeignland.com/home/api/v1/boat/tracking/track';
13
12
  const pluginApiKey = '0ede6cb6-5213-45f5-8ab4-b4836b236f97';
14
- // const msToKn = 1.944;
15
-
16
-
17
- module.exports = function(app) {
18
- var plugin = {};
19
- plugin.id = 'signalk-to-noforeignland';
20
- plugin.name = 'SignalK to Noforeignland';
21
- plugin.description = 'SignalK track logger to noforeignland.com';
22
-
23
- plugin.schema = {
24
- "title": plugin.name,
25
- "description": "Some parameters need for use",
26
- "type": "object",
27
- "required": ["emailCron", "boatApiKey"],
28
- "properties": {
29
- "trackFrequency": {
30
- "type": "integer",
31
- "title": "Position tracking frequency in seconds.",
32
- "description": "To keep file sizes small we only log positions once in a while (unless you set this value to 0)",
33
- "default": 60
34
- },
35
- "minMove": {
36
- "type": "number",
37
- "title": "Minimum boat move to log in meters",
38
- "description": "To keep file sizes small we only log positions if a move larger than this size is noted (if set to 0 will log every move)",
39
- "default": 50
40
- },
41
- "minSpeed": {
42
- "type": "number",
43
- "title": "Minimum boat speed to log in knots",
44
- "description": "To keep file sizes small we only log positions if boat speed goes above this value to minimize recording position on anchor or mooring (if set to 0 will log every move)",
45
- "default": 1.5
46
- },
47
- "emailCron": {
48
- "type": "string",
49
- "title": "Send attempt CRON",
50
- "description": "We send the tracking data to NFL once in a while, you can set the schedule with this setting. CRON format: https://crontab.guru/",
51
- "default": '*/10 * * * *',
52
- },
53
- 'boatApiKey': {
54
- "type": "string",
55
- "title": "Boat API key",
56
- "description": "Boat API key from noforeignland.com. Can be found in Account > Settings > Boat tracking > API Key. *required only in API method is set*",
57
- },
58
- "internetTestTimeout": {
59
- "type": "number",
60
- "title": "Timeout for testing internet connection in ms",
61
- "description": "Set this number higher for slower computers and internet connections",
62
- "default": 2000,
63
- },
64
- "sendWhileMoving": {
65
- "type": "boolean",
66
- "title": "Attempt sending location while moving",
67
- "description": "Should the plugin attempt to send tracking data to NFL while detecting the vessel is moving or only when stopped?",
68
- "default": false
69
- },
70
- "filterSource": {
71
- "type": "string",
72
- "title": "Position source device",
73
- "description": "Set this value to the name of a source if you want to only use the position given by that source.",
74
- },
75
- "trackDir": {
76
- "type": "string",
77
- "title": "Directory to cache tracks.",
78
- "description": "Path in server filesystem, absolute or from plugin directory. optional param (only used to keep file cache).",
79
- },
80
- "keepFiles": {
81
- "type": "boolean",
82
- "title": "Should keep track files on disk?",
83
- "description": "If you have a lot of hard drive space you can keep the track files for logging purposes.",
84
- "default": false
85
- },
86
- "ping": {
87
- "type": "boolean",
88
- "title": "Should I force a send every 24 hours",
89
- "description": "Keeps your boat active on NFL in your current location even if you do not move",
90
- "default": true
91
- },
92
- "apiUrl": {
93
- "type": "string",
94
- "title": "NFL tracking API endpoint",
95
- "description": "Change only if NFL gives you a different endpoint.",
96
- "default": "https://www.noforeignland.com/home/api/v1/boat/tracking/track"
97
- }
98
-
99
- }
100
- };
101
-
102
- var unsubscribes = [];
103
- var unsubscribesControl = [];
104
- var routeSaveName = 'nfl-track.jsonl';
105
- let lastPosition;
106
- let upSince;
107
- let cron;
108
- const creator = 'signalk-track-logger';
109
- const defaultTracksDir = 'track';
110
-
111
- let initialSent = false;
112
- let lastSentTime = 0;
113
-
114
- // const maxAllowedSpeed = 100;
115
-
116
- plugin.start = function(options, restartPlugin) {
117
- if (!options.trackDir) options.trackDir = defaultTracksDir;
118
- if (!path.isAbsolute(options.trackDir)) options.trackDir = path.join(__dirname, options.trackDir);
119
- //app.debug('options.trackDir=',options.trackDir);
120
- if (!createDir(options.trackDir)) {
121
- plugin.stop();
122
- return;
123
- }
124
-
125
- app.debug('track logger started, now logging to', options.trackDir);
126
- app.setPluginStatus(`Started`);
127
-
128
- doLogging();
129
-
130
- function doLogging() {
131
- let shouldDoLog = true
132
- //subscribe for position
133
- app.subscriptionmanager.subscribe({
134
- "context": "vessels.self",
135
- "subscribe": [
136
- {
137
- "path": "navigation.position",
138
- "format": "delta",
139
- "policy": "instant",
140
- "minPeriod": options.trackFrequency ? options.trackFrequency * 1000 : 0,
141
- }
142
- ]
143
- },
144
- unsubscribes,
145
- subscriptionError => {
146
- app.debug('Error subscription to data:' + subscriptionError);
147
- app.setPluginError('Error subscription to data:' + subscriptionError.message);
148
- },
149
- doOnValue
150
- );
151
-
152
- //subscribe for speed
153
- if (options.minSpeed) {
154
- app.subscriptionmanager.subscribe({
155
- "context": "vessels.self",
156
- "subscribe": [
157
- {
158
- "path": "navigation.speedOverGround",
159
- "format": "delta",
160
- "policy": "instant",
161
- }
162
- ]
163
- },
164
- unsubscribes,
165
- subscriptionError => {
166
- app.debug('Error subscription to data:' + subscriptionError);
167
- app.setPluginError('Error subscription to data:' + subscriptionError.message);
168
- },
169
- delta => {
170
- // app.debug('got speed delta', delta);
171
- delta.updates.forEach(update => {
172
- // app.debug(`update:`, update);
173
- if (options.filterSource && update.$source !== options.filterSource) {
174
- return;
175
- }
176
- update.values.forEach(value => {
177
- // value.value is sog in m/s so 'sog*2' is in knots
178
- if (!shouldDoLog && options.minSpeed < value.value * 2) {
179
- app.debug('setting shouldDoLog to true');
180
- shouldDoLog = true;
181
- }
182
- })
183
- })
184
- }
185
- );
186
- }
187
-
188
- async function doOnValue(delta) {
189
-
190
- for (update of delta.updates) {
191
- // app.debug(`update:`, update);
192
- if (options.filterSource && update.$source !== options.filterSource) {
193
- return;
194
- }
195
- let timestamp = update.timestamp;
196
- for (value of update.values) {
197
-
198
-
199
- if (
200
- Math.abs(value.value.latitude) <= 0.01 &&
201
- Math.abs(value.value.longitude) <= 0.01
202
- ) {
203
- // Coordinates are within ±0.1 of (0,0)
204
- return;
205
- }
206
-
207
- // app.debug(`value:`, value);
208
-
209
- if (!shouldDoLog) {
210
- return;
211
- }
212
- if (!isValidLatitude(value.value.latitude) || !isValidLongitude(value.value.longitude)) {
213
- return;
214
- }
215
-
216
-
217
- if (lastPosition) {
218
- if (new Date(lastPosition.timestamp).getTime() > new Date(timestamp).getTime()) {
219
- app.debug('got error in timestamp:', timestamp, 'is earlier than previous:', lastPosition.timestamp);
220
- // SK sometimes messes up timestamps, when that happens we throw the update
221
- return;
222
- }
223
- lastPosition = { pos: value.value, timestamp, currentTime: new Date().getTime() };
224
- app.debug('Updated lastPosition:', lastPosition);
225
-
226
- const distance = equirectangularDistance(lastPosition.pos, value.value)
227
- if (options.minMove && distance < options.minMove) {
228
- return;
229
- }
230
- // if (calculatedSpeed(distance, (timestamp - lastPosition.timestamp) / 1000) > maxAllowedSpeed) {
231
- // app.debug('got error position', value.value, 'ignoring...');
232
- // return;
233
- // }
234
- } else{
235
- lastPosition = { pos: value.value, timestamp, currentTime: new Date().getTime() };
236
- }
237
-
238
- await savePoint(lastPosition);
239
- if (options.minSpeed) {
240
- app.debug('setting shouldDoLog to false');
241
- shouldDoLog = false;
242
- }
243
- if (!initialSent) {
244
- initialSent = true;
245
- app.debug('sending initial fix');
246
- if (await testInternet()) {
247
- await sendLatestPoint();
248
- lastSentTime = Date.now();
249
- }
250
- }
251
-
252
-
253
-
254
-
255
- };
256
- };
257
- }
258
- }
259
-
260
- async function savePoint(point) {
261
- //{pos: {latitude, longitude}, timestamp}
262
- // Date.parse(timestamp)
263
- const obj = {
264
- lat: point.pos.latitude,
265
- lon: point.pos.longitude,
266
- t: point.timestamp,
267
- }
268
- app.debug(`save data point:`, obj);
269
- await fs.appendFile(path.join(options.trackDir, routeSaveName), JSON.stringify(obj) + EOL);
270
- }
271
-
272
- function isValidLatitude(obj) {
273
- return isDefinedNumber(obj) && obj > -90 && obj < 90
274
- }
275
-
276
- function isValidLongitude(obj) {
277
- return isDefinedNumber(obj) && obj > -180 && obj < 180
278
- }
279
-
280
- function isDefinedNumber(obj) {
281
- return (obj !== undefined && obj !== null && typeof obj === 'number');
282
- }
283
-
284
- // function calculatedSpeed(distance, timeSecs) {
285
- // // m/s to knots ~= speedinms * 1.944
286
- // return (distance / timeSecs) * msToKn
287
- // }
288
-
289
- function equirectangularDistance(from, to) {
290
- // https://www.movable-type.co.uk/scripts/latlong.html
291
- // from,to: {longitude: xx, latitude: xx}
292
- const rad = Math.PI / 180;
293
- const φ1 = from.latitude * rad;
294
- const φ2 = to.latitude * rad;
295
- const Δλ = (to.longitude - from.longitude) * rad;
296
- const R = 6371e3;
297
- const x = Δλ * Math.cos((φ1 + φ2) / 2);
298
- const y = (φ2 - φ1);
299
- const d = Math.sqrt(x * x + y * y) * R;
300
- return d;
301
- }
302
-
303
- function createDir(dir) {
304
- let res = true;
305
- if (fs.existsSync(dir)) {
306
- try {
307
- fs.accessSync(dir, fs.constants.R_OK | fs.constants.W_OK);
308
- }
309
- catch (error) {
310
- app.debug('[createDir]', error.message);
311
- app.setPluginError(`No rights to directory ${dir}`);
312
- res = false;
313
- }
314
- }
315
- else {
316
- try {
317
- fs.mkdirSync(dir, { recursive: true });
318
- }
319
- catch (error) {
320
- switch (error.code) {
321
- case 'EACCES': // Permission denied
322
- case 'EPERM': // Operation not permitted
323
- app.debug(`False to create ${dir} by Permission denied`);
324
- app.setPluginError(`False to create ${dir} by Permission denied`);
325
- res = false;
326
- break;
327
- case 'ETIMEDOUT': // Operation timed out
328
- app.debug(`False to create ${dir} by Operation timed out`);
329
- app.setPluginError(`False to create ${dir} by Operation timed out`);
330
- res = false;
331
- break;
332
- }
333
- }
334
- }
335
- return res;
336
- } // end function createDir
337
-
338
- async function interval() {
339
- const now = Date.now();
340
- const twentyFourHrs = 24 * 3600 * 1000;
341
- //const twentyFourHrs = 60 * 1000;
342
- if (options.ping && (!lastSentTime || now - lastSentTime >= twentyFourHrs)) {
343
- app.debug('24 hrs elapsed since last send, pushing periodic fix');
344
- if (await testInternet()) {
345
- await sendLatestPoint();
346
- }
347
- return;
348
- }
349
-
350
- if (checkBoatMoving() && await checkTrack() && await testInternet()) {
351
- await sendData();
352
- lastSentTime = now;
353
- }
354
- }
355
-
356
-
357
-
358
-
359
-
360
- function checkBoatMoving() {
361
- if (options.sendWhileMoving || !options.trackFrequency) {
362
- return true;
363
- }
364
- const time = lastPosition ? lastPosition.currentTime : upSince;
365
-
366
- const secsSinceLastPoint = (new Date().getTime() - time) / 1000
367
- if (secsSinceLastPoint > (options.trackFrequency * 2)) {
368
- app.debug('Boat stopped moving, last move at least', secsSinceLastPoint, 'seconds ago');
369
- return true;
370
- } else {
371
- app.debug('Boat is still moving, last move', secsSinceLastPoint, 'seconds ago');
372
- return false;
373
- }
374
- }
375
-
376
- async function testInternet() {
377
- app.debug('testing internet connection');
378
- const check = await isReachable(internetTestAddress, { timeout: options.internetTestTimeout || internetTestTimeout });
379
- app.debug('internet connection = ', check);
380
- return check;
381
- }
382
-
383
- async function checkTrack() {
384
- const trackFile = path.join(options.trackDir, routeSaveName);
385
- app.debug('checking the track', trackFile, 'if should send');
386
- const exists = await fs.pathExists(trackFile);
387
- const size = exists ? (await fs.lstat(trackFile)).size : 0;
388
- app.debug(`'${trackFile}'.size=${size} ${trackFile}'.exists=${exists}`);
389
- return size > 0;
390
- }
391
-
392
- async function sendLatestPoint() {
393
- if (!lastPosition) {
394
- app.debug('No lastPosition cached, skipping 24hr ping');
395
- return;
396
- }
397
- const url = options.apiUrl || "https://www.noforeignland.com/home/api/v1/boat/tracking/track";
398
- const timestamp = Date.now();//we want to trigger a refresh on NFL even if we didn't get a delta
399
- const lat = lastPosition.pos.latitude;
400
- const lon = lastPosition.pos.longitude;
401
-
402
- if (Math.abs(lat) <= 0.01 &&
403
- Math.abs(lon) <= 0.01
404
- ) {
405
- return;
406
- }
407
- if (!isValidLatitude(lat) || !isValidLongitude(lon)) {
408
- app.debug('Invalid lastPosition for 24hr ping, skipping');
409
- return;
410
- }
411
- const singlePointTrack = [[timestamp, lat, lon]];
412
- const params = new URLSearchParams();
413
- params.append('timestamp', timestamp);
414
- params.append('track', JSON.stringify(singlePointTrack));
415
- params.append('boatApiKey', options.boatApiKey);
416
-
417
- const headers = {
418
- 'X-NFL-API-Key': pluginApiKey
419
- };
420
- app.debug('Sending latest position to API as single-point track', singlePointTrack);
421
- try {
422
- const response = await fetch(url, { method: 'POST', body: params, headers: new fetch.Headers(headers) });
423
- if (response.ok) {
424
- const responseBody = await response.json();
425
- if (responseBody.status === 'ok') {
426
- app.debug('Latest position successfully sent to API');
427
- } else {
428
- app.debug('API responded with error:', responseBody);
429
- }
430
- } else {
431
- app.debug('API responded with HTTP error:', response.status, response.statusText);
432
- }
433
- } catch (err) {
434
- app.debug('Failed to send latest position to API:', err);
435
- }
436
- }
437
-
438
-
439
-
440
- async function sendData() {
441
- if (options.boatApiKey) {
442
- sendApiData();
443
- } else {
444
- sendEmailData();
445
- }
446
- }
447
-
448
- async function sendApiData() {
449
- const url = options.apiUrl || "https://www.noforeignland.com/home/api/v1/boat/tracking/track";
450
- app.debug('sending to ' + url)
451
- app.debug('sending the data');
452
- const trackData = await createTrack(path.join(options.trackDir, routeSaveName));
453
- if (!trackData) {
454
- app.debug('Recorded track did not contain any valid track points, aborting sending.');
455
- return;
456
- }
457
- app.debug('created track data with timestamp:', new Date(trackData.timestamp));
458
-
459
- const params = new URLSearchParams();
460
- params.append('timestamp', trackData.timestamp);
461
- params.append('track', JSON.stringify(trackData.track));
462
- params.append('boatApiKey', options.boatApiKey);
463
-
464
- const headers = {
465
- 'X-NFL-API-Key': pluginApiKey
466
- }
467
-
468
- app.debug('sending track to API');
469
- try {
470
- const response = await fetch(url, { method: 'POST', body: params, headers: new fetch.Headers(headers) });
471
- if (response.ok) {
472
- const responseBody = await response.json();
473
- if (responseBody.status === 'ok') {
474
- app.debug('Track successfully sent to API');
475
- if (options.keepFiles) {
476
- const filename = new Date().toJSON().slice(0, 19).replace(/:/g, '') + '-nfl-track.jsonl';
477
- app.debug('moving and keeping track file: ', filename);
478
- await fs.move(path.join(options.trackDir, routeSaveName), path.join(options.trackDir, filename));
479
- } else {
480
- app.debug('Deleting track file');
481
- await fs.remove(path.join(options.trackDir, routeSaveName));
482
- }
483
- } else {
484
- app.debug('Could not send track to API, returned response json:', responseBody);
485
- }
486
- } else {
487
- app.debug('Could not send track to API, returned response code:', response.status, response.statusText);
488
- }
489
- } catch (err) {
490
- app.debug('Could not send track to API due to error:', err);
491
- }
492
- }
493
-
494
- async function createTrack(inputPath) {
495
- const fileStream = fs.createReadStream(inputPath);
496
-
497
- const rl = readline.createInterface({
498
- input: fileStream,
499
- crlfDelay: Infinity
500
- });
501
- const track = []
502
- let lastTimestamp;
503
- for await (const line of rl) {
504
- if (line) {
505
- try {
506
- const point = JSON.parse(line);
507
-
508
- const timestamp = new Date(point.t).getTime();
509
- if (!isNaN(timestamp) && isValidLatitude(point.lat) && isValidLongitude(point.lon)) {
510
- track.push([timestamp, point.lat, point.lon]);
511
- lastTimestamp = timestamp;
512
- }
513
-
514
- } catch (error) {
515
- app.debug('could not parse line from track file:', line);
516
- }
517
- }
518
- }
519
- if (track.length > 0) {
520
- return { timestamp: new Date(lastTimestamp).getTime(), track };
521
- }
522
- }
523
-
524
- async function sendEmailData() {
525
- app.debug('sending the data');
526
- const gpxFiles = await createGPX({ input: path.join(options.trackDir, routeSaveName), outputDir: options.trackDir, creator });
527
- app.debug('created GPX files', gpxFiles);
528
- try {
529
- for (let file of gpxFiles) {
530
- app.debug('sending', file);
531
- try {
532
- !await sendEmail({
533
- emailService: options.emailService,
534
- user: options.emailUser,
535
- password: options.emailPassword,
536
- from: options.emailFrom,
537
- to: options.emailTo,
538
- trackFile: file
539
- })
540
- } catch (err) {
541
- app.debug('Sending email failed:', err);
542
- return;
543
- }
544
- }
545
- } finally {
546
- for (let file of gpxFiles) {
547
- app.debug('deleting', file);
548
- await fs.rm(file);
549
- }
550
- }
551
- await fs.rm(path.join(options.trackDir, routeSaveName));
552
- }
553
- //every 10 minute but staggered to the second so we don't all send at once.
554
- if (!options.emailCron || options.emailCron === '*/10 * * * *') {
555
- const startMinute = Math.floor(Math.random() * 10); // Random minute within the 10-minute range
556
- const startSecond = Math.floor(Math.random() * 60); // Random second within the minute
557
- options.emailCron = `${startSecond} ${startMinute}/10 * * * *`; // Every 10 minutes, starting at a random minute and second within each 10-minute block
558
- }
559
-
560
- upSince = new Date().getTime();
561
-
562
- app.debug('Setting CRON to ', options.emailCron);
563
- cron = new CronJob(
564
- options.emailCron,
565
- interval
566
- );
567
- cron.start();
568
- }; // end plugin.start
569
-
570
- plugin.stop = function() {
571
- app.debug('plugin stopped');
572
- if (cron) {
573
- cron.stop();
574
- cron = undefined;
575
- }
576
- unsubscribesControl.forEach(f => f());
577
- unsubscribesControl = [];
578
- unsubscribes.forEach(f => f());
579
- unsubscribes = [];
580
- app.setPluginStatus('Plugin stopped');
581
- }; // end plugin.stop
582
-
583
-
584
- return plugin;
13
+ const defaultTracksDir = 'track';
14
+ const routeSaveName = 'nfl-track.jsonl';
15
+
16
+ class SignalkToNoforeignland {
17
+ constructor(app) {
18
+ this.app = app;
19
+ this.pluginId = 'signalk-to-noforeignland';
20
+ this.pluginName = 'SignalK to Noforeland';
21
+ this.creator = 'signalk-track-logger';
22
+
23
+ // runtime state
24
+ this.unsubscribes = [];
25
+ this.unsubscribesControl = [];
26
+ this.lastPosition = null;
27
+ this.upSince = null;
28
+ this.cron = null;
29
+ this.options = {};
30
+ this.lastSuccessfulTransfer = null;
31
+ }
32
+
33
+ getSchema() {
34
+ return {
35
+ title: this.pluginName,
36
+ description: 'Some parameters need for use',
37
+ type: 'object',
38
+ required: ['apiCron', 'boatApiKey'],
39
+ properties: {
40
+ trackFrequency: {
41
+ type: 'integer',
42
+ title: 'Position tracking frequency in seconds.',
43
+ description: 'To keep file sizes small we only log positions once in a while (unless you set this value to 0)',
44
+ default: 60
45
+ },
46
+ minMove: {
47
+ type: 'number',
48
+ title: 'Minimum boat move to log in meters',
49
+ description: 'To keep file sizes small we only log positions if a move larger than this size (if set to 0 will log every move)',
50
+ default: 50
51
+ },
52
+ minSpeed: {
53
+ type: 'number',
54
+ title: 'Minimum boat speed to log in knots',
55
+ description: 'To keep file sizes small we only log positions if boat speed goes above this value to minimize recording position on anchor or mooring (if set to 0 will log every move)',
56
+ default: 1.5
57
+ },
58
+ apiCron: {
59
+ type: 'string',
60
+ title: 'Send attempt CRON',
61
+ description: 'We send the tracking data to NFL once in a while, you can set the schedule with this setting.\nCRON format: https://crontab.guru/',
62
+ default: '*/10 * * * *'
63
+ },
64
+ boatApiKey: {
65
+ type: 'string',
66
+ title: 'Boat API key',
67
+ description: 'Boat API key from noforeignland.com. Can be found in Account > Settings > Boat tracking > API Key.\n*required only in API method is set*'
68
+ },
69
+ internetTestTimeout: {
70
+ type: 'number',
71
+ title: 'Timeout for testing internet connection in ms',
72
+ description: 'Set this number higher for slower computers and internet connections',
73
+ default: 2000
74
+ },
75
+ sendWhileMoving: {
76
+ type: 'boolean',
77
+ title: 'Attempt sending location while moving',
78
+ description: 'Should the plugin attempt to send tracking data to NFL while detecting the vessel is moving or only when stopped?',
79
+ default: true
80
+ },
81
+ filterSource: {
82
+ type: 'string',
83
+ title: 'Position source device',
84
+ description: 'Set this value to the name of a source if you want to only use the position given by that source.'
85
+ },
86
+ trackDir: {
87
+ type: 'string',
88
+ title: 'Directory to cache tracks.',
89
+ description: 'Path in server filesystem, absolute or from plugin directory.\noptional param (only used to keep file cache).'
90
+ },
91
+ keepFiles: {
92
+ type: 'boolean',
93
+ title: 'Should keep track files on disk?',
94
+ description: 'If you have a lot of hard drive space you can keep the track files for logging purposes.',
95
+ default: false
96
+ },
97
+ ping_api_every_24h: {
98
+ type: 'boolean',
99
+ title: 'Should I force a send every 24 hours',
100
+ description: 'Keeps your boat active on NFL in your current location even if you do not move',
101
+ default: true
102
+ }
103
+ }
104
+ };
105
+ }
106
+
107
+ getPluginObject() {
108
+ return {
109
+ id: this.pluginId,
110
+ name: this.pluginName,
111
+ description: 'SignalK track logger to noforeignland.com',
112
+ schema: this.getSchema(),
113
+ start: this.start.bind(this),
114
+ stop: this.stop.bind(this)
115
+ };
116
+ }
117
+
118
+ async start(options = {}, restartPlugin) {
119
+ // normalize options
120
+ this.options = Object.assign({}, options);
121
+ if (!this.options.trackDir) this.options.trackDir = defaultTracksDir;
122
+ if (!path.isAbsolute(this.options.trackDir)) {
123
+ this.options.trackDir = path.join(__dirname, this.options.trackDir);
124
+ }
125
+
126
+ if (!this.createDir(this.options.trackDir)) {
127
+ this.stop();
128
+ return;
129
+ }
130
+
131
+ this.app.debug('track logger started, now logging to', this.options.trackDir);
132
+ this.app.setPluginStatus(`Started`);
133
+ this.upSince = new Date().getTime();
134
+
135
+ // adjust default CRON if unchanged
136
+ if (!this.options.apiCron || this.options.apiCron === '*/10 * * * *') {
137
+ const startMinute = Math.floor(Math.random() * 10);
138
+ const startSecond = Math.floor(Math.random() * 60);
139
+ this.options.apiCron = `${startSecond} ${startMinute}/10 * * * *`;
140
+ }
141
+
142
+ this.app.debug('Setting CRON to ', this.options.apiCron);
143
+
144
+ // subscribe and logging
145
+ this.doLogging();
146
+
147
+ // start cron job
148
+ this.cron = new CronJob(this.options.apiCron, this.interval.bind(this));
149
+ this.cron.start();
150
+ }
151
+
152
+ stop() {
153
+ this.app.debug('plugin stopped');
154
+ if (this.cron) {
155
+ this.cron.stop();
156
+ this.cron = undefined;
157
+ }
158
+ this.unsubscribesControl.forEach(f => f());
159
+ this.unsubscribesControl = [];
160
+ this.unsubscribes.forEach(f => f());
161
+ this.unsubscribes = [];
162
+ this.app.setPluginStatus('Plugin stopped');
163
+ }
164
+
165
+ doLogging() {
166
+ // subscribe for position
167
+ let shouldDoLog = true;
168
+
169
+ this.app.subscriptionmanager.subscribe({
170
+ context: 'vessels.self',
171
+ subscribe: [{
172
+ path: 'navigation.position',
173
+ format: 'delta',
174
+ policy: 'instant',
175
+ minPeriod: this.options.trackFrequency ? this.options.trackFrequency * 1000 : 0
176
+ }]
177
+ }, this.unsubscribes, (subscriptionError) => {
178
+ this.app.debug('Error subscription to data:' + subscriptionError);
179
+ this.app.setPluginError('Error subscription to data:' + subscriptionError.message);
180
+ }, this.doOnValue.bind(this, () => shouldDoLog, newShould => { shouldDoLog = newShould; }));
181
+
182
+ // subscribe for speed
183
+ if (this.options.minSpeed) {
184
+ this.app.subscriptionmanager.subscribe({
185
+ context: 'vessels.self',
186
+ subscribe: [{
187
+ path: 'navigation.speedOverGround',
188
+ format: 'delta',
189
+ policy: 'instant'
190
+ }]
191
+ }, this.unsubscribes, (subscriptionError) => {
192
+ this.app.debug('Error subscription to data:' + subscriptionError);
193
+ this.app.setPluginError('Error subscription to data:' + subscriptionError.message);
194
+ }, (delta) => {
195
+ delta.updates.forEach(update => {
196
+ if (this.options.filterSource && update.$source !== this.options.filterSource) {
197
+ return;
198
+ }
199
+ update.values.forEach(value => {
200
+ const speedInKnots = value.value * 1.94384;
201
+ if (!shouldDoLog && this.options.minSpeed < speedInKnots) {
202
+ this.app.debug('setting shouldDoLog to true, speed:', speedInKnots.toFixed(2), 'knots');
203
+ shouldDoLog = true;
204
+ }
205
+ });
206
+ });
207
+ });
208
+ }
209
+ }
210
+
211
+ async doOnValue(getShouldDoLog, setShouldDoLog, delta) {
212
+ for (const update of delta.updates) {
213
+ if (this.options.filterSource && update.$source !== this.options.filterSource) {
214
+ return;
215
+ }
216
+ const timestamp = update.timestamp;
217
+ for (const value of update.values) {
218
+ // Validierung: GPS nahe (0,0)
219
+ if (Math.abs(value.value.latitude) <= 0.01 && Math.abs(value.value.longitude) <= 0.01) {
220
+ this.app.debug('GPS coordinates near (0,0), ignoring point to avoid invalid data logging.');
221
+ return;
222
+ }
223
+
224
+ // Validate for valid lat/lon
225
+ if (!this.isValidLatitude(value.value.latitude) || !this.isValidLongitude(value.value.longitude)) {
226
+ this.app.debug('got invalid position, ignoring...', value.value);
227
+ return;
228
+ }
229
+
230
+ // 24h-Ping Check: Setze Flag, aber breche NICHT ab
231
+ let force24hSave = false;
232
+ if (this.options.ping_api_every_24h && this.lastPosition) {
233
+ const timeSinceLastPoint = (new Date().getTime() - this.lastPosition.currentTime);
234
+ if (timeSinceLastPoint >= 24 * 60 * 60 * 1000) {
235
+ this.app.debug('24h since last point, forcing save of point to keep boat active on NFL');
236
+ force24hSave = true;
237
+ }
238
+ }
239
+
240
+ // Wenn wir nicht loggen sollen UND es kein 24h-Force ist, dann raus
241
+ if (!force24hSave && !getShouldDoLog()) {
242
+ return;
243
+ }
244
+
245
+ // Wenn wir eine letzte Position haben, prüfe Timestamp und Distanz
246
+ if (this.lastPosition && !force24hSave) {
247
+ // Timestamp-Validierung
248
+ if (new Date(this.lastPosition.timestamp).getTime() > new Date(timestamp).getTime()) {
249
+ this.app.debug('got error in timestamp:', timestamp, 'is earlier than previous:', this.lastPosition.timestamp);
250
+ return;
251
+ }
252
+
253
+ // Distance-Check (nur wenn NICHT 24h-Force)
254
+ const distance = this.equirectangularDistance(this.lastPosition.pos, value.value);
255
+ if (this.options.minMove && distance < this.options.minMove) {
256
+ this.app.debug('Distance', distance.toFixed(2), 'm is less than minMove', this.options.minMove, 'm - skipping');
257
+ return;
258
+ }
259
+ }
260
+
261
+ // Punkt speichern
262
+ this.lastPosition = { pos: value.value, timestamp, currentTime: new Date().getTime() };
263
+ await this.savePoint(this.lastPosition);
264
+
265
+ // shouldDoLog zurücksetzen wenn minSpeed aktiv ist
266
+ if (this.options.minSpeed) {
267
+ this.app.debug('options.minSpeed - setting shouldDoLog to false');
268
+ setShouldDoLog(false);
269
+ }
270
+ }
271
+ }}
272
+
273
+
274
+ async savePoint(point) {
275
+ const obj = {
276
+ lat: point.pos.latitude,
277
+ lon: point.pos.longitude,
278
+ t: point.timestamp
279
+ };
280
+ this.app.debug(`save data point:`, obj);
281
+ await fs.appendFile(path.join(this.options.trackDir, routeSaveName), JSON.stringify(obj) + EOL);
282
+
283
+ // Inform user about last saved point
284
+ const lastSaveTime = new Date().toLocaleString();
285
+ const lastTransferTime = this.lastSuccessfulTransfer ? this.lastSuccessfulTransfer.toLocaleString() : 'Never';
286
+ this.app.setPluginStatus(`Last save: ${lastSaveTime} | Last transfer: ${lastTransferTime}`);
287
+ }
288
+
289
+ isValidLatitude(obj) {
290
+ return this.isDefinedNumber(obj) && obj > -90 && obj < 90;
291
+ }
292
+ isValidLongitude(obj) {
293
+ return this.isDefinedNumber(obj) && obj > -180 && obj < 180;
294
+ }
295
+ isDefinedNumber(obj) {
296
+ return (obj !== undefined && obj !== null && typeof obj === 'number');
297
+ }
298
+
299
+ equirectangularDistance(from, to) {
300
+ const rad = Math.PI / 180;
301
+ const φ1 = from.latitude * rad;
302
+ const φ2 = to.latitude * rad;
303
+ const Δλ = (to.longitude - from.longitude) * rad;
304
+ const R = 6371e3;
305
+ const x = Δλ * Math.cos((φ1 + φ2) / 2);
306
+ const y = (φ2 - φ1);
307
+ const d = Math.sqrt(x * x + y * y) * R;
308
+ return d;
309
+ }
310
+
311
+ createDir(dir) {
312
+ let res = true;
313
+ if (fs.existsSync(dir)) {
314
+ try {
315
+ fs.accessSync(dir, fs.constants.R_OK | fs.constants.W_OK);
316
+ } catch (error) {
317
+ this.app.debug('[createDir]', error.message);
318
+ this.app.setPluginError(`No rights to directory ${dir}`);
319
+ res = false;
320
+ }
321
+ } else {
322
+ try {
323
+ fs.mkdirSync(dir, { recursive: true });
324
+ } catch (error) {
325
+ switch (error.code) {
326
+ case 'EACCES':
327
+ case 'EPERM':
328
+ this.app.debug(`False to create ${dir} by Permission denied`);
329
+ this.app.setPluginError(`False to create ${dir} by Permission denied`);
330
+ res = false;
331
+ break;
332
+ case 'ETIMEDOUT':
333
+ this.app.debug(`False to create ${dir} by Operation timed out`);
334
+ this.app.setPluginError(`False to create ${dir} by Operation timed out`);
335
+ res = false;
336
+ break;
337
+ default:
338
+ this.app.debug(`Error creating directory ${dir}: ${error.message}`);
339
+ this.app.setPluginError(`Error creating directory ${dir}: ${error.message}`);
340
+ res = false;
341
+ }
342
+ }
343
+ }
344
+ return res;
345
+ }
346
+
347
+ // periodic interval called by cron
348
+ async interval() {
349
+ if ((this.checkBoatMoving()) && await this.checkTrack() && await this.testInternet()) {
350
+ await this.sendData();
351
+ }
352
+ }
353
+
354
+ checkBoatMoving() {
355
+ if (!this.options.trackFrequency) {
356
+ return true; // Kein Tracking → immer senden
357
+ }
358
+ const time = this.lastPosition ? this.lastPosition.currentTime : this.upSince;
359
+ const secsSinceLastPoint = (new Date().getTime() - time) / 1000;
360
+ const isMoving = secsSinceLastPoint <= (this.options.trackFrequency * 2);
361
+ if (isMoving) {
362
+ this.app.debug('Boat is still moving, last move', secsSinceLastPoint, 'seconds ago');
363
+ return this.options.sendWhileMoving; // Nur senden wenn gewünscht
364
+ } else {
365
+ this.app.debug('Boat stopped moving, last move at least', secsSinceLastPoint, 'seconds ago');
366
+ return true; // Immer senden wenn gestoppt
367
+ }
368
+ }
369
+
370
+ async testInternet() {
371
+ const dns = require('dns').promises;
372
+
373
+ this.app.debug('testing internet connection');
374
+
375
+ try {
376
+ // Force IPv4 DNS lookup with timeout
377
+ const timeoutMs = this.options.internetTestTimeout || internetTestTimeout;
378
+ const addresses = await Promise.race([
379
+ dns.resolve4(internetTestAddress),
380
+ new Promise((_, reject) =>
381
+ setTimeout(() => reject(new Error('DNS timeout')), timeoutMs)
382
+ )
383
+ ]);
384
+
385
+ if (addresses && addresses.length > 0) {
386
+ this.app.debug('internet connection = true, resolved IPv4:', addresses[0]);
387
+ return true;
388
+ } else {
389
+ this.app.debug('internet connection = false, no IPv4 addresses found');
390
+ return false;
391
+ }
392
+ } catch (err) {
393
+ this.app.debug('internet connection = false, error:', err.message);
394
+ return false;
395
+ }
396
+ }
397
+
398
+ async checkTrack() {
399
+ const trackFile = path.join(this.options.trackDir, routeSaveName);
400
+ this.app.debug('checking the track', trackFile, 'if should send');
401
+ const exists = await fs.pathExists(trackFile);
402
+ const size = exists ? (await fs.lstat(trackFile)).size : 0;
403
+ this.app.debug(`'${trackFile}'.size=${size} ${trackFile}'.exists=${exists}`);
404
+ return size > 0;
405
+ }
406
+
407
+ async sendData() {
408
+ if (this.options.boatApiKey) {
409
+ await this.sendApiData();
410
+ } else {
411
+ this.app.debug('Failed to send track - no boat API key set in plugin settings.');
412
+ this.app.setPluginError(`Failed to send track - no boat API key set in plugin settings.`);
413
+ }
414
+ }
415
+
416
+ async sendApiData() {
417
+ this.app.debug('sending the data');
418
+ const trackData = await this.createTrack(path.join(this.options.trackDir, routeSaveName));
419
+ if (!trackData) {
420
+ this.app.debug('Recorded track did not contain any valid track points, aborting sending.');
421
+ this.app.setPluginError(`Failed to send track - Recorded track did not contain any valid track points, aborting sending.`);
422
+ return;
423
+ }
424
+ this.app.debug('created track data with timestamp:', new Date(trackData.timestamp));
425
+ const params = new URLSearchParams();
426
+ params.append('timestamp', trackData.timestamp);
427
+ params.append('track', JSON.stringify(trackData.track));
428
+ params.append('boatApiKey', this.options.boatApiKey);
429
+ const headers = { 'X-NFL-API-Key': pluginApiKey };
430
+ this.app.debug('sending track to API');
431
+
432
+ try {
433
+ const response = await fetch(apiUrl, { method: 'POST', body: params, headers: new fetch.Headers(headers) });
434
+ if (response.ok) {
435
+ const responseBody = await response.json();
436
+ if (responseBody.status === 'ok') {
437
+ this.lastSuccessfulTransfer = new Date();
438
+ this.app.debug('Track successfully sent to API');
439
+ this.app.setPluginStatus(`Started - last Track sent successfully at ${new Date().toLocaleString()}`);
440
+ if (this.options.keepFiles) {
441
+ const filename = new Date().toJSON().slice(0, 19).replace(/:/g, '') + '-nfl-track.jsonl';
442
+ this.app.debug('moving and keeping track file: ', filename);
443
+ await fs.move(path.join(this.options.trackDir, routeSaveName), path.join(this.options.trackDir, filename));
444
+ } else {
445
+ this.app.debug('Deleting track file');
446
+ await fs.remove(path.join(this.options.trackDir, routeSaveName));
447
+ }
448
+ } else {
449
+ this.app.debug('Could not send track to API, returned response json:', responseBody);
450
+ this.app.setPluginError(`Failed to send track - check logs for details.`);
451
+ }
452
+ } else {
453
+ this.app.debug('Could not send track to API, returned response code:', response.status, response.statusText);
454
+ this.app.setPluginError(`Failed to send track - check logs for details.`);
455
+ }
456
+ } catch (err) {
457
+ this.app.debug('Could not send track to API due to error:', err);
458
+ this.app.setPluginError(`Failed to send track - check logs for details.`);
459
+ }
460
+ }
461
+
462
+ async createTrack(inputPath) {
463
+ const fileStream = fs.createReadStream(inputPath);
464
+ const rl = readline.createInterface({ input: fileStream, crlfDelay: Infinity });
465
+ const track = [];
466
+ let lastTimestamp;
467
+ for await (const line of rl) {
468
+ if (line) {
469
+ try {
470
+ const point = JSON.parse(line);
471
+ const timestamp = new Date(point.t).getTime();
472
+ if (!isNaN(timestamp) && this.isValidLatitude(point.lat) && this.isValidLongitude(point.lon)) {
473
+ track.push([timestamp, point.lat, point.lon]);
474
+ lastTimestamp = timestamp;
475
+ }
476
+ } catch (error) {
477
+ this.app.debug('could not parse line from track file:', line);
478
+ this.app.setPluginError(`Failed could not parse line from track file - check logs for details.`);
479
+ }
480
+ }
481
+ }
482
+ if (track.length > 0) {
483
+ return { timestamp: new Date(lastTimestamp).getTime(), track };
484
+ }
485
+ return null;
486
+ }
487
+ }
488
+
489
+ module.exports = function (app) {
490
+ const instance = new SignalkToNoforeignland(app);
491
+ return instance.getPluginObject();
585
492
  };
package/package.json CHANGED
@@ -1,13 +1,13 @@
1
1
  {
2
2
  "name": "signalk-to-noforeignland",
3
- "version": "0.1.21",
3
+ "version": "0.1.24",
4
4
  "description": "SignalK track logger to noforeignland.com ",
5
5
  "main": "index.js",
6
6
  "keywords": [
7
7
  "signalk-node-server-plugin",
8
8
  "signalk-category-utility"
9
9
  ],
10
- "author": "Ian Miller",
10
+ "author": "Dirk Wahrheit",
11
11
  "homepage": "https://github.com/noforeignland/nfl-signalk",
12
12
  "bugs": {
13
13
  "url": "https://github.com/noforeignland/nfl-signalk/issues"
@@ -23,7 +23,6 @@
23
23
  "cron": "^2.1.0",
24
24
  "fs-extra": "^10.1.0",
25
25
  "is-reachable": "^5.2.1",
26
- "node-fetch": "^2.6.7",
27
- "nodemailer": "^6.8.0"
26
+ "node-fetch": "^2.6.7"
28
27
  }
29
28
  }
package/createGPX.js DELETED
@@ -1,48 +0,0 @@
1
- const fs = require('fs-extra');
2
- const path = require('path');
3
- const readline = require('readline');
4
-
5
- async function createGPX(options) {
6
- await writeHeader(options);
7
- const fileStream = fs.createReadStream(options.input);
8
-
9
- const rl = readline.createInterface({
10
- input: fileStream,
11
- crlfDelay: Infinity
12
- });
13
-
14
- for await (const line of rl) {
15
- if (line){
16
- const point = JSON.parse(line);
17
- let trkpt = ' <trkpt ';
18
- trkpt += `lat="${point.lat}" lon="${point.lon}">\n`;
19
- trkpt += ` <time>${point.t}</time>\n`;
20
- trkpt += ' </trkpt>\n';
21
- fs.appendFileSync(path.join(options.outputDir, 'track.gpx'), trkpt);
22
- }
23
- }
24
- await writeFooter(options);
25
- return [path.join(options.outputDir, 'track.gpx')];
26
- }
27
-
28
- async function writeHeader(options) {
29
- const header = `<?xml version="1.0" encoding="UTF-8" standalone="no" ?>
30
- <gpx version="1.1" creator="${options.creator}"
31
- xmlns="http://www.topografix.com/GPX/1/1"
32
- xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
33
- xmlns:gpxx="http://www8.garmin.com/xmlschemas/GpxExtensions/v3"
34
- xsi:schemaLocation="http://www.topografix.com/GPX/1/1 http://www.topografix.com/GPX/1/1/gpx.xsd https://www8.garmin.com/xmlschemas/GpxExtensions/v3 https://www8.garmin.com/xmlschemas/GpxExtensions/v3/GpxExtensionsv3.xsd"
35
- >
36
- <metadata/>
37
- <trk>
38
- <trkseg>
39
- `;
40
- fs.writeFileSync(path.join(options.outputDir, 'track.gpx'), header);
41
- }
42
-
43
- async function writeFooter(options) {
44
- const footer = ' </trkseg>\n </trk>\n</gpx>';
45
- fs.appendFileSync(path.join(options.outputDir, 'track.gpx'), footer);
46
- }
47
-
48
- module.exports = createGPX;
package/sendEmail.js DELETED
@@ -1,24 +0,0 @@
1
- const nodemailer = require('nodemailer');
2
-
3
- async function sendEmail(options) {
4
- const mail = nodemailer.createTransport({
5
- service: options.emailService,
6
- auth: {
7
- user: options.user,
8
- pass: options.password,
9
- }
10
- });
11
- var mailOptions = {
12
- from: options.from,
13
- to: options.to,
14
- subject: `GPX ${(new Date()).toISOString()}`,
15
- text: 'Generated by SignalK Tracker',
16
- attachments: [{ // file on disk as an attachment
17
- filename: 'track.gpx',
18
- path: options.trackFile // stream this file
19
- }],
20
- };
21
- await mail.sendMail(mailOptions);
22
- }
23
-
24
- module.exports = sendEmail;