@noforeignland/signalk-to-noforeignland 0.2.1
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/.github/workflows/publish.yml +35 -0
- package/CHANGELOG.md +116 -0
- package/README.md +42 -0
- package/doc/beta_install.md +60 -0
- package/index.js +903 -0
- package/package.json +31 -0
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
name: Publish Package
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
tags:
|
|
6
|
+
- 'v*' # version tags e.g. v0.1.41
|
|
7
|
+
|
|
8
|
+
permissions:
|
|
9
|
+
id-token: write # REQUIRED for OIDC
|
|
10
|
+
contents: read
|
|
11
|
+
packages: write
|
|
12
|
+
|
|
13
|
+
jobs:
|
|
14
|
+
publish:
|
|
15
|
+
runs-on: ubuntu-latest
|
|
16
|
+
|
|
17
|
+
steps:
|
|
18
|
+
# 1. Checkout code
|
|
19
|
+
- uses: actions/checkout@v4
|
|
20
|
+
|
|
21
|
+
# 2. Setup Node.js and enable OIDC auth
|
|
22
|
+
- uses: actions/setup-node@v4
|
|
23
|
+
with:
|
|
24
|
+
node-version: '20'
|
|
25
|
+
registry-url: 'https://registry.npmjs.org'
|
|
26
|
+
always-auth: true
|
|
27
|
+
|
|
28
|
+
# 3. Install deps
|
|
29
|
+
- run: npm ci
|
|
30
|
+
|
|
31
|
+
# 4. Run tests (optional)
|
|
32
|
+
- run: npm test
|
|
33
|
+
|
|
34
|
+
# 5. Publish via OIDC (NO token, NO login!)
|
|
35
|
+
- run: npm publish --access public --provenance
|
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
0.2.0
|
|
2
|
+
* CHANGE: Deployment via OIDC authentication
|
|
3
|
+
|
|
4
|
+
0.1.35
|
|
5
|
+
* CHANGE: Deployment via OIDC authentication
|
|
6
|
+
|
|
7
|
+
0.1.34
|
|
8
|
+
* CHANGE: Fix repository URL format
|
|
9
|
+
|
|
10
|
+
0.1.33
|
|
11
|
+
* CHANGE: Deployment via OIDC authentication
|
|
12
|
+
|
|
13
|
+
0.1.32
|
|
14
|
+
* CHANGE: Deployment via OIDC authentication
|
|
15
|
+
|
|
16
|
+
0.1.31
|
|
17
|
+
* CHANGE: Deployment via OIDC authentication
|
|
18
|
+
|
|
19
|
+
0.1.30
|
|
20
|
+
* CHANGE: Deployment via OIDC authentication
|
|
21
|
+
|
|
22
|
+
0.1.29
|
|
23
|
+
* CHANGE: Removed unused dependency "is-reachable" in package.json
|
|
24
|
+
|
|
25
|
+
0.1.29-beta.2
|
|
26
|
+
* NEW: setPluginError and error in data path if Internet Connection is not working.
|
|
27
|
+
|
|
28
|
+
0.1.29-beta.1
|
|
29
|
+
* CHANGE: After talking to Treppo from Sugnal K core team we should use the data path "noforeignland.*", code and docs changed.
|
|
30
|
+
* CHANGE: README.md info added and format optimized
|
|
31
|
+
* CHANGE: GPS source at the end for setPluginStatus - Cerbo has a long source name and truncates it.
|
|
32
|
+
* CHANGE: Use "npm install instead" of "npm ci" to push from Github to npmjs using OIDC authentication
|
|
33
|
+
* CHANGE: Moved navigation.position source from path noforeignland.status to noforeignland.source
|
|
34
|
+
|
|
35
|
+
0.1.28
|
|
36
|
+
* BUGFIX: No GPS data found - introduced in 0.1.27 - Thanks Piotr
|
|
37
|
+
|
|
38
|
+
0.1.28-beta.2
|
|
39
|
+
* EXPERIMENTAL: Signal K data path to visualize the plugin behaviour or error in Node Red, KIP, etc. Using plugin.signalk-to-noforeignland.* for now. See README.md for details.
|
|
40
|
+
* CHANGE: Using app.getDataDirPath() to store transient data, thanks Jeremy, they are now in "plugin-config-data/signalk-to-noforeignland/nfl-track" and renamed to pending.json1 and sent.json1
|
|
41
|
+
* CHANGE: Optimize GPS detection if multiple navigation.position from different sources exist.
|
|
42
|
+
* CHANGE: PluginStatus optimized for limited space available.
|
|
43
|
+
* CHANGE: doc/beta_install.md changed for new file structure.
|
|
44
|
+
|
|
45
|
+
0.1.28-beta.1
|
|
46
|
+
* CHANGE: By design SK deletes the track folder upon Pluging update via the Appstore, so we have to save the long term track in a different location, default folder: signalk-to-noforeignland-data
|
|
47
|
+
|
|
48
|
+
0.1.27
|
|
49
|
+
* Final version after successful testing SV MOIN and SV KIAPA NUI
|
|
50
|
+
|
|
51
|
+
0.1.27-beta.1
|
|
52
|
+
* NEW: NPMJS requires new method for publishing. The old tokens will expire Nov 19th, 2025, so moving to OIDC authentication.
|
|
53
|
+
* NEW: Check the GPS status in navigation.position, else retry and throw PluginError on Dashboard
|
|
54
|
+
* CHANGE: Keep track data on disk rewritten and migrate old files to new structure, so nfl-track-sent.jsonl becomes a continuous archive of all sent track data over time, when enabled. New Logic:
|
|
55
|
+
* New points accumulate in nfl-track-pending.jsonl
|
|
56
|
+
* Send succeeds → API confirms receipt
|
|
57
|
+
* If keepFiles=true: The content of pending file is appended to nfl-track-sent.jsonl (line 588)
|
|
58
|
+
* Pending file is deleted
|
|
59
|
+
* Next GPS points → create a new pending file
|
|
60
|
+
* Next successful send → appends again to the same nfl-track-sent.jsonl
|
|
61
|
+
|
|
62
|
+
0.1.26
|
|
63
|
+
* Same as 0.1.26-beta.1
|
|
64
|
+
|
|
65
|
+
0.1.26-beta.1
|
|
66
|
+
* CHANGE: PluginStatus last track sent "Not transfered since plugin start" gets truncated by the dashboard. Changed to "None since start"
|
|
67
|
+
|
|
68
|
+
0.1.25
|
|
69
|
+
* CHANGE: Minimum boat move default increased from 50m to 80m
|
|
70
|
+
* CHANGE: Updated the README.md
|
|
71
|
+
* CHANGE: Use public ipv4 DNS instead of local with cache for testInternet()
|
|
72
|
+
* Final version after successful testing SV MOIN and SV KIAPA NUI
|
|
73
|
+
|
|
74
|
+
0.1.25-beta.3
|
|
75
|
+
* NEW: Check if boat key is set on startup, else report error to dashboard
|
|
76
|
+
* CHANGE: Changing the order and label of the Plugin Settings to make it more clear for unexpierienced users and grouped to Mandatory, Advanced and Expert.
|
|
77
|
+
* CHANGE: Migration of < 0.1.25 Plugin settings to new structure.
|
|
78
|
+
* CHANGE: PluginStatus last track sent "Never" changed to "Not transfered since plugin start" to avoid confusions.
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
0.1.25-beta.2
|
|
82
|
+
* CHANGE: Typo in pluginName fixed
|
|
83
|
+
* CHANGE: Dates for SetPlugin now ISO8601 formated (https://github.com/noforeignland/nfl-signalk/issues/9)
|
|
84
|
+
|
|
85
|
+
0.1.25-beta.1
|
|
86
|
+
* CHANGE: User mattzilla470 reported timout Issues on VE Cerbo with a small CPU and using 4G (https://github.com/noforeignland/nfl-signalk/issues/7). So added a timout option in the plugin config and a tripple retry while increasing the timeout for the API call.
|
|
87
|
+
|
|
88
|
+
0.1.24
|
|
89
|
+
* CHANGE: testInternet() only uses ipv4 now, some users don't have ipv6 configured properly and where unable to reach the API, when testInternet returned false
|
|
90
|
+
* NEW: PluginStatus on SK dashboard now shows last savePoint and last API transfer, so a user has more feedback what the app is doing without enabling the debug log and crawling though it.
|
|
91
|
+
* CHANGE: Renamed CHANGELOG to CHANGELOG.md
|
|
92
|
+
* CHANGE: CHANGELOG ORDER - newest on top.
|
|
93
|
+
* Final version after successful testing SV MOIN and SV KIAPA NUI
|
|
94
|
+
|
|
95
|
+
0.1.23
|
|
96
|
+
* Final version after successful testing SV MOIN and SV KIAPA NUI
|
|
97
|
+
|
|
98
|
+
0.1.23-beta.1
|
|
99
|
+
* Renamed branch to follow the release versions.
|
|
100
|
+
* CLEANUP - More debug info for the SK dashboard using this.app.setPluginError
|
|
101
|
+
* CLEANUP - Removed CreateGPX, was only used for removed Email transmission of the track
|
|
102
|
+
|
|
103
|
+
0.1.22-beta.2
|
|
104
|
+
|
|
105
|
+
* CLEANUP and move to Object Oriented Javascript
|
|
106
|
+
|
|
107
|
+
0.1.22-beta.1
|
|
108
|
+
|
|
109
|
+
* CONFIG: Attempt sending location while moving - Default changed from false to true
|
|
110
|
+
* CONFIG: Ping added for 24h ping if boat is not moved. - Default: true
|
|
111
|
+
* Package.json - Nodemailer dependency removed
|
|
112
|
+
* Marked for removal - Depricated "sendEmailData" function.
|
|
113
|
+
* REMOVED - sendEmail.js
|
|
114
|
+
* CLEANUP - Renamed emaiCron to apiCron
|
|
115
|
+
* NEW: 24h api ping, when enabled, even if boat didn't move.
|
|
116
|
+
|
package/README.md
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# Signal K To Noforeignland
|
|
2
|
+
Effortlessly log your boat's movement to **noforeignland.com**
|
|
3
|
+
|
|
4
|
+
## Features
|
|
5
|
+
* Automatically log your position to noforeignland.com
|
|
6
|
+
* Send detailed tracks to log your entire trip and not just your final position
|
|
7
|
+
* Can be used in near real time or cache and upload when stopped and data-connection is available
|
|
8
|
+
* Sends 24h keepalive
|
|
9
|
+
* Option to archive your track on the local disk
|
|
10
|
+
* Detailed plugin information in the SK dashboard
|
|
11
|
+
* SK data paths about the plugin status for your own dashboard or Node Red coding
|
|
12
|
+
|
|
13
|
+
## Issues
|
|
14
|
+
* Server -> Plugin Config -> Signal K to Noforeignland -> Enable debug log (top right)
|
|
15
|
+
* Report issues on GitHub (https://github.com/noforeignland/nfl-signalk/issues)
|
|
16
|
+
|
|
17
|
+
## Requirements
|
|
18
|
+
* An internet connection is required in order to update noforeignland.com
|
|
19
|
+
* A navigation.position data path inside Signal K for self, which is your current GPS position
|
|
20
|
+
* A **noforeignland.com** account
|
|
21
|
+
* Your Boat API Key from the **noforeignland.com** website:
|
|
22
|
+
* Account > Settings > Boat tracking > API Key
|
|
23
|
+
|
|
24
|
+
> Note your Boat API Key is not available in the app.
|
|
25
|
+
> You must sign in to the **noforeignland.com** website (using the same authentication method you use for the app: Google. Facebook, Email).
|
|
26
|
+
|
|
27
|
+
## Configuration
|
|
28
|
+
1. Add your boat's API Key into the Server > Plugin Config > Signal K to Noforeignland > Boat API Key
|
|
29
|
+
2. Hit "Submit"
|
|
30
|
+
3. Restart the Signal K server
|
|
31
|
+
|
|
32
|
+
## Data paths created by this plugin
|
|
33
|
+
```
|
|
34
|
+
noforeignland.savepoint - ISO8601 timestamp - when was last point saved to trackfile
|
|
35
|
+
noforeignland.savepoint_local - locale timestamp - when was last point saved to trackfile
|
|
36
|
+
noforeignland.sent_to_api - ISO8601 timestamp - last successful transfer to the API
|
|
37
|
+
noforeignland.sent_to_api_local - locale timestamp - last successful transfer to the API
|
|
38
|
+
noforeignland.status - string - Status & Error messages
|
|
39
|
+
noforeignland.status_boolean - number - 0 = normal operation, 1 = error
|
|
40
|
+
noforeignland.source - string - string - data source of navigation.position
|
|
41
|
+
notifications.noforeignland.status_boolean - json object - auto created
|
|
42
|
+
```
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
How-to install the latest beta on your device?
|
|
2
|
+
|
|
3
|
+
This guide assumes you have a default install with default folders.
|
|
4
|
+
This is written for a Cerbo GX or a RPI, jump to the section you need want to go to.
|
|
5
|
+
It is recommended to enable the Debug Log for this plugin in Server -> Plugin Config before updating.
|
|
6
|
+
|
|
7
|
+
**For Raspberry PI:**
|
|
8
|
+
|
|
9
|
+
1. Backup old data
|
|
10
|
+
|
|
11
|
+
```
|
|
12
|
+
cd ~
|
|
13
|
+
mkdir nfl-backup
|
|
14
|
+
cp -a .signalk/node_modules/signalk-to-noforeignland/* nfl-backup/
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
2. Get new files from repo (main for latest)
|
|
18
|
+
|
|
19
|
+
```
|
|
20
|
+
cd ~/.signalk/node_modules/signalk-to-noforeignland/
|
|
21
|
+
rm -rf *
|
|
22
|
+
wget https://github.com/noforeignland/nfl-signalk/archive/refs/heads/main.zip
|
|
23
|
+
unzip main.zip
|
|
24
|
+
cp -r nfl-signalk-main/* .
|
|
25
|
+
rm main.zip
|
|
26
|
+
rm -rf nfl-signalk-main/
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
3. Restart Server & Check logs
|
|
30
|
+
|
|
31
|
+
```
|
|
32
|
+
sudo systemctl restart signalk.service && sudo journalctl -u signalk.service -f
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
**For Cerbo GX with Image Large:**
|
|
37
|
+
1. Backup old data
|
|
38
|
+
|
|
39
|
+
```
|
|
40
|
+
cd ~
|
|
41
|
+
mkdir nfl-backup
|
|
42
|
+
cp -a /data/conf/signalk/node_modules/signalk-to-noforeignland/* nfl-backup
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
2. Get new files from repo (main for latest)
|
|
46
|
+
|
|
47
|
+
```
|
|
48
|
+
cd /data/conf/signalk/node_modules/signalk-to-noforeignland/
|
|
49
|
+
rm -rf *
|
|
50
|
+
wget https://github.com/noforeignland/nfl-signalk/archive/refs/heads/main.zip
|
|
51
|
+
unzip main.zip
|
|
52
|
+
cp -r nfl-signalk-main/* .
|
|
53
|
+
rm main.zip
|
|
54
|
+
rm -rf nfl-signalk-main/
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
3. Restart Server & Check logs
|
|
58
|
+
```
|
|
59
|
+
Restart Server vom Webgui and check logs in Webgui
|
|
60
|
+
```
|
package/index.js
ADDED
|
@@ -0,0 +1,903 @@
|
|
|
1
|
+
const { EOL } = require('os');
|
|
2
|
+
const fs = require('fs-extra');
|
|
3
|
+
const path = require('path');
|
|
4
|
+
const CronJob = require('cron').CronJob;
|
|
5
|
+
const readline = require('readline');
|
|
6
|
+
const fetch = require('node-fetch');
|
|
7
|
+
|
|
8
|
+
const apiUrl = 'https://www.noforeignland.com/home/api/v1/boat/tracking/track';
|
|
9
|
+
const pluginApiKey = '0ede6cb6-5213-45f5-8ab4-b4836b236f97';
|
|
10
|
+
const defaultTracksDir = 'nfl-track';
|
|
11
|
+
const routeSaveName = 'pending.jsonl';
|
|
12
|
+
const routeSentName = 'sent.jsonl';
|
|
13
|
+
|
|
14
|
+
class SignalkToNoforeignland {
|
|
15
|
+
constructor(app) {
|
|
16
|
+
this.app = app;
|
|
17
|
+
this.pluginId = 'signalk-to-noforeignland';
|
|
18
|
+
this.pluginName = 'Signal K to Noforeignland';
|
|
19
|
+
this.creator = 'signalk-track-logger';
|
|
20
|
+
|
|
21
|
+
// runtime state
|
|
22
|
+
this.unsubscribes = [];
|
|
23
|
+
this.unsubscribesControl = [];
|
|
24
|
+
this.lastPosition = null;
|
|
25
|
+
this.upSince = null;
|
|
26
|
+
this.cron = null;
|
|
27
|
+
this.options = {};
|
|
28
|
+
this.lastSuccessfulTransfer = null;
|
|
29
|
+
|
|
30
|
+
// Track status for data path updates
|
|
31
|
+
this.currentStatus = '';
|
|
32
|
+
this.currentError = null;
|
|
33
|
+
|
|
34
|
+
// Track auto-selected source
|
|
35
|
+
this.autoSelectedSource = null;
|
|
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();
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
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
|
+
};
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
getPluginObject() {
|
|
194
|
+
return {
|
|
195
|
+
id: this.pluginId,
|
|
196
|
+
name: this.pluginName,
|
|
197
|
+
description: 'SignalK track logger to noforeignland.com',
|
|
198
|
+
schema: this.getSchema(),
|
|
199
|
+
start: this.start.bind(this),
|
|
200
|
+
stop: this.stop.bind(this)
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
async start(options = {}, restartPlugin) {
|
|
205
|
+
// Position data health check
|
|
206
|
+
this.positionCheckInterval = null;
|
|
207
|
+
this.lastPositionReceived = null;
|
|
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;
|
|
214
|
+
|
|
215
|
+
options = {
|
|
216
|
+
mandatory: {
|
|
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
|
+
};
|
|
235
|
+
|
|
236
|
+
try {
|
|
237
|
+
this.app.debug('Saving migrated configuration...');
|
|
238
|
+
await this.app.savePluginOptions(options, () => {
|
|
239
|
+
this.app.debug('Configuration successfully migrated and saved');
|
|
240
|
+
});
|
|
241
|
+
} catch (err) {
|
|
242
|
+
this.app.debug('Failed to save migrated configuration:', err.message);
|
|
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
|
+
// Migrate old track files
|
|
283
|
+
await this.migrateOldTrackFile();
|
|
284
|
+
|
|
285
|
+
this.app.debug('track logger started, now logging to', this.options.trackDir);
|
|
286
|
+
this.setPluginStatus(`Started${needsSave ? ' (config migrated)' : ''}`);
|
|
287
|
+
this.upSince = new Date().getTime();
|
|
288
|
+
|
|
289
|
+
// Adjust default CRON if unchanged
|
|
290
|
+
if (!this.options.apiCron || this.options.apiCron === '*/10 * * * *') {
|
|
291
|
+
const startMinute = Math.floor(Math.random() * 10);
|
|
292
|
+
const startSecond = Math.floor(Math.random() * 60);
|
|
293
|
+
this.options.apiCron = `${startSecond} ${startMinute}/10 * * * *`;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
this.app.debug('Setting CRON to', this.options.apiCron);
|
|
297
|
+
this.app.debug('trackFrequency is set to', this.options.trackFrequency, 'seconds');
|
|
298
|
+
|
|
299
|
+
// Subscribe and start logging
|
|
300
|
+
this.doLogging();
|
|
301
|
+
|
|
302
|
+
// Start cron job
|
|
303
|
+
this.cron = new CronJob(this.options.apiCron, this.interval.bind(this));
|
|
304
|
+
this.cron.start();
|
|
305
|
+
|
|
306
|
+
// Start position health check
|
|
307
|
+
this.startPositionHealthCheck();
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
async migrateOldTrackFile() {
|
|
311
|
+
const oldTrackFile = path.join(this.options.trackDir, 'nfl-track.jsonl');
|
|
312
|
+
const oldPendingFile = path.join(this.options.trackDir, 'nfl-track-pending.jsonl');
|
|
313
|
+
const oldSentFile = path.join(this.options.trackDir, 'nfl-track-sent.jsonl');
|
|
314
|
+
const newPendingFile = path.join(this.options.trackDir, routeSaveName);
|
|
315
|
+
const newSentFile = path.join(this.options.trackDir, routeSentName);
|
|
316
|
+
|
|
317
|
+
try {
|
|
318
|
+
// Migrate old track file
|
|
319
|
+
if (await fs.pathExists(oldTrackFile) && !(await fs.pathExists(newPendingFile))) {
|
|
320
|
+
this.app.debug('Migrating old track file to new naming scheme...');
|
|
321
|
+
await fs.move(oldTrackFile, newPendingFile);
|
|
322
|
+
this.app.debug('Successfully migrated old track file to:', routeSaveName);
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
// Migrate old pending file
|
|
326
|
+
if (await fs.pathExists(oldPendingFile) && !(await fs.pathExists(newPendingFile))) {
|
|
327
|
+
this.app.debug('Migrating old pending file to new naming scheme...');
|
|
328
|
+
await fs.move(oldPendingFile, newPendingFile);
|
|
329
|
+
this.app.debug('Successfully migrated old pending file to:', routeSaveName);
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
// Migrate old sent file
|
|
333
|
+
if (await fs.pathExists(oldSentFile) && !(await fs.pathExists(newSentFile))) {
|
|
334
|
+
this.app.debug('Migrating old sent file to new naming scheme...');
|
|
335
|
+
await fs.move(oldSentFile, newSentFile);
|
|
336
|
+
this.app.debug('Successfully migrated old sent file to:', routeSentName);
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
// Check old plugin directory location
|
|
340
|
+
const oldPluginTrackDir = path.join(__dirname, 'track');
|
|
341
|
+
if (await fs.pathExists(oldPluginTrackDir)) {
|
|
342
|
+
this.app.debug('Found old track directory in plugin folder, migrating to new location...');
|
|
343
|
+
|
|
344
|
+
const oldFiles = [
|
|
345
|
+
'nfl-track.jsonl',
|
|
346
|
+
'nfl-track-pending.jsonl',
|
|
347
|
+
routeSaveName
|
|
348
|
+
];
|
|
349
|
+
|
|
350
|
+
for (const oldFile of oldFiles) {
|
|
351
|
+
const oldPath = path.join(oldPluginTrackDir, oldFile);
|
|
352
|
+
if (await fs.pathExists(oldPath) && !(await fs.pathExists(newPendingFile))) {
|
|
353
|
+
await fs.move(oldPath, newPendingFile);
|
|
354
|
+
this.app.debug('Migrated pending track file from old plugin location');
|
|
355
|
+
break;
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
// Migrate sent archive
|
|
360
|
+
const oldSentFiles = [routeSentName, 'nfl-track-sent.jsonl'];
|
|
361
|
+
for (const oldFile of oldSentFiles) {
|
|
362
|
+
const oldPath = path.join(oldPluginTrackDir, oldFile);
|
|
363
|
+
if (await fs.pathExists(oldPath) && !(await fs.pathExists(newSentFile))) {
|
|
364
|
+
await fs.move(oldPath, newSentFile);
|
|
365
|
+
this.app.debug('Migrated sent track archive from old plugin location');
|
|
366
|
+
break;
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
// Try to remove old directory if empty
|
|
371
|
+
try {
|
|
372
|
+
const remainingFiles = await fs.readdir(oldPluginTrackDir);
|
|
373
|
+
if (remainingFiles.length === 0) {
|
|
374
|
+
await fs.rmdir(oldPluginTrackDir);
|
|
375
|
+
this.app.debug('Removed empty old track directory');
|
|
376
|
+
}
|
|
377
|
+
} catch (err) {
|
|
378
|
+
this.app.debug('Could not remove old track directory:', err.message);
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
} catch (err) {
|
|
382
|
+
this.app.debug('Error during track file migration:', err.message);
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
stop() {
|
|
387
|
+
this.app.debug('plugin stopped');
|
|
388
|
+
|
|
389
|
+
this.autoSelectedSource = null;
|
|
390
|
+
|
|
391
|
+
if (this.positionCheckInterval) {
|
|
392
|
+
clearInterval(this.positionCheckInterval);
|
|
393
|
+
this.positionCheckInterval = null;
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
if (this.cron) {
|
|
397
|
+
this.cron.stop();
|
|
398
|
+
this.cron = undefined;
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
this.unsubscribesControl.forEach(f => f());
|
|
402
|
+
this.unsubscribesControl = [];
|
|
403
|
+
this.unsubscribes.forEach(f => f());
|
|
404
|
+
this.unsubscribes = [];
|
|
405
|
+
this.app.setPluginStatus('Plugin stopped');
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
doLogging() {
|
|
409
|
+
let shouldDoLog = true;
|
|
410
|
+
|
|
411
|
+
this.app.subscriptionmanager.subscribe({
|
|
412
|
+
context: 'vessels.self',
|
|
413
|
+
subscribe: [{
|
|
414
|
+
path: 'navigation.position',
|
|
415
|
+
format: 'delta',
|
|
416
|
+
policy: 'instant',
|
|
417
|
+
minPeriod: this.options.trackFrequency ? this.options.trackFrequency * 1000 : 0
|
|
418
|
+
}]
|
|
419
|
+
}, this.unsubscribes, (subscriptionError) => {
|
|
420
|
+
this.app.debug('Error subscription to data:' + subscriptionError);
|
|
421
|
+
this.setPluginError('Error subscription to data:' + subscriptionError.message);
|
|
422
|
+
}, this.doOnValue.bind(this, () => shouldDoLog, newShould => { shouldDoLog = newShould; }));
|
|
423
|
+
|
|
424
|
+
// Subscribe for speed
|
|
425
|
+
if (this.options.minSpeed) {
|
|
426
|
+
this.app.subscriptionmanager.subscribe({
|
|
427
|
+
context: 'vessels.self',
|
|
428
|
+
subscribe: [{
|
|
429
|
+
path: 'navigation.speedOverGround',
|
|
430
|
+
format: 'delta',
|
|
431
|
+
policy: 'instant'
|
|
432
|
+
}]
|
|
433
|
+
}, this.unsubscribes, (subscriptionError) => {
|
|
434
|
+
this.app.debug('Error subscription to data:' + subscriptionError);
|
|
435
|
+
this.setPluginError('Error subscription to data:' + subscriptionError.message);
|
|
436
|
+
}, (delta) => {
|
|
437
|
+
delta.updates.forEach(update => {
|
|
438
|
+
if (this.options.filterSource && update.$source !== this.options.filterSource) {
|
|
439
|
+
return;
|
|
440
|
+
}
|
|
441
|
+
update.values.forEach(value => {
|
|
442
|
+
const speedInKnots = value.value * 1.94384;
|
|
443
|
+
if (!shouldDoLog && this.options.minSpeed < speedInKnots) {
|
|
444
|
+
this.app.debug('setting shouldDoLog to true, speed:', speedInKnots.toFixed(2), 'knots');
|
|
445
|
+
shouldDoLog = true;
|
|
446
|
+
}
|
|
447
|
+
});
|
|
448
|
+
});
|
|
449
|
+
});
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
// FIXED: Use continue instead of return to handle multiple updates properly
|
|
454
|
+
async doOnValue(getShouldDoLog, setShouldDoLog, delta) {
|
|
455
|
+
for (const update of delta.updates) {
|
|
456
|
+
// Auto-select source logic
|
|
457
|
+
if (!this.options.filterSource) {
|
|
458
|
+
const timeSinceLastPosition = this.lastPositionReceived
|
|
459
|
+
? (new Date().getTime() - this.lastPositionReceived) / 1000
|
|
460
|
+
: null;
|
|
461
|
+
|
|
462
|
+
if (!this.autoSelectedSource) {
|
|
463
|
+
this.autoSelectedSource = update.$source;
|
|
464
|
+
this.lastPositionReceived = new Date().getTime();
|
|
465
|
+
this.app.debug(`Auto-selected GPS source: '${this.autoSelectedSource}'`);
|
|
466
|
+
} else if (update.$source !== this.autoSelectedSource) {
|
|
467
|
+
if (timeSinceLastPosition && timeSinceLastPosition > 300) {
|
|
468
|
+
this.app.debug(`Switching from stale source '${this.autoSelectedSource}' to '${update.$source}' (no data for ${timeSinceLastPosition.toFixed(0)}s)`);
|
|
469
|
+
this.autoSelectedSource = update.$source;
|
|
470
|
+
this.lastPositionReceived = new Date().getTime();
|
|
471
|
+
} else {
|
|
472
|
+
this.app.debug(`Ignoring position from '${update.$source}', using auto-selected source '${this.autoSelectedSource}'`);
|
|
473
|
+
continue;
|
|
474
|
+
}
|
|
475
|
+
} else {
|
|
476
|
+
this.lastPositionReceived = new Date().getTime();
|
|
477
|
+
}
|
|
478
|
+
} else if (update.$source !== this.options.filterSource) {
|
|
479
|
+
this.app.debug(`Ignoring position from '${update.$source}', filterSource is set to '${this.options.filterSource}'`);
|
|
480
|
+
continue;
|
|
481
|
+
} else {
|
|
482
|
+
this.lastPositionReceived = new Date().getTime();
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
const timestamp = update.timestamp;
|
|
486
|
+
for (const value of update.values) {
|
|
487
|
+
// Validation: GPS near (0,0)
|
|
488
|
+
if (Math.abs(value.value.latitude) <= 0.01 && Math.abs(value.value.longitude) <= 0.01) {
|
|
489
|
+
this.app.debug('GPS coordinates near (0,0), ignoring point to avoid invalid data logging.');
|
|
490
|
+
continue;
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
// Validate lat/lon
|
|
494
|
+
if (!this.isValidLatitude(value.value.latitude) || !this.isValidLongitude(value.value.longitude)) {
|
|
495
|
+
this.app.debug('got invalid position, ignoring...', value.value);
|
|
496
|
+
continue;
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
// 24h ping check
|
|
500
|
+
let force24hSave = false;
|
|
501
|
+
if (this.options.ping_api_every_24h && this.lastPosition) {
|
|
502
|
+
const timeSinceLastPoint = (new Date().getTime() - this.lastPosition.currentTime);
|
|
503
|
+
if (timeSinceLastPoint >= 24 * 60 * 60 * 1000) {
|
|
504
|
+
this.app.debug('24h since last point, forcing save of point to keep boat active on NFL');
|
|
505
|
+
force24hSave = true;
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
// Check if we should log
|
|
510
|
+
if (!force24hSave && !getShouldDoLog()) {
|
|
511
|
+
this.app.debug('shouldDoLog is false, not logging position');
|
|
512
|
+
continue;
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
// Check timestamp and distance
|
|
516
|
+
if (this.lastPosition && !force24hSave) {
|
|
517
|
+
if (new Date(this.lastPosition.timestamp).getTime() > new Date(timestamp).getTime()) {
|
|
518
|
+
this.app.debug('got error in timestamp:', timestamp, 'is earlier than previous:', this.lastPosition.timestamp);
|
|
519
|
+
continue;
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
const distance = this.equirectangularDistance(this.lastPosition.pos, value.value);
|
|
523
|
+
if (this.options.minMove && distance < this.options.minMove) {
|
|
524
|
+
this.app.debug('Distance', distance.toFixed(2), 'm is less than minMove', this.options.minMove, 'm - skipping');
|
|
525
|
+
continue;
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
this.app.debug('Distance', distance.toFixed(2), 'm is greater than minMove', this.options.minMove, 'm - logging');
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
// Save point
|
|
532
|
+
this.app.debug('Saving position from source:', update.$source, 'lat:', value.value.latitude, 'lon:', value.value.longitude);
|
|
533
|
+
this.lastPosition = { pos: value.value, timestamp, currentTime: new Date().getTime() };
|
|
534
|
+
await this.savePoint(this.lastPosition);
|
|
535
|
+
|
|
536
|
+
// Reset shouldDoLog if minSpeed is active
|
|
537
|
+
if (this.options.minSpeed) {
|
|
538
|
+
this.app.debug('options.minSpeed - setting shouldDoLog to false');
|
|
539
|
+
setShouldDoLog(false);
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
async savePoint(point) {
|
|
546
|
+
const obj = {
|
|
547
|
+
lat: point.pos.latitude,
|
|
548
|
+
lon: point.pos.longitude,
|
|
549
|
+
t: point.timestamp
|
|
550
|
+
};
|
|
551
|
+
this.app.debug(`save data point:`, obj);
|
|
552
|
+
await fs.appendFile(path.join(this.options.trackDir, routeSaveName), JSON.stringify(obj) + EOL);
|
|
553
|
+
|
|
554
|
+
const now = new Date();
|
|
555
|
+
this.emitDelta('noforeignland.savepoint', now.toISOString());
|
|
556
|
+
this.emitDelta('noforeignland.savepoint_local', now.toLocaleString());
|
|
557
|
+
|
|
558
|
+
// ISO8601 format for Dashboard
|
|
559
|
+
const activeSource = this.options.filterSource || this.autoSelectedSource || '';
|
|
560
|
+
const sourcePrefix = activeSource ? `${activeSource} | ` : '';
|
|
561
|
+
const saveTime = now.toISOString();
|
|
562
|
+
const transferTime = this.lastSuccessfulTransfer ? this.lastSuccessfulTransfer.toISOString() : 'None since start';
|
|
563
|
+
|
|
564
|
+
this.setPluginStatus(`${sourcePrefix}Save: ${saveTime} | Transfer: ${transferTime}`);
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
isValidLatitude(obj) {
|
|
568
|
+
return this.isDefinedNumber(obj) && obj > -90 && obj < 90;
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
isValidLongitude(obj) {
|
|
572
|
+
return this.isDefinedNumber(obj) && obj > -180 && obj < 180;
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
isDefinedNumber(obj) {
|
|
576
|
+
return (obj !== undefined && obj !== null && typeof obj === 'number');
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
equirectangularDistance(from, to) {
|
|
580
|
+
const rad = Math.PI / 180;
|
|
581
|
+
const φ1 = from.latitude * rad;
|
|
582
|
+
const φ2 = to.latitude * rad;
|
|
583
|
+
const Δλ = (to.longitude - from.longitude) * rad;
|
|
584
|
+
const R = 6371e3;
|
|
585
|
+
const x = Δλ * Math.cos((φ1 + φ2) / 2);
|
|
586
|
+
const y = (φ2 - φ1);
|
|
587
|
+
const d = Math.sqrt(x * x + y * y) * R;
|
|
588
|
+
return d;
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
createDir(dir) {
|
|
592
|
+
let res = true;
|
|
593
|
+
if (fs.existsSync(dir)) {
|
|
594
|
+
try {
|
|
595
|
+
fs.accessSync(dir, fs.constants.R_OK | fs.constants.W_OK);
|
|
596
|
+
} catch (error) {
|
|
597
|
+
this.app.debug('[createDir]', error.message);
|
|
598
|
+
this.setPluginError(`No rights to directory ${dir}`);
|
|
599
|
+
res = false;
|
|
600
|
+
}
|
|
601
|
+
} else {
|
|
602
|
+
try {
|
|
603
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
604
|
+
} catch (error) {
|
|
605
|
+
switch (error.code) {
|
|
606
|
+
case 'EACCES':
|
|
607
|
+
case 'EPERM':
|
|
608
|
+
this.app.debug(`Failed to create ${dir} by Permission denied`);
|
|
609
|
+
this.setPluginError(`Failed to create ${dir} by Permission denied`);
|
|
610
|
+
res = false;
|
|
611
|
+
break;
|
|
612
|
+
case 'ETIMEDOUT':
|
|
613
|
+
this.app.debug(`Failed to create ${dir} by Operation timed out`);
|
|
614
|
+
this.setPluginError(`Failed to create ${dir} by Operation timed out`);
|
|
615
|
+
res = false;
|
|
616
|
+
break;
|
|
617
|
+
default:
|
|
618
|
+
this.app.debug(`Error creating directory ${dir}: ${error.message}`);
|
|
619
|
+
this.setPluginError(`Error creating directory ${dir}: ${error.message}`);
|
|
620
|
+
res = false;
|
|
621
|
+
}
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
return res;
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
startPositionHealthCheck() {
|
|
628
|
+
this.positionCheckInterval = setInterval(() => {
|
|
629
|
+
const now = new Date().getTime();
|
|
630
|
+
const timeSinceLastPosition = this.lastPositionReceived
|
|
631
|
+
? (now - this.lastPositionReceived) / 1000
|
|
632
|
+
: null;
|
|
633
|
+
|
|
634
|
+
const activeSource = this.options.filterSource || this.autoSelectedSource || 'any';
|
|
635
|
+
const filterMsg = activeSource !== 'any' ? ` from source '${activeSource}'` : '';
|
|
636
|
+
|
|
637
|
+
if (!this.lastPositionReceived) {
|
|
638
|
+
const errorMsg = this.options.filterSource
|
|
639
|
+
? `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.`
|
|
640
|
+
: 'No GPS position data received. Check that your GPS is connected and SignalK is receiving navigation.position data.';
|
|
641
|
+
this.setPluginError(errorMsg);
|
|
642
|
+
this.app.debug('Position health check: No position data ever received' + filterMsg);
|
|
643
|
+
} else if (timeSinceLastPosition > 300) {
|
|
644
|
+
const errorMsg = this.options.filterSource
|
|
645
|
+
? `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.`
|
|
646
|
+
: `No GPS position data${filterMsg} for ${Math.floor(timeSinceLastPosition / 60)} minutes. Check your GPS connection.`;
|
|
647
|
+
this.setPluginError(errorMsg);
|
|
648
|
+
this.app.debug(`Position health check: No position for ${timeSinceLastPosition.toFixed(0)} seconds` + filterMsg);
|
|
649
|
+
} else {
|
|
650
|
+
this.app.debug(`Position health check: OK (last position ${timeSinceLastPosition.toFixed(0)} seconds ago${filterMsg})`);
|
|
651
|
+
|
|
652
|
+
// Clear any previous errors when position health is OK
|
|
653
|
+
if (this.currentError) {
|
|
654
|
+
const activeSource = this.options.filterSource || this.autoSelectedSource || '';
|
|
655
|
+
const sourcePrefix = activeSource ? `${activeSource} | ` : '';
|
|
656
|
+
const saveTime = this.lastPosition ? new Date(this.lastPosition.currentTime).toISOString() : 'None since start';
|
|
657
|
+
const transferTime = this.lastSuccessfulTransfer ? this.lastSuccessfulTransfer.toISOString() : 'None since start';
|
|
658
|
+
this.setPluginStatus(`${sourcePrefix}Save: ${saveTime} | Transfer: ${transferTime}`);
|
|
659
|
+
}
|
|
660
|
+
}
|
|
661
|
+
}, 5 * 60 * 1000);
|
|
662
|
+
|
|
663
|
+
// Initial check after 2 minutes of startup
|
|
664
|
+
setTimeout(() => {
|
|
665
|
+
if (!this.lastPositionReceived) {
|
|
666
|
+
const activeSource = this.options.filterSource || this.autoSelectedSource || 'any';
|
|
667
|
+
const errorMsg = this.options.filterSource
|
|
668
|
+
? `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.`
|
|
669
|
+
: 'No GPS position data received after 2 minutes. Check that your GPS is connected and SignalK is receiving navigation.position data.';
|
|
670
|
+
this.setPluginError(errorMsg);
|
|
671
|
+
this.app.debug('Initial position check: No position data received' + (activeSource !== 'any' ? ` from source '${activeSource}'` : ''));
|
|
672
|
+
}
|
|
673
|
+
}, 2 * 60 * 1000);
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
async interval() {
|
|
677
|
+
const boatMoving = this.checkBoatMoving();
|
|
678
|
+
if (!boatMoving) {
|
|
679
|
+
return;
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
const hasTrack = await this.checkTrack();
|
|
683
|
+
if (!hasTrack) {
|
|
684
|
+
return;
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
const hasInternet = await this.testInternet();
|
|
688
|
+
if (!hasInternet) {
|
|
689
|
+
const errorMsg = 'No internet connection detected. Unable to send tracking data to NFL. DNS lookups failed - check your internet connection.';
|
|
690
|
+
this.app.debug(errorMsg);
|
|
691
|
+
this.setPluginError(errorMsg);
|
|
692
|
+
return;
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
await this.sendData();
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
checkBoatMoving() {
|
|
699
|
+
if (!this.options.trackFrequency) {
|
|
700
|
+
return true;
|
|
701
|
+
}
|
|
702
|
+
const time = this.lastPosition ? this.lastPosition.currentTime : this.upSince;
|
|
703
|
+
const secsSinceLastPoint = (new Date().getTime() - time) / 1000;
|
|
704
|
+
const isMoving = secsSinceLastPoint <= (this.options.trackFrequency * 2);
|
|
705
|
+
if (isMoving) {
|
|
706
|
+
this.app.debug('Boat is still moving, last move', secsSinceLastPoint, 'seconds ago');
|
|
707
|
+
return this.options.sendWhileMoving;
|
|
708
|
+
} else {
|
|
709
|
+
this.app.debug('Boat stopped moving, last move at least', secsSinceLastPoint, 'seconds ago');
|
|
710
|
+
return true;
|
|
711
|
+
}
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
async testInternet() {
|
|
715
|
+
const dns = require('dns').promises;
|
|
716
|
+
|
|
717
|
+
this.app.debug('testing internet connection');
|
|
718
|
+
|
|
719
|
+
const timeoutMs = this.options.internetTestTimeout || 2000;
|
|
720
|
+
this.app.debug(`Using internet test timeout: ${timeoutMs}ms`);
|
|
721
|
+
|
|
722
|
+
const dnsServers = [
|
|
723
|
+
{ name: 'Google DNS', ip: '8.8.8.8' },
|
|
724
|
+
{ name: 'Cloudflare DNS', ip: '1.1.1.1' }
|
|
725
|
+
];
|
|
726
|
+
|
|
727
|
+
for (const server of dnsServers) {
|
|
728
|
+
try {
|
|
729
|
+
const startTime = Date.now();
|
|
730
|
+
const result = await Promise.race([
|
|
731
|
+
dns.reverse(server.ip),
|
|
732
|
+
new Promise((_, reject) =>
|
|
733
|
+
setTimeout(() => reject(new Error('DNS timeout')), timeoutMs)
|
|
734
|
+
)
|
|
735
|
+
]);
|
|
736
|
+
const elapsed = Date.now() - startTime;
|
|
737
|
+
|
|
738
|
+
this.app.debug(`internet connection = true, ${server.name} (${server.ip}) is reachable (took ${elapsed}ms)`);
|
|
739
|
+
return true;
|
|
740
|
+
} catch (err) {
|
|
741
|
+
this.app.debug(`${server.name} (${server.ip}) not reachable:`, err.message);
|
|
742
|
+
}
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
this.app.debug(`internet connection = false, no public DNS servers reachable (timeout was ${timeoutMs}ms)`);
|
|
746
|
+
return false;
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
async checkTrack() {
|
|
750
|
+
const trackFile = path.join(this.options.trackDir, routeSaveName);
|
|
751
|
+
this.app.debug('checking the track', trackFile, 'if should send');
|
|
752
|
+
const exists = await fs.pathExists(trackFile);
|
|
753
|
+
const size = exists ? (await fs.lstat(trackFile)).size : 0;
|
|
754
|
+
this.app.debug(`'${trackFile}'.size=${size} ${trackFile}'.exists=${exists}`);
|
|
755
|
+
return size > 0;
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
async sendData() {
|
|
759
|
+
if (this.options.boatApiKey) {
|
|
760
|
+
await this.sendApiData();
|
|
761
|
+
} else {
|
|
762
|
+
this.app.debug('Failed to send track - no boat API key set in plugin settings.');
|
|
763
|
+
this.setPluginError(`Failed to send track - no boat API key set in plugin settings.`);
|
|
764
|
+
}
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
async sendApiData() {
|
|
768
|
+
this.app.debug('sending the data');
|
|
769
|
+
const pendingFile = path.join(this.options.trackDir, routeSaveName);
|
|
770
|
+
const trackData = await this.createTrack(pendingFile);
|
|
771
|
+
if (!trackData) {
|
|
772
|
+
this.app.debug('Recorded track did not contain any valid track points, aborting sending.');
|
|
773
|
+
this.setPluginError(`Failed to send track - Recorded track did not contain any valid track points, aborting sending.`);
|
|
774
|
+
return;
|
|
775
|
+
}
|
|
776
|
+
this.app.debug('created track data with timestamp:', new Date(trackData.timestamp));
|
|
777
|
+
const params = new URLSearchParams();
|
|
778
|
+
params.append('timestamp', trackData.timestamp);
|
|
779
|
+
params.append('track', JSON.stringify(trackData.track));
|
|
780
|
+
params.append('boatApiKey', this.options.boatApiKey);
|
|
781
|
+
const headers = { 'X-NFL-API-Key': pluginApiKey };
|
|
782
|
+
this.app.debug('sending track to API');
|
|
783
|
+
|
|
784
|
+
const maxRetries = 3;
|
|
785
|
+
const baseTimeout = (this.options.apiTimeout || 30) * 1000;
|
|
786
|
+
|
|
787
|
+
for (let attempt = 1; attempt <= maxRetries; attempt++) {
|
|
788
|
+
try {
|
|
789
|
+
const currentTimeout = baseTimeout * attempt;
|
|
790
|
+
this.app.debug(`Attempt ${attempt}/${maxRetries} with ${currentTimeout}ms timeout`);
|
|
791
|
+
|
|
792
|
+
const controller = new AbortController();
|
|
793
|
+
const timeoutId = setTimeout(() => controller.abort(), currentTimeout);
|
|
794
|
+
|
|
795
|
+
const response = await fetch(apiUrl, {
|
|
796
|
+
method: 'POST',
|
|
797
|
+
body: params,
|
|
798
|
+
headers: new fetch.Headers(headers),
|
|
799
|
+
signal: controller.signal
|
|
800
|
+
});
|
|
801
|
+
|
|
802
|
+
clearTimeout(timeoutId);
|
|
803
|
+
|
|
804
|
+
if (response.ok) {
|
|
805
|
+
const responseBody = await response.json();
|
|
806
|
+
if (responseBody.status === 'ok') {
|
|
807
|
+
this.lastSuccessfulTransfer = new Date();
|
|
808
|
+
|
|
809
|
+
this.emitDelta('noforeignland.sent_to_api', this.lastSuccessfulTransfer.toISOString());
|
|
810
|
+
this.emitDelta('noforeignland.sent_to_api_local', this.lastSuccessfulTransfer.toLocaleString());
|
|
811
|
+
|
|
812
|
+
this.app.debug('Track successfully sent to API');
|
|
813
|
+
|
|
814
|
+
// ISO8601 format for Dashboard
|
|
815
|
+
const activeSource = this.options.filterSource || this.autoSelectedSource || '';
|
|
816
|
+
const sourcePrefix = activeSource ? `${activeSource} | ` : '';
|
|
817
|
+
const saveTime = this.lastPosition ? new Date(this.lastPosition.currentTime).toISOString() : 'None since start';
|
|
818
|
+
const transferTime = this.lastSuccessfulTransfer.toISOString();
|
|
819
|
+
this.setPluginStatus(`${sourcePrefix}Save: ${saveTime} | Transfer: ${transferTime}`);
|
|
820
|
+
|
|
821
|
+
await this.handleSuccessfulSend(pendingFile);
|
|
822
|
+
return;
|
|
823
|
+
} else {
|
|
824
|
+
this.app.debug('Could not send track to API, returned response json:', responseBody);
|
|
825
|
+
this.setPluginError(`Failed to send track - API returned error.`);
|
|
826
|
+
return;
|
|
827
|
+
}
|
|
828
|
+
} else {
|
|
829
|
+
this.app.debug('Could not send track to API, returned response code:', response.status, response.statusText);
|
|
830
|
+
if (response.status >= 400 && response.status < 500) {
|
|
831
|
+
this.setPluginError(`Failed to send track - HTTP ${response.status}.`);
|
|
832
|
+
return;
|
|
833
|
+
}
|
|
834
|
+
throw new Error(`HTTP ${response.status}`);
|
|
835
|
+
}
|
|
836
|
+
} catch (err) {
|
|
837
|
+
this.app.debug(`Attempt ${attempt} failed:`, err.message);
|
|
838
|
+
|
|
839
|
+
if (attempt === maxRetries) {
|
|
840
|
+
this.app.debug('Could not send track to API after', maxRetries, 'attempts:', err);
|
|
841
|
+
this.setPluginError(`Failed to send track after ${maxRetries} attempts - check logs for details.`);
|
|
842
|
+
} else {
|
|
843
|
+
const waitTime = 2000 * attempt;
|
|
844
|
+
this.app.debug(`Waiting ${waitTime}ms before retry...`);
|
|
845
|
+
await new Promise(resolve => setTimeout(resolve, waitTime));
|
|
846
|
+
}
|
|
847
|
+
}
|
|
848
|
+
}
|
|
849
|
+
}
|
|
850
|
+
|
|
851
|
+
async handleSuccessfulSend(pendingFile) {
|
|
852
|
+
const sentFile = path.join(this.options.trackDir, routeSentName);
|
|
853
|
+
|
|
854
|
+
try {
|
|
855
|
+
if (this.options.keepFiles) {
|
|
856
|
+
this.app.debug('Appending sent data to archive file:', routeSentName);
|
|
857
|
+
const pendingContent = await fs.readFile(pendingFile, 'utf8');
|
|
858
|
+
await fs.appendFile(sentFile, pendingContent);
|
|
859
|
+
this.app.debug('Successfully archived sent track data');
|
|
860
|
+
} else {
|
|
861
|
+
this.app.debug('keepFiles disabled, will delete pending file');
|
|
862
|
+
}
|
|
863
|
+
|
|
864
|
+
this.app.debug('Deleting pending track file');
|
|
865
|
+
await fs.remove(pendingFile);
|
|
866
|
+
this.app.debug('Successfully processed track files after send');
|
|
867
|
+
|
|
868
|
+
} catch (err) {
|
|
869
|
+
this.app.debug('Error handling files after successful send:', err.message);
|
|
870
|
+
}
|
|
871
|
+
}
|
|
872
|
+
|
|
873
|
+
async createTrack(inputPath) {
|
|
874
|
+
const fileStream = fs.createReadStream(inputPath);
|
|
875
|
+
const rl = readline.createInterface({ input: fileStream, crlfDelay: Infinity });
|
|
876
|
+
const track = [];
|
|
877
|
+
let lastTimestamp;
|
|
878
|
+
for await (const line of rl) {
|
|
879
|
+
if (line) {
|
|
880
|
+
try {
|
|
881
|
+
const point = JSON.parse(line);
|
|
882
|
+
const timestamp = new Date(point.t).getTime();
|
|
883
|
+
if (!isNaN(timestamp) && this.isValidLatitude(point.lat) && this.isValidLongitude(point.lon)) {
|
|
884
|
+
track.push([timestamp, point.lat, point.lon]);
|
|
885
|
+
lastTimestamp = timestamp;
|
|
886
|
+
}
|
|
887
|
+
} catch (error) {
|
|
888
|
+
this.app.debug('could not parse line from track file:', line);
|
|
889
|
+
this.setPluginError(`Failed could not parse line from track file - check logs for details.`);
|
|
890
|
+
}
|
|
891
|
+
}
|
|
892
|
+
}
|
|
893
|
+
if (track.length > 0) {
|
|
894
|
+
return { timestamp: new Date(lastTimestamp).getTime(), track };
|
|
895
|
+
}
|
|
896
|
+
return null;
|
|
897
|
+
}
|
|
898
|
+
}
|
|
899
|
+
|
|
900
|
+
module.exports = function (app) {
|
|
901
|
+
const instance = new SignalkToNoforeignland(app);
|
|
902
|
+
return instance.getPluginObject();
|
|
903
|
+
};
|
package/package.json
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@noforeignland/signalk-to-noforeignland",
|
|
3
|
+
"version": "0.2.1",
|
|
4
|
+
"description": "SignalK track logger to noforeignland.com",
|
|
5
|
+
"main": "index.js",
|
|
6
|
+
"keywords": [
|
|
7
|
+
"signalk-node-server-plugin",
|
|
8
|
+
"signalk-category-utility"
|
|
9
|
+
],
|
|
10
|
+
"author": "Dirk Wahrheit",
|
|
11
|
+
"license": "MIT",
|
|
12
|
+
"homepage": "https://github.com/noforeignland/nfl-signalk",
|
|
13
|
+
"bugs": {
|
|
14
|
+
"url": "https://github.com/noforeignland/nfl-signalk/issues"
|
|
15
|
+
},
|
|
16
|
+
"repository": {
|
|
17
|
+
"type": "git",
|
|
18
|
+
"url": "git+https://github.com/noforeignland/nfl-signalk.git"
|
|
19
|
+
},
|
|
20
|
+
"engines": {
|
|
21
|
+
"node": ">=18.0.0"
|
|
22
|
+
},
|
|
23
|
+
"scripts": {
|
|
24
|
+
"test": "echo \"No tests specified\" && exit 0"
|
|
25
|
+
},
|
|
26
|
+
"dependencies": {
|
|
27
|
+
"cron": "^2.1.0",
|
|
28
|
+
"fs-extra": "^10.1.0",
|
|
29
|
+
"node-fetch": "^2.6.7"
|
|
30
|
+
}
|
|
31
|
+
}
|