@noforeignland/signalk-to-noforeignland 1.0.1-beta.8 → 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 +14 -5
- package/PROJECT_STRUCTURE.md +357 -0
- package/README.md +15 -6
- package/cleanup-old-plugin.js +69 -21
- package/doc/beta_install_cerbo.md +27 -6
- package/index.js +206 -891
- 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 +1 -1
- package/doc/dev +0 -17
|
@@ -0,0 +1,306 @@
|
|
|
1
|
+
const { EOL } = require('os');
|
|
2
|
+
const fs = require('fs-extra');
|
|
3
|
+
const path = require('path');
|
|
4
|
+
|
|
5
|
+
class TrackLogger {
|
|
6
|
+
constructor(app, options, trackDir) {
|
|
7
|
+
this.app = app;
|
|
8
|
+
this.options = options;
|
|
9
|
+
this.trackDir = trackDir;
|
|
10
|
+
this.routeSaveName = 'pending.jsonl';
|
|
11
|
+
|
|
12
|
+
this.lastPosition = null;
|
|
13
|
+
this.lastPositionReceived = null;
|
|
14
|
+
this.autoSelectedSource = null;
|
|
15
|
+
this.unsubscribes = [];
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Start logging position data
|
|
20
|
+
*/
|
|
21
|
+
startLogging(onSavePoint) {
|
|
22
|
+
let shouldDoLog = true;
|
|
23
|
+
|
|
24
|
+
// Subscribe to position updates
|
|
25
|
+
this.app.subscriptionmanager.subscribe({
|
|
26
|
+
context: 'vessels.self',
|
|
27
|
+
subscribe: [{
|
|
28
|
+
path: 'navigation.position',
|
|
29
|
+
format: 'delta',
|
|
30
|
+
policy: 'instant',
|
|
31
|
+
minPeriod: this.options.trackFrequency ? this.options.trackFrequency * 1000 : 0
|
|
32
|
+
}]
|
|
33
|
+
}, this.unsubscribes,
|
|
34
|
+
(subscriptionError) => {
|
|
35
|
+
this.app.debug('Error subscription to data:' + subscriptionError);
|
|
36
|
+
throw new Error('Error subscription to data:' + subscriptionError.message);
|
|
37
|
+
},
|
|
38
|
+
this.doOnValue.bind(this, () => shouldDoLog, newShould => { shouldDoLog = newShould; }, onSavePoint));
|
|
39
|
+
|
|
40
|
+
// Subscribe for speed if minSpeed is configured
|
|
41
|
+
if (this.options.minSpeed) {
|
|
42
|
+
this.subscribeToSpeed(() => shouldDoLog, newShould => { shouldDoLog = newShould; });
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Subscribe to speed over ground
|
|
48
|
+
*/
|
|
49
|
+
subscribeToSpeed(getShouldDoLog, setShouldDoLog) {
|
|
50
|
+
this.app.subscriptionmanager.subscribe({
|
|
51
|
+
context: 'vessels.self',
|
|
52
|
+
subscribe: [{
|
|
53
|
+
path: 'navigation.speedOverGround',
|
|
54
|
+
format: 'delta',
|
|
55
|
+
policy: 'instant'
|
|
56
|
+
}]
|
|
57
|
+
}, this.unsubscribes,
|
|
58
|
+
(subscriptionError) => {
|
|
59
|
+
this.app.debug('Error subscription to data:' + subscriptionError);
|
|
60
|
+
throw new Error('Error subscription to data:' + subscriptionError.message);
|
|
61
|
+
},
|
|
62
|
+
(delta) => {
|
|
63
|
+
delta.updates.forEach(update => {
|
|
64
|
+
if (this.options.filterSource && update.$source !== this.options.filterSource) {
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
update.values.forEach(value => {
|
|
68
|
+
const speedInKnots = value.value * 1.94384;
|
|
69
|
+
if (!getShouldDoLog() && this.options.minSpeed < speedInKnots) {
|
|
70
|
+
this.app.debug('setting shouldDoLog to true, speed:', speedInKnots.toFixed(2), 'knots');
|
|
71
|
+
setShouldDoLog(true);
|
|
72
|
+
}
|
|
73
|
+
});
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Handle incoming position values
|
|
80
|
+
*/
|
|
81
|
+
async doOnValue(getShouldDoLog, setShouldDoLog, onSavePoint, delta) {
|
|
82
|
+
for (const update of delta.updates) {
|
|
83
|
+
// Handle source selection (auto or filtered)
|
|
84
|
+
if (!this.handleSourceSelection(update)) {
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const timestamp = update.timestamp;
|
|
89
|
+
|
|
90
|
+
for (const value of update.values) {
|
|
91
|
+
// Validate position
|
|
92
|
+
if (!this.isValidPosition(value.value)) {
|
|
93
|
+
continue;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Check if we should save (24h ping or shouldDoLog)
|
|
97
|
+
const force24hSave = this.should24hPing();
|
|
98
|
+
if (!force24hSave && !getShouldDoLog()) {
|
|
99
|
+
this.app.debug('shouldDoLog is false, not logging position');
|
|
100
|
+
continue;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// Check timestamp and distance
|
|
104
|
+
if (this.lastPosition && !force24hSave) {
|
|
105
|
+
if (!this.shouldLogPosition(timestamp, value.value)) {
|
|
106
|
+
continue;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Save point
|
|
111
|
+
this.app.debug('Saving position from source:', update.$source,
|
|
112
|
+
'lat:', value.value.latitude, 'lon:', value.value.longitude);
|
|
113
|
+
|
|
114
|
+
this.lastPosition = {
|
|
115
|
+
pos: value.value,
|
|
116
|
+
timestamp,
|
|
117
|
+
currentTime: new Date().getTime()
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
await this.savePoint(this.lastPosition);
|
|
121
|
+
onSavePoint(this.lastPosition);
|
|
122
|
+
|
|
123
|
+
// Reset shouldDoLog if minSpeed is active
|
|
124
|
+
if (this.options.minSpeed) {
|
|
125
|
+
this.app.debug('options.minSpeed - setting shouldDoLog to false');
|
|
126
|
+
setShouldDoLog(false);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Handle GNSS source selection (auto-select or filtered)
|
|
134
|
+
*/
|
|
135
|
+
handleSourceSelection(update) {
|
|
136
|
+
if (!this.options.filterSource) {
|
|
137
|
+
// Auto-select logic
|
|
138
|
+
const timeSinceLastPosition = this.lastPositionReceived
|
|
139
|
+
? (new Date().getTime() - this.lastPositionReceived) / 1000
|
|
140
|
+
: null;
|
|
141
|
+
|
|
142
|
+
if (!this.autoSelectedSource) {
|
|
143
|
+
this.autoSelectedSource = update.$source;
|
|
144
|
+
this.lastPositionReceived = new Date().getTime();
|
|
145
|
+
this.app.debug(`Auto-selected GNSS source: '${this.autoSelectedSource}'`);
|
|
146
|
+
} else if (update.$source !== this.autoSelectedSource) {
|
|
147
|
+
if (timeSinceLastPosition && timeSinceLastPosition > 300) {
|
|
148
|
+
this.app.debug(`Switching from stale source '${this.autoSelectedSource}' to '${update.$source}' (no data for ${timeSinceLastPosition.toFixed(0)}s)`);
|
|
149
|
+
this.autoSelectedSource = update.$source;
|
|
150
|
+
this.lastPositionReceived = new Date().getTime();
|
|
151
|
+
} else {
|
|
152
|
+
this.app.debug(`Ignoring position from '${update.$source}', using auto-selected source '${this.autoSelectedSource}'`);
|
|
153
|
+
return false;
|
|
154
|
+
}
|
|
155
|
+
} else {
|
|
156
|
+
this.lastPositionReceived = new Date().getTime();
|
|
157
|
+
}
|
|
158
|
+
} else if (update.$source !== this.options.filterSource) {
|
|
159
|
+
// Filtered source
|
|
160
|
+
this.app.debug(`Ignoring position from '${update.$source}', filterSource is set to '${this.options.filterSource}'`);
|
|
161
|
+
return false;
|
|
162
|
+
} else {
|
|
163
|
+
this.lastPositionReceived = new Date().getTime();
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
return true;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Validate position (lat/lon and not at 0,0)
|
|
171
|
+
*/
|
|
172
|
+
isValidPosition(position) {
|
|
173
|
+
// Check if near (0,0) - likely invalid
|
|
174
|
+
if (Math.abs(position.latitude) <= 0.01 && Math.abs(position.longitude) <= 0.01) {
|
|
175
|
+
this.app.debug('GNSS coordinates near (0,0), ignoring point to avoid invalid data logging.');
|
|
176
|
+
return false;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// Validate lat/lon ranges
|
|
180
|
+
if (!this.isValidLatitude(position.latitude) || !this.isValidLongitude(position.longitude)) {
|
|
181
|
+
this.app.debug('got invalid position, ignoring...', position);
|
|
182
|
+
return false;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
return true;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Check if 24h ping should force a save
|
|
190
|
+
*/
|
|
191
|
+
should24hPing() {
|
|
192
|
+
if (this.options.ping_api_every_24h && this.lastPosition) {
|
|
193
|
+
const timeSinceLastPoint = (new Date().getTime() - this.lastPosition.currentTime);
|
|
194
|
+
if (timeSinceLastPoint >= 24 * 60 * 60 * 1000) {
|
|
195
|
+
this.app.debug('24h since last point, forcing save of point to keep boat active on NFL');
|
|
196
|
+
return true;
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
return false;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Check if position should be logged based on timestamp and distance
|
|
204
|
+
*/
|
|
205
|
+
shouldLogPosition(timestamp, position) {
|
|
206
|
+
// Check timestamp
|
|
207
|
+
if (new Date(this.lastPosition.timestamp).getTime() > new Date(timestamp).getTime()) {
|
|
208
|
+
this.app.debug('got error in timestamp:', timestamp, 'is earlier than previous:', this.lastPosition.timestamp);
|
|
209
|
+
return false;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// Check distance
|
|
213
|
+
const distance = this.equirectangularDistance(this.lastPosition.pos, position);
|
|
214
|
+
if (this.options.minMove && distance < this.options.minMove) {
|
|
215
|
+
this.app.debug('Distance', distance.toFixed(2), 'm is less than minMove', this.options.minMove, 'm - skipping');
|
|
216
|
+
return false;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
this.app.debug('Distance', distance.toFixed(2), 'm is greater than minMove', this.options.minMove, 'm - logging');
|
|
220
|
+
return true;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* Save position point to file
|
|
225
|
+
*/
|
|
226
|
+
async savePoint(point) {
|
|
227
|
+
const obj = {
|
|
228
|
+
lat: point.pos.latitude,
|
|
229
|
+
lon: point.pos.longitude,
|
|
230
|
+
t: point.timestamp
|
|
231
|
+
};
|
|
232
|
+
this.app.debug(`save data point:`, obj);
|
|
233
|
+
await fs.appendFile(
|
|
234
|
+
path.join(this.trackDir, this.routeSaveName),
|
|
235
|
+
JSON.stringify(obj) + EOL
|
|
236
|
+
);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* Calculate distance between two positions
|
|
241
|
+
*/
|
|
242
|
+
equirectangularDistance(from, to) {
|
|
243
|
+
const rad = Math.PI / 180;
|
|
244
|
+
const φ1 = from.latitude * rad;
|
|
245
|
+
const φ2 = to.latitude * rad;
|
|
246
|
+
const Δλ = (to.longitude - from.longitude) * rad;
|
|
247
|
+
const R = 6371e3;
|
|
248
|
+
const x = Δλ * Math.cos((φ1 + φ2) / 2);
|
|
249
|
+
const y = (φ2 - φ1);
|
|
250
|
+
const d = Math.sqrt(x * x + y * y) * R;
|
|
251
|
+
return d;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* Validate latitude
|
|
256
|
+
*/
|
|
257
|
+
isValidLatitude(obj) {
|
|
258
|
+
return this.isDefinedNumber(obj) && obj > -90 && obj < 90;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* Validate longitude
|
|
263
|
+
*/
|
|
264
|
+
isValidLongitude(obj) {
|
|
265
|
+
return this.isDefinedNumber(obj) && obj > -180 && obj < 180;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* Check if value is a defined number
|
|
270
|
+
*/
|
|
271
|
+
isDefinedNumber(obj) {
|
|
272
|
+
return (obj !== undefined && obj !== null && typeof obj === 'number');
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* Stop logging and unsubscribe
|
|
277
|
+
*/
|
|
278
|
+
stop() {
|
|
279
|
+
this.unsubscribes.forEach(f => f());
|
|
280
|
+
this.unsubscribes = [];
|
|
281
|
+
this.autoSelectedSource = null;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/**
|
|
285
|
+
* Get last position
|
|
286
|
+
*/
|
|
287
|
+
getLastPosition() {
|
|
288
|
+
return this.lastPosition;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/**
|
|
292
|
+
* Get last position received time
|
|
293
|
+
*/
|
|
294
|
+
getLastPositionReceived() {
|
|
295
|
+
return this.lastPositionReceived;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/**
|
|
299
|
+
* Get auto-selected source
|
|
300
|
+
*/
|
|
301
|
+
getAutoSelectedSource() {
|
|
302
|
+
return this.autoSelectedSource;
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
module.exports = TrackLogger;
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
const fs = require('fs-extra');
|
|
2
|
+
const path = require('path');
|
|
3
|
+
|
|
4
|
+
class TrackMigration {
|
|
5
|
+
constructor(app, trackDir) {
|
|
6
|
+
this.app = app;
|
|
7
|
+
this.trackDir = trackDir;
|
|
8
|
+
this.routeSaveName = 'pending.jsonl';
|
|
9
|
+
this.routeSentName = 'sent.jsonl';
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Migrate old track file names to new naming scheme
|
|
14
|
+
*/
|
|
15
|
+
async migrate() {
|
|
16
|
+
const oldTrackFile = path.join(this.trackDir, 'nfl-track.jsonl');
|
|
17
|
+
const oldPendingFile = path.join(this.trackDir, 'nfl-track-pending.jsonl');
|
|
18
|
+
const oldSentFile = path.join(this.trackDir, 'nfl-track-sent.jsonl');
|
|
19
|
+
const newPendingFile = path.join(this.trackDir, this.routeSaveName);
|
|
20
|
+
const newSentFile = path.join(this.trackDir, this.routeSentName);
|
|
21
|
+
|
|
22
|
+
try {
|
|
23
|
+
// Migrate old track file
|
|
24
|
+
await this.migrateSingleFile(oldTrackFile, newPendingFile, 'track file');
|
|
25
|
+
|
|
26
|
+
// Migrate old pending file
|
|
27
|
+
await this.migrateSingleFile(oldPendingFile, newPendingFile, 'pending file');
|
|
28
|
+
|
|
29
|
+
// Migrate old sent file
|
|
30
|
+
await this.migrateSingleFile(oldSentFile, newSentFile, 'sent file');
|
|
31
|
+
|
|
32
|
+
// Check old plugin directory location
|
|
33
|
+
await this.migrateFromOldPluginDir(newPendingFile, newSentFile);
|
|
34
|
+
|
|
35
|
+
} catch (err) {
|
|
36
|
+
this.app.debug('Error during track file migration:', err.message);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Migrate a single file if it exists
|
|
42
|
+
*/
|
|
43
|
+
async migrateSingleFile(oldPath, newPath, description) {
|
|
44
|
+
if (await fs.pathExists(oldPath) && !(await fs.pathExists(newPath))) {
|
|
45
|
+
this.app.debug(`Migrating old ${description} to new naming scheme...`);
|
|
46
|
+
await fs.move(oldPath, newPath);
|
|
47
|
+
this.app.debug(`Successfully migrated old ${description} to: ${path.basename(newPath)}`);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Migrate track files from old plugin directory location
|
|
53
|
+
*/
|
|
54
|
+
async migrateFromOldPluginDir(newPendingFile, newSentFile) {
|
|
55
|
+
const oldPluginTrackDir = path.join(__dirname, '..', 'track');
|
|
56
|
+
|
|
57
|
+
if (!(await fs.pathExists(oldPluginTrackDir))) {
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
this.app.debug('Found old track directory in plugin folder, migrating to new location...');
|
|
62
|
+
|
|
63
|
+
// Migrate pending files
|
|
64
|
+
const oldPendingFiles = [
|
|
65
|
+
'nfl-track.jsonl',
|
|
66
|
+
'nfl-track-pending.jsonl',
|
|
67
|
+
this.routeSaveName
|
|
68
|
+
];
|
|
69
|
+
|
|
70
|
+
for (const oldFile of oldPendingFiles) {
|
|
71
|
+
const oldPath = path.join(oldPluginTrackDir, oldFile);
|
|
72
|
+
if (await fs.pathExists(oldPath) && !(await fs.pathExists(newPendingFile))) {
|
|
73
|
+
await fs.move(oldPath, newPendingFile);
|
|
74
|
+
this.app.debug('Migrated pending track file from old plugin location');
|
|
75
|
+
break;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Migrate sent archive
|
|
80
|
+
const oldSentFiles = [this.routeSentName, 'nfl-track-sent.jsonl'];
|
|
81
|
+
for (const oldFile of oldSentFiles) {
|
|
82
|
+
const oldPath = path.join(oldPluginTrackDir, oldFile);
|
|
83
|
+
if (await fs.pathExists(oldPath) && !(await fs.pathExists(newSentFile))) {
|
|
84
|
+
await fs.move(oldPath, newSentFile);
|
|
85
|
+
this.app.debug('Migrated sent track archive from old plugin location');
|
|
86
|
+
break;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Try to remove old directory if empty
|
|
91
|
+
await this.removeOldDirectoryIfEmpty(oldPluginTrackDir);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Remove old directory if it's empty
|
|
96
|
+
*/
|
|
97
|
+
async removeOldDirectoryIfEmpty(dirPath) {
|
|
98
|
+
try {
|
|
99
|
+
const remainingFiles = await fs.readdir(dirPath);
|
|
100
|
+
if (remainingFiles.length === 0) {
|
|
101
|
+
await fs.rmdir(dirPath);
|
|
102
|
+
this.app.debug('Removed empty old track directory');
|
|
103
|
+
}
|
|
104
|
+
} catch (err) {
|
|
105
|
+
this.app.debug('Could not remove old track directory:', err.message);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
module.exports = TrackMigration;
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
const fs = require('fs-extra');
|
|
2
|
+
const path = require('path');
|
|
3
|
+
const readline = require('readline');
|
|
4
|
+
const fetch = require('node-fetch');
|
|
5
|
+
|
|
6
|
+
class TrackSender {
|
|
7
|
+
constructor(app, options, trackDir) {
|
|
8
|
+
this.app = app;
|
|
9
|
+
this.options = options;
|
|
10
|
+
this.trackDir = trackDir;
|
|
11
|
+
this.routeSaveName = 'pending.jsonl';
|
|
12
|
+
this.routeSentName = 'sent.jsonl';
|
|
13
|
+
|
|
14
|
+
this.apiUrl = 'https://www.noforeignland.com/home/api/v1/boat/tracking/track';
|
|
15
|
+
this.pluginApiKey = '0ede6cb6-5213-45f5-8ab4-b4836b236f97';
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Check if boat is currently moving
|
|
20
|
+
*/
|
|
21
|
+
isBoatMoving(lastPosition, upSince) {
|
|
22
|
+
if (!this.options.trackFrequency) {
|
|
23
|
+
return true;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const time = lastPosition ? lastPosition.currentTime : upSince;
|
|
27
|
+
const secsSinceLastPoint = (new Date().getTime() - time) / 1000;
|
|
28
|
+
const isMoving = secsSinceLastPoint <= (this.options.trackFrequency * 2);
|
|
29
|
+
|
|
30
|
+
if (isMoving) {
|
|
31
|
+
this.app.debug('Boat is still moving, last move', secsSinceLastPoint, 'seconds ago');
|
|
32
|
+
return this.options.sendWhileMoving;
|
|
33
|
+
} else {
|
|
34
|
+
this.app.debug('Boat stopped moving, last move at least', secsSinceLastPoint, 'seconds ago');
|
|
35
|
+
return true;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Check if track file exists and has content
|
|
41
|
+
*/
|
|
42
|
+
async hasTrackData() {
|
|
43
|
+
const trackFile = path.join(this.trackDir, this.routeSaveName);
|
|
44
|
+
this.app.debug('checking the track', trackFile, 'if should send');
|
|
45
|
+
|
|
46
|
+
const exists = await fs.pathExists(trackFile);
|
|
47
|
+
const size = exists ? (await fs.lstat(trackFile)).size : 0;
|
|
48
|
+
|
|
49
|
+
this.app.debug(`'${trackFile}'.size=${size} ${trackFile}'.exists=${exists}`);
|
|
50
|
+
return size > 0;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Send track data to API
|
|
55
|
+
*/
|
|
56
|
+
async sendTrack() {
|
|
57
|
+
if (!this.options.boatApiKey) {
|
|
58
|
+
throw new Error('No boat API key set in plugin settings.');
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
this.app.debug('sending the data');
|
|
62
|
+
const pendingFile = path.join(this.trackDir, this.routeSaveName);
|
|
63
|
+
const trackData = await this.createTrackFromFile(pendingFile);
|
|
64
|
+
|
|
65
|
+
if (!trackData) {
|
|
66
|
+
throw new Error('Recorded track did not contain any valid track points, aborting sending.');
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
this.app.debug('created track data with timestamp:', new Date(trackData.timestamp));
|
|
70
|
+
|
|
71
|
+
const params = new URLSearchParams();
|
|
72
|
+
params.append('timestamp', trackData.timestamp);
|
|
73
|
+
params.append('track', JSON.stringify(trackData.track));
|
|
74
|
+
params.append('boatApiKey', this.options.boatApiKey);
|
|
75
|
+
|
|
76
|
+
const headers = { 'X-NFL-API-Key': this.pluginApiKey };
|
|
77
|
+
this.app.debug('sending track to API');
|
|
78
|
+
|
|
79
|
+
const success = await this.sendWithRetry(params, headers);
|
|
80
|
+
|
|
81
|
+
if (success) {
|
|
82
|
+
await this.handleSuccessfulSend(pendingFile);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
return success;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Send data with retry logic
|
|
90
|
+
*/
|
|
91
|
+
async sendWithRetry(params, headers) {
|
|
92
|
+
const maxRetries = 3;
|
|
93
|
+
const baseTimeout = (this.options.apiTimeout || 30) * 1000;
|
|
94
|
+
|
|
95
|
+
for (let attempt = 1; attempt <= maxRetries; attempt++) {
|
|
96
|
+
try {
|
|
97
|
+
const currentTimeout = baseTimeout * attempt;
|
|
98
|
+
this.app.debug(`Attempt ${attempt}/${maxRetries} with ${currentTimeout}ms timeout`);
|
|
99
|
+
|
|
100
|
+
const controller = new AbortController();
|
|
101
|
+
const timeoutId = setTimeout(() => controller.abort(), currentTimeout);
|
|
102
|
+
|
|
103
|
+
const response = await fetch(this.apiUrl, {
|
|
104
|
+
method: 'POST',
|
|
105
|
+
body: params,
|
|
106
|
+
headers: new fetch.Headers(headers),
|
|
107
|
+
signal: controller.signal
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
clearTimeout(timeoutId);
|
|
111
|
+
|
|
112
|
+
if (response.ok) {
|
|
113
|
+
const responseBody = await response.json();
|
|
114
|
+
if (responseBody.status === 'ok') {
|
|
115
|
+
this.app.debug('Track successfully sent to API');
|
|
116
|
+
return true;
|
|
117
|
+
} else {
|
|
118
|
+
this.app.debug('Could not send track to API, returned response json:', responseBody);
|
|
119
|
+
throw new Error('API returned error.');
|
|
120
|
+
}
|
|
121
|
+
} else {
|
|
122
|
+
this.app.debug('Could not send track to API, returned response code:', response.status, response.statusText);
|
|
123
|
+
if (response.status >= 400 && response.status < 500) {
|
|
124
|
+
throw new Error(`HTTP ${response.status}.`);
|
|
125
|
+
}
|
|
126
|
+
throw new Error(`HTTP ${response.status}`);
|
|
127
|
+
}
|
|
128
|
+
} catch (err) {
|
|
129
|
+
this.app.debug(`Attempt ${attempt} failed:`, err.message);
|
|
130
|
+
|
|
131
|
+
if (attempt === maxRetries) {
|
|
132
|
+
this.app.debug('Could not send track to API after', maxRetries, 'attempts:', err);
|
|
133
|
+
throw new Error(`Failed to send track after ${maxRetries} attempts - check logs for details.`);
|
|
134
|
+
} else {
|
|
135
|
+
const waitTime = 2000 * attempt;
|
|
136
|
+
this.app.debug(`Waiting ${waitTime}ms before retry...`);
|
|
137
|
+
await new Promise(resolve => setTimeout(resolve, waitTime));
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
return false;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Handle successful send (archive or delete track file)
|
|
147
|
+
*/
|
|
148
|
+
async handleSuccessfulSend(pendingFile) {
|
|
149
|
+
const sentFile = path.join(this.trackDir, this.routeSentName);
|
|
150
|
+
|
|
151
|
+
try {
|
|
152
|
+
if (this.options.keepFiles) {
|
|
153
|
+
this.app.debug('Appending sent data to archive file:', this.routeSentName);
|
|
154
|
+
const pendingContent = await fs.readFile(pendingFile, 'utf8');
|
|
155
|
+
await fs.appendFile(sentFile, pendingContent);
|
|
156
|
+
this.app.debug('Successfully archived sent track data');
|
|
157
|
+
} else {
|
|
158
|
+
this.app.debug('keepFiles disabled, will delete pending file');
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
this.app.debug('Deleting pending track file');
|
|
162
|
+
await fs.remove(pendingFile);
|
|
163
|
+
this.app.debug('Successfully processed track files after send');
|
|
164
|
+
|
|
165
|
+
} catch (err) {
|
|
166
|
+
this.app.debug('Error handling files after successful send:', err.message);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Create track data from file
|
|
172
|
+
*/
|
|
173
|
+
async createTrackFromFile(inputPath) {
|
|
174
|
+
const fileStream = fs.createReadStream(inputPath);
|
|
175
|
+
const rl = readline.createInterface({ input: fileStream, crlfDelay: Infinity });
|
|
176
|
+
const track = [];
|
|
177
|
+
let lastTimestamp;
|
|
178
|
+
|
|
179
|
+
for await (const line of rl) {
|
|
180
|
+
if (line) {
|
|
181
|
+
try {
|
|
182
|
+
const point = JSON.parse(line);
|
|
183
|
+
const timestamp = new Date(point.t).getTime();
|
|
184
|
+
|
|
185
|
+
if (!isNaN(timestamp) &&
|
|
186
|
+
this.isValidLatitude(point.lat) &&
|
|
187
|
+
this.isValidLongitude(point.lon)) {
|
|
188
|
+
track.push([timestamp, point.lat, point.lon]);
|
|
189
|
+
lastTimestamp = timestamp;
|
|
190
|
+
}
|
|
191
|
+
} catch (error) {
|
|
192
|
+
this.app.debug('could not parse line from track file:', line);
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
if (track.length > 0) {
|
|
198
|
+
return { timestamp: new Date(lastTimestamp).getTime(), track };
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
return null;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Validate latitude
|
|
206
|
+
*/
|
|
207
|
+
isValidLatitude(obj) {
|
|
208
|
+
return (obj !== undefined && obj !== null && typeof obj === 'number' && obj > -90 && obj < 90);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Validate longitude
|
|
213
|
+
*/
|
|
214
|
+
isValidLongitude(obj) {
|
|
215
|
+
return (obj !== undefined && obj !== null && typeof obj === 'number' && obj > -180 && obj < 180);
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
module.exports = TrackSender;
|
package/package.json
CHANGED
package/doc/dev
DELETED
|
@@ -1,17 +0,0 @@
|
|
|
1
|
-
|
|
2
|
-
INTERNAL NOTES ONLY, IGNORE.
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
cd ~/dirk/nfl-signalk
|
|
6
|
-
|
|
7
|
-
# Aktuelle Version zu Beta machen
|
|
8
|
-
npm version 1.0.1-beta.1 --no-git-tag-version
|
|
9
|
-
|
|
10
|
-
# Package bauen
|
|
11
|
-
npm pack
|
|
12
|
-
|
|
13
|
-
# Mit beta tag publishen (nicht als 'latest')
|
|
14
|
-
npm publish --tag beta
|
|
15
|
-
|
|
16
|
-
# Falls du noch nicht eingeloggt bist:
|
|
17
|
-
npm login
|