signalk-edge-link 2.6.0 → 2.6.2

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.
@@ -72,13 +72,63 @@ function walkValues(node, pathParts, onLeaf) {
72
72
  walkValues(node[key], pathParts.concat(key), onLeaf);
73
73
  }
74
74
  }
75
+ /**
76
+ * Build a lookup map from $source reference string → structured source object
77
+ * using the top-level `sources` section of the SK full model tree.
78
+ *
79
+ * signalk-server stores sources as sources[provider][key] = { label, type, ... }
80
+ * and formats $source references as "provider.key". This function inverts that
81
+ * structure so we can attach the correct source object to each synthetic update.
82
+ *
83
+ * Falls back to a minimal { label } entry derived from the $source string if
84
+ * the sources section is absent or the exact reference is not found there.
85
+ */
86
+ function buildSourceLookup(tree) {
87
+ const lookup = new Map();
88
+ const sourcesNode = tree.sources;
89
+ if (!isRecord(sourcesNode)) {
90
+ return lookup;
91
+ }
92
+ for (const [provider, providerNode] of Object.entries(sourcesNode)) {
93
+ if (!isRecord(providerNode)) {
94
+ continue;
95
+ }
96
+ for (const [key, sourceObj] of Object.entries(providerNode)) {
97
+ if (isRecord(sourceObj)) {
98
+ lookup.set(`${provider}.${key}`, sourceObj);
99
+ }
100
+ }
101
+ }
102
+ return lookup;
103
+ }
104
+ /**
105
+ * Resolve the source object for a $source reference string. Tries the exact
106
+ * reference in the lookup, then falls back to a { label } derived from the
107
+ * part of the reference before the first ".".
108
+ */
109
+ function resolveSource(sourceRef, lookup) {
110
+ const found = lookup.get(sourceRef);
111
+ if (found) {
112
+ return found;
113
+ }
114
+ // Fallback: provider label is the part before the first "."
115
+ const dotIdx = sourceRef.indexOf(".");
116
+ const label = dotIdx > 0 ? sourceRef.slice(0, dotIdx) : sourceRef;
117
+ return label ? { label } : undefined;
118
+ }
75
119
  /**
76
120
  * Build synthetic deltas for every value currently in the Signal K tree.
77
121
  *
78
- * Returns one delta per `(context, source)` pair, with all matching leaves
79
- * grouped into a single `updates[].values[]` array. `DeltaUpdate.timestamp`
80
- * is per-update (not per-leaf), so the latest timestamp across the group is
81
- * used receivers treat the delta as "current state" anyway.
122
+ * Returns one delta per `(context, source, timestamp)` triple so that the
123
+ * original per-path measurement time is preserved. Values from the same
124
+ * source that were last updated at different times (e.g. GPS speed vs
125
+ * autopilot settings) end up in separate updates rather than being collapsed
126
+ * under the latest timestamp of the group.
127
+ *
128
+ * Each update carries both `$source` (the reference string) and `source`
129
+ * (the structured source object looked up from the SK sources tree) so that
130
+ * `handleMessageBySource` can call `app.handleMessage(source.label, delta)`
131
+ * with the original instrument label rather than an empty provider ID.
82
132
  *
83
133
  * Returns [] when `app.signalk` isn't exposed (older signalk-server) or the
84
134
  * tree is empty.
@@ -98,7 +148,13 @@ function collectValuesSnapshot(app) {
98
148
  catch {
99
149
  return [];
100
150
  }
101
- // Group leaves by (context, source) so we emit one delta per group.
151
+ // Build $source source object lookup from the top-level sources tree so
152
+ // each synthetic update can carry the correct source.label for attribution.
153
+ const sourceLookup = buildSourceLookup(tree);
154
+ // Group leaves by (context, source, timestamp) so each distinct measurement
155
+ // time gets its own update. This preserves per-path timestamps: paths from
156
+ // the same source that were last updated at different times (e.g. GPS vs
157
+ // autopilot settings) are not collapsed under the same (latest) timestamp.
102
158
  const grouped = new Map();
103
159
  for (const contextGroup of Object.keys(tree)) {
104
160
  if (SK_NON_CONTEXT_KEYS.has(contextGroup)) {
@@ -115,13 +171,23 @@ function collectValuesSnapshot(app) {
115
171
  }
116
172
  const context = `${contextGroup}.${contextId}`;
117
173
  walkValues(contextNode, [], (leaf) => {
118
- const key = `${context}|${leaf.source ?? ""}`;
174
+ // Skip values that this plugin injected from remote instances.
175
+ // SK stores them under "signalk-edge-link.*" $source keys. Including
176
+ // them in the snapshot would loop remote data back to its origin and
177
+ // propagate wrong source labels (the fallback label derived from the
178
+ // "signalk-edge-link" prefix is never the original sensor label).
179
+ // Live streaming handles relay correctly via subscription callbacks,
180
+ // which SK populates with the full source object automatically.
181
+ const src = leaf.source ?? "";
182
+ if (src === "signalk-edge-link" ||
183
+ src.startsWith("signalk-edge-link.") ||
184
+ src.startsWith("signalk-edge-link:")) {
185
+ return;
186
+ }
187
+ const key = `${context}|${leaf.source ?? ""}|${leaf.timestamp}`;
119
188
  const existing = grouped.get(key);
120
189
  if (existing) {
121
190
  existing.values.push({ path: leaf.path, value: leaf.value });
122
- if (leaf.timestamp > existing.timestamp) {
123
- existing.timestamp = leaf.timestamp;
124
- }
125
191
  }
126
192
  else {
127
193
  grouped.set(key, {
@@ -145,6 +211,10 @@ function collectValuesSnapshot(app) {
145
211
  };
146
212
  if (entry.source) {
147
213
  update.$source = entry.source;
214
+ const sourceObj = resolveSource(entry.source, sourceLookup);
215
+ if (sourceObj) {
216
+ update.source = sourceObj;
217
+ }
148
218
  }
149
219
  deltas.push({ context: entry.context, updates: [update] });
150
220
  }
package/package.json CHANGED
@@ -1,165 +1,165 @@
1
- {
2
- "name": "signalk-edge-link",
3
- "version": "2.6.0",
4
- "description": "SignalK Edge Link. Secure UDP link for data exchange.",
5
- "main": "lib/index.js",
6
- "files": [
7
- "lib/",
8
- "public/"
9
- ],
10
- "keywords": [
11
- "signalk-node-server-plugin",
12
- "signalk-category-network",
13
- "signalk-webapp",
14
- "signalk-category-utility",
15
- "signalk-plugin-configurator"
16
- ],
17
- "signalk": {
18
- "appIcon": "./icons/icon-72x72.png",
19
- "displayName": "Edge Link Configuration"
20
- },
21
- "signalk-plugin-enabled-by-default": false,
22
- "scripts": {
23
- "clean:lib": "node -e \"const fs=require('fs');if(fs.existsSync('lib'))fs.rmSync('lib',{recursive:true,force:true});\"",
24
- "clean:public": "node -e \"const fs=require('fs');if(fs.existsSync('public'))fs.rmSync('public',{recursive:true,force:true});\"",
25
- "build": "npm run build:ts && npm run build:web",
26
- "build:web": "npm run clean:public && webpack --mode production",
27
- "build:ts": "npm run clean:lib && tsc",
28
- "check:ts": "tsc --noEmit",
29
- "check:release-docs": "node scripts/check-release-truth.js",
30
- "dev": "webpack --mode development --watch",
31
- "test": "jest --runInBand",
32
- "test:v2": "jest __tests__/v2/",
33
- "test:integration": "jest test/integration/",
34
- "test:watch": "jest --watch",
35
- "test:coverage": "jest --coverage",
36
- "lint": "eslint .",
37
- "lint:fix": "eslint . --fix",
38
- "format": "prettier --write \"**/*.{js,ts,json,md}\"",
39
- "migrate:config": "node lib/scripts/migrate-config.js",
40
- "cli": "node lib/bin/edge-link-cli.js",
41
- "prepare": "husky"
42
- },
43
- "dependencies": {
44
- "@msgpack/msgpack": "^3.0.0",
45
- "@rjsf/core": "^5.18.4",
46
- "@rjsf/utils": "^5.18.4",
47
- "@rjsf/validator-ajv8": "^5.18.4",
48
- "ping-monitor": "^0.8.2"
49
- },
50
- "devDependencies": {
51
- "@babel/core": "^7.22.0",
52
- "@babel/preset-env": "^7.22.0",
53
- "@babel/preset-react": "^7.22.0",
54
- "@testing-library/jest-dom": "^5.17.0",
55
- "@testing-library/react": "^12.1.5",
56
- "@types/node": "^25.3.5",
57
- "@types/react": "^16.14.0",
58
- "@types/react-dom": "^16.9.0",
59
- "babel-loader": "^9.1.2",
60
- "copy-webpack-plugin": "^14.0.0",
61
- "css-loader": "^6.8.1",
62
- "eslint": "^8.57.1",
63
- "eslint-plugin-react": "^7.37.5",
64
- "html-webpack-plugin": "^5.5.3",
65
- "husky": "^9.1.7",
66
- "jest": "^29.7.0",
67
- "jest-environment-jsdom": "^30.3.0",
68
- "lint-staged": "^15.4.3",
69
- "mini-css-extract-plugin": "^2.7.6",
70
- "prettier": "^3.6.2",
71
- "react": "^16.13.1",
72
- "react-dom": "^16.13.1",
73
- "react-test-renderer": "^16.14.0",
74
- "style-loader": "^3.3.3",
75
- "ts-jest": "^29.4.6",
76
- "ts-loader": "^9.5.4",
77
- "typescript": "^5.9.3",
78
- "webpack": "^5.102.1",
79
- "webpack-cli": "^5.1.4"
80
- },
81
- "engines": {
82
- "node": ">=16"
83
- },
84
- "author": "Karl-Erik Gustafsson",
85
- "repository": "https://github.com/KEGustafsson/signalk-edge-link",
86
- "homepage": "https://github.com/KEGustafsson/signalk-edge-link#readme",
87
- "bugs": {
88
- "url": "https://github.com/KEGustafsson/signalk-edge-link/issues"
89
- },
90
- "license": "MIT",
91
- "jest": {
92
- "testEnvironment": "node",
93
- "coverageDirectory": "coverage",
94
- "collectCoverageFrom": [
95
- "lib/**/*.js",
96
- "!lib/webapp/**",
97
- "!lib/components/**",
98
- "!lib/utils/**"
99
- ],
100
- "testMatch": [
101
- "**/__tests__/**/*.js",
102
- "**/*.test.js",
103
- "**/*.spec.js"
104
- ],
105
- "transform": {
106
- "^.+\\.js$": [
107
- "babel-jest",
108
- {
109
- "presets": [
110
- [
111
- "@babel/preset-env",
112
- {
113
- "targets": {
114
- "node": "current"
115
- }
116
- }
117
- ]
118
- ]
119
- }
120
- ],
121
- ".+\\.tsx$": [
122
- "ts-jest",
123
- {
124
- "tsconfig": "tsconfig.webapp.json",
125
- "diagnostics": false
126
- }
127
- ],
128
- "^.+\\.ts$": "ts-jest"
129
- },
130
- "moduleFileExtensions": [
131
- "ts",
132
- "tsx",
133
- "js",
134
- "json",
135
- "node"
136
- ],
137
- "testPathIgnorePatterns": [
138
- "/node_modules/",
139
- "/public/"
140
- ],
141
- "coverageThreshold": {
142
- "global": {
143
- "branches": 60,
144
- "functions": 65,
145
- "lines": 65,
146
- "statements": 65
147
- }
148
- }
149
- },
150
- "bin": {
151
- "edge-link-cli": "lib/bin/edge-link-cli.js"
152
- },
153
- "lint-staged": {
154
- "*.js": [
155
- "prettier --write",
156
- "eslint --fix"
157
- ],
158
- "*.ts": [
159
- "prettier --write"
160
- ],
161
- "*.{json,md}": [
162
- "prettier --write"
163
- ]
164
- }
165
- }
1
+ {
2
+ "name": "signalk-edge-link",
3
+ "version": "2.6.2",
4
+ "description": "SignalK Edge Link. Secure UDP link for data exchange.",
5
+ "main": "lib/index.js",
6
+ "files": [
7
+ "lib/",
8
+ "public/"
9
+ ],
10
+ "keywords": [
11
+ "signalk-node-server-plugin",
12
+ "signalk-category-network",
13
+ "signalk-webapp",
14
+ "signalk-category-utility",
15
+ "signalk-plugin-configurator"
16
+ ],
17
+ "signalk": {
18
+ "appIcon": "./icons/icon-72x72.png",
19
+ "displayName": "Edge Link Configuration"
20
+ },
21
+ "signalk-plugin-enabled-by-default": false,
22
+ "scripts": {
23
+ "clean:lib": "node -e \"const fs=require('fs');if(fs.existsSync('lib'))fs.rmSync('lib',{recursive:true,force:true});\"",
24
+ "clean:public": "node -e \"const fs=require('fs');if(fs.existsSync('public'))fs.rmSync('public',{recursive:true,force:true});\"",
25
+ "build": "npm run build:ts && npm run build:web",
26
+ "build:web": "npm run clean:public && webpack --mode production",
27
+ "build:ts": "npm run clean:lib && tsc",
28
+ "check:ts": "tsc --noEmit",
29
+ "check:release-docs": "node scripts/check-release-truth.js",
30
+ "dev": "webpack --mode development --watch",
31
+ "test": "jest --runInBand",
32
+ "test:v2": "jest __tests__/v2/",
33
+ "test:integration": "jest test/integration/",
34
+ "test:watch": "jest --watch",
35
+ "test:coverage": "jest --coverage",
36
+ "lint": "eslint .",
37
+ "lint:fix": "eslint . --fix",
38
+ "format": "prettier --write \"**/*.{js,ts,json,md}\"",
39
+ "migrate:config": "node lib/scripts/migrate-config.js",
40
+ "cli": "node lib/bin/edge-link-cli.js",
41
+ "prepare": "husky"
42
+ },
43
+ "dependencies": {
44
+ "@msgpack/msgpack": "^3.0.0",
45
+ "@rjsf/core": "^5.18.4",
46
+ "@rjsf/utils": "^5.18.4",
47
+ "@rjsf/validator-ajv8": "^5.18.4",
48
+ "ping-monitor": "^0.8.2"
49
+ },
50
+ "devDependencies": {
51
+ "@babel/core": "^7.22.0",
52
+ "@babel/preset-env": "^7.22.0",
53
+ "@babel/preset-react": "^7.22.0",
54
+ "@testing-library/jest-dom": "^5.17.0",
55
+ "@testing-library/react": "^12.1.5",
56
+ "@types/node": "^25.3.5",
57
+ "@types/react": "^16.14.0",
58
+ "@types/react-dom": "^16.9.0",
59
+ "babel-loader": "^9.1.2",
60
+ "copy-webpack-plugin": "^14.0.0",
61
+ "css-loader": "^6.8.1",
62
+ "eslint": "^8.57.1",
63
+ "eslint-plugin-react": "^7.37.5",
64
+ "html-webpack-plugin": "^5.5.3",
65
+ "husky": "^9.1.7",
66
+ "jest": "^29.7.0",
67
+ "jest-environment-jsdom": "^30.3.0",
68
+ "lint-staged": "^15.4.3",
69
+ "mini-css-extract-plugin": "^2.7.6",
70
+ "prettier": "^3.6.2",
71
+ "react": "^16.13.1",
72
+ "react-dom": "^16.13.1",
73
+ "react-test-renderer": "^16.14.0",
74
+ "style-loader": "^3.3.3",
75
+ "ts-jest": "^29.4.6",
76
+ "ts-loader": "^9.5.4",
77
+ "typescript": "^5.9.3",
78
+ "webpack": "^5.102.1",
79
+ "webpack-cli": "^5.1.4"
80
+ },
81
+ "engines": {
82
+ "node": ">=16"
83
+ },
84
+ "author": "Karl-Erik Gustafsson",
85
+ "repository": "https://github.com/KEGustafsson/signalk-edge-link",
86
+ "homepage": "https://github.com/KEGustafsson/signalk-edge-link#readme",
87
+ "bugs": {
88
+ "url": "https://github.com/KEGustafsson/signalk-edge-link/issues"
89
+ },
90
+ "license": "MIT",
91
+ "jest": {
92
+ "testEnvironment": "node",
93
+ "coverageDirectory": "coverage",
94
+ "collectCoverageFrom": [
95
+ "lib/**/*.js",
96
+ "!lib/webapp/**",
97
+ "!lib/components/**",
98
+ "!lib/utils/**"
99
+ ],
100
+ "testMatch": [
101
+ "**/__tests__/**/*.js",
102
+ "**/*.test.js",
103
+ "**/*.spec.js"
104
+ ],
105
+ "transform": {
106
+ "^.+\\.js$": [
107
+ "babel-jest",
108
+ {
109
+ "presets": [
110
+ [
111
+ "@babel/preset-env",
112
+ {
113
+ "targets": {
114
+ "node": "current"
115
+ }
116
+ }
117
+ ]
118
+ ]
119
+ }
120
+ ],
121
+ ".+\\.tsx$": [
122
+ "ts-jest",
123
+ {
124
+ "tsconfig": "tsconfig.webapp.json",
125
+ "diagnostics": false
126
+ }
127
+ ],
128
+ "^.+\\.ts$": "ts-jest"
129
+ },
130
+ "moduleFileExtensions": [
131
+ "ts",
132
+ "tsx",
133
+ "js",
134
+ "json",
135
+ "node"
136
+ ],
137
+ "testPathIgnorePatterns": [
138
+ "/node_modules/",
139
+ "/public/"
140
+ ],
141
+ "coverageThreshold": {
142
+ "global": {
143
+ "branches": 60,
144
+ "functions": 65,
145
+ "lines": 65,
146
+ "statements": 65
147
+ }
148
+ }
149
+ },
150
+ "bin": {
151
+ "edge-link-cli": "lib/bin/edge-link-cli.js"
152
+ },
153
+ "lint-staged": {
154
+ "*.js": [
155
+ "prettier --write",
156
+ "eslint --fix"
157
+ ],
158
+ "*.ts": [
159
+ "prettier --write"
160
+ ],
161
+ "*.{json,md}": [
162
+ "prettier --write"
163
+ ]
164
+ }
165
+ }
@@ -0,0 +1,2 @@
1
+ "use strict";(self.webpackChunksignalk_edge_link=self.webpackChunksignalk_edge_link||[]).push([[982],{4353(e,t,n){n.r(t),n.d(t,{default:()=>M});var r=n(4147),i=n(6718),o=n(4810),a=n(7936);const s="Management token required/invalid.",l={token:null,localStorageKey:"signalkEdgeLinkManagementToken",queryParam:"edgeLinkToken",includeTokenInQuery:!1,headerMode:"both"};function c(e,t={}){const n=function(){if("undefined"==typeof window)return l;const e=window.__EDGE_LINK_AUTH__;return e&&"object"==typeof e?{...l,...e}:l}(),r=function(e){if(e.token)return String(e.token).trim();if("undefined"==typeof window)return"";if(e.includeTokenInQuery&&e.queryParam){const t=new URLSearchParams(window.location.search).get(e.queryParam);if(t)return t.trim()}if(e.localStorageKey&&window.localStorage){const t=window.localStorage.getItem(e.localStorageKey);if(t)return t.trim()}return""}(n),i=new Headers(t.headers||{});return function(e,t,n){if(!t)return e;const r=(n||"both").toLowerCase();"x-edge-link-token"!==r&&"token"!==r&&"both"!==r||e.set("X-Edge-Link-Token",t),"authorization"!==r&&"bearer"!==r&&"both"!==r||e.set("Authorization",`Bearer ${t}`)}(i,r,n.headerMode),fetch(e,{...t,headers:i})}const d={name:{type:"string",title:"Connection Name",description:"Human-readable label for this connection (e.g. 'Shore Server', 'Sat Client'). Used to namespace config files and Signal K metrics paths.",default:"connection",maxLength:40},serverType:{type:"string",title:"Operation Mode",description:"Select Server to receive data, or Client to send data.",default:"client",oneOf:[{const:"server",title:"Server Mode – Receive Data"},{const:"client",title:"Client Mode – Send Data"}]},udpPort:{type:"number",title:"UDP Port",description:"UDP port for data transmission (must match on both ends).",default:4446,minimum:1024,maximum:65535},secretKey:{type:"string",title:"Encryption Key",description:"32-byte secret key: 32-character ASCII, 64-character hex, or 44-character base64.",minLength:32,maxLength:64,pattern:"^(?:.{32}|[0-9a-fA-F]{64}|[A-Za-z0-9+/]{43}=?)$"},stretchAsciiKey:{type:"boolean",title:"Stretch 32-char ASCII Key (PBKDF2)",description:`When the secretKey is 32-character ASCII, route it through PBKDF2-SHA256 (${6e5.toLocaleString("en-US")} iterations) to raise it to full 256-bit AES strength. Hex and base64 keys are unaffected. BOTH ENDS OF THE CONNECTION MUST USE THE SAME SETTING — otherwise authentication will fail and every packet will be dropped.`,default:!1},useMsgpack:{type:"boolean",title:"Use MessagePack",description:"Binary serialization for smaller payloads (must match on both ends).",default:!1},usePathDictionary:{type:"boolean",title:"Use Path Dictionary",description:"Encode paths as numeric IDs for bandwidth savings (must match on both ends).",default:!1},protocolVersion:{type:"number",title:"Protocol Version",description:"v1: encrypted UDP. v2 adds reliable delivery and metrics. v3 keeps the v2 data path and authenticates control packets (ACK/NAK/HEARTBEAT/HELLO). Must match on both ends.",default:1,oneOf:[{const:1,title:"v1 – Standard encrypted UDP"},{const:2,title:"v2 – Reliability, congestion control, bonding, metrics"},{const:3,title:"v3 - v2 features with authenticated control packets"}]}},m={testAddress:{type:"string",title:"Connectivity Test Address (v1 only)",description:"Host used for reachability checks (e.g. 8.8.8.8). v1 only.",default:"127.0.0.1"},testPort:{type:"number",title:"Connectivity Test Port (v1 only)",description:"Port used for reachability checks (e.g. 53, 80, or 443). v1 only.",default:80,minimum:1,maximum:65535},pingIntervalTime:{type:"number",title:"Check Interval (minutes, v1 only)",description:"Frequency of network reachability checks. v1 only.",default:1,minimum:.1,maximum:60}},u={udpAddress:{type:"string",title:"Server Address",description:"IP address or hostname of the remote Signal K endpoint.",default:"127.0.0.1"},helloMessageSender:{type:"integer",title:"Heartbeat Interval (seconds)",description:"Send periodic heartbeat messages to keep NAT/firewall mappings alive.",default:60,minimum:10,maximum:3600},heartbeatInterval:{type:"number",title:"NAT Keepalive Heartbeat Interval (ms)",description:"v2/v3 only. How often to send UDP heartbeat packets for NAT traversal. Typical NAT timeouts range from 30s to 120s.",default:25e3,minimum:5e3,maximum:12e4}},p={type:"object",title:"Reliability Settings (v2/v3 only)",description:"Requires Protocol v2 or v3. Controls retransmit queue behavior and packet retry limits.",properties:{retransmitQueueSize:{type:"number",title:"Retransmit Queue Size",description:"Maximum number of sent packets stored for potential retransmission.",default:5e3,minimum:100,maximum:5e4},maxRetransmits:{type:"number",title:"Max Retransmit Attempts",description:"Maximum resend attempts before a packet is dropped from the retransmit queue.",default:3,minimum:1,maximum:20},retransmitMaxAge:{type:"number",title:"Retransmit Max Age (ms)",description:"Expire stale unacknowledged packets older than this age.",default:12e4,minimum:1e3,maximum:3e5},retransmitMinAge:{type:"number",title:"Retransmit Min Age (ms)",description:"Minimum packet age before expiration is allowed.",default:1e4,minimum:200,maximum:3e4},retransmitRttMultiplier:{type:"number",title:"RTT Expiry Multiplier",description:"Dynamic expiry age = RTT × this multiplier.",default:12,minimum:2,maximum:20},ackIdleDrainAge:{type:"number",title:"ACK Idle Drain Age (ms)",description:"If ACKs are idle longer than this, expiry becomes more aggressive.",default:2e4,minimum:500,maximum:3e4},forceDrainAfterAckIdle:{type:"boolean",title:"Force Drain After ACK Idle",description:"When enabled, clear retransmit queue if no ACKs arrive for too long.",default:!1},forceDrainAfterMs:{type:"number",title:"Force Drain Timeout (ms)",description:"ACK idle duration before force-draining retransmit queue to zero.",default:45e3,minimum:2e3,maximum:12e4},recoveryBurstEnabled:{type:"boolean",title:"Recovery Burst Enabled",description:"When ACKs return after outage, rapidly retransmit queued packets to catch up.",default:!0},recoveryBurstSize:{type:"number",title:"Recovery Burst Size",description:"Max queued packets to retransmit per recovery burst cycle.",default:100,minimum:10,maximum:1e3},recoveryBurstIntervalMs:{type:"number",title:"Recovery Burst Interval (ms)",description:"Interval between recovery burst cycles while backlog exists.",default:200,minimum:50,maximum:5e3},recoveryAckGapMs:{type:"number",title:"Recovery ACK Gap (ms)",description:"Minimum ACK silence before triggering fast recovery bursts.",default:4e3,minimum:500,maximum:12e4}}},g={type:"boolean",title:"Request Full Status on Server Start (v2/v3 only)",description:"When enabled, the server sends a request to each client on first contact asking it to replay its complete current values snapshot. This rebuilds the server's state immediately after a restart instead of waiting for incremental deltas to arrive.",default:!1},f={type:"object",title:"Reliability Settings (v2/v3 only)",description:"Requires Protocol v2 or v3. Controls ACK/NAK timing for reliable delivery.",properties:{ackInterval:{type:"number",title:"ACK Interval (ms)",description:"How often server sends cumulative ACK updates.",default:100,minimum:20,maximum:5e3},ackResendInterval:{type:"number",title:"ACK Resend Interval (ms)",description:"Re-send duplicate ACK periodically to recover from lost ACK packets.",default:1e3,minimum:100,maximum:1e4},nakTimeout:{type:"number",title:"NAK Timeout (ms)",description:"Delay before requesting retransmission for missing sequence numbers.",default:100,minimum:20,maximum:5e3}}},y={type:"object",title:"Dynamic Congestion Control (v2/v3 only)",description:"Requires Protocol v2 or v3. AIMD algorithm to dynamically adjust send rate based on network conditions.",properties:{enabled:{type:"boolean",title:"Enable Congestion Control",description:"Automatically adjust delta timer based on RTT and packet loss.",default:!1},targetRTT:{type:"number",title:"Target RTT (ms)",description:"RTT threshold above which send rate is reduced.",default:200,minimum:50,maximum:2e3},nominalDeltaTimer:{type:"number",title:"Nominal Delta Timer (ms)",description:"Preferred steady-state send interval.",default:1e3,minimum:100,maximum:1e4},minDeltaTimer:{type:"number",title:"Minimum Delta Timer (ms)",description:"Fastest allowed send interval.",default:100,minimum:50,maximum:1e3},maxDeltaTimer:{type:"number",title:"Maximum Delta Timer (ms)",description:"Slowest allowed send interval.",default:5e3,minimum:1e3,maximum:3e4}}},b={type:"object",title:"Connection Bonding (v2/v3 only)",description:"Requires Protocol v2 or v3. Dual-link bonding with automatic failover between primary and backup connections.",properties:{enabled:{type:"boolean",title:"Enable Connection Bonding",description:"Enable dual-link bonding with automatic failover.",default:!1},mode:{type:"string",title:"Bonding Mode",description:"Bonding operating mode.",default:"main-backup",oneOf:[{const:"main-backup",title:"Main/Backup – Failover to backup when primary degrades"}]},primary:{type:"object",title:"Primary Link",description:"Primary connection (e.g. LTE modem).",properties:{address:{type:"string",title:"Server Address",default:"127.0.0.1"},port:{type:"number",title:"UDP Port",default:4446,minimum:1024,maximum:65535},interface:{type:"string",title:"Bind Interface (optional)",description:"Network interface IP to bind to."}}},backup:{type:"object",title:"Backup Link",description:"Backup connection (e.g. Starlink, satellite).",properties:{address:{type:"string",title:"Server Address",default:"127.0.0.1"},port:{type:"number",title:"UDP Port",default:4447,minimum:1024,maximum:65535},interface:{type:"string",title:"Bind Interface (optional)",description:"Network interface IP to bind to."}}},failover:{type:"object",title:"Failover Thresholds",description:"Configure when failover is triggered.",properties:{rttThreshold:{type:"number",title:"RTT Threshold (ms)",default:500,minimum:100,maximum:5e3},lossThreshold:{type:"number",title:"Packet Loss Threshold (0-1)",default:.1,minimum:.01,maximum:.5},healthCheckInterval:{type:"number",title:"Health Check Interval (ms)",default:1e3,minimum:500,maximum:1e4},failbackDelay:{type:"number",title:"Failback Delay (ms)",default:3e4,minimum:5e3,maximum:3e5},heartbeatTimeout:{type:"number",title:"Heartbeat Timeout (ms)",default:5e3,minimum:1e3,maximum:3e4}}}}},h={type:"boolean",title:"Enable Signal K Notifications",description:"Emit Signal K notifications for alerts and failover events.",default:!1},k={type:"boolean",title:"Skip Plugin's Own Data",description:"Do not forward data this plugin publishes locally over the link. Strips entries under 'networking.edgeLink.*' and the v1 RTT path 'networking.modem.rtt' / 'networking.modem.<id>.rtt'; other 'networking.modem.*' paths from external providers are left intact. Also suppresses the v2/v3 client telemetry packet that mirrors local link metrics to the receiver.",default:!1},v={type:"object",title:"Monitoring Alert Thresholds (v2/v3 only)",description:"Customize warning/critical thresholds for network monitoring alerts.",properties:{rtt:{type:"object",title:"RTT Thresholds",properties:{warning:{type:"number",title:"Warning RTT (ms)",default:300},critical:{type:"number",title:"Critical RTT (ms)",default:800}}},packetLoss:{type:"object",title:"Packet Loss Thresholds",properties:{warning:{type:"number",title:"Warning Loss Ratio",default:.03},critical:{type:"number",title:"Critical Loss Ratio",default:.1}}},retransmitRate:{type:"object",title:"Retransmit Rate Thresholds",properties:{warning:{type:"number",title:"Warning Retransmit Ratio",default:.05},critical:{type:"number",title:"Critical Retransmit Ratio",default:.15}}},jitter:{type:"object",title:"Jitter Thresholds",properties:{warning:{type:"number",title:"Warning Jitter (ms)",default:100},critical:{type:"number",title:"Critical Jitter (ms)",default:300}}},queueDepth:{type:"object",title:"Queue Depth Thresholds",properties:{warning:{type:"number",title:"Warning Queue Depth",default:100},critical:{type:"number",title:"Critical Queue Depth",default:500}}}}};function x(e,t){const n=Number(t)>=2,r={...d},i=["serverType","udpPort","secretKey"];return e?(Object.assign(r,u),r.enableNotifications=h,r.skipOwnData=k,i.push("udpAddress"),n?(r.reliability=p,r.congestionControl=y,r.bonding=b,r.alertThresholds=v):(Object.assign(r,m),i.push("testAddress","testPort"))):n&&(r.requestFullStatusOnRestart=g,r.reliability=f),{type:"object",required:i,properties:r}}const T="/plugins/signalk-edge-link";let w=0;function A(){return`skel-${Date.now()}-${++w}`}function S(e){const t=A();return{_id:t,connectionId:t,name:e||"client",serverType:"client",udpPort:4446,secretKey:"",stretchAsciiKey:!1,useMsgpack:!1,usePathDictionary:!1,enableNotifications:!1,skipOwnData:!1,protocolVersion:1,udpAddress:"127.0.0.1",helloMessageSender:60,testAddress:"127.0.0.1",testPort:80,pingIntervalTime:1}}function E(e){const t=A();return{_id:t,connectionId:t,name:e||"server",serverType:"server",udpPort:4446,secretKey:"",stretchAsciiKey:!1,useMsgpack:!1,usePathDictionary:!1,protocolVersion:1}}function C(e){const t="string"==typeof e.connectionId&&e.connectionId.trim()?e.connectionId.trim():e._id||A();return{...e,_id:e._id||t,connectionId:t}}function I(e){const t=x("server"!==e.serverType,e.protocolVersion),{_id:n,...r}=e;return{...(0,a.NV)(o.Ay,t,r),_id:n}}function P(e){if(null===e||"object"!=typeof e)return JSON.stringify(e);if(Array.isArray(e))return"["+e.map(P).join(",")+"]";const t=e;return"{"+Object.keys(t).sort().map(e=>JSON.stringify(e)+":"+P(t[e])).join(",")+"}"}const N={"ui:order":["name","serverType","udpAddress","udpPort","secretKey","stretchAsciiKey","protocolVersion","useMsgpack","usePathDictionary","testAddress","testPort","pingIntervalTime","helloMessageSender","heartbeatInterval","reliability","congestionControl","bonding","skipOwnData","enableNotifications","alertThresholds"],secretKey:{"ui:widget":"password","ui:help":"Use 32-character ASCII, 64-character hex, or 44-character base64"},stretchAsciiKey:{"ui:help":"Only applies to 32-char ASCII keys. Must match on both peers."},serverType:{"ui:widget":"select"},reliability:{"ui:classNames":"skel-optional-group"},congestionControl:{"ui:classNames":"skel-optional-group"},bonding:{"ui:classNames":"skel-optional-group"},alertThresholds:{"ui:classNames":"skel-optional-group"}},D={"ui:order":["name","serverType","udpPort","secretKey","stretchAsciiKey","useMsgpack","usePathDictionary","protocolVersion","requestFullStatusOnRestart","reliability"],secretKey:{"ui:widget":"password","ui:help":"Use 32-character ASCII, 64-character hex, or 44-character base64"},stretchAsciiKey:{"ui:help":"Only applies to 32-char ASCII keys. Must match on both peers."},serverType:{"ui:widget":"select"}},K=["name","udpPort","secretKey","stretchAsciiKey","useMsgpack","usePathDictionary","protocolVersion"];function R({conn:e,index:t,totalCount:n,expanded:a,onToggle:s,onChange:l,onRemove:c}){const d="server"!==e.serverType,m=x(d,e.protocolVersion),u=d?N:D,p=d?"Client":"Server",g=(e.name||`Connection ${t+1}`).trim(),{_id:f,...y}=e;return r.createElement("div",{className:"skel-card"},r.createElement("div",{className:"skel-card-header",onClick:s,role:"button","aria-expanded":a},r.createElement("span",{className:"skel-badge "+(d?"skel-badge-client":"skel-badge-server")},p),r.createElement("span",{className:"skel-card-title"},g),r.createElement("span",{className:"skel-expand-icon"},a?"▲":"▼"),r.createElement("button",{className:"skel-btn-remove",disabled:n<=1,onClick:e=>{e.stopPropagation(),c()},title:n<=1?"Cannot remove the only connection":"Remove this connection"},"Remove")),a&&r.createElement("div",{className:"skel-card-body"},r.createElement(i.Ay,{schema:m,uiSchema:u,formData:y,validator:o.Ay,onChange:function(t){const n=t.formData;if(!n)return;if(n.serverType&&n.serverType!==e.serverType){const t={..."server"===n.serverType?E(n.name):S(n.name),_id:e._id,connectionId:e.connectionId||e._id};for(const e of K)void 0!==n[e]&&(t[e]=n[e]);return t.serverType=n.serverType,void l(t)}const r={...n,_id:e._id,connectionId:n.connectionId||e.connectionId||e._id};"server"!==r.serverType&&(r.protocolVersion??1)>=2&&(delete r.testAddress,delete r.testPort,delete r.pingIntervalTime);const{_id:i,...o}=r,{_id:a,...s}=e;(function(e,t){const n=Object.keys(e),r=Object.keys(t);if(n.length!==r.length)return!1;for(const r of n){if(!Object.prototype.hasOwnProperty.call(t,r))return!1;const n=e[r],i=t[r];if(n!==i){if(null===n||null===i||"object"!=typeof n||"object"!=typeof i)return!1;if(P(n)!==P(i))return!1}}return!0})(o,s)||l(r)},onSubmit:()=>{},liveValidate:!1},r.createElement("div",null))))}const M=function(e){const[t,n]=(0,r.useState)([]),[i,o]=(0,r.useState)(""),[a,l]=(0,r.useState)(!1),[d,m]=(0,r.useState)(!0),[u,p]=(0,r.useState)(null),[g,f]=(0,r.useState)(null),[y,b]=(0,r.useState)(null),[h,k]=(0,r.useState)(0),[v,x]=(0,r.useState)(!1),w=(0,r.useRef)(!1);(0,r.useEffect)(()=>{!async function(){try{const e=await c(`${T}/plugin-config`);if(401===e.status)throw new Error(s);if(!e.ok)throw new Error(`HTTP ${e.status}: ${e.statusText}`);const t=await e.json();if(!t.success)throw new Error(t.error||"Failed to load configuration");const r=t.configuration||{};let i;i=Array.isArray(r.connections)&&r.connections.length>0?r.connections.map(e=>I(C(e))):r.serverType?[I(C(r))]:[S()],n(i),o("string"==typeof r.managementApiToken?r.managementApiToken:""),l(!0===r.requireManagementApiToken),k(0),x(!1)}catch(e){p(e instanceof Error?e.message:String(e))}finally{m(!1)}}()},[]);const A=t.filter(e=>"server"===e.serverType).map(e=>e.udpPort),P=new Set(A.filter((e,t)=>A.indexOf(e)!==t));function N(){x(!0),f(null),b(null)}const D=(0,r.useCallback)(async()=>{if(!w.current){if(0===t.length)return b("At least one connection is required before saving."),void f({type:"error",message:"Cannot save an empty configuration. Add at least one connection."});if(b(null),P.size>0)f({type:"error",message:`Duplicate server ports detected: ${[...P].join(", ")}. Each server must use a unique UDP port.`});else{w.current=!0,f({type:"saving",message:"Saving configuration..."});try{const e=t.map(({_id:e,...t})=>({...t,connectionId:"string"==typeof t.connectionId&&t.connectionId.trim()?t.connectionId.trim():e})),n=await c(`${T}/plugin-config`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({connections:e,managementApiToken:i,requireManagementApiToken:a})});if(401===n.status)throw new Error(s);const r=await n.json();if(!n.ok||!r.success)throw new Error(r.error||"Failed to save");f({type:"success",message:r.message||"Configuration saved. Plugin restarting..."}),x(!1)}catch(e){f({type:"error",message:e instanceof Error?e.message:String(e)})}finally{w.current=!1}}}},[t,P,i,a]);return d?r.createElement("div",{style:{padding:"20px",textAlign:"center"}},"Loading configuration..."):u?r.createElement("div",{style:{padding:"20px"}},r.createElement("div",{className:"skel-alert skel-alert-error"},r.createElement("strong",null,"Error loading configuration:")," ",u)):r.createElement("div",{className:"skel-config"},r.createElement("style",null,'\n.skel-config { font-family: inherit; }\n.skel-dirty-banner {\n display: flex;\n align-items: center;\n gap: 8px;\n padding: 8px 14px;\n background: #fff3cd;\n color: #664d03;\n border: 1px solid #ffe69c;\n border-radius: 4px;\n margin-bottom: 12px;\n font-size: 0.88rem;\n}\n.skel-card {\n border: 1px solid #dee2e6;\n border-radius: 6px;\n margin-bottom: 12px;\n overflow: hidden;\n}\n.skel-card-header {\n display: flex;\n align-items: center;\n padding: 10px 14px;\n background: #f8f9fa;\n cursor: pointer;\n user-select: none;\n gap: 10px;\n}\n.skel-card-header:hover { background: #e9ecef; }\n.skel-badge {\n display: inline-block;\n padding: 2px 8px;\n border-radius: 12px;\n font-size: 0.75rem;\n font-weight: 600;\n text-transform: uppercase;\n letter-spacing: 0.03em;\n}\n.skel-badge-server { background: #cfe2ff; color: #084298; }\n.skel-badge-client { background: #d1e7dd; color: #0a3622; }\n.skel-card-title { font-weight: 600; flex: 1; }\n.skel-expand-icon { font-size: 0.8rem; color: #6c757d; }\n.skel-btn-remove {\n background: none;\n border: 1px solid #dc3545;\n color: #dc3545;\n border-radius: 4px;\n padding: 2px 8px;\n font-size: 0.8rem;\n cursor: pointer;\n}\n.skel-btn-remove:hover { background: #dc3545; color: white; }\n.skel-btn-remove:disabled { opacity: 0.4; cursor: default; border-color: #aaa; color: #aaa; }\n.skel-btn-remove:disabled:hover { background: none; }\n.skel-card-body { padding: 16px; border-top: 1px solid #dee2e6; }\n.skel-toolbar {\n display: flex;\n gap: 10px;\n align-items: center;\n margin-top: 16px;\n padding-top: 16px;\n border-top: 1px solid #dee2e6;\n flex-wrap: wrap;\n}\n.skel-btn {\n padding: 7px 16px;\n border-radius: 4px;\n font-size: 0.95rem;\n cursor: pointer;\n border: none;\n}\n.skel-btn-primary { background: #0d6efd; color: white; }\n.skel-btn-primary:hover { background: #0b5ed7; }\n.skel-btn-primary:disabled { background: #6c757d; cursor: default; }\n.skel-btn-secondary { background: white; color: #0d6efd; border: 1px solid #0d6efd; }\n.skel-btn-secondary:hover { background: #e7f0ff; }\n.skel-alert {\n padding: 10px 14px;\n border-radius: 4px;\n margin-bottom: 14px;\n font-size: 0.9rem;\n}\n.skel-alert-success { background: #d1e7dd; color: #0a3622; border: 1px solid #a3cfbb; }\n.skel-alert-error { background: #f8d7da; color: #58151c; border: 1px solid #f1aeb5; }\n.skel-alert-saving { background: #fff3cd; color: #664d03; border: 1px solid #ffe69c; }\n.skel-dup-warn { font-size: 0.8rem; color: #dc3545; margin-top: 4px; }\n.skel-plugin-settings {\n border: 1px solid #dee2e6;\n border-radius: 6px;\n margin-bottom: 20px;\n padding: 16px;\n background: #f8f9fa;\n}\n.skel-plugin-settings h3 {\n margin: 0 0 12px;\n font-size: 1rem;\n font-weight: 600;\n}\n.skel-field-group {\n margin-bottom: 14px;\n}\n.skel-field-group label {\n display: block;\n font-weight: 500;\n margin-bottom: 4px;\n font-size: 0.9rem;\n}\n.skel-field-group input[type="text"],\n.skel-field-group input[type="password"] {\n width: 100%;\n max-width: 420px;\n padding: 6px 10px;\n border: 1px solid #ced4da;\n border-radius: 4px;\n font-size: 0.9rem;\n}\n.skel-field-group input[type="checkbox"] {\n margin-right: 6px;\n}\n.skel-field-desc {\n font-size: 0.8rem;\n color: #5c6773;\n margin-top: 3px;\n}\n.skel-config .field-description {\n color: #5c6773;\n font-size: 0.83rem;\n line-height: 1.35;\n}\n.skel-config legend,\n.skel-config label {\n line-height: 1.2;\n overflow-wrap: anywhere;\n}\n.skel-optional-group {\n margin-top: 12px;\n border: 1px dashed #ccd5df;\n border-radius: 6px;\n padding: 10px 12px 4px;\n background: #fbfcfe;\n}\n.skel-optional-group legend {\n font-size: 0.92rem;\n margin-bottom: 6px;\n}\n.skel-optional-group .form-group {\n margin-bottom: 10px;\n}\n.skel-optional-group .form-control {\n max-width: 340px;\n}\n'),v&&"saving"!==g?.type&&r.createElement("div",{className:"skel-dirty-banner"},r.createElement("span",null,"⚠"),r.createElement("span",null,"You have unsaved changes.")),g&&r.createElement("div",{className:"skel-alert skel-alert-"+("saving"===g.type?"saving":"success"===g.type?"success":"error")},g.message),r.createElement("div",{className:"skel-plugin-settings"},r.createElement("h3",null,"Plugin Security Settings"),r.createElement("div",{className:"skel-field-group"},r.createElement("label",{htmlFor:"skel-mgmt-token"},"Management API Token"),r.createElement("input",{id:"skel-mgmt-token",type:"password",value:i,placeholder:"Leave empty for open access",onChange:e=>{o(e.target.value),N()},autoComplete:"new-password"}),r.createElement("div",{className:"skel-field-desc"},"Shared secret to protect the management API endpoints. Strongly recommended for production. Can also be set via the ",r.createElement("code",null,"SIGNALK_EDGE_LINK_MANAGEMENT_TOKEN")," ","environment variable (env var takes priority). Leave empty to allow open access.")),r.createElement("div",{className:"skel-field-group"},r.createElement("label",null,r.createElement("input",{type:"checkbox",checked:a,onChange:e=>{l(e.target.checked),N()}}),"Require Management API Token"),r.createElement("div",{className:"skel-field-desc"},"When enabled, all management API requests are rejected if no token is configured (fail-closed). When disabled, requests are allowed if no token is set (open access)."))),t.map((e,i)=>r.createElement("div",{key:e._id},r.createElement(R,{conn:e,index:i,totalCount:t.length,expanded:h===i,onToggle:()=>function(e){k(t=>t===e?null:e)}(i),onChange:e=>function(e,t){n(n=>n.map((n,r)=>r===e?t:n)),N()}(i,e),onRemove:()=>function(e){n(t=>{if(t.length<=1)return t;const n=t.filter((t,n)=>n!==e);return k(t=>null!==t&&t>=e&&t>0?t-1:t),n}),N()}(i)}),"server"===e.serverType&&P.has(e.udpPort)&&r.createElement("div",{className:"skel-dup-warn"},"Port ",e.udpPort," is used by multiple server connections. Each server requires a unique port."))),r.createElement("div",{className:"skel-toolbar"},r.createElement("button",{className:"skel-btn skel-btn-secondary",onClick:function(){n(e=>{const t=[...e,E(`server-${e.length+1}`)];return k(t.length-1),t}),N()}},"+ Add Server"),r.createElement("button",{className:"skel-btn skel-btn-secondary",onClick:function(){n(e=>{const t=[...e,S(`client-${e.length+1}`)];return k(t.length-1),t}),N()}},"+ Add Client"),r.createElement("button",{className:"skel-btn skel-btn-primary",onClick:D,disabled:g&&"saving"===g.type||0===t.length},v?"Save Changes":"Save Configuration"),y&&r.createElement("span",{style:{color:"#dc3545",fontSize:"0.85rem",fontWeight:500}},y),r.createElement("span",{style:{fontSize:"0.85rem",color:"#6c757d"}},t.length," connection",1!==t.length?"s":""," · ",t.filter(e=>"server"===e.serverType).length," server",1!==t.filter(e=>"server"===e.serverType).length?"s":"",", ",t.filter(e=>"server"!==e.serverType).length," client",1!==t.filter(e=>"server"!==e.serverType).length?"s":"")))}}}]);
2
+ //# sourceMappingURL=982.fb1b6560eada159d88ee.js.map