@noforeignland/signalk-to-noforeignland 1.0.1-beta.5 → 1.1.0-beta.11
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 +15 -6
- package/PROJECT_STRUCTURE.md +357 -0
- package/README.md +15 -6
- package/cleanup-old-plugin.js +111 -19
- package/doc/beta_install_cerbo.md +127 -0
- package/doc/beta_install_rpi.md +64 -0
- package/index.js +210 -868
- package/lib/ConfigManager.js +216 -0
- package/lib/DataPathEmitter.js +93 -0
- package/lib/DirectoryUtils.js +38 -0
- package/lib/HealthMonitor.js +91 -0
- package/lib/PluginCleanup.js +259 -0
- package/lib/TrackLogger.js +306 -0
- package/lib/TrackMigration.js +110 -0
- package/lib/TrackSender.js +219 -0
- package/package.json +3 -2
- package/doc/beta_install.md +0 -60
- package/doc/dev +0 -13
package/index.js
CHANGED
|
@@ -1,195 +1,47 @@
|
|
|
1
|
-
const { EOL } = require('os');
|
|
2
|
-
const fs = require('fs-extra');
|
|
3
|
-
const path = require('path');
|
|
4
1
|
const CronJob = require('cron').CronJob;
|
|
5
|
-
const readline = require('readline');
|
|
6
|
-
const fetch = require('node-fetch');
|
|
7
2
|
|
|
8
|
-
|
|
9
|
-
const
|
|
10
|
-
const
|
|
11
|
-
const
|
|
12
|
-
const
|
|
3
|
+
// Import all modules
|
|
4
|
+
const ConfigManager = require('./lib/ConfigManager');
|
|
5
|
+
const PluginCleanup = require('./lib/PluginCleanup');
|
|
6
|
+
const TrackMigration = require('./lib/TrackMigration');
|
|
7
|
+
const TrackLogger = require('./lib/TrackLogger');
|
|
8
|
+
const TrackSender = require('./lib/TrackSender');
|
|
9
|
+
const HealthMonitor = require('./lib/HealthMonitor');
|
|
10
|
+
const DataPathEmitter = require('./lib/DataPathEmitter');
|
|
11
|
+
const DirectoryUtils = require('./lib/DirectoryUtils');
|
|
13
12
|
|
|
14
13
|
class SignalkToNoforeignland {
|
|
15
14
|
constructor(app) {
|
|
16
15
|
this.app = app;
|
|
17
16
|
this.pluginId = 'signalk-to-noforeignland';
|
|
18
17
|
this.pluginName = 'Signal K to Noforeignland';
|
|
19
|
-
this.creator = 'signalk-track-logger';
|
|
20
18
|
|
|
21
|
-
//
|
|
22
|
-
this.
|
|
23
|
-
this.unsubscribesControl = [];
|
|
24
|
-
this.lastPosition = null;
|
|
19
|
+
// Runtime state
|
|
20
|
+
this.options = {};
|
|
25
21
|
this.upSince = null;
|
|
26
22
|
this.cron = null;
|
|
27
|
-
this.options = {};
|
|
28
23
|
this.lastSuccessfulTransfer = null;
|
|
29
24
|
|
|
30
|
-
//
|
|
31
|
-
this.
|
|
32
|
-
this.
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
this.
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
// Emit SignalK deltas for data paths
|
|
39
|
-
emitDelta(path, value) {
|
|
40
|
-
try {
|
|
41
|
-
const delta = {
|
|
42
|
-
context: 'vessels.self',
|
|
43
|
-
updates: [{
|
|
44
|
-
timestamp: new Date().toISOString(),
|
|
45
|
-
values: [{
|
|
46
|
-
path: path,
|
|
47
|
-
value: value
|
|
48
|
-
}]
|
|
49
|
-
}]
|
|
50
|
-
};
|
|
51
|
-
this.app.handleMessage(this.pluginId, delta);
|
|
52
|
-
} catch (err) {
|
|
53
|
-
this.app.debug(`Failed to emit delta for ${path}:`, err.message);
|
|
54
|
-
}
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
updateStatusPaths() {
|
|
58
|
-
const hasError = this.currentError !== null;
|
|
59
|
-
|
|
60
|
-
// SHORT format for data path
|
|
61
|
-
if (!hasError) {
|
|
62
|
-
const activeSource = this.options.filterSource || this.autoSelectedSource || '';
|
|
63
|
-
const saveTime = this.lastPosition ? new Date(this.lastPosition.currentTime).toLocaleTimeString() : 'None since start';
|
|
64
|
-
const transferTime = this.lastSuccessfulTransfer ? this.lastSuccessfulTransfer.toLocaleTimeString() : 'None since start';
|
|
65
|
-
const shortStatus = `Save: ${saveTime} | Transfer: ${transferTime}`;
|
|
66
|
-
this.emitDelta('noforeignland.status', shortStatus);
|
|
67
|
-
this.emitDelta('noforeignland.source', activeSource);
|
|
68
|
-
} else {
|
|
69
|
-
this.emitDelta('noforeignland.status', `ERROR: ${this.currentError}`);
|
|
70
|
-
}
|
|
71
|
-
this.emitDelta('noforeignland.status_boolean', hasError ? 1 : 0);
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
// Override setPluginStatus to also emit data path
|
|
75
|
-
setPluginStatus(status) {
|
|
76
|
-
this.currentStatus = status;
|
|
77
|
-
this.currentError = null;
|
|
78
|
-
this.app.setPluginStatus(status);
|
|
79
|
-
this.updateStatusPaths();
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
// Override setPluginError to also emit data path
|
|
83
|
-
setPluginError(error) {
|
|
84
|
-
this.currentError = error;
|
|
85
|
-
this.app.setPluginError(error);
|
|
86
|
-
this.updateStatusPaths();
|
|
25
|
+
// Module instances
|
|
26
|
+
this.configManager = new ConfigManager(app);
|
|
27
|
+
this.pluginCleanup = new PluginCleanup(app);
|
|
28
|
+
this.dataPathEmitter = new DataPathEmitter(app, this.pluginId);
|
|
29
|
+
this.trackLogger = null;
|
|
30
|
+
this.trackSender = null;
|
|
31
|
+
this.healthMonitor = null;
|
|
32
|
+
this.trackMigration = null;
|
|
87
33
|
}
|
|
88
34
|
|
|
35
|
+
/**
|
|
36
|
+
* Get plugin schema
|
|
37
|
+
*/
|
|
89
38
|
getSchema() {
|
|
90
|
-
return
|
|
91
|
-
title: this.pluginName,
|
|
92
|
-
description: 'Some parameters need for use',
|
|
93
|
-
type: 'object',
|
|
94
|
-
properties: {
|
|
95
|
-
// Mandatory Settings Group
|
|
96
|
-
mandatory: {
|
|
97
|
-
type: 'object',
|
|
98
|
-
title: 'Mandatory Settings',
|
|
99
|
-
properties: {
|
|
100
|
-
boatApiKey: {
|
|
101
|
-
type: 'string',
|
|
102
|
-
title: 'Boat API Key',
|
|
103
|
-
description: 'Boat API Key from noforeignland.com. Can be found in Account > Settings > Boat tracking > API Key.'
|
|
104
|
-
}
|
|
105
|
-
}
|
|
106
|
-
},
|
|
107
|
-
|
|
108
|
-
// Advanced Settings Group
|
|
109
|
-
advanced: {
|
|
110
|
-
type: 'object',
|
|
111
|
-
title: 'Advanced Settings',
|
|
112
|
-
properties: {
|
|
113
|
-
minMove: {
|
|
114
|
-
type: 'number',
|
|
115
|
-
title: 'Minimum boat move to log in meters',
|
|
116
|
-
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)',
|
|
117
|
-
default: 80
|
|
118
|
-
},
|
|
119
|
-
minSpeed: {
|
|
120
|
-
type: 'number',
|
|
121
|
-
title: 'Minimum boat speed to log in knots',
|
|
122
|
-
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)',
|
|
123
|
-
default: 1.5
|
|
124
|
-
},
|
|
125
|
-
sendWhileMoving: {
|
|
126
|
-
type: 'boolean',
|
|
127
|
-
title: 'Attempt sending location while moving',
|
|
128
|
-
description: 'Should the plugin attempt to send tracking data to NFL while detecting the vessel is moving or only when stopped?',
|
|
129
|
-
default: true
|
|
130
|
-
},
|
|
131
|
-
ping_api_every_24h: {
|
|
132
|
-
type: 'boolean',
|
|
133
|
-
title: 'Force a send every 24 hours',
|
|
134
|
-
description: 'Keeps your boat active on NFL in your current location even if you do not move',
|
|
135
|
-
default: true
|
|
136
|
-
}
|
|
137
|
-
}
|
|
138
|
-
},
|
|
139
|
-
|
|
140
|
-
// Expert Settings Group
|
|
141
|
-
expert: {
|
|
142
|
-
type: 'object',
|
|
143
|
-
title: 'Expert Settings',
|
|
144
|
-
properties: {
|
|
145
|
-
filterSource: {
|
|
146
|
-
type: 'string',
|
|
147
|
-
title: 'Position source device',
|
|
148
|
-
description: 'EMPTY DEFAULT IS FINE - Set this value to the name of a source if you want to only use the position given by that source.'
|
|
149
|
-
},
|
|
150
|
-
trackDir: {
|
|
151
|
-
type: 'string',
|
|
152
|
-
title: 'Directory to cache tracks',
|
|
153
|
-
description: 'EMPTY DEFAULT IS FINE - Path to store track data. Relative paths are stored in plugin data directory. Absolute paths can point anywhere.\nDefault: nfl-track'
|
|
154
|
-
},
|
|
155
|
-
keepFiles: {
|
|
156
|
-
type: 'boolean',
|
|
157
|
-
title: 'Keep track files on disk',
|
|
158
|
-
description: 'If you have a lot of hard drive space you can keep the track files for logging purposes.',
|
|
159
|
-
default: false
|
|
160
|
-
},
|
|
161
|
-
trackFrequency: {
|
|
162
|
-
type: 'integer',
|
|
163
|
-
title: 'Position tracking frequency in seconds',
|
|
164
|
-
description: 'To keep file sizes small we only log positions once in a while (unless you set this value to 0)',
|
|
165
|
-
default: 60
|
|
166
|
-
},
|
|
167
|
-
apiCron: {
|
|
168
|
-
type: 'string',
|
|
169
|
-
title: 'Send attempt CRON',
|
|
170
|
-
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/',
|
|
171
|
-
default: '*/10 * * * *'
|
|
172
|
-
},
|
|
173
|
-
internetTestTimeout: {
|
|
174
|
-
type: 'number',
|
|
175
|
-
title: 'Timeout for testing internet connection in ms',
|
|
176
|
-
description: 'Set this number higher for slower computers and internet connections',
|
|
177
|
-
default: 2000
|
|
178
|
-
},
|
|
179
|
-
apiTimeout: {
|
|
180
|
-
type: 'integer',
|
|
181
|
-
title: 'API request timeout in seconds',
|
|
182
|
-
description: 'Timeout for sending data to NFL API. Increase for slow connections.',
|
|
183
|
-
default: 30,
|
|
184
|
-
minimum: 10,
|
|
185
|
-
maximum: 180
|
|
186
|
-
}
|
|
187
|
-
}
|
|
188
|
-
}
|
|
189
|
-
}
|
|
190
|
-
};
|
|
39
|
+
return ConfigManager.getSchema(this.pluginName);
|
|
191
40
|
}
|
|
192
41
|
|
|
42
|
+
/**
|
|
43
|
+
* Get plugin object for SignalK
|
|
44
|
+
*/
|
|
193
45
|
getPluginObject() {
|
|
194
46
|
return {
|
|
195
47
|
id: this.pluginId,
|
|
@@ -201,749 +53,239 @@ class SignalkToNoforeignland {
|
|
|
201
53
|
};
|
|
202
54
|
}
|
|
203
55
|
|
|
56
|
+
/**
|
|
57
|
+
* Start the plugin
|
|
58
|
+
*/
|
|
204
59
|
async start(options = {}, restartPlugin) {
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
// Backward compatibility: migrate old flat structure to new nested structure
|
|
210
|
-
let needsSave = false;
|
|
211
|
-
if (options.boatApiKey && !options.mandatory) {
|
|
212
|
-
this.app.debug('Migrating old configuration to new grouped structure');
|
|
213
|
-
needsSave = true;
|
|
60
|
+
try {
|
|
61
|
+
// 1. Migrate old config structure if needed
|
|
62
|
+
const { options: migratedOptions, migrated } = await this.configManager.migrateOldConfig(options);
|
|
63
|
+
options = migratedOptions;
|
|
214
64
|
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
boatApiKey: options.boatApiKey
|
|
218
|
-
},
|
|
219
|
-
advanced: {
|
|
220
|
-
minMove: options.minMove !== undefined ? options.minMove : 50,
|
|
221
|
-
minSpeed: options.minSpeed !== undefined ? options.minSpeed : 1.5,
|
|
222
|
-
sendWhileMoving: options.sendWhileMoving !== undefined ? options.sendWhileMoving : true,
|
|
223
|
-
ping_api_every_24h: options.ping_api_every_24h !== undefined ? options.ping_api_every_24h : true
|
|
224
|
-
},
|
|
225
|
-
expert: {
|
|
226
|
-
filterSource: options.filterSource,
|
|
227
|
-
trackDir: options.trackDir,
|
|
228
|
-
keepFiles: options.keepFiles !== undefined ? options.keepFiles : false,
|
|
229
|
-
trackFrequency: options.trackFrequency !== undefined ? options.trackFrequency : 60,
|
|
230
|
-
internetTestTimeout: options.internetTestTimeout !== undefined ? options.internetTestTimeout : 2000,
|
|
231
|
-
apiCron: options.apiCron || '*/10 * * * *',
|
|
232
|
-
apiTimeout: options.apiTimeout !== undefined ? options.apiTimeout : 30
|
|
233
|
-
}
|
|
234
|
-
};
|
|
65
|
+
// 2. Flatten config and apply defaults
|
|
66
|
+
this.options = this.configManager.flattenConfig(options);
|
|
235
67
|
|
|
68
|
+
// 3. Validate API key
|
|
236
69
|
try {
|
|
237
|
-
this.
|
|
238
|
-
await this.app.savePluginOptions(options, () => {
|
|
239
|
-
this.app.debug('Configuration successfully migrated and saved');
|
|
240
|
-
});
|
|
70
|
+
this.configManager.validateApiKey(this.options);
|
|
241
71
|
} catch (err) {
|
|
242
|
-
this.app.debug(
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
// Flatten the nested structure for easier access and apply defaults
|
|
247
|
-
this.options = {
|
|
248
|
-
boatApiKey: options.mandatory?.boatApiKey,
|
|
249
|
-
minMove: options.advanced?.minMove !== undefined ? options.advanced.minMove : 80,
|
|
250
|
-
minSpeed: options.advanced?.minSpeed !== undefined ? options.advanced.minSpeed : 1.5,
|
|
251
|
-
sendWhileMoving: options.advanced?.sendWhileMoving !== undefined ? options.advanced.sendWhileMoving : true,
|
|
252
|
-
ping_api_every_24h: options.advanced?.ping_api_every_24h !== undefined ? options.advanced.ping_api_every_24h : true,
|
|
253
|
-
filterSource: options.expert?.filterSource,
|
|
254
|
-
trackDir: options.expert?.trackDir || defaultTracksDir,
|
|
255
|
-
keepFiles: options.expert?.keepFiles !== undefined ? options.expert.keepFiles : false,
|
|
256
|
-
trackFrequency: options.expert?.trackFrequency !== undefined ? options.expert.trackFrequency : 60,
|
|
257
|
-
internetTestTimeout: options.expert?.internetTestTimeout !== undefined ? options.expert.internetTestTimeout : 2000,
|
|
258
|
-
apiCron: options.expert?.apiCron || '*/10 * * * *',
|
|
259
|
-
apiTimeout: options.expert?.apiTimeout !== undefined ? options.expert.apiTimeout : 30
|
|
260
|
-
};
|
|
261
|
-
|
|
262
|
-
// Validate that boatApiKey is set
|
|
263
|
-
if (!this.options.boatApiKey || this.options.boatApiKey.trim() === '') {
|
|
264
|
-
const errorMsg = 'No boat API key configured. Please set your API key in plugin settings (Mandatory Settings > Boat API key). You can find your API key at noforeignland.com under Account > Settings > Boat tracking > API Key.';
|
|
265
|
-
this.app.debug(errorMsg);
|
|
266
|
-
this.setPluginError(errorMsg);
|
|
267
|
-
this.stop();
|
|
268
|
-
return;
|
|
269
|
-
}
|
|
270
|
-
|
|
271
|
-
// Resolve track directory path
|
|
272
|
-
if (!path.isAbsolute(this.options.trackDir)) {
|
|
273
|
-
const dataDirPath = this.app.getDataDirPath();
|
|
274
|
-
this.options.trackDir = path.join(dataDirPath, this.options.trackDir);
|
|
275
|
-
}
|
|
276
|
-
|
|
277
|
-
if (!this.createDir(this.options.trackDir)) {
|
|
278
|
-
this.stop();
|
|
279
|
-
return;
|
|
280
|
-
}
|
|
281
|
-
|
|
282
|
-
// NEW: Cleanup old plugin
|
|
283
|
-
await this.cleanupOldPlugin();
|
|
284
|
-
|
|
285
|
-
// Migrate old track files
|
|
286
|
-
await this.migrateOldTrackFile();
|
|
287
|
-
|
|
288
|
-
this.app.debug('track logger started, now logging to', this.options.trackDir);
|
|
289
|
-
this.setPluginStatus(`Started${needsSave ? ' (config migrated)' : ''}`);
|
|
290
|
-
this.upSince = new Date().getTime();
|
|
291
|
-
|
|
292
|
-
// Adjust default CRON if unchanged
|
|
293
|
-
if (!this.options.apiCron || this.options.apiCron === '*/10 * * * *') {
|
|
294
|
-
const startMinute = Math.floor(Math.random() * 10);
|
|
295
|
-
const startSecond = Math.floor(Math.random() * 60);
|
|
296
|
-
this.options.apiCron = `${startSecond} ${startMinute}/10 * * * *`;
|
|
297
|
-
}
|
|
298
|
-
|
|
299
|
-
this.app.debug('Setting CRON to', this.options.apiCron);
|
|
300
|
-
this.app.debug('trackFrequency is set to', this.options.trackFrequency, 'seconds');
|
|
301
|
-
|
|
302
|
-
// Subscribe and start logging
|
|
303
|
-
this.doLogging();
|
|
304
|
-
|
|
305
|
-
// Start cron job
|
|
306
|
-
this.cron = new CronJob(this.options.apiCron, this.interval.bind(this));
|
|
307
|
-
this.cron.start();
|
|
308
|
-
|
|
309
|
-
// Start position health check
|
|
310
|
-
this.startPositionHealthCheck();
|
|
311
|
-
}
|
|
312
|
-
|
|
313
|
-
async cleanupOldPlugin() {
|
|
314
|
-
try {
|
|
315
|
-
// 1. Config Migration
|
|
316
|
-
const configDir = process.env.SIGNALK_NODE_CONFIG_DIR ||
|
|
317
|
-
path.join(process.env.HOME || process.env.USERPROFILE, '.signalk');
|
|
318
|
-
const configPath = path.join(configDir, 'plugin-config-data');
|
|
319
|
-
const oldConfigFile = path.join(configPath, 'signalk-to-noforeignland.json');
|
|
320
|
-
const newConfigFile = path.join(configPath, '@noforeignland-signalk-to-noforeignland.json');
|
|
321
|
-
|
|
322
|
-
if (fs.existsSync(oldConfigFile) && !fs.existsSync(newConfigFile)) {
|
|
323
|
-
this.app.debug('Migrating configuration from old plugin...');
|
|
324
|
-
fs.copyFileSync(oldConfigFile, newConfigFile);
|
|
325
|
-
fs.copyFileSync(oldConfigFile, `${oldConfigFile}.backup`);
|
|
326
|
-
this.app.debug('✓ Configuration migrated successfully');
|
|
327
|
-
}
|
|
328
|
-
|
|
329
|
-
// 2. Check if old plugin still exists
|
|
330
|
-
const oldPluginDir = path.join(configDir, 'node_modules', 'signalk-to-noforeignland');
|
|
331
|
-
|
|
332
|
-
if (fs.existsSync(oldPluginDir)) {
|
|
333
|
-
this.app.debug('Old plugin "signalk-to-noforeignland" detected');
|
|
334
|
-
this.app.setPluginError(
|
|
335
|
-
'Old plugin "signalk-to-noforeignland" is still installed. ' +
|
|
336
|
-
'Please uninstall it manually: cd ~/.signalk && npm uninstall signalk-to-noforeignland'
|
|
337
|
-
);
|
|
338
|
-
|
|
339
|
-
// Try to remove it after a delay (non-blocking)
|
|
340
|
-
setTimeout(async () => {
|
|
341
|
-
try {
|
|
342
|
-
this.app.debug('Attempting to remove old plugin directory...');
|
|
343
|
-
await fs.remove(oldPluginDir);
|
|
344
|
-
this.app.debug('✓ Old plugin directory removed');
|
|
345
|
-
// Clear error if removal successful
|
|
346
|
-
this.setPluginStatus('Started (old plugin cleaned up)');
|
|
347
|
-
} catch (err) {
|
|
348
|
-
this.app.debug('Could not automatically remove old plugin:', err.message);
|
|
349
|
-
this.app.debug('Please manually run: npm uninstall signalk-to-noforeignland');
|
|
350
|
-
}
|
|
351
|
-
}, 5000); // 5 Sekunden warten bis SignalK vollständig gestartet ist
|
|
352
|
-
}
|
|
353
|
-
|
|
354
|
-
} catch (err) {
|
|
355
|
-
this.app.debug('Error during old plugin cleanup:', err.message);
|
|
356
|
-
}
|
|
357
|
-
}
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
async migrateOldTrackFile() {
|
|
361
|
-
const oldTrackFile = path.join(this.options.trackDir, 'nfl-track.jsonl');
|
|
362
|
-
const oldPendingFile = path.join(this.options.trackDir, 'nfl-track-pending.jsonl');
|
|
363
|
-
const oldSentFile = path.join(this.options.trackDir, 'nfl-track-sent.jsonl');
|
|
364
|
-
const newPendingFile = path.join(this.options.trackDir, routeSaveName);
|
|
365
|
-
const newSentFile = path.join(this.options.trackDir, routeSentName);
|
|
366
|
-
|
|
367
|
-
try {
|
|
368
|
-
// Migrate old track file
|
|
369
|
-
if (await fs.pathExists(oldTrackFile) && !(await fs.pathExists(newPendingFile))) {
|
|
370
|
-
this.app.debug('Migrating old track file to new naming scheme...');
|
|
371
|
-
await fs.move(oldTrackFile, newPendingFile);
|
|
372
|
-
this.app.debug('Successfully migrated old track file to:', routeSaveName);
|
|
72
|
+
this.app.debug(err.message);
|
|
73
|
+
this.setPluginError(err.message);
|
|
74
|
+
this.stop();
|
|
75
|
+
return;
|
|
373
76
|
}
|
|
374
77
|
|
|
375
|
-
//
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
await fs.move(oldPendingFile, newPendingFile);
|
|
379
|
-
this.app.debug('Successfully migrated old pending file to:', routeSaveName);
|
|
380
|
-
}
|
|
78
|
+
// 4. Resolve track directory path
|
|
79
|
+
const dataDirPath = this.app.getDataDirPath();
|
|
80
|
+
this.options.trackDir = this.configManager.resolveTrackDir(this.options, dataDirPath);
|
|
381
81
|
|
|
382
|
-
//
|
|
383
|
-
|
|
384
|
-
this.
|
|
385
|
-
|
|
386
|
-
this.
|
|
82
|
+
// 5. Create track directory
|
|
83
|
+
try {
|
|
84
|
+
DirectoryUtils.createDir(this.options.trackDir, this.app);
|
|
85
|
+
} catch (err) {
|
|
86
|
+
this.setPluginError(err.message);
|
|
87
|
+
this.stop();
|
|
88
|
+
return;
|
|
387
89
|
}
|
|
388
90
|
|
|
389
|
-
//
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
routeSaveName
|
|
398
|
-
];
|
|
399
|
-
|
|
400
|
-
for (const oldFile of oldFiles) {
|
|
401
|
-
const oldPath = path.join(oldPluginTrackDir, oldFile);
|
|
402
|
-
if (await fs.pathExists(oldPath) && !(await fs.pathExists(newPendingFile))) {
|
|
403
|
-
await fs.move(oldPath, newPendingFile);
|
|
404
|
-
this.app.debug('Migrated pending track file from old plugin location');
|
|
405
|
-
break;
|
|
406
|
-
}
|
|
407
|
-
}
|
|
408
|
-
|
|
409
|
-
// Migrate sent archive
|
|
410
|
-
const oldSentFiles = [routeSentName, 'nfl-track-sent.jsonl'];
|
|
411
|
-
for (const oldFile of oldSentFiles) {
|
|
412
|
-
const oldPath = path.join(oldPluginTrackDir, oldFile);
|
|
413
|
-
if (await fs.pathExists(oldPath) && !(await fs.pathExists(newSentFile))) {
|
|
414
|
-
await fs.move(oldPath, newSentFile);
|
|
415
|
-
this.app.debug('Migrated sent track archive from old plugin location');
|
|
416
|
-
break;
|
|
417
|
-
}
|
|
418
|
-
}
|
|
419
|
-
|
|
420
|
-
// Try to remove old directory if empty
|
|
421
|
-
try {
|
|
422
|
-
const remainingFiles = await fs.readdir(oldPluginTrackDir);
|
|
423
|
-
if (remainingFiles.length === 0) {
|
|
424
|
-
await fs.rmdir(oldPluginTrackDir);
|
|
425
|
-
this.app.debug('Removed empty old track directory');
|
|
426
|
-
}
|
|
427
|
-
} catch (err) {
|
|
428
|
-
this.app.debug('Could not remove old track directory:', err.message);
|
|
429
|
-
}
|
|
430
|
-
}
|
|
431
|
-
} catch (err) {
|
|
432
|
-
this.app.debug('Error during track file migration:', err.message);
|
|
433
|
-
}
|
|
434
|
-
}
|
|
435
|
-
|
|
436
|
-
stop() {
|
|
437
|
-
this.app.debug('plugin stopped');
|
|
438
|
-
|
|
439
|
-
this.autoSelectedSource = null;
|
|
440
|
-
|
|
441
|
-
if (this.positionCheckInterval) {
|
|
442
|
-
clearInterval(this.positionCheckInterval);
|
|
443
|
-
this.positionCheckInterval = null;
|
|
444
|
-
}
|
|
445
|
-
|
|
446
|
-
if (this.cron) {
|
|
447
|
-
this.cron.stop();
|
|
448
|
-
this.cron = undefined;
|
|
449
|
-
}
|
|
450
|
-
|
|
451
|
-
this.unsubscribesControl.forEach(f => f());
|
|
452
|
-
this.unsubscribesControl = [];
|
|
453
|
-
this.unsubscribes.forEach(f => f());
|
|
454
|
-
this.unsubscribes = [];
|
|
455
|
-
this.app.setPluginStatus('Plugin stopped');
|
|
456
|
-
}
|
|
457
|
-
|
|
458
|
-
doLogging() {
|
|
459
|
-
let shouldDoLog = true;
|
|
460
|
-
|
|
461
|
-
this.app.subscriptionmanager.subscribe({
|
|
462
|
-
context: 'vessels.self',
|
|
463
|
-
subscribe: [{
|
|
464
|
-
path: 'navigation.position',
|
|
465
|
-
format: 'delta',
|
|
466
|
-
policy: 'instant',
|
|
467
|
-
minPeriod: this.options.trackFrequency ? this.options.trackFrequency * 1000 : 0
|
|
468
|
-
}]
|
|
469
|
-
}, this.unsubscribes, (subscriptionError) => {
|
|
470
|
-
this.app.debug('Error subscription to data:' + subscriptionError);
|
|
471
|
-
this.setPluginError('Error subscription to data:' + subscriptionError.message);
|
|
472
|
-
}, this.doOnValue.bind(this, () => shouldDoLog, newShould => { shouldDoLog = newShould; }));
|
|
473
|
-
|
|
474
|
-
// Subscribe for speed
|
|
475
|
-
if (this.options.minSpeed) {
|
|
476
|
-
this.app.subscriptionmanager.subscribe({
|
|
477
|
-
context: 'vessels.self',
|
|
478
|
-
subscribe: [{
|
|
479
|
-
path: 'navigation.speedOverGround',
|
|
480
|
-
format: 'delta',
|
|
481
|
-
policy: 'instant'
|
|
482
|
-
}]
|
|
483
|
-
}, this.unsubscribes, (subscriptionError) => {
|
|
484
|
-
this.app.debug('Error subscription to data:' + subscriptionError);
|
|
485
|
-
this.setPluginError('Error subscription to data:' + subscriptionError.message);
|
|
486
|
-
}, (delta) => {
|
|
487
|
-
delta.updates.forEach(update => {
|
|
488
|
-
if (this.options.filterSource && update.$source !== this.options.filterSource) {
|
|
489
|
-
return;
|
|
490
|
-
}
|
|
491
|
-
update.values.forEach(value => {
|
|
492
|
-
const speedInKnots = value.value * 1.94384;
|
|
493
|
-
if (!shouldDoLog && this.options.minSpeed < speedInKnots) {
|
|
494
|
-
this.app.debug('setting shouldDoLog to true, speed:', speedInKnots.toFixed(2), 'knots');
|
|
495
|
-
shouldDoLog = true;
|
|
91
|
+
// 6. Cleanup old plugin versions (with callback handling)
|
|
92
|
+
this.pluginCleanup.cleanup()
|
|
93
|
+
.then((result) => {
|
|
94
|
+
if (result === 'all_removed') {
|
|
95
|
+
this.app.debug('Old plugins successfully cleaned up');
|
|
96
|
+
// Update status if plugin started successfully
|
|
97
|
+
if (this.options.boatApiKey) {
|
|
98
|
+
this.setPluginStatus('Started (old plugins cleaned up)');
|
|
496
99
|
}
|
|
497
|
-
}
|
|
100
|
+
}
|
|
101
|
+
})
|
|
102
|
+
.catch(err => {
|
|
103
|
+
this.app.debug('Error in cleanupOldPlugin:', err.message);
|
|
498
104
|
});
|
|
105
|
+
|
|
106
|
+
// 7. Migrate old track files
|
|
107
|
+
this.trackMigration = new TrackMigration(this.app, this.options.trackDir);
|
|
108
|
+
await this.trackMigration.migrate();
|
|
109
|
+
|
|
110
|
+
// 8. Initialize modules
|
|
111
|
+
this.trackLogger = new TrackLogger(this.app, this.options, this.options.trackDir);
|
|
112
|
+
this.trackSender = new TrackSender(this.app, this.options, this.options.trackDir);
|
|
113
|
+
this.healthMonitor = new HealthMonitor(this.app, this.options);
|
|
114
|
+
|
|
115
|
+
// 9. Update startup time and randomize CRON
|
|
116
|
+
this.upSince = new Date().getTime();
|
|
117
|
+
this.options = this.configManager.randomizeCron(this.options);
|
|
118
|
+
|
|
119
|
+
this.app.debug('Setting CRON to', this.options.apiCron);
|
|
120
|
+
this.app.debug('trackFrequency is set to', this.options.trackFrequency, 'seconds');
|
|
121
|
+
this.app.debug('track logger started, now logging to', this.options.trackDir);
|
|
122
|
+
|
|
123
|
+
// 10. Start logging
|
|
124
|
+
this.trackLogger.startLogging((lastPosition) => {
|
|
125
|
+
this.handleSavePoint(lastPosition);
|
|
499
126
|
});
|
|
500
|
-
}
|
|
501
|
-
}
|
|
502
|
-
|
|
503
|
-
// FIXED: Use continue instead of return to handle multiple updates properly
|
|
504
|
-
async doOnValue(getShouldDoLog, setShouldDoLog, delta) {
|
|
505
|
-
for (const update of delta.updates) {
|
|
506
|
-
// Auto-select source logic
|
|
507
|
-
if (!this.options.filterSource) {
|
|
508
|
-
const timeSinceLastPosition = this.lastPositionReceived
|
|
509
|
-
? (new Date().getTime() - this.lastPositionReceived) / 1000
|
|
510
|
-
: null;
|
|
511
|
-
|
|
512
|
-
if (!this.autoSelectedSource) {
|
|
513
|
-
this.autoSelectedSource = update.$source;
|
|
514
|
-
this.lastPositionReceived = new Date().getTime();
|
|
515
|
-
this.app.debug(`Auto-selected GPS source: '${this.autoSelectedSource}'`);
|
|
516
|
-
} else if (update.$source !== this.autoSelectedSource) {
|
|
517
|
-
if (timeSinceLastPosition && timeSinceLastPosition > 300) {
|
|
518
|
-
this.app.debug(`Switching from stale source '${this.autoSelectedSource}' to '${update.$source}' (no data for ${timeSinceLastPosition.toFixed(0)}s)`);
|
|
519
|
-
this.autoSelectedSource = update.$source;
|
|
520
|
-
this.lastPositionReceived = new Date().getTime();
|
|
521
|
-
} else {
|
|
522
|
-
this.app.debug(`Ignoring position from '${update.$source}', using auto-selected source '${this.autoSelectedSource}'`);
|
|
523
|
-
continue;
|
|
524
|
-
}
|
|
525
|
-
} else {
|
|
526
|
-
this.lastPositionReceived = new Date().getTime();
|
|
527
|
-
}
|
|
528
|
-
} else if (update.$source !== this.options.filterSource) {
|
|
529
|
-
this.app.debug(`Ignoring position from '${update.$source}', filterSource is set to '${this.options.filterSource}'`);
|
|
530
|
-
continue;
|
|
531
|
-
} else {
|
|
532
|
-
this.lastPositionReceived = new Date().getTime();
|
|
533
|
-
}
|
|
534
127
|
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
this.app.debug('24h since last point, forcing save of point to keep boat active on NFL');
|
|
555
|
-
force24hSave = true;
|
|
556
|
-
}
|
|
557
|
-
}
|
|
558
|
-
|
|
559
|
-
// Check if we should log
|
|
560
|
-
if (!force24hSave && !getShouldDoLog()) {
|
|
561
|
-
this.app.debug('shouldDoLog is false, not logging position');
|
|
562
|
-
continue;
|
|
563
|
-
}
|
|
564
|
-
|
|
565
|
-
// Check timestamp and distance
|
|
566
|
-
if (this.lastPosition && !force24hSave) {
|
|
567
|
-
if (new Date(this.lastPosition.timestamp).getTime() > new Date(timestamp).getTime()) {
|
|
568
|
-
this.app.debug('got error in timestamp:', timestamp, 'is earlier than previous:', this.lastPosition.timestamp);
|
|
569
|
-
continue;
|
|
570
|
-
}
|
|
571
|
-
|
|
572
|
-
const distance = this.equirectangularDistance(this.lastPosition.pos, value.value);
|
|
573
|
-
if (this.options.minMove && distance < this.options.minMove) {
|
|
574
|
-
this.app.debug('Distance', distance.toFixed(2), 'm is less than minMove', this.options.minMove, 'm - skipping');
|
|
575
|
-
continue;
|
|
576
|
-
}
|
|
577
|
-
|
|
578
|
-
this.app.debug('Distance', distance.toFixed(2), 'm is greater than minMove', this.options.minMove, 'm - logging');
|
|
579
|
-
}
|
|
580
|
-
|
|
581
|
-
// Save point
|
|
582
|
-
this.app.debug('Saving position from source:', update.$source, 'lat:', value.value.latitude, 'lon:', value.value.longitude);
|
|
583
|
-
this.lastPosition = { pos: value.value, timestamp, currentTime: new Date().getTime() };
|
|
584
|
-
await this.savePoint(this.lastPosition);
|
|
585
|
-
|
|
586
|
-
// Reset shouldDoLog if minSpeed is active
|
|
587
|
-
if (this.options.minSpeed) {
|
|
588
|
-
this.app.debug('options.minSpeed - setting shouldDoLog to false');
|
|
589
|
-
setShouldDoLog(false);
|
|
590
|
-
}
|
|
591
|
-
}
|
|
128
|
+
// 11. Start CRON job for sending data
|
|
129
|
+
this.cron = new CronJob(this.options.apiCron, this.interval.bind(this));
|
|
130
|
+
this.cron.start();
|
|
131
|
+
|
|
132
|
+
// 12. Start health monitoring
|
|
133
|
+
this.healthMonitor.start(
|
|
134
|
+
() => this.trackLogger.getLastPositionReceived(),
|
|
135
|
+
() => this.trackLogger.getAutoSelectedSource(),
|
|
136
|
+
(errorMsg) => this.setPluginError(errorMsg),
|
|
137
|
+
() => this.handleHealthy()
|
|
138
|
+
);
|
|
139
|
+
|
|
140
|
+
// 13. Set plugin status
|
|
141
|
+
this.setPluginStatus(`Started${migrated ? ' (config migrated)' : ''}`);
|
|
142
|
+
|
|
143
|
+
} catch (err) {
|
|
144
|
+
this.app.debug('Error during plugin start:', err.message);
|
|
145
|
+
this.setPluginError(`Failed to start: ${err.message}`);
|
|
146
|
+
this.stop();
|
|
592
147
|
}
|
|
593
148
|
}
|
|
594
149
|
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
t: point.timestamp
|
|
600
|
-
};
|
|
601
|
-
this.app.debug(`save data point:`, obj);
|
|
602
|
-
await fs.appendFile(path.join(this.options.trackDir, routeSaveName), JSON.stringify(obj) + EOL);
|
|
603
|
-
|
|
150
|
+
/**
|
|
151
|
+
* Handle savepoint event
|
|
152
|
+
*/
|
|
153
|
+
handleSavePoint(lastPosition) {
|
|
604
154
|
const now = new Date();
|
|
605
|
-
this.
|
|
606
|
-
this.emitDelta('noforeignland.savepoint_local', now.toLocaleString());
|
|
155
|
+
this.dataPathEmitter.emitSavepoint();
|
|
607
156
|
|
|
608
|
-
//
|
|
609
|
-
const activeSource = this.options.filterSource || this.
|
|
157
|
+
// Update plugin status
|
|
158
|
+
const activeSource = this.options.filterSource || this.trackLogger.getAutoSelectedSource() || '';
|
|
610
159
|
const sourcePrefix = activeSource ? `${activeSource} | ` : '';
|
|
611
160
|
const saveTime = now.toISOString();
|
|
612
|
-
const transferTime = this.lastSuccessfulTransfer
|
|
613
|
-
|
|
161
|
+
const transferTime = this.lastSuccessfulTransfer
|
|
162
|
+
? this.lastSuccessfulTransfer.toISOString()
|
|
163
|
+
: 'None since start';
|
|
164
|
+
|
|
614
165
|
this.setPluginStatus(`Save: ${saveTime} | Transfer: ${transferTime} | ${sourcePrefix}`);
|
|
615
166
|
}
|
|
616
167
|
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
const x = Δλ * Math.cos((φ1 + φ2) / 2);
|
|
636
|
-
const y = (φ2 - φ1);
|
|
637
|
-
const d = Math.sqrt(x * x + y * y) * R;
|
|
638
|
-
return d;
|
|
168
|
+
/**
|
|
169
|
+
* Handle healthy status from health monitor
|
|
170
|
+
*/
|
|
171
|
+
handleHealthy() {
|
|
172
|
+
// Clear error if position health is OK
|
|
173
|
+
if (this.dataPathEmitter.getError()) {
|
|
174
|
+
const lastPosition = this.trackLogger.getLastPosition();
|
|
175
|
+
const activeSource = this.options.filterSource || this.trackLogger.getAutoSelectedSource() || '';
|
|
176
|
+
const sourcePrefix = activeSource ? `${activeSource} | ` : '';
|
|
177
|
+
const saveTime = lastPosition
|
|
178
|
+
? new Date(lastPosition.currentTime).toISOString()
|
|
179
|
+
: 'None since start';
|
|
180
|
+
const transferTime = this.lastSuccessfulTransfer
|
|
181
|
+
? this.lastSuccessfulTransfer.toISOString()
|
|
182
|
+
: 'None since start';
|
|
183
|
+
|
|
184
|
+
this.setPluginStatus(`Save: ${saveTime} | Transfer: ${transferTime} | ${sourcePrefix}`);
|
|
185
|
+
}
|
|
639
186
|
}
|
|
640
187
|
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
188
|
+
/**
|
|
189
|
+
* CRON interval - check and send data
|
|
190
|
+
*/
|
|
191
|
+
async interval() {
|
|
192
|
+
try {
|
|
193
|
+
// Check if boat is moving
|
|
194
|
+
const lastPosition = this.trackLogger.getLastPosition();
|
|
195
|
+
const boatMoving = this.trackSender.isBoatMoving(lastPosition, this.upSince);
|
|
196
|
+
if (!boatMoving) {
|
|
197
|
+
return;
|
|
650
198
|
}
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
case 'EACCES':
|
|
657
|
-
case 'EPERM':
|
|
658
|
-
this.app.debug(`Failed to create ${dir} by Permission denied`);
|
|
659
|
-
this.setPluginError(`Failed to create ${dir} by Permission denied`);
|
|
660
|
-
res = false;
|
|
661
|
-
break;
|
|
662
|
-
case 'ETIMEDOUT':
|
|
663
|
-
this.app.debug(`Failed to create ${dir} by Operation timed out`);
|
|
664
|
-
this.setPluginError(`Failed to create ${dir} by Operation timed out`);
|
|
665
|
-
res = false;
|
|
666
|
-
break;
|
|
667
|
-
default:
|
|
668
|
-
this.app.debug(`Error creating directory ${dir}: ${error.message}`);
|
|
669
|
-
this.setPluginError(`Error creating directory ${dir}: ${error.message}`);
|
|
670
|
-
res = false;
|
|
671
|
-
}
|
|
199
|
+
|
|
200
|
+
// Check if we have track data
|
|
201
|
+
const hasTrack = await this.trackSender.hasTrackData();
|
|
202
|
+
if (!hasTrack) {
|
|
203
|
+
return;
|
|
672
204
|
}
|
|
673
|
-
}
|
|
674
|
-
return res;
|
|
675
|
-
}
|
|
676
205
|
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
const now = new Date().getTime();
|
|
680
|
-
const timeSinceLastPosition = this.lastPositionReceived
|
|
681
|
-
? (now - this.lastPositionReceived) / 1000
|
|
682
|
-
: null;
|
|
683
|
-
|
|
684
|
-
const activeSource = this.options.filterSource || this.autoSelectedSource || 'any';
|
|
685
|
-
const filterMsg = activeSource !== 'any' ? ` from source '${activeSource}'` : '';
|
|
686
|
-
|
|
687
|
-
if (!this.lastPositionReceived) {
|
|
688
|
-
const errorMsg = this.options.filterSource
|
|
689
|
-
? `No GPS position data received from filtered source '${this.options.filterSource}'. Check Expert Settings > Position source device, or leave empty to use any GPS source.`
|
|
690
|
-
: 'No GPS position data received. Check that your GPS is connected and SignalK is receiving navigation.position data.';
|
|
691
|
-
this.setPluginError(errorMsg);
|
|
692
|
-
this.app.debug('Position health check: No position data ever received' + filterMsg);
|
|
693
|
-
} else if (timeSinceLastPosition > 300) {
|
|
694
|
-
const errorMsg = this.options.filterSource
|
|
695
|
-
? `No GPS position data${filterMsg} for ${Math.floor(timeSinceLastPosition / 60)} minutes. Check that source '${this.options.filterSource}' is active, or change/clear Position source device in Expert Settings.`
|
|
696
|
-
: `No GPS position data${filterMsg} for ${Math.floor(timeSinceLastPosition / 60)} minutes. Check your GPS connection.`;
|
|
697
|
-
this.setPluginError(errorMsg);
|
|
698
|
-
this.app.debug(`Position health check: No position for ${timeSinceLastPosition.toFixed(0)} seconds` + filterMsg);
|
|
699
|
-
} else {
|
|
700
|
-
this.app.debug(`Position health check: OK (last position ${timeSinceLastPosition.toFixed(0)} seconds ago${filterMsg})`);
|
|
206
|
+
// Send track data (has built-in retry logic)
|
|
207
|
+
const success = await this.trackSender.sendTrack();
|
|
701
208
|
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
209
|
+
if (success) {
|
|
210
|
+
this.lastSuccessfulTransfer = new Date();
|
|
211
|
+
this.dataPathEmitter.emitApiTransfer(this.lastSuccessfulTransfer);
|
|
212
|
+
|
|
213
|
+
// Update status
|
|
214
|
+
const activeSource = this.options.filterSource || this.trackLogger.getAutoSelectedSource() || '';
|
|
705
215
|
const sourcePrefix = activeSource ? `${activeSource} | ` : '';
|
|
706
|
-
const saveTime =
|
|
707
|
-
|
|
216
|
+
const saveTime = lastPosition
|
|
217
|
+
? new Date(lastPosition.currentTime).toISOString()
|
|
218
|
+
: 'None since start';
|
|
219
|
+
const transferTime = this.lastSuccessfulTransfer.toISOString();
|
|
220
|
+
|
|
708
221
|
this.setPluginStatus(`Save: ${saveTime} | Transfer: ${transferTime} | ${sourcePrefix}`);
|
|
709
222
|
}
|
|
223
|
+
|
|
224
|
+
} catch (err) {
|
|
225
|
+
this.app.debug('Error during send interval:', err.message);
|
|
226
|
+
this.setPluginError(`Failed to send track - ${err.message}`);
|
|
710
227
|
}
|
|
711
|
-
}
|
|
712
|
-
|
|
713
|
-
// Initial check after 2 minutes of startup
|
|
714
|
-
setTimeout(() => {
|
|
715
|
-
if (!this.lastPositionReceived) {
|
|
716
|
-
const activeSource = this.options.filterSource || this.autoSelectedSource || 'any';
|
|
717
|
-
const errorMsg = this.options.filterSource
|
|
718
|
-
? `No GPS position data received after 2 minutes from filtered source '${this.options.filterSource}'. Check Expert Settings > Position source device. You may need to leave it empty to use any available GPS source.`
|
|
719
|
-
: 'No GPS position data received after 2 minutes. Check that your GPS is connected and SignalK is receiving navigation.position data.';
|
|
720
|
-
this.setPluginError(errorMsg);
|
|
721
|
-
this.app.debug('Initial position check: No position data received' + (activeSource !== 'any' ? ` from source '${activeSource}'` : ''));
|
|
722
|
-
}
|
|
723
|
-
}, 2 * 60 * 1000);
|
|
724
|
-
}
|
|
228
|
+
}
|
|
725
229
|
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
230
|
+
/**
|
|
231
|
+
* Stop the plugin
|
|
232
|
+
*/
|
|
233
|
+
stop() {
|
|
234
|
+
this.app.debug('plugin stopped');
|
|
235
|
+
|
|
236
|
+
// Stop CRON job
|
|
237
|
+
if (this.cron) {
|
|
238
|
+
this.cron.stop();
|
|
239
|
+
this.cron = undefined;
|
|
730
240
|
}
|
|
731
241
|
|
|
732
|
-
|
|
733
|
-
if (
|
|
734
|
-
|
|
242
|
+
// Stop track logger
|
|
243
|
+
if (this.trackLogger) {
|
|
244
|
+
this.trackLogger.stop();
|
|
735
245
|
}
|
|
736
246
|
|
|
737
|
-
|
|
738
|
-
if (
|
|
739
|
-
|
|
740
|
-
this.app.debug(errorMsg);
|
|
741
|
-
this.setPluginError(errorMsg);
|
|
742
|
-
return;
|
|
247
|
+
// Stop health monitor
|
|
248
|
+
if (this.healthMonitor) {
|
|
249
|
+
this.healthMonitor.stop();
|
|
743
250
|
}
|
|
744
251
|
|
|
745
|
-
|
|
746
|
-
}
|
|
747
|
-
|
|
748
|
-
checkBoatMoving() {
|
|
749
|
-
if (!this.options.trackFrequency) {
|
|
750
|
-
return true;
|
|
751
|
-
}
|
|
752
|
-
const time = this.lastPosition ? this.lastPosition.currentTime : this.upSince;
|
|
753
|
-
const secsSinceLastPoint = (new Date().getTime() - time) / 1000;
|
|
754
|
-
const isMoving = secsSinceLastPoint <= (this.options.trackFrequency * 2);
|
|
755
|
-
if (isMoving) {
|
|
756
|
-
this.app.debug('Boat is still moving, last move', secsSinceLastPoint, 'seconds ago');
|
|
757
|
-
return this.options.sendWhileMoving;
|
|
758
|
-
} else {
|
|
759
|
-
this.app.debug('Boat stopped moving, last move at least', secsSinceLastPoint, 'seconds ago');
|
|
760
|
-
return true;
|
|
761
|
-
}
|
|
252
|
+
this.app.setPluginStatus('Plugin stopped');
|
|
762
253
|
}
|
|
763
254
|
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
this.app.debug(`Using internet test timeout: ${timeoutMs}ms`);
|
|
771
|
-
|
|
772
|
-
const dnsServers = [
|
|
773
|
-
{ name: 'Google DNS', ip: '8.8.8.8' },
|
|
774
|
-
{ name: 'Cloudflare DNS', ip: '1.1.1.1' }
|
|
775
|
-
];
|
|
255
|
+
/**
|
|
256
|
+
* Set plugin status and update data paths
|
|
257
|
+
*/
|
|
258
|
+
setPluginStatus(status) {
|
|
259
|
+
this.dataPathEmitter.clearError();
|
|
260
|
+
this.app.setPluginStatus(status);
|
|
776
261
|
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
const startTime = Date.now();
|
|
780
|
-
const result = await Promise.race([
|
|
781
|
-
dns.reverse(server.ip),
|
|
782
|
-
new Promise((_, reject) =>
|
|
783
|
-
setTimeout(() => reject(new Error('DNS timeout')), timeoutMs)
|
|
784
|
-
)
|
|
785
|
-
]);
|
|
786
|
-
const elapsed = Date.now() - startTime;
|
|
787
|
-
|
|
788
|
-
this.app.debug(`internet connection = true, ${server.name} (${server.ip}) is reachable (took ${elapsed}ms)`);
|
|
789
|
-
return true;
|
|
790
|
-
} catch (err) {
|
|
791
|
-
this.app.debug(`${server.name} (${server.ip}) not reachable:`, err.message);
|
|
792
|
-
}
|
|
793
|
-
}
|
|
262
|
+
const lastPosition = this.trackLogger ? this.trackLogger.getLastPosition() : null;
|
|
263
|
+
const autoSelectedSource = this.trackLogger ? this.trackLogger.getAutoSelectedSource() : null;
|
|
794
264
|
|
|
795
|
-
this.
|
|
796
|
-
|
|
265
|
+
this.dataPathEmitter.updateStatusPaths(
|
|
266
|
+
this.options,
|
|
267
|
+
lastPosition,
|
|
268
|
+
this.lastSuccessfulTransfer,
|
|
269
|
+
autoSelectedSource
|
|
270
|
+
);
|
|
797
271
|
}
|
|
798
272
|
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
this.app.
|
|
805
|
-
return size > 0;
|
|
806
|
-
}
|
|
807
|
-
|
|
808
|
-
async sendData() {
|
|
809
|
-
if (this.options.boatApiKey) {
|
|
810
|
-
await this.sendApiData();
|
|
811
|
-
} else {
|
|
812
|
-
this.app.debug('Failed to send track - no boat API key set in plugin settings.');
|
|
813
|
-
this.setPluginError(`Failed to send track - no boat API key set in plugin settings.`);
|
|
814
|
-
}
|
|
815
|
-
}
|
|
816
|
-
|
|
817
|
-
async sendApiData() {
|
|
818
|
-
this.app.debug('sending the data');
|
|
819
|
-
const pendingFile = path.join(this.options.trackDir, routeSaveName);
|
|
820
|
-
const trackData = await this.createTrack(pendingFile);
|
|
821
|
-
if (!trackData) {
|
|
822
|
-
this.app.debug('Recorded track did not contain any valid track points, aborting sending.');
|
|
823
|
-
this.setPluginError(`Failed to send track - Recorded track did not contain any valid track points, aborting sending.`);
|
|
824
|
-
return;
|
|
825
|
-
}
|
|
826
|
-
this.app.debug('created track data with timestamp:', new Date(trackData.timestamp));
|
|
827
|
-
const params = new URLSearchParams();
|
|
828
|
-
params.append('timestamp', trackData.timestamp);
|
|
829
|
-
params.append('track', JSON.stringify(trackData.track));
|
|
830
|
-
params.append('boatApiKey', this.options.boatApiKey);
|
|
831
|
-
const headers = { 'X-NFL-API-Key': pluginApiKey };
|
|
832
|
-
this.app.debug('sending track to API');
|
|
833
|
-
|
|
834
|
-
const maxRetries = 3;
|
|
835
|
-
const baseTimeout = (this.options.apiTimeout || 30) * 1000;
|
|
273
|
+
/**
|
|
274
|
+
* Set plugin error and update data paths
|
|
275
|
+
*/
|
|
276
|
+
setPluginError(error) {
|
|
277
|
+
this.dataPathEmitter.setError(error);
|
|
278
|
+
this.app.setPluginError(error);
|
|
836
279
|
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
const currentTimeout = baseTimeout * attempt;
|
|
840
|
-
this.app.debug(`Attempt ${attempt}/${maxRetries} with ${currentTimeout}ms timeout`);
|
|
841
|
-
|
|
842
|
-
const controller = new AbortController();
|
|
843
|
-
const timeoutId = setTimeout(() => controller.abort(), currentTimeout);
|
|
844
|
-
|
|
845
|
-
const response = await fetch(apiUrl, {
|
|
846
|
-
method: 'POST',
|
|
847
|
-
body: params,
|
|
848
|
-
headers: new fetch.Headers(headers),
|
|
849
|
-
signal: controller.signal
|
|
850
|
-
});
|
|
851
|
-
|
|
852
|
-
clearTimeout(timeoutId);
|
|
853
|
-
|
|
854
|
-
if (response.ok) {
|
|
855
|
-
const responseBody = await response.json();
|
|
856
|
-
if (responseBody.status === 'ok') {
|
|
857
|
-
this.lastSuccessfulTransfer = new Date();
|
|
858
|
-
|
|
859
|
-
this.emitDelta('noforeignland.sent_to_api', this.lastSuccessfulTransfer.toISOString());
|
|
860
|
-
this.emitDelta('noforeignland.sent_to_api_local', this.lastSuccessfulTransfer.toLocaleString());
|
|
861
|
-
|
|
862
|
-
this.app.debug('Track successfully sent to API');
|
|
863
|
-
|
|
864
|
-
// ISO8601 format for Dashboard
|
|
865
|
-
const activeSource = this.options.filterSource || this.autoSelectedSource || '';
|
|
866
|
-
const sourcePrefix = activeSource ? `${activeSource} | ` : '';
|
|
867
|
-
const saveTime = this.lastPosition ? new Date(this.lastPosition.currentTime).toISOString() : 'None since start';
|
|
868
|
-
const transferTime = this.lastSuccessfulTransfer.toISOString();
|
|
869
|
-
this.setPluginStatus(`Save: ${saveTime} | Transfer: ${transferTime} | ${sourcePrefix}`);
|
|
870
|
-
|
|
871
|
-
await this.handleSuccessfulSend(pendingFile);
|
|
872
|
-
return;
|
|
873
|
-
} else {
|
|
874
|
-
this.app.debug('Could not send track to API, returned response json:', responseBody);
|
|
875
|
-
this.setPluginError(`Failed to send track - API returned error.`);
|
|
876
|
-
return;
|
|
877
|
-
}
|
|
878
|
-
} else {
|
|
879
|
-
this.app.debug('Could not send track to API, returned response code:', response.status, response.statusText);
|
|
880
|
-
if (response.status >= 400 && response.status < 500) {
|
|
881
|
-
this.setPluginError(`Failed to send track - HTTP ${response.status}.`);
|
|
882
|
-
return;
|
|
883
|
-
}
|
|
884
|
-
throw new Error(`HTTP ${response.status}`);
|
|
885
|
-
}
|
|
886
|
-
} catch (err) {
|
|
887
|
-
this.app.debug(`Attempt ${attempt} failed:`, err.message);
|
|
888
|
-
|
|
889
|
-
if (attempt === maxRetries) {
|
|
890
|
-
this.app.debug('Could not send track to API after', maxRetries, 'attempts:', err);
|
|
891
|
-
this.setPluginError(`Failed to send track after ${maxRetries} attempts - check logs for details.`);
|
|
892
|
-
} else {
|
|
893
|
-
const waitTime = 2000 * attempt;
|
|
894
|
-
this.app.debug(`Waiting ${waitTime}ms before retry...`);
|
|
895
|
-
await new Promise(resolve => setTimeout(resolve, waitTime));
|
|
896
|
-
}
|
|
897
|
-
}
|
|
898
|
-
}
|
|
899
|
-
}
|
|
900
|
-
|
|
901
|
-
async handleSuccessfulSend(pendingFile) {
|
|
902
|
-
const sentFile = path.join(this.options.trackDir, routeSentName);
|
|
280
|
+
const lastPosition = this.trackLogger ? this.trackLogger.getLastPosition() : null;
|
|
281
|
+
const autoSelectedSource = this.trackLogger ? this.trackLogger.getAutoSelectedSource() : null;
|
|
903
282
|
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
} else {
|
|
911
|
-
this.app.debug('keepFiles disabled, will delete pending file');
|
|
912
|
-
}
|
|
913
|
-
|
|
914
|
-
this.app.debug('Deleting pending track file');
|
|
915
|
-
await fs.remove(pendingFile);
|
|
916
|
-
this.app.debug('Successfully processed track files after send');
|
|
917
|
-
|
|
918
|
-
} catch (err) {
|
|
919
|
-
this.app.debug('Error handling files after successful send:', err.message);
|
|
920
|
-
}
|
|
921
|
-
}
|
|
922
|
-
|
|
923
|
-
async createTrack(inputPath) {
|
|
924
|
-
const fileStream = fs.createReadStream(inputPath);
|
|
925
|
-
const rl = readline.createInterface({ input: fileStream, crlfDelay: Infinity });
|
|
926
|
-
const track = [];
|
|
927
|
-
let lastTimestamp;
|
|
928
|
-
for await (const line of rl) {
|
|
929
|
-
if (line) {
|
|
930
|
-
try {
|
|
931
|
-
const point = JSON.parse(line);
|
|
932
|
-
const timestamp = new Date(point.t).getTime();
|
|
933
|
-
if (!isNaN(timestamp) && this.isValidLatitude(point.lat) && this.isValidLongitude(point.lon)) {
|
|
934
|
-
track.push([timestamp, point.lat, point.lon]);
|
|
935
|
-
lastTimestamp = timestamp;
|
|
936
|
-
}
|
|
937
|
-
} catch (error) {
|
|
938
|
-
this.app.debug('could not parse line from track file:', line);
|
|
939
|
-
this.setPluginError(`Failed could not parse line from track file - check logs for details.`);
|
|
940
|
-
}
|
|
941
|
-
}
|
|
942
|
-
}
|
|
943
|
-
if (track.length > 0) {
|
|
944
|
-
return { timestamp: new Date(lastTimestamp).getTime(), track };
|
|
945
|
-
}
|
|
946
|
-
return null;
|
|
283
|
+
this.dataPathEmitter.updateStatusPaths(
|
|
284
|
+
this.options,
|
|
285
|
+
lastPosition,
|
|
286
|
+
this.lastSuccessfulTransfer,
|
|
287
|
+
autoSelectedSource
|
|
288
|
+
);
|
|
947
289
|
}
|
|
948
290
|
}
|
|
949
291
|
|