@noforeignland/signalk-to-noforeignland 1.0.1-beta.8 → 1.1.0-beta.3

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.
@@ -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,161 @@
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. Check and remove old plugins
28
+ await this.removeOldPlugins(configDir);
29
+
30
+ } catch (err) {
31
+ this.app.debug('Error during old plugin cleanup:', err.message);
32
+ }
33
+ }
34
+
35
+ /**
36
+ * Migrate config from old plugin to new scoped version
37
+ */
38
+ async migrateConfig(configPath) {
39
+ const oldConfigFile = path.join(configPath, 'signalk-to-noforeignland.json');
40
+ const newConfigFile = path.join(configPath, '@noforeignland-signalk-to-noforeignland.json');
41
+
42
+ if (fs.existsSync(oldConfigFile) && !fs.existsSync(newConfigFile)) {
43
+ this.app.debug('Migrating configuration from old plugin "signalk-to-noforeignland"...');
44
+ fs.copyFileSync(oldConfigFile, newConfigFile);
45
+ fs.copyFileSync(oldConfigFile, `${oldConfigFile}.backup`);
46
+ this.app.debug('✓ Configuration migrated successfully');
47
+ }
48
+ }
49
+
50
+ /**
51
+ * Remove old plugin directories - with immediate and delayed attempts
52
+ */
53
+ async removeOldPlugins(configDir) {
54
+ const oldPlugins = [
55
+ { dir: path.join(configDir, 'node_modules', 'signalk-to-noforeignland'), name: 'signalk-to-noforeignland' },
56
+ { dir: path.join(configDir, 'node_modules', 'signalk-to-nfl'), name: 'signalk-to-nfl' }
57
+ ];
58
+
59
+ const foundOldPlugins = oldPlugins.filter(plugin => fs.existsSync(plugin.dir));
60
+
61
+ if (foundOldPlugins.length === 0) {
62
+ return null;
63
+ }
64
+
65
+ const pluginNames = foundOldPlugins.map(p => `"${p.name}"`).join(' and ');
66
+ const uninstallCmd = foundOldPlugins.map(p => p.name).join(' ');
67
+
68
+ this.app.debug(`Old plugin(s) detected: ${pluginNames}`);
69
+
70
+ // Immediate removal attempt
71
+ let anyRemovedNow = false;
72
+ const stillPresent = [];
73
+
74
+ for (const plugin of foundOldPlugins) {
75
+ try {
76
+ this.app.debug(`Attempting immediate removal of: ${plugin.name}...`);
77
+ await fs.remove(plugin.dir);
78
+ this.app.debug(`✓ Old plugin "${plugin.name}" removed immediately`);
79
+ anyRemovedNow = true;
80
+ } catch (err) {
81
+ this.app.debug(`Could not remove "${plugin.name}" immediately:`, err.message);
82
+ stillPresent.push(plugin);
83
+ }
84
+ }
85
+
86
+ if (stillPresent.length === 0) {
87
+ this.app.debug('All old plugins removed successfully');
88
+ return 'all_removed';
89
+ }
90
+
91
+ // Show warning for plugins that couldn't be removed immediately
92
+ const stillPresentNames = stillPresent.map(p => `"${p.name}"`).join(' and ');
93
+ const stillPresentCmd = stillPresent.map(p => p.name).join(' ');
94
+
95
+ this.app.setPluginError(
96
+ `Old plugin(s) ${stillPresentNames} still installed. ` +
97
+ `Will retry removal, or uninstall manually: cd ${configDir} && npm uninstall ${stillPresentCmd}`
98
+ );
99
+
100
+ // Delayed removal attempts (multiple tries with increasing delays)
101
+ return new Promise((resolve) => {
102
+ const delays = [5000, 15000, 30000]; // 5s, 15s, 30s
103
+ let attemptIndex = 0;
104
+
105
+ const attemptRemoval = async () => {
106
+ if (attemptIndex >= delays.length) {
107
+ // Final attempt failed
108
+ const remaining = stillPresent.filter(p => fs.existsSync(p.dir));
109
+ if (remaining.length > 0) {
110
+ const remainingNames = remaining.map(p => p.name).join(' ');
111
+ this.app.debug(`Could not remove old plugins after multiple attempts: ${remainingNames}`);
112
+ this.app.debug(`Please manually run: cd ${configDir} && npm uninstall ${remainingNames}`);
113
+ resolve('partial_removal');
114
+ } else {
115
+ this.app.debug('All old plugins eventually removed');
116
+ resolve('all_removed');
117
+ }
118
+ return;
119
+ }
120
+
121
+ const delay = delays[attemptIndex];
122
+ attemptIndex++;
123
+
124
+ setTimeout(async () => {
125
+ this.app.debug(`Delayed removal attempt ${attemptIndex}/${delays.length}...`);
126
+
127
+ const remaining = [];
128
+ for (const plugin of stillPresent) {
129
+ if (!fs.existsSync(plugin.dir)) {
130
+ this.app.debug(`Plugin "${plugin.name}" already removed`);
131
+ continue;
132
+ }
133
+
134
+ try {
135
+ await fs.remove(plugin.dir);
136
+ this.app.debug(`✓ Old plugin "${plugin.name}" removed on attempt ${attemptIndex}`);
137
+ } catch (err) {
138
+ this.app.debug(`Still cannot remove "${plugin.name}":`, err.message);
139
+ remaining.push(plugin);
140
+ }
141
+ }
142
+
143
+ if (remaining.length === 0) {
144
+ this.app.debug('All old plugins successfully removed');
145
+ // Clear the error
146
+ this.app.setPluginStatus('Started (old plugins cleaned up)');
147
+ resolve('all_removed');
148
+ } else {
149
+ stillPresent.length = 0;
150
+ stillPresent.push(...remaining);
151
+ attemptRemoval(); // Next attempt
152
+ }
153
+ }, delay);
154
+ };
155
+
156
+ attemptRemoval();
157
+ });
158
+ }
159
+ }
160
+
161
+ module.exports = PluginCleanup;