@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,216 @@
|
|
|
1
|
+
const path = require('path');
|
|
2
|
+
|
|
3
|
+
const defaultTracksDir = 'nfl-track';
|
|
4
|
+
|
|
5
|
+
class ConfigManager {
|
|
6
|
+
constructor(app) {
|
|
7
|
+
this.app = app;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Migrate old flat config structure to new grouped structure
|
|
12
|
+
*/
|
|
13
|
+
async migrateOldConfig(options) {
|
|
14
|
+
if (options.boatApiKey && !options.mandatory) {
|
|
15
|
+
this.app.debug('Migrating old configuration to new grouped structure');
|
|
16
|
+
|
|
17
|
+
const migratedOptions = {
|
|
18
|
+
mandatory: {
|
|
19
|
+
boatApiKey: options.boatApiKey
|
|
20
|
+
},
|
|
21
|
+
advanced: {
|
|
22
|
+
minMove: options.minMove !== undefined ? options.minMove : 50,
|
|
23
|
+
minSpeed: options.minSpeed !== undefined ? options.minSpeed : 1.5,
|
|
24
|
+
sendWhileMoving: options.sendWhileMoving !== undefined ? options.sendWhileMoving : true,
|
|
25
|
+
ping_api_every_24h: options.ping_api_every_24h !== undefined ? options.ping_api_every_24h : true
|
|
26
|
+
},
|
|
27
|
+
expert: {
|
|
28
|
+
filterSource: options.filterSource,
|
|
29
|
+
trackDir: options.trackDir,
|
|
30
|
+
keepFiles: options.keepFiles !== undefined ? options.keepFiles : false,
|
|
31
|
+
trackFrequency: options.trackFrequency !== undefined ? options.trackFrequency : 60,
|
|
32
|
+
internetTestTimeout: options.internetTestTimeout !== undefined ? options.internetTestTimeout : 2000,
|
|
33
|
+
apiCron: options.apiCron || '*/10 * * * *',
|
|
34
|
+
apiTimeout: options.apiTimeout !== undefined ? options.apiTimeout : 30
|
|
35
|
+
}
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
try {
|
|
39
|
+
this.app.debug('Saving migrated configuration...');
|
|
40
|
+
await this.app.savePluginOptions(migratedOptions, () => {
|
|
41
|
+
this.app.debug('Configuration successfully migrated and saved');
|
|
42
|
+
});
|
|
43
|
+
return { options: migratedOptions, migrated: true };
|
|
44
|
+
} catch (err) {
|
|
45
|
+
this.app.debug('Failed to save migrated configuration:', err.message);
|
|
46
|
+
return { options: migratedOptions, migrated: true };
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
return { options, migrated: false };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Flatten nested config structure and apply defaults
|
|
55
|
+
*/
|
|
56
|
+
flattenConfig(options) {
|
|
57
|
+
return {
|
|
58
|
+
boatApiKey: options.mandatory?.boatApiKey,
|
|
59
|
+
minMove: options.advanced?.minMove !== undefined ? options.advanced.minMove : 80,
|
|
60
|
+
minSpeed: options.advanced?.minSpeed !== undefined ? options.advanced.minSpeed : 1.5,
|
|
61
|
+
sendWhileMoving: options.advanced?.sendWhileMoving !== undefined ? options.advanced.sendWhileMoving : true,
|
|
62
|
+
ping_api_every_24h: options.advanced?.ping_api_every_24h !== undefined ? options.advanced.ping_api_every_24h : true,
|
|
63
|
+
filterSource: options.expert?.filterSource,
|
|
64
|
+
trackDir: options.expert?.trackDir || defaultTracksDir,
|
|
65
|
+
keepFiles: options.expert?.keepFiles !== undefined ? options.expert.keepFiles : false,
|
|
66
|
+
trackFrequency: options.expert?.trackFrequency !== undefined ? options.expert.trackFrequency : 60,
|
|
67
|
+
internetTestTimeout: options.expert?.internetTestTimeout !== undefined ? options.expert.internetTestTimeout : 2000,
|
|
68
|
+
apiCron: options.expert?.apiCron || '*/10 * * * *',
|
|
69
|
+
apiTimeout: options.expert?.apiTimeout !== undefined ? options.expert.apiTimeout : 30
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Validate boat API key
|
|
75
|
+
*/
|
|
76
|
+
validateApiKey(config) {
|
|
77
|
+
if (!config.boatApiKey || config.boatApiKey.trim() === '') {
|
|
78
|
+
throw new Error(
|
|
79
|
+
'No boat API key configured. Please set your API key in plugin settings ' +
|
|
80
|
+
'(Mandatory Settings > Boat API key). You can find your API key at ' +
|
|
81
|
+
'noforeignland.com under Account > Settings > Boat tracking > API Key.'
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Resolve track directory path (absolute or relative to data dir)
|
|
88
|
+
*/
|
|
89
|
+
resolveTrackDir(config, dataDirPath) {
|
|
90
|
+
if (!path.isAbsolute(config.trackDir)) {
|
|
91
|
+
return path.join(dataDirPath, config.trackDir);
|
|
92
|
+
}
|
|
93
|
+
return config.trackDir;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Randomize CRON schedule to avoid all instances running at same time
|
|
98
|
+
*/
|
|
99
|
+
randomizeCron(config) {
|
|
100
|
+
if (!config.apiCron || config.apiCron === '*/10 * * * *') {
|
|
101
|
+
const startMinute = Math.floor(Math.random() * 10);
|
|
102
|
+
const startSecond = Math.floor(Math.random() * 60);
|
|
103
|
+
config.apiCron = `${startSecond} ${startMinute}/10 * * * *`;
|
|
104
|
+
}
|
|
105
|
+
return config;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Get the full schema for plugin configuration
|
|
110
|
+
*/
|
|
111
|
+
static getSchema(pluginName) {
|
|
112
|
+
return {
|
|
113
|
+
title: pluginName,
|
|
114
|
+
description: 'Some parameters need for use',
|
|
115
|
+
type: 'object',
|
|
116
|
+
properties: {
|
|
117
|
+
// Mandatory Settings Group
|
|
118
|
+
mandatory: {
|
|
119
|
+
type: 'object',
|
|
120
|
+
title: 'Mandatory Settings',
|
|
121
|
+
properties: {
|
|
122
|
+
boatApiKey: {
|
|
123
|
+
type: 'string',
|
|
124
|
+
title: 'Boat API Key',
|
|
125
|
+
description: 'Boat API Key from noforeignland.com. Can be found in Account > Settings > Boat tracking > API Key.'
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
},
|
|
129
|
+
|
|
130
|
+
// Advanced Settings Group
|
|
131
|
+
advanced: {
|
|
132
|
+
type: 'object',
|
|
133
|
+
title: 'Advanced Settings',
|
|
134
|
+
properties: {
|
|
135
|
+
minMove: {
|
|
136
|
+
type: 'number',
|
|
137
|
+
title: 'Minimum boat move to log in meters',
|
|
138
|
+
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)',
|
|
139
|
+
default: 80
|
|
140
|
+
},
|
|
141
|
+
minSpeed: {
|
|
142
|
+
type: 'number',
|
|
143
|
+
title: 'Minimum boat speed to log in knots',
|
|
144
|
+
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)',
|
|
145
|
+
default: 1.5
|
|
146
|
+
},
|
|
147
|
+
sendWhileMoving: {
|
|
148
|
+
type: 'boolean',
|
|
149
|
+
title: 'Attempt sending location while moving',
|
|
150
|
+
description: 'Should the plugin attempt to send tracking data to NFL while detecting the vessel is moving or only when stopped?',
|
|
151
|
+
default: true
|
|
152
|
+
},
|
|
153
|
+
ping_api_every_24h: {
|
|
154
|
+
type: 'boolean',
|
|
155
|
+
title: 'Force a send every 24 hours',
|
|
156
|
+
description: 'Keeps your boat active on NFL in your current location even if you do not move',
|
|
157
|
+
default: true
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
},
|
|
161
|
+
|
|
162
|
+
// Expert Settings Group
|
|
163
|
+
expert: {
|
|
164
|
+
type: 'object',
|
|
165
|
+
title: 'Expert Settings',
|
|
166
|
+
properties: {
|
|
167
|
+
filterSource: {
|
|
168
|
+
type: 'string',
|
|
169
|
+
title: 'Position source device',
|
|
170
|
+
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.'
|
|
171
|
+
},
|
|
172
|
+
trackDir: {
|
|
173
|
+
type: 'string',
|
|
174
|
+
title: 'Directory to cache tracks',
|
|
175
|
+
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'
|
|
176
|
+
},
|
|
177
|
+
keepFiles: {
|
|
178
|
+
type: 'boolean',
|
|
179
|
+
title: 'Keep track files on disk',
|
|
180
|
+
description: 'If you have a lot of hard drive space you can keep the track files for logging purposes.',
|
|
181
|
+
default: false
|
|
182
|
+
},
|
|
183
|
+
trackFrequency: {
|
|
184
|
+
type: 'integer',
|
|
185
|
+
title: 'Position tracking frequency in seconds',
|
|
186
|
+
description: 'To keep file sizes small we only log positions once in a while (unless you set this value to 0)',
|
|
187
|
+
default: 60
|
|
188
|
+
},
|
|
189
|
+
apiCron: {
|
|
190
|
+
type: 'string',
|
|
191
|
+
title: 'Send attempt CRON',
|
|
192
|
+
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/',
|
|
193
|
+
default: '*/10 * * * *'
|
|
194
|
+
},
|
|
195
|
+
internetTestTimeout: {
|
|
196
|
+
type: 'number',
|
|
197
|
+
title: 'Timeout for testing internet connection in ms',
|
|
198
|
+
description: 'Set this number higher for slower computers and internet connections',
|
|
199
|
+
default: 2000
|
|
200
|
+
},
|
|
201
|
+
apiTimeout: {
|
|
202
|
+
type: 'integer',
|
|
203
|
+
title: 'API request timeout in seconds',
|
|
204
|
+
description: 'Timeout for sending data to NFL API. Increase for slow connections.',
|
|
205
|
+
default: 30,
|
|
206
|
+
minimum: 10,
|
|
207
|
+
maximum: 180
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
};
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
module.exports = ConfigManager;
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
class DataPathEmitter {
|
|
2
|
+
constructor(app, pluginId) {
|
|
3
|
+
this.app = app;
|
|
4
|
+
this.pluginId = pluginId;
|
|
5
|
+
this.currentError = null;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Emit SignalK delta for a data path
|
|
10
|
+
*/
|
|
11
|
+
emitDelta(path, value) {
|
|
12
|
+
try {
|
|
13
|
+
const delta = {
|
|
14
|
+
context: 'vessels.self',
|
|
15
|
+
updates: [{
|
|
16
|
+
timestamp: new Date().toISOString(),
|
|
17
|
+
values: [{
|
|
18
|
+
path: path,
|
|
19
|
+
value: value
|
|
20
|
+
}]
|
|
21
|
+
}]
|
|
22
|
+
};
|
|
23
|
+
this.app.handleMessage(this.pluginId, delta);
|
|
24
|
+
} catch (err) {
|
|
25
|
+
this.app.debug(`Failed to emit delta for ${path}:`, err.message);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Update status paths with current state
|
|
31
|
+
*/
|
|
32
|
+
updateStatusPaths(options, lastPosition, lastSuccessfulTransfer, autoSelectedSource) {
|
|
33
|
+
const hasError = this.currentError !== null;
|
|
34
|
+
|
|
35
|
+
if (!hasError) {
|
|
36
|
+
const activeSource = options.filterSource || autoSelectedSource || '';
|
|
37
|
+
const saveTime = lastPosition
|
|
38
|
+
? new Date(lastPosition.currentTime).toLocaleTimeString()
|
|
39
|
+
: 'None since start';
|
|
40
|
+
const transferTime = lastSuccessfulTransfer
|
|
41
|
+
? lastSuccessfulTransfer.toLocaleTimeString()
|
|
42
|
+
: 'None since start';
|
|
43
|
+
const shortStatus = `Save: ${saveTime} | Transfer: ${transferTime}`;
|
|
44
|
+
|
|
45
|
+
this.emitDelta('noforeignland.status', shortStatus);
|
|
46
|
+
this.emitDelta('noforeignland.source', activeSource);
|
|
47
|
+
} else {
|
|
48
|
+
this.emitDelta('noforeignland.status', `ERROR: ${this.currentError}`);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
this.emitDelta('noforeignland.status_boolean', hasError ? 1 : 0);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Emit savepoint deltas
|
|
56
|
+
*/
|
|
57
|
+
emitSavepoint() {
|
|
58
|
+
const now = new Date();
|
|
59
|
+
this.emitDelta('noforeignland.savepoint', now.toISOString());
|
|
60
|
+
this.emitDelta('noforeignland.savepoint_local', now.toLocaleString());
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Emit API transfer deltas
|
|
65
|
+
*/
|
|
66
|
+
emitApiTransfer(transferTime) {
|
|
67
|
+
this.emitDelta('noforeignland.sent_to_api', transferTime.toISOString());
|
|
68
|
+
this.emitDelta('noforeignland.sent_to_api_local', transferTime.toLocaleString());
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Set error state
|
|
73
|
+
*/
|
|
74
|
+
setError(error) {
|
|
75
|
+
this.currentError = error;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Clear error state
|
|
80
|
+
*/
|
|
81
|
+
clearError() {
|
|
82
|
+
this.currentError = null;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Get current error
|
|
87
|
+
*/
|
|
88
|
+
getError() {
|
|
89
|
+
return this.currentError;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
module.exports = DataPathEmitter;
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
const fs = require('fs');
|
|
2
|
+
|
|
3
|
+
class DirectoryUtils {
|
|
4
|
+
/**
|
|
5
|
+
* Create directory with proper error handling
|
|
6
|
+
*/
|
|
7
|
+
static createDir(dir, app) {
|
|
8
|
+
if (fs.existsSync(dir)) {
|
|
9
|
+
try {
|
|
10
|
+
fs.accessSync(dir, fs.constants.R_OK | fs.constants.W_OK);
|
|
11
|
+
return true;
|
|
12
|
+
} catch (error) {
|
|
13
|
+
app.debug('[createDir]', error.message);
|
|
14
|
+
throw new Error(`No rights to directory ${dir}`);
|
|
15
|
+
}
|
|
16
|
+
} else {
|
|
17
|
+
try {
|
|
18
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
19
|
+
return true;
|
|
20
|
+
} catch (error) {
|
|
21
|
+
switch (error.code) {
|
|
22
|
+
case 'EACCES':
|
|
23
|
+
case 'EPERM':
|
|
24
|
+
app.debug(`Failed to create ${dir} by Permission denied`);
|
|
25
|
+
throw new Error(`Failed to create ${dir} by Permission denied`);
|
|
26
|
+
case 'ETIMEDOUT':
|
|
27
|
+
app.debug(`Failed to create ${dir} by Operation timed out`);
|
|
28
|
+
throw new Error(`Failed to create ${dir} by Operation timed out`);
|
|
29
|
+
default:
|
|
30
|
+
app.debug(`Error creating directory ${dir}: ${error.message}`);
|
|
31
|
+
throw new Error(`Error creating directory ${dir}: ${error.message}`);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
module.exports = DirectoryUtils;
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
class HealthMonitor {
|
|
2
|
+
constructor(app, options) {
|
|
3
|
+
this.app = app;
|
|
4
|
+
this.options = options;
|
|
5
|
+
this.checkInterval = null;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Start position health monitoring
|
|
10
|
+
*/
|
|
11
|
+
start(getLastPositionReceived, getAutoSelectedSource, onError, onHealthy) {
|
|
12
|
+
// Periodic health check every 5 minutes
|
|
13
|
+
this.checkInterval = setInterval(() => {
|
|
14
|
+
this.performHealthCheck(
|
|
15
|
+
getLastPositionReceived(),
|
|
16
|
+
getAutoSelectedSource(),
|
|
17
|
+
onError,
|
|
18
|
+
onHealthy
|
|
19
|
+
);
|
|
20
|
+
}, 5 * 60 * 1000);
|
|
21
|
+
|
|
22
|
+
// Initial check after 2 minutes of startup
|
|
23
|
+
setTimeout(() => {
|
|
24
|
+
this.performInitialCheck(
|
|
25
|
+
getLastPositionReceived(),
|
|
26
|
+
getAutoSelectedSource(),
|
|
27
|
+
onError
|
|
28
|
+
);
|
|
29
|
+
}, 2 * 60 * 1000);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Perform periodic health check
|
|
34
|
+
*/
|
|
35
|
+
performHealthCheck(lastPositionReceived, autoSelectedSource, onError, onHealthy) {
|
|
36
|
+
const now = new Date().getTime();
|
|
37
|
+
const timeSinceLastPosition = lastPositionReceived
|
|
38
|
+
? (now - lastPositionReceived) / 1000
|
|
39
|
+
: null;
|
|
40
|
+
|
|
41
|
+
const activeSource = this.options.filterSource || autoSelectedSource || 'any';
|
|
42
|
+
const filterMsg = activeSource !== 'any' ? ` from source '${activeSource}'` : '';
|
|
43
|
+
|
|
44
|
+
if (!lastPositionReceived) {
|
|
45
|
+
const errorMsg = this.options.filterSource
|
|
46
|
+
? `No GNSS position data received from filtered source '${this.options.filterSource}'. Check Expert Settings > Position source device, or leave empty to use any GNSS source.`
|
|
47
|
+
: 'No GNSS position data received. Check that your GNSS is connected and SignalK is receiving navigation.position data.';
|
|
48
|
+
|
|
49
|
+
onError(errorMsg);
|
|
50
|
+
this.app.debug('Position health check: No position data ever received' + filterMsg);
|
|
51
|
+
} else if (timeSinceLastPosition > 300) {
|
|
52
|
+
const errorMsg = this.options.filterSource
|
|
53
|
+
? `No GNSS 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.`
|
|
54
|
+
: `No GNSS position data${filterMsg} for ${Math.floor(timeSinceLastPosition / 60)} minutes. Check your GNSS connection.`;
|
|
55
|
+
|
|
56
|
+
onError(errorMsg);
|
|
57
|
+
this.app.debug(`Position health check: No position for ${timeSinceLastPosition.toFixed(0)} seconds` + filterMsg);
|
|
58
|
+
} else {
|
|
59
|
+
this.app.debug(`Position health check: OK (last position ${timeSinceLastPosition.toFixed(0)} seconds ago${filterMsg})`);
|
|
60
|
+
onHealthy();
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Perform initial health check after startup
|
|
66
|
+
*/
|
|
67
|
+
performInitialCheck(lastPositionReceived, autoSelectedSource, onError) {
|
|
68
|
+
if (!lastPositionReceived) {
|
|
69
|
+
const activeSource = this.options.filterSource || autoSelectedSource || 'any';
|
|
70
|
+
const errorMsg = this.options.filterSource
|
|
71
|
+
? `No GNSS 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 GNSS source.`
|
|
72
|
+
: 'No GNSS position data received after 2 minutes. Check that your GNSS is connected and SignalK is receiving navigation.position data.';
|
|
73
|
+
|
|
74
|
+
onError(errorMsg);
|
|
75
|
+
this.app.debug('Initial position check: No position data received' +
|
|
76
|
+
(activeSource !== 'any' ? ` from source '${activeSource}'` : ''));
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Stop health monitoring
|
|
82
|
+
*/
|
|
83
|
+
stop() {
|
|
84
|
+
if (this.checkInterval) {
|
|
85
|
+
clearInterval(this.checkInterval);
|
|
86
|
+
this.checkInterval = null;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
module.exports = HealthMonitor;
|
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
const fs = require('fs-extra');
|
|
2
|
+
const path = require('path');
|
|
3
|
+
|
|
4
|
+
class PluginCleanup {
|
|
5
|
+
constructor(app) {
|
|
6
|
+
this.app = app;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Cleanup old plugin versions (signalk-to-noforeignland and signalk-to-nfl)
|
|
11
|
+
*/
|
|
12
|
+
async cleanup() {
|
|
13
|
+
try {
|
|
14
|
+
// Detect SignalK directory (standard or Victron Cerbo)
|
|
15
|
+
const victronPath = '/data/conf/signalk';
|
|
16
|
+
const standardPath = process.env.SIGNALK_NODE_CONFIG_DIR ||
|
|
17
|
+
path.join(process.env.HOME || process.env.USERPROFILE, '.signalk');
|
|
18
|
+
|
|
19
|
+
const configDir = fs.existsSync(victronPath) ? victronPath : standardPath;
|
|
20
|
+
this.app.debug(`Using SignalK directory: ${configDir}`);
|
|
21
|
+
|
|
22
|
+
const configPath = path.join(configDir, 'plugin-config-data');
|
|
23
|
+
|
|
24
|
+
// 1. Config Migration - only from signalk-to-noforeignland
|
|
25
|
+
await this.migrateConfig(configPath);
|
|
26
|
+
|
|
27
|
+
// 2. Verify what plugins are actually present
|
|
28
|
+
this.logInstalledPlugins(configDir);
|
|
29
|
+
|
|
30
|
+
// 3. Check and remove old plugins
|
|
31
|
+
return await this.removeOldPlugins(configDir);
|
|
32
|
+
|
|
33
|
+
} catch (err) {
|
|
34
|
+
this.app.debug('Error during old plugin cleanup:', err.message);
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Log which SignalK NFL plugins are currently installed (for debugging)
|
|
41
|
+
*/
|
|
42
|
+
logInstalledPlugins(configDir) {
|
|
43
|
+
const nodeModulesDir = path.join(configDir, 'node_modules');
|
|
44
|
+
const pluginsToCheck = [
|
|
45
|
+
'@noforeignland/signalk-to-noforeignland',
|
|
46
|
+
'signalk-to-noforeignland',
|
|
47
|
+
'signalk-to-nfl'
|
|
48
|
+
];
|
|
49
|
+
|
|
50
|
+
const found = [];
|
|
51
|
+
for (const pluginName of pluginsToCheck) {
|
|
52
|
+
const pluginPath = path.join(nodeModulesDir, pluginName);
|
|
53
|
+
if (fs.existsSync(pluginPath)) {
|
|
54
|
+
const packageJsonPath = path.join(pluginPath, 'package.json');
|
|
55
|
+
let version = 'unknown';
|
|
56
|
+
try {
|
|
57
|
+
if (fs.existsSync(packageJsonPath)) {
|
|
58
|
+
const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8'));
|
|
59
|
+
version = packageJson.version;
|
|
60
|
+
}
|
|
61
|
+
} catch (e) {
|
|
62
|
+
// Ignore version read errors
|
|
63
|
+
}
|
|
64
|
+
found.push(`${pluginName}@${version}`);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
if (found.length > 0) {
|
|
69
|
+
this.app.debug(`Installed NFL plugins: ${found.join(', ')}`);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Migrate config from old plugin ID to new plugin ID
|
|
75
|
+
*
|
|
76
|
+
* Handles config migration from:
|
|
77
|
+
* 1. Old unscoped plugin "signalk-to-noforeignland" (v0.1.x)
|
|
78
|
+
* 2. Beta versions with wrong plugin ID (v1.1.0-beta.1/2/3)
|
|
79
|
+
*
|
|
80
|
+
* Both used config filename: "signalk-to-noforeignland.json"
|
|
81
|
+
* New version uses: "@noforeignland-signalk-to-noforeignland.json"
|
|
82
|
+
*
|
|
83
|
+
* Since both sources use the same filename, we simply copy if it exists.
|
|
84
|
+
*/
|
|
85
|
+
async migrateConfig(configPath) {
|
|
86
|
+
const oldConfigFile = path.join(configPath, 'signalk-to-noforeignland.json');
|
|
87
|
+
const newConfigFile = path.join(configPath, '@noforeignland-signalk-to-noforeignland.json');
|
|
88
|
+
|
|
89
|
+
// Only migrate if old config exists and new config doesn't
|
|
90
|
+
if (fs.existsSync(oldConfigFile) && !fs.existsSync(newConfigFile)) {
|
|
91
|
+
this.app.debug('Migrating configuration from old plugin to new scoped plugin...');
|
|
92
|
+
this.app.debug(` Source: ${oldConfigFile}`);
|
|
93
|
+
this.app.debug(` Target: ${newConfigFile}`);
|
|
94
|
+
|
|
95
|
+
try {
|
|
96
|
+
// Copy to new location
|
|
97
|
+
fs.copyFileSync(oldConfigFile, newConfigFile);
|
|
98
|
+
|
|
99
|
+
// Create backup of old config
|
|
100
|
+
const backupFile = `${oldConfigFile}.backup-${Date.now()}`;
|
|
101
|
+
fs.copyFileSync(oldConfigFile, backupFile);
|
|
102
|
+
|
|
103
|
+
this.app.debug('✓ Configuration successfully migrated');
|
|
104
|
+
this.app.debug(` Backup saved: ${backupFile}`);
|
|
105
|
+
} catch (err) {
|
|
106
|
+
this.app.debug(`⨯ Config migration failed: ${err.message}`);
|
|
107
|
+
this.app.debug(' You may need to reconfigure the plugin manually');
|
|
108
|
+
}
|
|
109
|
+
} else if (fs.existsSync(newConfigFile)) {
|
|
110
|
+
this.app.debug('Configuration already in new location');
|
|
111
|
+
|
|
112
|
+
// Check if config has incorrect "configuration" wrapper and fix it
|
|
113
|
+
try {
|
|
114
|
+
const configData = JSON.parse(fs.readFileSync(newConfigFile, 'utf8'));
|
|
115
|
+
if (configData.configuration && typeof configData.configuration === 'object') {
|
|
116
|
+
this.app.debug('Detected nested "configuration" wrapper, unwrapping...');
|
|
117
|
+
|
|
118
|
+
// Unwrap: move properties from configuration object to root
|
|
119
|
+
const unwrapped = {
|
|
120
|
+
...configData.configuration,
|
|
121
|
+
enabled: configData.enabled,
|
|
122
|
+
enableLogging: configData.enableLogging,
|
|
123
|
+
enableDebug: configData.enableDebug
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
// Backup before fixing
|
|
127
|
+
const fixBackupFile = `${newConfigFile}.backup-unwrap-${Date.now()}`;
|
|
128
|
+
fs.copyFileSync(newConfigFile, fixBackupFile);
|
|
129
|
+
|
|
130
|
+
// Write fixed config
|
|
131
|
+
fs.writeFileSync(newConfigFile, JSON.stringify(unwrapped, null, 2));
|
|
132
|
+
this.app.debug('✓ Configuration unwrapped successfully');
|
|
133
|
+
this.app.debug(` Backup: ${fixBackupFile}`);
|
|
134
|
+
}
|
|
135
|
+
} catch (err) {
|
|
136
|
+
this.app.debug(`⨯ Could not check/fix config structure: ${err.message}`);
|
|
137
|
+
}
|
|
138
|
+
} else {
|
|
139
|
+
this.app.debug('No old configuration found, first-time setup');
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Remove old plugin directories - with immediate and delayed attempts
|
|
145
|
+
*/
|
|
146
|
+
async removeOldPlugins(configDir) {
|
|
147
|
+
const oldPlugins = [
|
|
148
|
+
{ dir: path.join(configDir, 'node_modules', 'signalk-to-noforeignland'), name: 'signalk-to-noforeignland' },
|
|
149
|
+
{ dir: path.join(configDir, 'node_modules', 'signalk-to-nfl'), name: 'signalk-to-nfl' }
|
|
150
|
+
];
|
|
151
|
+
|
|
152
|
+
const foundOldPlugins = oldPlugins.filter(plugin => fs.existsSync(plugin.dir));
|
|
153
|
+
|
|
154
|
+
if (foundOldPlugins.length === 0) {
|
|
155
|
+
return null;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const pluginNames = foundOldPlugins.map(p => `"${p.name}"`).join(' and ');
|
|
159
|
+
const uninstallCmd = foundOldPlugins.map(p => p.name).join(' ');
|
|
160
|
+
|
|
161
|
+
this.app.debug(`Old plugin(s) detected: ${pluginNames}`);
|
|
162
|
+
|
|
163
|
+
// Immediate removal attempt
|
|
164
|
+
let anyRemovedNow = false;
|
|
165
|
+
const stillPresent = [];
|
|
166
|
+
|
|
167
|
+
for (const plugin of foundOldPlugins) {
|
|
168
|
+
try {
|
|
169
|
+
this.app.debug(`Attempting immediate removal of: ${plugin.name}...`);
|
|
170
|
+
await fs.remove(plugin.dir);
|
|
171
|
+
this.app.debug(`✓ Old plugin "${plugin.name}" removed immediately`);
|
|
172
|
+
anyRemovedNow = true;
|
|
173
|
+
} catch (err) {
|
|
174
|
+
this.app.debug(`Could not remove "${plugin.name}" immediately:`, err.message);
|
|
175
|
+
stillPresent.push(plugin);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
if (stillPresent.length === 0) {
|
|
180
|
+
this.app.debug('All old plugins removed successfully');
|
|
181
|
+
return 'all_removed';
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// Don't show error immediately - log that we're retrying
|
|
185
|
+
const stillPresentNames = stillPresent.map(p => `"${p.name}"`).join(' and ');
|
|
186
|
+
this.app.debug(`Old plugin(s) ${stillPresentNames} still present, will retry removal...`);
|
|
187
|
+
|
|
188
|
+
// Delayed removal attempts (multiple tries with increasing delays)
|
|
189
|
+
return new Promise((resolve) => {
|
|
190
|
+
const delays = [5000, 15000, 30000]; // 5s, 15s, 30s
|
|
191
|
+
let attemptIndex = 0;
|
|
192
|
+
|
|
193
|
+
const attemptRemoval = async () => {
|
|
194
|
+
if (attemptIndex >= delays.length) {
|
|
195
|
+
// Final attempt failed - NOW show error only if plugins still exist
|
|
196
|
+
const remaining = stillPresent.filter(p => fs.existsSync(p.dir));
|
|
197
|
+
if (remaining.length > 0) {
|
|
198
|
+
const remainingNames = remaining.map(p => `"${p.name}"`).join(' and ');
|
|
199
|
+
const remainingCmd = remaining.map(p => p.name).join(' ');
|
|
200
|
+
|
|
201
|
+
this.app.debug(`Could not remove old plugins after ${delays.length} attempts: ${remainingNames}`);
|
|
202
|
+
|
|
203
|
+
// Show error with platform-specific commands
|
|
204
|
+
const isVictronCerbo = configDir === '/data/conf/signalk';
|
|
205
|
+
const cmdPrefix = isVictronCerbo ? 'On Victron Cerbo GX, ' : '';
|
|
206
|
+
|
|
207
|
+
this.app.setPluginError(
|
|
208
|
+
`${cmdPrefix}Old plugin(s) ${remainingNames} detected. ` +
|
|
209
|
+
`Manual removal required: cd ${configDir} && npm uninstall ${remainingCmd}`
|
|
210
|
+
);
|
|
211
|
+
resolve('partial_removal');
|
|
212
|
+
} else {
|
|
213
|
+
this.app.debug('All old plugins eventually removed');
|
|
214
|
+
resolve('all_removed');
|
|
215
|
+
}
|
|
216
|
+
return;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
const delay = delays[attemptIndex];
|
|
220
|
+
attemptIndex++;
|
|
221
|
+
|
|
222
|
+
setTimeout(async () => {
|
|
223
|
+
this.app.debug(`Delayed removal attempt ${attemptIndex}/${delays.length}...`);
|
|
224
|
+
|
|
225
|
+
const remaining = [];
|
|
226
|
+
for (const plugin of stillPresent) {
|
|
227
|
+
if (!fs.existsSync(plugin.dir)) {
|
|
228
|
+
this.app.debug(`Plugin "${plugin.name}" already removed`);
|
|
229
|
+
continue;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
try {
|
|
233
|
+
await fs.remove(plugin.dir);
|
|
234
|
+
this.app.debug(`✓ Old plugin "${plugin.name}" removed on attempt ${attemptIndex}`);
|
|
235
|
+
} catch (err) {
|
|
236
|
+
this.app.debug(`Still cannot remove "${plugin.name}":`, err.message);
|
|
237
|
+
remaining.push(plugin);
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
if (remaining.length === 0) {
|
|
242
|
+
this.app.debug('All old plugins successfully removed');
|
|
243
|
+
// Clear the error
|
|
244
|
+
this.app.setPluginStatus('Started (old plugins cleaned up)');
|
|
245
|
+
resolve('all_removed');
|
|
246
|
+
} else {
|
|
247
|
+
stillPresent.length = 0;
|
|
248
|
+
stillPresent.push(...remaining);
|
|
249
|
+
attemptRemoval(); // Next attempt
|
|
250
|
+
}
|
|
251
|
+
}, delay);
|
|
252
|
+
};
|
|
253
|
+
|
|
254
|
+
attemptRemoval();
|
|
255
|
+
});
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
module.exports = PluginCleanup;
|