signalk-pypilot-newui 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +186 -0
- package/NOTICE +30 -0
- package/README.md +171 -0
- package/dist/autopilot-provider.js +220 -0
- package/dist/index.js +1551 -0
- package/dist/publisher.js +265 -0
- package/dist/pypilot-client.js +218 -0
- package/dist/scanner.js +125 -0
- package/package.json +68 -0
- package/public/Pypilot_New_UI.svg +25 -0
- package/public/app.js +2831 -0
- package/public/boat-cenital.svg +865 -0
- package/public/icon.svg +6 -0
- package/public/index.html +823 -0
- package/public/screenshots/01-control-rose.jpg +0 -0
- package/public/screenshots/02-tune-gains.jpg +0 -0
- package/public/screenshots/03-info.jpg +0 -0
- package/public/screenshots/04-setup.jpg +0 -0
- package/public/style.css +1487 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,1551 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
const pypilot_client_1 = require("./pypilot-client");
|
|
4
|
+
const publisher_1 = require("./publisher");
|
|
5
|
+
const scanner_1 = require("./scanner");
|
|
6
|
+
const autopilot_provider_1 = require("./autopilot-provider");
|
|
7
|
+
// Rev counter bumped on every build so the user can distinguish deploys
|
|
8
|
+
// from the webapp header (feedback_revision_bump_each_build).
|
|
9
|
+
const PLUGIN_REVISION = "Rev62";
|
|
10
|
+
// Rev59: read package.json once at load time so /status can report the
|
|
11
|
+
// npm package version alongside the internal Rev counter.
|
|
12
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
13
|
+
const PLUGIN_PKG_VERSION = (() => {
|
|
14
|
+
try {
|
|
15
|
+
return require("../package.json").version || "";
|
|
16
|
+
}
|
|
17
|
+
catch {
|
|
18
|
+
return "";
|
|
19
|
+
}
|
|
20
|
+
})();
|
|
21
|
+
const PLUGIN_ID = "signalk-pypilot-newui";
|
|
22
|
+
const SOURCE_LABEL = "pypilot-newui";
|
|
23
|
+
// Default watch periods per rate class. NEVER `true` (event-driven) - on
|
|
24
|
+
// the Tunatunes Pi Zero W hosting pypilot_web, event-driven watches from
|
|
25
|
+
// two or three concurrent clients (pypilot-autopilot-provider + upstream
|
|
26
|
+
// UI + ours) saturated the process and cascaded to a hung SK server.
|
|
27
|
+
// Reference: memory/project_tinypilot_pi_zero_limit.md
|
|
28
|
+
const WATCH_HIGH = 0.5; // 2 Hz - UI-facing values users may slide
|
|
29
|
+
const WATCH_MED = 1; // 1 Hz - telemetry (voltage, current, temps)
|
|
30
|
+
const WATCH_LOW = 5; // 0.2 Hz - runtime / version
|
|
31
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
32
|
+
module.exports = function (app) {
|
|
33
|
+
let client = null;
|
|
34
|
+
let apProvider = null;
|
|
35
|
+
let props = { host: "", port: 80 };
|
|
36
|
+
let lastCatalog = {};
|
|
37
|
+
let lastPingLatencyMs = null;
|
|
38
|
+
let publishedSkPaths = new Set();
|
|
39
|
+
let metaSent = new Set();
|
|
40
|
+
let putHandlersRegistered = new Set();
|
|
41
|
+
let lastConnectAt = null;
|
|
42
|
+
let lastDisconnectReason = null;
|
|
43
|
+
let deltaSentCount = 0;
|
|
44
|
+
const plugin = {
|
|
45
|
+
id: PLUGIN_ID,
|
|
46
|
+
name: "PyPilot New-UI + SK Paths",
|
|
47
|
+
description: "Modern touch-first control panel for pypilot autopilot plus every pypilot value as a first-class Signal K path (KIP-ready). Complements the official pypilot-autopilot-provider.",
|
|
48
|
+
revision: PLUGIN_REVISION,
|
|
49
|
+
schema: () => ({
|
|
50
|
+
type: "object",
|
|
51
|
+
required: ["host", "port"],
|
|
52
|
+
properties: {
|
|
53
|
+
host: {
|
|
54
|
+
type: "string",
|
|
55
|
+
title: "pypilot_web host",
|
|
56
|
+
description: "IP or hostname of the machine running pypilot_web. Use the SETUP tab of the webapp to scan the LAN.",
|
|
57
|
+
default: "",
|
|
58
|
+
},
|
|
59
|
+
port: {
|
|
60
|
+
type: "number",
|
|
61
|
+
title: "pypilot_web port",
|
|
62
|
+
description: "TinyPilot ships pypilot_web on port 80. Classic pypilot install uses 8000.",
|
|
63
|
+
default: 80,
|
|
64
|
+
},
|
|
65
|
+
reconnectDelayMs: {
|
|
66
|
+
type: "number",
|
|
67
|
+
title: "Reconnect delay (ms)",
|
|
68
|
+
default: 3000,
|
|
69
|
+
},
|
|
70
|
+
allowWrites: {
|
|
71
|
+
type: "boolean",
|
|
72
|
+
title: "Allow writes",
|
|
73
|
+
description: "Enables PUT handlers so Signal K clients can send commands (engage, mode, target, gains). Off = read-only mode.",
|
|
74
|
+
default: true,
|
|
75
|
+
},
|
|
76
|
+
allowDirectServo: {
|
|
77
|
+
type: "boolean",
|
|
78
|
+
title: "Allow direct servo command",
|
|
79
|
+
description: "DANGER: exposes the raw servo.command back-door used for manual steering. Watchdog is still enforced, but leave this OFF unless you understand the safety implications.",
|
|
80
|
+
default: false,
|
|
81
|
+
},
|
|
82
|
+
publishUnmapped: {
|
|
83
|
+
type: "boolean",
|
|
84
|
+
title: "Publish unmapped values",
|
|
85
|
+
description: "When on, every pypilot value discovered at runtime that is not in the fixed mapping table is auto-published under steering.autopilot.pypilot.<sanitized_name>.",
|
|
86
|
+
default: false,
|
|
87
|
+
},
|
|
88
|
+
nudgeSmall: {
|
|
89
|
+
type: "number",
|
|
90
|
+
title: "Small nudge step (degrees)",
|
|
91
|
+
description: "Label and value of the fine nudge buttons in the mobile UI. Default 1.",
|
|
92
|
+
default: 1,
|
|
93
|
+
},
|
|
94
|
+
nudgeBig: {
|
|
95
|
+
type: "number",
|
|
96
|
+
title: "Big nudge step (degrees)",
|
|
97
|
+
description: "Label and value of the coarse nudge buttons in the mobile UI. Default 10.",
|
|
98
|
+
default: 10,
|
|
99
|
+
},
|
|
100
|
+
absorbProvider: {
|
|
101
|
+
type: "boolean",
|
|
102
|
+
title: "Absorb pypilot-autopilot-provider (one-socket mode)",
|
|
103
|
+
description: "When on, this plugin registers itself as the SK Autopilot Provider (WilhelmSK, freeboard, etc. control it via /signalk/v2/api/vessels/self/autopilots). REQUIRES you to disable the official 'pypilot-autopilot-provider' plugin at the same time - otherwise both fight for the deviceId. Benefit: only one socket to pypilot_web (halves the load on a Pi Zero TinyPilot).",
|
|
104
|
+
default: false,
|
|
105
|
+
},
|
|
106
|
+
enabledPaths: {
|
|
107
|
+
type: "object",
|
|
108
|
+
title: "Enabled paths (pypilot name -> boolean)",
|
|
109
|
+
description: "When publishOnlyEssentials is true: publish a non-essential path by setting it to true. When false: skip a path by setting it to false. Configure interactively via the webapp's Paths & API tab.",
|
|
110
|
+
default: {},
|
|
111
|
+
},
|
|
112
|
+
publishOnlyEssentials: {
|
|
113
|
+
type: "boolean",
|
|
114
|
+
title: "Publish only essential paths (opt-in the rest)",
|
|
115
|
+
description: "When ON, this plugin only publishes state/mode/target/engaged + the paths the UI needs (pilot list, modes list, tack, servo.engaged) + paths you explicitly turn on in the Paths & API tab. Reduces noise on the SK data browser. New installs default to ON.",
|
|
116
|
+
default: true,
|
|
117
|
+
},
|
|
118
|
+
sshUser: {
|
|
119
|
+
type: "string",
|
|
120
|
+
title: "SSH user on the TinyPilot",
|
|
121
|
+
description: "Username used by the 'Restart pypilot' button in Setup to log into the machine hosting pypilot_web. Default 'tc' matches TinyCore-based images.",
|
|
122
|
+
default: "tc",
|
|
123
|
+
},
|
|
124
|
+
sshPassword: {
|
|
125
|
+
type: "string",
|
|
126
|
+
title: "SSH password (used by 'Restart pypilot')",
|
|
127
|
+
description: "Password for the SSH user above. Stored in the plugin's config file in plain text (Signal K does not encrypt plugin settings). Leave blank to disable remote restart.",
|
|
128
|
+
default: "",
|
|
129
|
+
},
|
|
130
|
+
},
|
|
131
|
+
}),
|
|
132
|
+
start: (options) => {
|
|
133
|
+
props = normalizeProps(options);
|
|
134
|
+
if (!props.host) {
|
|
135
|
+
app.setPluginStatus("Not configured - open Plugin Config and set host:port, or use the SETUP tab of the webapp.");
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
app.setPluginStatus(`${PLUGIN_REVISION} - connecting to pypilot_web at ${props.host}:${props.port}`);
|
|
139
|
+
client = new pypilot_client_1.PypilotClient({
|
|
140
|
+
host: props.host,
|
|
141
|
+
port: props.port,
|
|
142
|
+
reconnectDelayMs: props.reconnectDelayMs,
|
|
143
|
+
log: (level, msg) => {
|
|
144
|
+
if (level === "error")
|
|
145
|
+
app.error(msg);
|
|
146
|
+
else if (level === "warn")
|
|
147
|
+
app.debug(msg);
|
|
148
|
+
else
|
|
149
|
+
app.debug(msg);
|
|
150
|
+
},
|
|
151
|
+
});
|
|
152
|
+
client.on("connect", () => {
|
|
153
|
+
lastConnectAt = Date.now();
|
|
154
|
+
lastDisconnectReason = null;
|
|
155
|
+
app.setPluginStatus(`${PLUGIN_REVISION} - connected to ${props.host}:${props.port}${apProvider ? " (AutopilotProvider active)" : ""}`);
|
|
156
|
+
});
|
|
157
|
+
client.on("disconnect", (reason) => {
|
|
158
|
+
lastDisconnectReason = reason;
|
|
159
|
+
if (apProvider)
|
|
160
|
+
apProvider.markOffline();
|
|
161
|
+
pushAutopilotUpdate();
|
|
162
|
+
app.setPluginStatus(`${PLUGIN_REVISION} - reconnecting (last: ${reason})`);
|
|
163
|
+
});
|
|
164
|
+
client.on("pong", (latency) => {
|
|
165
|
+
lastPingLatencyMs = latency;
|
|
166
|
+
});
|
|
167
|
+
client.on("catalog", (catalog) => {
|
|
168
|
+
lastCatalog = catalog;
|
|
169
|
+
metaSent = new Set(); // resend meta on catalog refresh
|
|
170
|
+
const catalogKeys = Object.keys(catalog);
|
|
171
|
+
const gainKeys = catalogKeys.filter((k) => k.startsWith("ap.pilot.") && catalog[k].AutopilotGain);
|
|
172
|
+
const apKeys = catalogKeys.filter((k) => k.startsWith("ap.pilot."));
|
|
173
|
+
app.setPluginStatus(`${PLUGIN_REVISION} - connected, catalog ${catalogKeys.length} vars (ap.pilots.*=${apKeys.length}, AutopilotGain=${gainKeys.length})`);
|
|
174
|
+
setupWatches(client, catalog);
|
|
175
|
+
registerPutHandlers(catalog);
|
|
176
|
+
publishCatalogDerived(catalog);
|
|
177
|
+
});
|
|
178
|
+
client.on("value", (name, value) => {
|
|
179
|
+
publishValue(name, value);
|
|
180
|
+
if (apProvider) {
|
|
181
|
+
const changed = apProvider.receiveValue(name, value);
|
|
182
|
+
if (changed)
|
|
183
|
+
pushAutopilotUpdate();
|
|
184
|
+
}
|
|
185
|
+
// Rev38: forward profile / profiles updates to the dynamic profile
|
|
186
|
+
// switch registrar so KIP's radio group stays in sync.
|
|
187
|
+
try {
|
|
188
|
+
const hook = app._pypilotNewuiProfileHook;
|
|
189
|
+
if (hook)
|
|
190
|
+
hook(name, value);
|
|
191
|
+
}
|
|
192
|
+
catch { /* silent */ }
|
|
193
|
+
});
|
|
194
|
+
// Rev24: register KIP-friendly action PUT handlers regardless of
|
|
195
|
+
// absorbProvider. These give KIP simple bool/number/string paths to
|
|
196
|
+
// PUT from custom buttons (+10, AP toggle, tack).
|
|
197
|
+
registerActionHandlers();
|
|
198
|
+
// Rev27: force one autopilot-update push after start so canonical
|
|
199
|
+
// steering.autopilot.{state,mode,target,engaged} appear immediately
|
|
200
|
+
// even if pypilot has not sent an ap.enabled/mode update yet.
|
|
201
|
+
setTimeout(() => pushAutopilotUpdate(), 4000);
|
|
202
|
+
// Optional: absorb the official pypilot-autopilot-provider by registering
|
|
203
|
+
// ourselves as the SK Autopilot Provider. Reduces the second socket to
|
|
204
|
+
// pypilot_web that the Pi Zero cannot afford.
|
|
205
|
+
if (props.absorbProvider && typeof app.registerAutopilotProvider === "function") {
|
|
206
|
+
try {
|
|
207
|
+
apProvider = new autopilot_provider_1.AutopilotProvider(client, app, {
|
|
208
|
+
allowDodge: !!props.allowDirectServo,
|
|
209
|
+
});
|
|
210
|
+
app.registerAutopilotProvider(apProvider.toProviderInterface(), apProvider.pilotIds);
|
|
211
|
+
// Watches for ap.enabled/mode/heading_command/modes are registered
|
|
212
|
+
// in setupWatches() after the catalog arrives. Registering them here
|
|
213
|
+
// (pre-connect) raced with the socket.io connect + pypilot_values
|
|
214
|
+
// burst and the subscriptions were silently dropped by pypilot_web:
|
|
215
|
+
// Rev32 diagnosis on Tunatunes showed apProvider.data stuck at the
|
|
216
|
+
// "off-line" default for the whole session because ap.enabled never
|
|
217
|
+
// arrived. ap.tack.* worked because setupWatches registers it
|
|
218
|
+
// post-catalog when the socket is fully settled.
|
|
219
|
+
app.setPluginStatus(`${PLUGIN_REVISION} - AutopilotProvider registered. IMPORTANT: disable the 'pypilot-autopilot-provider' plugin to avoid conflict.`);
|
|
220
|
+
}
|
|
221
|
+
catch (e) {
|
|
222
|
+
app.error(`[absorb] registerAutopilotProvider failed: ${e?.message || e}`);
|
|
223
|
+
apProvider = null;
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
else if (props.absorbProvider) {
|
|
227
|
+
app.setPluginStatus(`${PLUGIN_REVISION} - absorbProvider requested but app.registerAutopilotProvider is not available (SK Server too old).`);
|
|
228
|
+
}
|
|
229
|
+
client.start();
|
|
230
|
+
},
|
|
231
|
+
stop: () => {
|
|
232
|
+
if (client) {
|
|
233
|
+
try {
|
|
234
|
+
client.stop();
|
|
235
|
+
}
|
|
236
|
+
catch { /* defensive */ }
|
|
237
|
+
client = null;
|
|
238
|
+
}
|
|
239
|
+
// Clear the action-paths keep-alive interval registered on the app.
|
|
240
|
+
const ka = app._pypilotNewuiKeepAlive;
|
|
241
|
+
if (ka) {
|
|
242
|
+
try {
|
|
243
|
+
clearInterval(ka);
|
|
244
|
+
}
|
|
245
|
+
catch { /* defensive */ }
|
|
246
|
+
app._pypilotNewuiKeepAlive = null;
|
|
247
|
+
}
|
|
248
|
+
// The SK autopilot API does not expose an unregister; on plugin stop
|
|
249
|
+
// the server drops our provider when it garbage-collects the plugin.
|
|
250
|
+
apProvider = null;
|
|
251
|
+
lastCatalog = {};
|
|
252
|
+
publishedSkPaths = new Set();
|
|
253
|
+
metaSent = new Set();
|
|
254
|
+
putHandlersRegistered = new Set();
|
|
255
|
+
deltaSentCount = 0;
|
|
256
|
+
lastConnectAt = null;
|
|
257
|
+
lastDisconnectReason = null;
|
|
258
|
+
app.setPluginStatus(`${PLUGIN_REVISION} - stopped`);
|
|
259
|
+
},
|
|
260
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
261
|
+
registerWithRouter: (router) => {
|
|
262
|
+
router.get("/status", (_req, res) => {
|
|
263
|
+
res.json({
|
|
264
|
+
revision: PLUGIN_REVISION,
|
|
265
|
+
version: PLUGIN_PKG_VERSION,
|
|
266
|
+
host: props.host,
|
|
267
|
+
port: props.port,
|
|
268
|
+
connected: client?.connected ?? false,
|
|
269
|
+
lastConnectAt,
|
|
270
|
+
lastDisconnectReason,
|
|
271
|
+
catalogSize: Object.keys(lastCatalog).length,
|
|
272
|
+
publishedPaths: [...publishedSkPaths],
|
|
273
|
+
putHandlersRegistered: [...putHandlersRegistered],
|
|
274
|
+
deltaSentCount,
|
|
275
|
+
lastPingLatencyMs,
|
|
276
|
+
allowWrites: props.allowWrites ?? true,
|
|
277
|
+
allowDirectServo: props.allowDirectServo ?? false,
|
|
278
|
+
nudgeSmall: props.nudgeSmall ?? 1,
|
|
279
|
+
nudgeBig: props.nudgeBig ?? 10,
|
|
280
|
+
absorbProvider: !!apProvider,
|
|
281
|
+
apData: apProvider ? apProvider.data : null,
|
|
282
|
+
enabledPaths: props.enabledPaths || {},
|
|
283
|
+
publishOnlyEssentials: !!props.publishOnlyEssentials,
|
|
284
|
+
sshUser: props.sshUser || "tc",
|
|
285
|
+
sshPasswordSet: !!(props.sshPassword && props.sshPassword.length),
|
|
286
|
+
});
|
|
287
|
+
});
|
|
288
|
+
router.get("/paths", (_req, res) => {
|
|
289
|
+
const items = [];
|
|
290
|
+
for (const [name, meta] of Object.entries(lastCatalog)) {
|
|
291
|
+
if (publisher_1.RESERVED_PYPILOT_KEYS.has(name))
|
|
292
|
+
continue;
|
|
293
|
+
let mapping = publisher_1.FIXED_MAPPINGS[name] || null;
|
|
294
|
+
if (!mapping)
|
|
295
|
+
mapping = (0, publisher_1.mapDynamicName)(name, lastCatalog);
|
|
296
|
+
if (!mapping) {
|
|
297
|
+
if (props.publishUnmapped)
|
|
298
|
+
mapping = (0, publisher_1.autoMap)(name);
|
|
299
|
+
else
|
|
300
|
+
continue;
|
|
301
|
+
}
|
|
302
|
+
items.push({
|
|
303
|
+
pypilotName: name,
|
|
304
|
+
skPath: mapping.skPath,
|
|
305
|
+
units: mapping.units,
|
|
306
|
+
displayName: mapping.displayName,
|
|
307
|
+
catalog: meta,
|
|
308
|
+
essential: publisher_1.ESSENTIAL_PYPILOT_KEYS.has(name),
|
|
309
|
+
get: `/signalk/v1/api/vessels/self/${mapping.skPath.replace(/\./g, "/")}`,
|
|
310
|
+
put: mapping.putKind === "plain"
|
|
311
|
+
? `PUT /plugins/${PLUGIN_ID}/raw {"name":"${name}","value":<value>}`
|
|
312
|
+
: null,
|
|
313
|
+
});
|
|
314
|
+
}
|
|
315
|
+
res.json({
|
|
316
|
+
count: items.length,
|
|
317
|
+
items,
|
|
318
|
+
enabledPaths: props.enabledPaths || {},
|
|
319
|
+
essentials: [...publisher_1.ESSENTIAL_PYPILOT_KEYS],
|
|
320
|
+
publishOnlyEssentials: !!props.publishOnlyEssentials,
|
|
321
|
+
});
|
|
322
|
+
});
|
|
323
|
+
router.get("/catalog", (_req, res) => {
|
|
324
|
+
res.json(lastCatalog);
|
|
325
|
+
});
|
|
326
|
+
// Rev21: expose the current pypilot value cache so the webapp can
|
|
327
|
+
// populate slider positions (gains, calibration inputs, configuration
|
|
328
|
+
// RangeSetting sliders) with the actual values on tab open.
|
|
329
|
+
router.get("/values", (_req, res) => {
|
|
330
|
+
res.json(client ? client.getValues() : {});
|
|
331
|
+
});
|
|
332
|
+
// Rev21/22: hot-apply enabledPaths (per-path publish toggle) WITHOUT a
|
|
333
|
+
// plugin restart. Uses savePluginOptions to persist. Rev22: verifies
|
|
334
|
+
// the call worked and reports persistence status back to the webapp
|
|
335
|
+
// so a failure is visible in the UI (Carlos: "no se guardan").
|
|
336
|
+
router.post("/publish", (req, res) => {
|
|
337
|
+
if (!props.allowWrites)
|
|
338
|
+
return res.status(403).json({ error: "allowWrites disabled" });
|
|
339
|
+
const ep = req.body?.enabledPaths;
|
|
340
|
+
if (!ep || typeof ep !== "object")
|
|
341
|
+
return res.status(400).json({ error: "enabledPaths object required" });
|
|
342
|
+
props.enabledPaths = ep;
|
|
343
|
+
const settings = {
|
|
344
|
+
host: props.host,
|
|
345
|
+
port: props.port,
|
|
346
|
+
reconnectDelayMs: props.reconnectDelayMs,
|
|
347
|
+
allowWrites: props.allowWrites,
|
|
348
|
+
allowDirectServo: props.allowDirectServo,
|
|
349
|
+
publishUnmapped: props.publishUnmapped,
|
|
350
|
+
nudgeSmall: props.nudgeSmall,
|
|
351
|
+
nudgeBig: props.nudgeBig,
|
|
352
|
+
absorbProvider: props.absorbProvider,
|
|
353
|
+
enabledPaths: ep,
|
|
354
|
+
publishOnlyEssentials: props.publishOnlyEssentials,
|
|
355
|
+
};
|
|
356
|
+
const hasSave = typeof app.savePluginOptions === "function";
|
|
357
|
+
if (!hasSave) {
|
|
358
|
+
app.error("[publish] app.savePluginOptions is not available; changes are in-memory only");
|
|
359
|
+
return res.json({ ok: true, count: Object.keys(ep).length, persisted: false, warning: "savePluginOptions API missing" });
|
|
360
|
+
}
|
|
361
|
+
try {
|
|
362
|
+
app.savePluginOptions(settings, (err) => {
|
|
363
|
+
if (err) {
|
|
364
|
+
app.error(`[publish] savePluginOptions callback error: ${err?.message || err}`);
|
|
365
|
+
return res.json({ ok: true, count: Object.keys(ep).length, persisted: false, warning: String(err?.message || err) });
|
|
366
|
+
}
|
|
367
|
+
app.debug(`[publish] persisted ${Object.keys(ep).length} toggles`);
|
|
368
|
+
res.json({ ok: true, count: Object.keys(ep).length, persisted: true });
|
|
369
|
+
});
|
|
370
|
+
}
|
|
371
|
+
catch (e) {
|
|
372
|
+
app.error(`[publish] savePluginOptions threw: ${e?.message || e}`);
|
|
373
|
+
res.json({ ok: true, count: Object.keys(ep).length, persisted: false, warning: String(e?.message || e) });
|
|
374
|
+
}
|
|
375
|
+
});
|
|
376
|
+
router.get("/scan", async (req, res) => {
|
|
377
|
+
try {
|
|
378
|
+
const subnet = typeof req.query.subnet === "string" ? req.query.subnet : undefined;
|
|
379
|
+
const hits = await (0, scanner_1.scanLan)({ subnet });
|
|
380
|
+
res.json({ subnet: subnet ?? "auto", hits });
|
|
381
|
+
}
|
|
382
|
+
catch (e) {
|
|
383
|
+
res.status(500).json({ error: e?.message || String(e) });
|
|
384
|
+
}
|
|
385
|
+
});
|
|
386
|
+
router.put("/raw", (req, res) => {
|
|
387
|
+
if (!props.allowWrites) {
|
|
388
|
+
return res.status(403).json({ error: "allowWrites is disabled" });
|
|
389
|
+
}
|
|
390
|
+
if (!client || !client.connected) {
|
|
391
|
+
return res.status(503).json({ error: "pypilot not connected" });
|
|
392
|
+
}
|
|
393
|
+
const name = req.body?.name;
|
|
394
|
+
const value = req.body?.value;
|
|
395
|
+
if (typeof name !== "string") {
|
|
396
|
+
return res.status(400).json({ error: "missing 'name' string" });
|
|
397
|
+
}
|
|
398
|
+
if (!props.allowDirectServo && name === "servo.command") {
|
|
399
|
+
return res.status(403).json({ error: "servo.command requires allowDirectServo" });
|
|
400
|
+
}
|
|
401
|
+
client.set(name, value);
|
|
402
|
+
res.json({ ok: true, name, value });
|
|
403
|
+
});
|
|
404
|
+
// Emergency valves for when pypilot_web on the Pi Zero starts to
|
|
405
|
+
// choke: pause disconnects our socket without unregistering the
|
|
406
|
+
// plugin, resume opens a fresh one. State/catalog are kept in place.
|
|
407
|
+
// Autopilot control endpoints - proxied through our plugin so writes
|
|
408
|
+
// require the SAME auth as GET /status (which the SK admin session
|
|
409
|
+
// already has). Avoids the extra permission wall that
|
|
410
|
+
// /signalk/v2/api/vessels/self/autopilots/*/* enforces.
|
|
411
|
+
const requireProvider = (res) => {
|
|
412
|
+
if (!apProvider) {
|
|
413
|
+
res.status(409).json({ error: "absorbProvider is not enabled - either enable it in Setup, or POST to /raw with e.g. name='ap.enabled'." });
|
|
414
|
+
return false;
|
|
415
|
+
}
|
|
416
|
+
return true;
|
|
417
|
+
};
|
|
418
|
+
router.post("/ap/engage", async (_req, res) => {
|
|
419
|
+
if (!props.allowWrites)
|
|
420
|
+
return res.status(403).json({ error: "allowWrites is disabled" });
|
|
421
|
+
try {
|
|
422
|
+
if (apProvider) {
|
|
423
|
+
await apProvider.toProviderInterface().engage(apProvider.deviceId);
|
|
424
|
+
}
|
|
425
|
+
else if (client?.connected) {
|
|
426
|
+
client.set("ap.enabled", true);
|
|
427
|
+
}
|
|
428
|
+
else {
|
|
429
|
+
return res.status(503).json({ error: "not connected" });
|
|
430
|
+
}
|
|
431
|
+
res.json({ ok: true });
|
|
432
|
+
}
|
|
433
|
+
catch (e) {
|
|
434
|
+
res.status(500).json({ error: e?.message || String(e) });
|
|
435
|
+
}
|
|
436
|
+
});
|
|
437
|
+
router.post("/ap/disengage", async (_req, res) => {
|
|
438
|
+
if (!props.allowWrites)
|
|
439
|
+
return res.status(403).json({ error: "allowWrites is disabled" });
|
|
440
|
+
try {
|
|
441
|
+
if (apProvider) {
|
|
442
|
+
await apProvider.toProviderInterface().disengage(apProvider.deviceId);
|
|
443
|
+
}
|
|
444
|
+
else if (client?.connected) {
|
|
445
|
+
client.set("ap.enabled", false);
|
|
446
|
+
}
|
|
447
|
+
else {
|
|
448
|
+
return res.status(503).json({ error: "not connected" });
|
|
449
|
+
}
|
|
450
|
+
res.json({ ok: true });
|
|
451
|
+
}
|
|
452
|
+
catch (e) {
|
|
453
|
+
res.status(500).json({ error: e?.message || String(e) });
|
|
454
|
+
}
|
|
455
|
+
});
|
|
456
|
+
router.put("/ap/mode", async (req, res) => {
|
|
457
|
+
if (!props.allowWrites)
|
|
458
|
+
return res.status(403).json({ error: "allowWrites is disabled" });
|
|
459
|
+
const value = req.body?.value;
|
|
460
|
+
if (typeof value !== "string")
|
|
461
|
+
return res.status(400).json({ error: "value must be a string" });
|
|
462
|
+
try {
|
|
463
|
+
if (apProvider) {
|
|
464
|
+
await apProvider.toProviderInterface().setMode(value, apProvider.deviceId);
|
|
465
|
+
}
|
|
466
|
+
else if (client?.connected) {
|
|
467
|
+
client.set("ap.mode", value);
|
|
468
|
+
}
|
|
469
|
+
else {
|
|
470
|
+
return res.status(503).json({ error: "not connected" });
|
|
471
|
+
}
|
|
472
|
+
res.json({ ok: true });
|
|
473
|
+
}
|
|
474
|
+
catch (e) {
|
|
475
|
+
res.status(500).json({ error: e?.message || String(e) });
|
|
476
|
+
}
|
|
477
|
+
});
|
|
478
|
+
router.put("/ap/target", async (req, res) => {
|
|
479
|
+
if (!props.allowWrites)
|
|
480
|
+
return res.status(403).json({ error: "allowWrites is disabled" });
|
|
481
|
+
const rad = req.body?.value;
|
|
482
|
+
if (typeof rad !== "number")
|
|
483
|
+
return res.status(400).json({ error: "value must be a number (radians)" });
|
|
484
|
+
try {
|
|
485
|
+
if (apProvider) {
|
|
486
|
+
await apProvider.toProviderInterface().setTarget(rad, apProvider.deviceId);
|
|
487
|
+
}
|
|
488
|
+
else if (client?.connected) {
|
|
489
|
+
client.set("ap.heading_command", rad * 180 / Math.PI);
|
|
490
|
+
}
|
|
491
|
+
else {
|
|
492
|
+
return res.status(503).json({ error: "not connected" });
|
|
493
|
+
}
|
|
494
|
+
res.json({ ok: true });
|
|
495
|
+
}
|
|
496
|
+
catch (e) {
|
|
497
|
+
res.status(500).json({ error: e?.message || String(e) });
|
|
498
|
+
}
|
|
499
|
+
});
|
|
500
|
+
router.post("/ap/tack/:direction", async (req, res) => {
|
|
501
|
+
if (!props.allowWrites)
|
|
502
|
+
return res.status(403).json({ error: "allowWrites is disabled" });
|
|
503
|
+
const dir = req.params.direction;
|
|
504
|
+
if (dir !== "port" && dir !== "starboard")
|
|
505
|
+
return res.status(400).json({ error: "direction must be port or starboard" });
|
|
506
|
+
try {
|
|
507
|
+
if (apProvider) {
|
|
508
|
+
await apProvider.toProviderInterface().tack(dir, apProvider.deviceId);
|
|
509
|
+
}
|
|
510
|
+
else if (client?.connected) {
|
|
511
|
+
client.set("ap.tack.direction", dir);
|
|
512
|
+
client.set("ap.tack.state", "begin");
|
|
513
|
+
}
|
|
514
|
+
else {
|
|
515
|
+
return res.status(503).json({ error: "not connected" });
|
|
516
|
+
}
|
|
517
|
+
res.json({ ok: true });
|
|
518
|
+
}
|
|
519
|
+
catch (e) {
|
|
520
|
+
res.status(500).json({ error: e?.message || String(e) });
|
|
521
|
+
}
|
|
522
|
+
});
|
|
523
|
+
router.post("/pause", (_req, res) => {
|
|
524
|
+
try {
|
|
525
|
+
client?.pause();
|
|
526
|
+
app.setPluginStatus(`${PLUGIN_REVISION} - paused (manual)`);
|
|
527
|
+
res.json({ ok: true, state: "paused" });
|
|
528
|
+
}
|
|
529
|
+
catch (e) {
|
|
530
|
+
res.status(500).json({ error: e?.message || String(e) });
|
|
531
|
+
}
|
|
532
|
+
});
|
|
533
|
+
router.post("/resume", (_req, res) => {
|
|
534
|
+
try {
|
|
535
|
+
client?.resume();
|
|
536
|
+
res.json({ ok: true, state: "resuming" });
|
|
537
|
+
}
|
|
538
|
+
catch (e) {
|
|
539
|
+
res.status(500).json({ error: e?.message || String(e) });
|
|
540
|
+
}
|
|
541
|
+
});
|
|
542
|
+
// Rev55: restart pypilot on the TinyPilot via SSH password auth.
|
|
543
|
+
// Credentials come from plugin config (sshUser + sshPassword). Uses
|
|
544
|
+
// the ssh2 npm package - no reliance on external ssh/sshpass binaries.
|
|
545
|
+
// Falls back to local socket reconnect if credentials are missing
|
|
546
|
+
// or the SSH connection fails.
|
|
547
|
+
router.post("/restart-pypilot", async (_req, res) => {
|
|
548
|
+
if (!props.allowWrites)
|
|
549
|
+
return res.status(403).json({ error: "allowWrites disabled" });
|
|
550
|
+
const host = props.host;
|
|
551
|
+
const sshUser = props.sshUser || "tc";
|
|
552
|
+
const sshPassword = props.sshPassword || "";
|
|
553
|
+
const restartCmd = "sudo systemctl restart pypilot pypilot_web 2>&1 || sudo /etc/init.d/pypilot restart 2>&1 || true";
|
|
554
|
+
// Local reconnect helper used both on success and on error paths.
|
|
555
|
+
const doReconnect = () => { try {
|
|
556
|
+
client?.pause();
|
|
557
|
+
setTimeout(() => client?.resume(), 800);
|
|
558
|
+
}
|
|
559
|
+
catch { } };
|
|
560
|
+
if (!sshPassword) {
|
|
561
|
+
doReconnect();
|
|
562
|
+
return res.status(202).json({
|
|
563
|
+
ok: false,
|
|
564
|
+
method: "reconnect-only",
|
|
565
|
+
hint: "SSH password not set. Open Plugin Config in SK Admin (or the Setup tab) and fill 'SSH password'. Meanwhile the local socket was reconnected.",
|
|
566
|
+
});
|
|
567
|
+
}
|
|
568
|
+
try {
|
|
569
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
570
|
+
const { Client } = require("ssh2");
|
|
571
|
+
const conn = new Client();
|
|
572
|
+
const runOnce = () => new Promise((resolve) => {
|
|
573
|
+
let settled = false;
|
|
574
|
+
const finish = (r) => {
|
|
575
|
+
if (settled)
|
|
576
|
+
return;
|
|
577
|
+
settled = true;
|
|
578
|
+
try {
|
|
579
|
+
conn.end();
|
|
580
|
+
}
|
|
581
|
+
catch { }
|
|
582
|
+
resolve(r);
|
|
583
|
+
};
|
|
584
|
+
conn.on("ready", () => {
|
|
585
|
+
conn.exec(restartCmd, { pty: true }, (err, stream) => {
|
|
586
|
+
if (err) {
|
|
587
|
+
finish({ ok: false, out: err.message });
|
|
588
|
+
return;
|
|
589
|
+
}
|
|
590
|
+
let out = "";
|
|
591
|
+
stream.on("close", (code) => finish({ ok: code === 0, out, code }));
|
|
592
|
+
stream.on("data", (data) => { out += data.toString(); });
|
|
593
|
+
stream.stderr.on("data", (data) => { out += data.toString(); });
|
|
594
|
+
// If sudo prompts for a password on stdin (NOPASSWD not
|
|
595
|
+
// configured), feed the SSH password again. Works on many
|
|
596
|
+
// TinyCore setups where the same password is used for both.
|
|
597
|
+
setTimeout(() => { try {
|
|
598
|
+
stream.write(sshPassword + "\n");
|
|
599
|
+
}
|
|
600
|
+
catch { } }, 300);
|
|
601
|
+
});
|
|
602
|
+
});
|
|
603
|
+
conn.on("error", (err) => finish({ ok: false, out: `ssh: ${err?.message || err}` }));
|
|
604
|
+
conn.on("timeout", () => finish({ ok: false, out: "ssh timeout" }));
|
|
605
|
+
try {
|
|
606
|
+
conn.connect({
|
|
607
|
+
host, port: 22, username: sshUser, password: sshPassword,
|
|
608
|
+
readyTimeout: 8000,
|
|
609
|
+
tryKeyboard: true,
|
|
610
|
+
});
|
|
611
|
+
conn.on("keyboard-interactive", (_n, _i, _l, _p, finish2) => {
|
|
612
|
+
finish2([sshPassword]);
|
|
613
|
+
});
|
|
614
|
+
}
|
|
615
|
+
catch (e) {
|
|
616
|
+
finish({ ok: false, out: e?.message || String(e) });
|
|
617
|
+
}
|
|
618
|
+
});
|
|
619
|
+
const r = await runOnce();
|
|
620
|
+
if (r.ok) {
|
|
621
|
+
setTimeout(doReconnect, 3000);
|
|
622
|
+
return res.json({ ok: true, method: "ssh", detail: r.out.slice(0, 400) });
|
|
623
|
+
}
|
|
624
|
+
doReconnect();
|
|
625
|
+
return res.status(202).json({
|
|
626
|
+
ok: false,
|
|
627
|
+
method: "reconnect-only",
|
|
628
|
+
hint: `SSH to ${sshUser}@${host} failed (check password + user). Local socket was reconnected.`,
|
|
629
|
+
detail: r.out.slice(0, 400),
|
|
630
|
+
});
|
|
631
|
+
}
|
|
632
|
+
catch (e) {
|
|
633
|
+
doReconnect();
|
|
634
|
+
return res.status(500).json({ error: e?.message || String(e) });
|
|
635
|
+
}
|
|
636
|
+
});
|
|
637
|
+
},
|
|
638
|
+
};
|
|
639
|
+
// ---- helpers ----
|
|
640
|
+
function normalizeProps(options) {
|
|
641
|
+
// Rev23 migration: infer publishOnlyEssentials for legacy configs so we
|
|
642
|
+
// do not silently stop publishing paths a user already had turned on.
|
|
643
|
+
// Rule: field explicitly set -> respect it. Field missing AND
|
|
644
|
+
// enabledPaths already populated -> user is on a legacy setup, default
|
|
645
|
+
// to false (keep publishing everything except the ones they disabled).
|
|
646
|
+
// Field missing AND enabledPaths empty -> fresh install, default to true.
|
|
647
|
+
let poe;
|
|
648
|
+
if (typeof options.publishOnlyEssentials === "boolean") {
|
|
649
|
+
poe = options.publishOnlyEssentials;
|
|
650
|
+
}
|
|
651
|
+
else if (options.enabledPaths && typeof options.enabledPaths === "object" && Object.keys(options.enabledPaths).length > 0) {
|
|
652
|
+
poe = false;
|
|
653
|
+
}
|
|
654
|
+
else {
|
|
655
|
+
poe = true;
|
|
656
|
+
}
|
|
657
|
+
return {
|
|
658
|
+
host: (options.host || "").trim(),
|
|
659
|
+
port: typeof options.port === "number" ? options.port : 80,
|
|
660
|
+
reconnectDelayMs: typeof options.reconnectDelayMs === "number"
|
|
661
|
+
? options.reconnectDelayMs
|
|
662
|
+
: 3000,
|
|
663
|
+
allowWrites: options.allowWrites !== false,
|
|
664
|
+
allowDirectServo: options.allowDirectServo === true,
|
|
665
|
+
publishUnmapped: options.publishUnmapped === true,
|
|
666
|
+
nudgeSmall: typeof options.nudgeSmall === "number" ? options.nudgeSmall : 1,
|
|
667
|
+
nudgeBig: typeof options.nudgeBig === "number" ? options.nudgeBig : 10,
|
|
668
|
+
absorbProvider: options.absorbProvider === true,
|
|
669
|
+
enabledPaths: (options.enabledPaths && typeof options.enabledPaths === "object")
|
|
670
|
+
? options.enabledPaths
|
|
671
|
+
: {},
|
|
672
|
+
publishOnlyEssentials: poe,
|
|
673
|
+
sshUser: typeof options.sshUser === "string" && options.sshUser.trim()
|
|
674
|
+
? options.sshUser.trim() : "tc",
|
|
675
|
+
sshPassword: typeof options.sshPassword === "string" ? options.sshPassword : "",
|
|
676
|
+
};
|
|
677
|
+
}
|
|
678
|
+
function pushAutopilotUpdate() {
|
|
679
|
+
if (!apProvider)
|
|
680
|
+
return;
|
|
681
|
+
try {
|
|
682
|
+
// Push structured update through the Autopilot API (WilhelmSK etc.)
|
|
683
|
+
if (typeof app.autopilotUpdate === "function") {
|
|
684
|
+
app.autopilotUpdate(apProvider.deviceId, {
|
|
685
|
+
state: apProvider.data.state,
|
|
686
|
+
mode: apProvider.data.mode,
|
|
687
|
+
target: apProvider.data.target,
|
|
688
|
+
engaged: apProvider.data.engaged,
|
|
689
|
+
actions: apProvider.data.options.actions,
|
|
690
|
+
});
|
|
691
|
+
}
|
|
692
|
+
}
|
|
693
|
+
catch (e) {
|
|
694
|
+
app.debug(`[absorb] autopilotUpdate failed: ${e?.message || e}`);
|
|
695
|
+
}
|
|
696
|
+
// ALSO emit canonical steering.autopilot.* deltas so anything subscribed
|
|
697
|
+
// to the WebSocket stream (including our own webapp) sees the change.
|
|
698
|
+
// autopilotUpdate() alone only feeds the REST endpoint - deltas would not
|
|
699
|
+
// arrive to stream subscribers otherwise.
|
|
700
|
+
const values = [
|
|
701
|
+
{ path: "steering.autopilot.state", value: apProvider.data.state },
|
|
702
|
+
{ path: "steering.autopilot.mode", value: apProvider.data.mode },
|
|
703
|
+
{ path: "steering.autopilot.target", value: apProvider.data.target },
|
|
704
|
+
{ path: "steering.autopilot.engaged", value: apProvider.data.engaged },
|
|
705
|
+
{ path: "steering.autopilot.availableActions",
|
|
706
|
+
value: apProvider.data.options.actions.filter((a) => a.available).map((a) => a.id) },
|
|
707
|
+
];
|
|
708
|
+
try {
|
|
709
|
+
app.handleMessage(PLUGIN_ID, {
|
|
710
|
+
context: "vessels." + app.selfId,
|
|
711
|
+
updates: [{
|
|
712
|
+
$source: SOURCE_LABEL,
|
|
713
|
+
timestamp: new Date().toISOString(),
|
|
714
|
+
values,
|
|
715
|
+
}],
|
|
716
|
+
});
|
|
717
|
+
}
|
|
718
|
+
catch (e) {
|
|
719
|
+
app.debug(`[absorb] canonical delta emit failed: ${e?.message || e}`);
|
|
720
|
+
}
|
|
721
|
+
// Rev34: mirror the active mode into the mode.* radio switch group
|
|
722
|
+
// so KIP's Simple Switch widgets reflect pypilot's current mode.
|
|
723
|
+
try {
|
|
724
|
+
const emit = app._pypilotNewuiEmitModeSwitches;
|
|
725
|
+
if (emit)
|
|
726
|
+
emit(apProvider.data.mode);
|
|
727
|
+
}
|
|
728
|
+
catch { /* silent */ }
|
|
729
|
+
}
|
|
730
|
+
function setupWatches(c, catalog) {
|
|
731
|
+
// High-rate: engage/mode changes and gains that the user might slide.
|
|
732
|
+
for (const name of Object.keys(catalog)) {
|
|
733
|
+
if (publisher_1.RESERVED_PYPILOT_KEYS.has(name))
|
|
734
|
+
continue;
|
|
735
|
+
// Rev45: watch EVERY RangeSetting in the catalog. Before, only the
|
|
736
|
+
// explicit branches below covered ap.tack.*, servo telemetry and
|
|
737
|
+
// imu/rudder. That left servo.max_slew_speed, servo.max_slew_slow,
|
|
738
|
+
// servo.min_speed, servo.max_current and any future RangeSetting
|
|
739
|
+
// unwatched, so their Ajustes sliders were empty forever.
|
|
740
|
+
const meta = catalog[name];
|
|
741
|
+
if (meta && meta.type === "RangeSetting") {
|
|
742
|
+
c.watch(name, WATCH_MED);
|
|
743
|
+
continue;
|
|
744
|
+
}
|
|
745
|
+
// Note: pypilot exposes gains as ap.pilot.<pilot>.<gain> (SINGULAR),
|
|
746
|
+
// not ap.pilots.*. Confirmed by inspecting pypilot_values on the wire.
|
|
747
|
+
if (name.startsWith("ap.pilot.") && !publisher_1.RESERVED_PYPILOT_KEYS.has(name)) {
|
|
748
|
+
c.watch(name, WATCH_HIGH);
|
|
749
|
+
}
|
|
750
|
+
else if (name.startsWith("ap.tack.") ||
|
|
751
|
+
name === "ap.pilot" ||
|
|
752
|
+
name === "profile" ||
|
|
753
|
+
name === "profiles" ||
|
|
754
|
+
name === "ap.modes") {
|
|
755
|
+
c.watch(name, WATCH_HIGH);
|
|
756
|
+
}
|
|
757
|
+
else if (name === "servo.voltage" ||
|
|
758
|
+
name === "servo.current" ||
|
|
759
|
+
name === "servo.controller_temp" ||
|
|
760
|
+
name === "servo.motor_temp" ||
|
|
761
|
+
name === "servo.amp_hours" ||
|
|
762
|
+
name === "servo.engaged" ||
|
|
763
|
+
name === "servo.flags" ||
|
|
764
|
+
name === "servo.controller") {
|
|
765
|
+
c.watch(name, WATCH_MED);
|
|
766
|
+
}
|
|
767
|
+
else if (name.startsWith("rudder.") || name.startsWith("imu.")) {
|
|
768
|
+
c.watch(name, WATCH_MED);
|
|
769
|
+
}
|
|
770
|
+
else if (name === "ap.runtime" || name === "ap.version") {
|
|
771
|
+
c.watch(name, WATCH_LOW);
|
|
772
|
+
}
|
|
773
|
+
else if (name === "imu.warning" || name === "imu.error") {
|
|
774
|
+
c.watch(name, WATCH_HIGH);
|
|
775
|
+
}
|
|
776
|
+
// Anything else is left unwatched by default. User can request via /raw
|
|
777
|
+
// (future: expose per-path opt-in via config UI).
|
|
778
|
+
}
|
|
779
|
+
// Rev32: watch the four core AP vars that RESERVED_PYPILOT_KEYS skips
|
|
780
|
+
// above. These feed apProvider.data.{state,mode,target,engaged} which in
|
|
781
|
+
// turn drive the canonical steering.autopilot.* deltas. Registered here
|
|
782
|
+
// (post-catalog) rather than in plugin.start() so the socket is fully
|
|
783
|
+
// settled and pypilot_web does not drop the subscription.
|
|
784
|
+
if (apProvider) {
|
|
785
|
+
c.watch("ap.enabled", WATCH_HIGH);
|
|
786
|
+
c.watch("ap.mode", WATCH_HIGH);
|
|
787
|
+
c.watch("ap.heading_command", WATCH_HIGH);
|
|
788
|
+
}
|
|
789
|
+
}
|
|
790
|
+
function publishValue(name, value) {
|
|
791
|
+
if (publisher_1.RESERVED_PYPILOT_KEYS.has(name))
|
|
792
|
+
return;
|
|
793
|
+
// Rev23 policy:
|
|
794
|
+
// - Essential paths (ESSENTIAL_PYPILOT_KEYS) ALWAYS publish.
|
|
795
|
+
// - Non-essentials: if publishOnlyEssentials, require enabledPaths[name] === true.
|
|
796
|
+
// - Otherwise (legacy mode): publish unless explicitly false.
|
|
797
|
+
if (!publisher_1.ESSENTIAL_PYPILOT_KEYS.has(name)) {
|
|
798
|
+
const en = props.enabledPaths || {};
|
|
799
|
+
if (props.publishOnlyEssentials) {
|
|
800
|
+
if (en[name] !== true)
|
|
801
|
+
return;
|
|
802
|
+
}
|
|
803
|
+
else {
|
|
804
|
+
if (Object.prototype.hasOwnProperty.call(en, name) && en[name] === false)
|
|
805
|
+
return;
|
|
806
|
+
}
|
|
807
|
+
}
|
|
808
|
+
let mapping = publisher_1.FIXED_MAPPINGS[name] || null;
|
|
809
|
+
if (!mapping)
|
|
810
|
+
mapping = (0, publisher_1.mapDynamicName)(name, lastCatalog);
|
|
811
|
+
if (!mapping) {
|
|
812
|
+
if (!props.publishUnmapped)
|
|
813
|
+
return;
|
|
814
|
+
mapping = (0, publisher_1.autoMap)(name);
|
|
815
|
+
}
|
|
816
|
+
if (mapping.reserved)
|
|
817
|
+
return;
|
|
818
|
+
const skValue = (0, publisher_1.convertForSK)(mapping, value);
|
|
819
|
+
const updateEntry = {
|
|
820
|
+
$source: SOURCE_LABEL,
|
|
821
|
+
timestamp: new Date().toISOString(),
|
|
822
|
+
values: [{ path: mapping.skPath, value: skValue }],
|
|
823
|
+
};
|
|
824
|
+
// Attach meta INLINE on the first publish of each path. Sending it in a
|
|
825
|
+
// separate delta with empty `values` upsets some third-party plugins
|
|
826
|
+
// (signalk-pushover-plugin@0.0.6 crashes with 'update.values is not
|
|
827
|
+
// iterable' when it iterates a values-empty update).
|
|
828
|
+
if (!metaSent.has(mapping.skPath)) {
|
|
829
|
+
const metaObj = buildMetaObj(mapping, lastCatalog[name]);
|
|
830
|
+
if (metaObj) {
|
|
831
|
+
updateEntry.meta = [{ path: mapping.skPath, value: metaObj }];
|
|
832
|
+
}
|
|
833
|
+
metaSent.add(mapping.skPath);
|
|
834
|
+
}
|
|
835
|
+
try {
|
|
836
|
+
app.handleMessage(PLUGIN_ID, {
|
|
837
|
+
context: "vessels." + app.selfId,
|
|
838
|
+
updates: [updateEntry],
|
|
839
|
+
});
|
|
840
|
+
publishedSkPaths.add(mapping.skPath);
|
|
841
|
+
deltaSentCount++;
|
|
842
|
+
}
|
|
843
|
+
catch (e) {
|
|
844
|
+
app.debug(`[publish] handleMessage failed for ${mapping.skPath}: ${e?.message || e}`);
|
|
845
|
+
}
|
|
846
|
+
}
|
|
847
|
+
function publishCatalogDerived(catalog) {
|
|
848
|
+
const items = (0, publisher_1.extractCatalogDerivedPublishes)(catalog);
|
|
849
|
+
for (const it of items) {
|
|
850
|
+
try {
|
|
851
|
+
app.handleMessage(PLUGIN_ID, {
|
|
852
|
+
context: "vessels." + app.selfId,
|
|
853
|
+
updates: [{
|
|
854
|
+
$source: SOURCE_LABEL,
|
|
855
|
+
timestamp: new Date().toISOString(),
|
|
856
|
+
values: [{ path: it.skPath, value: it.value }],
|
|
857
|
+
...(it.displayName ? { meta: [{ path: it.skPath, value: { displayName: it.displayName } }] } : {}),
|
|
858
|
+
}],
|
|
859
|
+
});
|
|
860
|
+
publishedSkPaths.add(it.skPath);
|
|
861
|
+
}
|
|
862
|
+
catch (e) {
|
|
863
|
+
app.debug(`[publish] catalog-derived failed for ${it.skPath}: ${e?.message || e}`);
|
|
864
|
+
}
|
|
865
|
+
}
|
|
866
|
+
}
|
|
867
|
+
function buildMetaObj(mapping, catalogEntry) {
|
|
868
|
+
const metaObj = {};
|
|
869
|
+
if (mapping.units)
|
|
870
|
+
metaObj.units = mapping.units;
|
|
871
|
+
if (mapping.displayName)
|
|
872
|
+
metaObj.displayName = mapping.displayName;
|
|
873
|
+
if (mapping.description)
|
|
874
|
+
metaObj.description = mapping.description;
|
|
875
|
+
if (catalogEntry && typeof catalogEntry.min === "number") {
|
|
876
|
+
// Reserved for future zone metadata.
|
|
877
|
+
}
|
|
878
|
+
return Object.keys(metaObj).length ? metaObj : null;
|
|
879
|
+
}
|
|
880
|
+
function registerPutHandlers(catalog) {
|
|
881
|
+
if (!props.allowWrites)
|
|
882
|
+
return;
|
|
883
|
+
const registerOne = (skPath) => {
|
|
884
|
+
if (putHandlersRegistered.has(skPath))
|
|
885
|
+
return;
|
|
886
|
+
const cb = (_context, _path, value, _callback) => {
|
|
887
|
+
if (!client || !client.connected) {
|
|
888
|
+
return { state: "COMPLETED", statusCode: 503, message: "pypilot not connected" };
|
|
889
|
+
}
|
|
890
|
+
const name = (0, publisher_1.skPathToPypilotName)(skPath, catalog);
|
|
891
|
+
if (!name) {
|
|
892
|
+
return { state: "COMPLETED", statusCode: 404, message: "unknown path" };
|
|
893
|
+
}
|
|
894
|
+
// Reverse unit conversion for angle paths that we stored in rad.
|
|
895
|
+
let pypilotValue = value;
|
|
896
|
+
const mapping = publisher_1.FIXED_MAPPINGS[name];
|
|
897
|
+
if (mapping?.units === "rad" && typeof value === "number") {
|
|
898
|
+
pypilotValue = value * 180 / Math.PI;
|
|
899
|
+
}
|
|
900
|
+
client.set(name, pypilotValue);
|
|
901
|
+
return { state: "COMPLETED", statusCode: 200 };
|
|
902
|
+
};
|
|
903
|
+
try {
|
|
904
|
+
app.registerPutHandler("vessels.self", skPath, cb, SOURCE_LABEL);
|
|
905
|
+
putHandlersRegistered.add(skPath);
|
|
906
|
+
}
|
|
907
|
+
catch (e) {
|
|
908
|
+
app.debug(`[put] register failed for ${skPath}: ${e?.message || e}`);
|
|
909
|
+
}
|
|
910
|
+
};
|
|
911
|
+
for (const [name, mapping] of Object.entries(publisher_1.FIXED_MAPPINGS)) {
|
|
912
|
+
if (mapping.putKind !== "plain")
|
|
913
|
+
continue;
|
|
914
|
+
if (!catalog[name])
|
|
915
|
+
continue;
|
|
916
|
+
registerOne(mapping.skPath);
|
|
917
|
+
}
|
|
918
|
+
// Gains: register handler for each discovered ap.pilots.<pilot>.<gain> that
|
|
919
|
+
// catalog exposes as AutopilotGain.
|
|
920
|
+
for (const [name, meta] of Object.entries(catalog)) {
|
|
921
|
+
if (!meta.AutopilotGain)
|
|
922
|
+
continue;
|
|
923
|
+
const m = (0, publisher_1.mapDynamicName)(name, catalog);
|
|
924
|
+
if (m)
|
|
925
|
+
registerOne(m.skPath);
|
|
926
|
+
}
|
|
927
|
+
}
|
|
928
|
+
// Rev24: dedicated ACTION paths for KIP-friendly PUT buttons.
|
|
929
|
+
// KIP has widgets that PUT a fixed value on click - perfect for +10/+1/AP/Tack buttons.
|
|
930
|
+
// These paths always exist regardless of publishOnlyEssentials.
|
|
931
|
+
function registerActionHandlers() {
|
|
932
|
+
const ACTIONS_PREFIX = "steering.autopilot.pypilot.actions";
|
|
933
|
+
const okLog = (path, v) => {
|
|
934
|
+
app.error(`[pypilot-newui ACTION] PUT ${path} value=${JSON.stringify(v)}`);
|
|
935
|
+
return { state: "COMPLETED", statusCode: 200 };
|
|
936
|
+
};
|
|
937
|
+
const ok = { state: "COMPLETED", statusCode: 200 };
|
|
938
|
+
const bad = (msg) => ({ state: "COMPLETED", statusCode: 400, message: msg });
|
|
939
|
+
const noConn = () => ({ state: "COMPLETED", statusCode: 503, message: "not connected" });
|
|
940
|
+
// Rev29: KIP (and OpenPlotter switches) send booleans as 1/0 int or "on"/"off"
|
|
941
|
+
// string, not true/false. Ewelink plugin accepts all of them. We do the same.
|
|
942
|
+
const coerceBool = (v) => {
|
|
943
|
+
if (v === true || v === 1 || v === "1" || v === "on" || v === "true")
|
|
944
|
+
return true;
|
|
945
|
+
if (v === false || v === 0 || v === "0" || v === "off" || v === "false")
|
|
946
|
+
return false;
|
|
947
|
+
return null;
|
|
948
|
+
};
|
|
949
|
+
const coerceNum = (v) => {
|
|
950
|
+
if (typeof v === "number" && Number.isFinite(v))
|
|
951
|
+
return v;
|
|
952
|
+
if (typeof v === "string") {
|
|
953
|
+
const n = parseFloat(v);
|
|
954
|
+
return Number.isFinite(n) ? n : null;
|
|
955
|
+
}
|
|
956
|
+
return null;
|
|
957
|
+
};
|
|
958
|
+
// ENGAGE - bool. true=engage, false=disengage.
|
|
959
|
+
try {
|
|
960
|
+
app.registerPutHandler("vessels.self", `${ACTIONS_PREFIX}.engage`, (_c, _p, value) => {
|
|
961
|
+
const b = coerceBool(value);
|
|
962
|
+
if (b === null)
|
|
963
|
+
return bad("value must be boolean-like (true/false/1/0/on/off)");
|
|
964
|
+
if (!client?.connected)
|
|
965
|
+
return noConn();
|
|
966
|
+
if (apProvider) {
|
|
967
|
+
const iface = apProvider.toProviderInterface();
|
|
968
|
+
if (b)
|
|
969
|
+
iface.engage(apProvider.deviceId).catch(() => { });
|
|
970
|
+
else
|
|
971
|
+
iface.disengage(apProvider.deviceId).catch(() => { });
|
|
972
|
+
}
|
|
973
|
+
else {
|
|
974
|
+
client.set("ap.enabled", b);
|
|
975
|
+
}
|
|
976
|
+
return okLog(`${ACTIONS_PREFIX}.engage`, b);
|
|
977
|
+
}, SOURCE_LABEL);
|
|
978
|
+
putHandlersRegistered.add(`${ACTIONS_PREFIX}.engage`);
|
|
979
|
+
}
|
|
980
|
+
catch (e) {
|
|
981
|
+
app.debug(`[actions] engage register failed: ${e?.message || e}`);
|
|
982
|
+
}
|
|
983
|
+
// NUDGE - number, degrees. Adds this delta to the current target.
|
|
984
|
+
try {
|
|
985
|
+
app.registerPutHandler("vessels.self", `${ACTIONS_PREFIX}.nudge`, (_c, _p, value) => {
|
|
986
|
+
const delta = coerceNum(value);
|
|
987
|
+
if (delta === null)
|
|
988
|
+
return bad("value must be a number in degrees");
|
|
989
|
+
if (!client?.connected)
|
|
990
|
+
return noConn();
|
|
991
|
+
if (apProvider) {
|
|
992
|
+
const rad = delta * Math.PI / 180;
|
|
993
|
+
const iface = apProvider.toProviderInterface();
|
|
994
|
+
iface.adjustTarget(rad, apProvider.deviceId).catch(() => { });
|
|
995
|
+
}
|
|
996
|
+
else {
|
|
997
|
+
// Fallback: read current heading_command, add delta, write back.
|
|
998
|
+
const cur = client.getValues()["ap.heading_command"];
|
|
999
|
+
const base = typeof cur === "number" ? cur : 0;
|
|
1000
|
+
client.set("ap.heading_command", base + delta);
|
|
1001
|
+
}
|
|
1002
|
+
return okLog(`${ACTIONS_PREFIX}.nudge`, delta);
|
|
1003
|
+
}, SOURCE_LABEL);
|
|
1004
|
+
putHandlersRegistered.add(`${ACTIONS_PREFIX}.nudge`);
|
|
1005
|
+
}
|
|
1006
|
+
catch (e) {
|
|
1007
|
+
app.debug(`[actions] nudge register failed: ${e?.message || e}`);
|
|
1008
|
+
}
|
|
1009
|
+
// TACK - string "port" | "starboard" | "cancel".
|
|
1010
|
+
try {
|
|
1011
|
+
app.registerPutHandler("vessels.self", `${ACTIONS_PREFIX}.tack`, (_c, _p, value) => {
|
|
1012
|
+
if (typeof value !== "string")
|
|
1013
|
+
return bad("value must be a string");
|
|
1014
|
+
if (!client?.connected)
|
|
1015
|
+
return noConn();
|
|
1016
|
+
if (value === "cancel") {
|
|
1017
|
+
client.set("ap.tack.state", "none");
|
|
1018
|
+
return okLog(`${ACTIONS_PREFIX}.tack`, "cancel");
|
|
1019
|
+
}
|
|
1020
|
+
if (value !== "port" && value !== "starboard")
|
|
1021
|
+
return bad("value must be port, starboard or cancel");
|
|
1022
|
+
if (apProvider) {
|
|
1023
|
+
const iface = apProvider.toProviderInterface();
|
|
1024
|
+
iface.tack(value, apProvider.deviceId).catch(() => { });
|
|
1025
|
+
}
|
|
1026
|
+
else {
|
|
1027
|
+
client.set("ap.tack.direction", value);
|
|
1028
|
+
client.set("ap.tack.state", "begin");
|
|
1029
|
+
}
|
|
1030
|
+
return okLog(`${ACTIONS_PREFIX}.tack`, value);
|
|
1031
|
+
}, SOURCE_LABEL);
|
|
1032
|
+
putHandlersRegistered.add(`${ACTIONS_PREFIX}.tack`);
|
|
1033
|
+
}
|
|
1034
|
+
catch (e) {
|
|
1035
|
+
app.debug(`[actions] tack register failed: ${e?.message || e}`);
|
|
1036
|
+
}
|
|
1037
|
+
// Emit INITIAL VALID VALUES for each action path. KIP filters paths with
|
|
1038
|
+
// value:null in its picker because it cannot infer the type. Meta also
|
|
1039
|
+
// includes an explicit `type` string so KIP knows without guessing.
|
|
1040
|
+
try {
|
|
1041
|
+
app.handleMessage(PLUGIN_ID, {
|
|
1042
|
+
context: "vessels." + app.selfId,
|
|
1043
|
+
updates: [{
|
|
1044
|
+
$source: SOURCE_LABEL,
|
|
1045
|
+
timestamp: new Date().toISOString(),
|
|
1046
|
+
values: [
|
|
1047
|
+
{ path: `${ACTIONS_PREFIX}.engage`, value: false },
|
|
1048
|
+
{ path: `${ACTIONS_PREFIX}.nudge`, value: 0 },
|
|
1049
|
+
{ path: `${ACTIONS_PREFIX}.tack`, value: "none" },
|
|
1050
|
+
],
|
|
1051
|
+
meta: [
|
|
1052
|
+
{ path: `${ACTIONS_PREFIX}.engage`, value: {
|
|
1053
|
+
supportsPut: true, type: "boolean", units: "bool",
|
|
1054
|
+
displayName: "AP engage (bool)",
|
|
1055
|
+
description: "PUT true to engage the autopilot, false to disengage",
|
|
1056
|
+
} },
|
|
1057
|
+
{ path: `${ACTIONS_PREFIX}.nudge`, value: {
|
|
1058
|
+
supportsPut: true, type: "number", units: "deg",
|
|
1059
|
+
displayName: "Nudge target (deg)",
|
|
1060
|
+
description: "PUT a number in degrees (+10, +1, -1, -10) to shift the AP target",
|
|
1061
|
+
} },
|
|
1062
|
+
{ path: `${ACTIONS_PREFIX}.tack`, value: {
|
|
1063
|
+
supportsPut: true, type: "string", units: "enum",
|
|
1064
|
+
enum: ["port", "starboard", "cancel", "none"],
|
|
1065
|
+
displayName: "Tack action (string)",
|
|
1066
|
+
description: "PUT 'port', 'starboard' or 'cancel' to start or cancel a tack",
|
|
1067
|
+
} },
|
|
1068
|
+
],
|
|
1069
|
+
}],
|
|
1070
|
+
});
|
|
1071
|
+
}
|
|
1072
|
+
catch (e) {
|
|
1073
|
+
app.debug(`[actions] initial delta failed: ${e?.message || e}`);
|
|
1074
|
+
}
|
|
1075
|
+
// Rev30: NUMERIC aliases for engage and tack so KIP's "Numeric Put"
|
|
1076
|
+
// widget (which only lists paths of type number in its picker) can
|
|
1077
|
+
// drive them from a single widget family. Semantics:
|
|
1078
|
+
// engageInt: PUT 1 to engage, 0 to disengage
|
|
1079
|
+
// tackInt: PUT 1 to tack port, 2 to tack starboard, 0 to cancel
|
|
1080
|
+
try {
|
|
1081
|
+
app.registerPutHandler("vessels.self", `${ACTIONS_PREFIX}.engageInt`, (_c, _p, value) => {
|
|
1082
|
+
const n = coerceNum(value);
|
|
1083
|
+
if (n === null)
|
|
1084
|
+
return bad("value must be 1 (engage) or 0 (disengage)");
|
|
1085
|
+
const b = n >= 1;
|
|
1086
|
+
if (!client?.connected)
|
|
1087
|
+
return noConn();
|
|
1088
|
+
if (apProvider) {
|
|
1089
|
+
const iface = apProvider.toProviderInterface();
|
|
1090
|
+
if (b)
|
|
1091
|
+
iface.engage(apProvider.deviceId).catch(() => { });
|
|
1092
|
+
else
|
|
1093
|
+
iface.disengage(apProvider.deviceId).catch(() => { });
|
|
1094
|
+
}
|
|
1095
|
+
else {
|
|
1096
|
+
client.set("ap.enabled", b);
|
|
1097
|
+
}
|
|
1098
|
+
return okLog(`${ACTIONS_PREFIX}.engageInt`, n);
|
|
1099
|
+
}, SOURCE_LABEL);
|
|
1100
|
+
putHandlersRegistered.add(`${ACTIONS_PREFIX}.engageInt`);
|
|
1101
|
+
}
|
|
1102
|
+
catch (e) {
|
|
1103
|
+
app.debug(`[actions] engageInt failed: ${e?.message || e}`);
|
|
1104
|
+
}
|
|
1105
|
+
try {
|
|
1106
|
+
app.registerPutHandler("vessels.self", `${ACTIONS_PREFIX}.tackInt`, (_c, _p, value) => {
|
|
1107
|
+
const n = coerceNum(value);
|
|
1108
|
+
if (n === null)
|
|
1109
|
+
return bad("value must be 1 (port) / 2 (starboard) / 0 (cancel)");
|
|
1110
|
+
if (!client?.connected)
|
|
1111
|
+
return noConn();
|
|
1112
|
+
if (n === 0) {
|
|
1113
|
+
client.set("ap.tack.state", "none");
|
|
1114
|
+
return okLog(`${ACTIONS_PREFIX}.tackInt`, "cancel");
|
|
1115
|
+
}
|
|
1116
|
+
const dir = n === 1 ? "port" : n === 2 ? "starboard" : null;
|
|
1117
|
+
if (!dir)
|
|
1118
|
+
return bad("value must be 1 / 2 / 0");
|
|
1119
|
+
if (apProvider) {
|
|
1120
|
+
const iface = apProvider.toProviderInterface();
|
|
1121
|
+
iface.tack(dir, apProvider.deviceId).catch(() => { });
|
|
1122
|
+
}
|
|
1123
|
+
else {
|
|
1124
|
+
client.set("ap.tack.direction", dir);
|
|
1125
|
+
client.set("ap.tack.state", "begin");
|
|
1126
|
+
}
|
|
1127
|
+
return okLog(`${ACTIONS_PREFIX}.tackInt`, dir);
|
|
1128
|
+
}, SOURCE_LABEL);
|
|
1129
|
+
putHandlersRegistered.add(`${ACTIONS_PREFIX}.tackInt`);
|
|
1130
|
+
app.handleMessage(PLUGIN_ID, {
|
|
1131
|
+
context: "vessels." + app.selfId,
|
|
1132
|
+
updates: [{
|
|
1133
|
+
$source: SOURCE_LABEL,
|
|
1134
|
+
timestamp: new Date().toISOString(),
|
|
1135
|
+
values: [
|
|
1136
|
+
{ path: `${ACTIONS_PREFIX}.engageInt`, value: 0 },
|
|
1137
|
+
{ path: `${ACTIONS_PREFIX}.tackInt`, value: 0 },
|
|
1138
|
+
],
|
|
1139
|
+
meta: [
|
|
1140
|
+
{ path: `${ACTIONS_PREFIX}.engageInt`, value: {
|
|
1141
|
+
supportsPut: true, type: "number", units: "bool",
|
|
1142
|
+
displayName: "AP engage (int)",
|
|
1143
|
+
description: "PUT 1 to engage, 0 to disengage. Numeric alias for KIP's Numeric Put widget.",
|
|
1144
|
+
} },
|
|
1145
|
+
{ path: `${ACTIONS_PREFIX}.tackInt`, value: {
|
|
1146
|
+
supportsPut: true, type: "number", units: "enum",
|
|
1147
|
+
displayName: "Tack (int)",
|
|
1148
|
+
description: "PUT 1 to tack to port, 2 to starboard, 0 to cancel. Numeric alias for KIP's Numeric Put widget.",
|
|
1149
|
+
} },
|
|
1150
|
+
],
|
|
1151
|
+
}],
|
|
1152
|
+
});
|
|
1153
|
+
}
|
|
1154
|
+
catch (e) {
|
|
1155
|
+
app.debug(`[actions] tackInt failed: ${e?.message || e}`);
|
|
1156
|
+
}
|
|
1157
|
+
// Rev26: alias the AP engage bool under electrical.switches.* so KIP's
|
|
1158
|
+
// "Simple Switch" widget (which filters paths by that prefix) finds it.
|
|
1159
|
+
// The alias points to the SAME action - the PUT handler here proxies to
|
|
1160
|
+
// .actions.engage. Kip lists this instantly under any switch selector.
|
|
1161
|
+
const SW_ENGAGE = "electrical.switches.pypilot.ap.state";
|
|
1162
|
+
try {
|
|
1163
|
+
app.registerPutHandler("vessels.self", SW_ENGAGE, (_c, _p, value) => {
|
|
1164
|
+
const b = coerceBool(value);
|
|
1165
|
+
if (b === null)
|
|
1166
|
+
return bad("value must be boolean-like");
|
|
1167
|
+
if (!client?.connected)
|
|
1168
|
+
return noConn();
|
|
1169
|
+
if (apProvider) {
|
|
1170
|
+
const iface = apProvider.toProviderInterface();
|
|
1171
|
+
if (b)
|
|
1172
|
+
iface.engage(apProvider.deviceId).catch(() => { });
|
|
1173
|
+
else
|
|
1174
|
+
iface.disengage(apProvider.deviceId).catch(() => { });
|
|
1175
|
+
}
|
|
1176
|
+
else {
|
|
1177
|
+
client.set("ap.enabled", b);
|
|
1178
|
+
}
|
|
1179
|
+
// Mirror the new state into the switch value so KIP's toggle reflects it.
|
|
1180
|
+
try {
|
|
1181
|
+
app.handleMessage(PLUGIN_ID, {
|
|
1182
|
+
context: "vessels." + app.selfId,
|
|
1183
|
+
updates: [{
|
|
1184
|
+
$source: SOURCE_LABEL,
|
|
1185
|
+
timestamp: new Date().toISOString(),
|
|
1186
|
+
values: [{ path: SW_ENGAGE, value: b ? 1 : 0 }],
|
|
1187
|
+
}],
|
|
1188
|
+
});
|
|
1189
|
+
}
|
|
1190
|
+
catch { /* silent */ }
|
|
1191
|
+
return okLog(SW_ENGAGE, b);
|
|
1192
|
+
}, SOURCE_LABEL);
|
|
1193
|
+
putHandlersRegistered.add(SW_ENGAGE);
|
|
1194
|
+
// Rev29: emit initial value as INTEGER 1/0, matching OpenPlotter/Sonoff
|
|
1195
|
+
// convention. KIP's Simple Switch widget expects 1/0, not true/false.
|
|
1196
|
+
app.handleMessage(PLUGIN_ID, {
|
|
1197
|
+
context: "vessels." + app.selfId,
|
|
1198
|
+
updates: [{
|
|
1199
|
+
$source: SOURCE_LABEL,
|
|
1200
|
+
timestamp: new Date().toISOString(),
|
|
1201
|
+
values: [{ path: SW_ENGAGE, value: 0 }],
|
|
1202
|
+
meta: [{ path: SW_ENGAGE, value: {
|
|
1203
|
+
supportsPut: true, type: "boolean", units: "bool",
|
|
1204
|
+
displayName: "AP engage switch",
|
|
1205
|
+
description: "AP engage switch. PUT 1 to engage the autopilot, 0 to disengage. Accepts boolean, 1/0, 'on'/'off' or 'true'/'false'.",
|
|
1206
|
+
} }],
|
|
1207
|
+
}],
|
|
1208
|
+
});
|
|
1209
|
+
}
|
|
1210
|
+
catch (e) {
|
|
1211
|
+
app.debug(`[actions] switch alias register failed: ${e?.message || e}`);
|
|
1212
|
+
}
|
|
1213
|
+
// Rev33: momentary boolean switches under electrical.switches.pypilot.* .
|
|
1214
|
+
// KIP has no "Numeric Put" widget - its Simple Switch only writes booleans.
|
|
1215
|
+
// So each concrete action (+10, +1, -1, -10, tack port/star/cancel) needs
|
|
1216
|
+
// its own boolean path. PUT true triggers the action, then we emit false
|
|
1217
|
+
// ~200 ms later so the KIP toggle springs back and can be pressed again.
|
|
1218
|
+
const SW_NUDGE_PREFIX = "electrical.switches.pypilot.nudge";
|
|
1219
|
+
const SW_TACK_PREFIX = "electrical.switches.pypilot.tack";
|
|
1220
|
+
const momentaryPaths = [];
|
|
1221
|
+
const registerMomentaryBool = (skPath, displayName, action) => {
|
|
1222
|
+
try {
|
|
1223
|
+
app.registerPutHandler("vessels.self", skPath, (_c, _p, value) => {
|
|
1224
|
+
const b = coerceBool(value);
|
|
1225
|
+
if (b === null)
|
|
1226
|
+
return bad("value must be boolean-like");
|
|
1227
|
+
if (!client?.connected)
|
|
1228
|
+
return noConn();
|
|
1229
|
+
if (b) {
|
|
1230
|
+
try {
|
|
1231
|
+
action();
|
|
1232
|
+
}
|
|
1233
|
+
catch (e) {
|
|
1234
|
+
app.error(`[actions] ${skPath} action failed: ${e?.message || e}`);
|
|
1235
|
+
}
|
|
1236
|
+
// Auto-reset to false so the KIP switch returns to OFF and can be
|
|
1237
|
+
// pressed again. 200 ms is enough for the round-trip to be visible.
|
|
1238
|
+
setTimeout(() => {
|
|
1239
|
+
try {
|
|
1240
|
+
app.handleMessage(PLUGIN_ID, {
|
|
1241
|
+
context: "vessels." + app.selfId,
|
|
1242
|
+
updates: [{
|
|
1243
|
+
$source: SOURCE_LABEL,
|
|
1244
|
+
timestamp: new Date().toISOString(),
|
|
1245
|
+
values: [{ path: skPath, value: false }],
|
|
1246
|
+
}],
|
|
1247
|
+
});
|
|
1248
|
+
}
|
|
1249
|
+
catch { /* silent */ }
|
|
1250
|
+
}, 200);
|
|
1251
|
+
}
|
|
1252
|
+
return okLog(skPath, b);
|
|
1253
|
+
}, SOURCE_LABEL);
|
|
1254
|
+
putHandlersRegistered.add(skPath);
|
|
1255
|
+
momentaryPaths.push(skPath);
|
|
1256
|
+
// Initial value + meta so KIP's picker sees the path immediately.
|
|
1257
|
+
app.handleMessage(PLUGIN_ID, {
|
|
1258
|
+
context: "vessels." + app.selfId,
|
|
1259
|
+
updates: [{
|
|
1260
|
+
$source: SOURCE_LABEL,
|
|
1261
|
+
timestamp: new Date().toISOString(),
|
|
1262
|
+
values: [{ path: skPath, value: false }],
|
|
1263
|
+
meta: [{ path: skPath, value: {
|
|
1264
|
+
supportsPut: true, type: "boolean", units: "bool",
|
|
1265
|
+
displayName,
|
|
1266
|
+
description: `Momentary switch. PUT true to trigger; auto-resets to false in 200 ms so the KIP toggle can be pressed again.`,
|
|
1267
|
+
} }],
|
|
1268
|
+
}],
|
|
1269
|
+
});
|
|
1270
|
+
}
|
|
1271
|
+
catch (e) {
|
|
1272
|
+
app.debug(`[actions] momentary ${skPath} register failed: ${e?.message || e}`);
|
|
1273
|
+
}
|
|
1274
|
+
};
|
|
1275
|
+
// Nudge helper: applies delta degrees to ap.heading_command using the same
|
|
1276
|
+
// logic as the numeric .actions.nudge handler above.
|
|
1277
|
+
const doNudge = (delta) => {
|
|
1278
|
+
if (!client)
|
|
1279
|
+
return;
|
|
1280
|
+
if (apProvider) {
|
|
1281
|
+
const rad = delta * Math.PI / 180;
|
|
1282
|
+
const iface = apProvider.toProviderInterface();
|
|
1283
|
+
iface.adjustTarget(rad, apProvider.deviceId).catch(() => { });
|
|
1284
|
+
}
|
|
1285
|
+
else {
|
|
1286
|
+
const cur = client.getValues()["ap.heading_command"];
|
|
1287
|
+
const base = typeof cur === "number" ? cur : 0;
|
|
1288
|
+
client.set("ap.heading_command", base + delta);
|
|
1289
|
+
}
|
|
1290
|
+
};
|
|
1291
|
+
registerMomentaryBool(`${SW_NUDGE_PREFIX}.plus10`, "Nudge +10", () => doNudge(+10));
|
|
1292
|
+
registerMomentaryBool(`${SW_NUDGE_PREFIX}.plus1`, "Nudge +1", () => doNudge(+1));
|
|
1293
|
+
registerMomentaryBool(`${SW_NUDGE_PREFIX}.minus1`, "Nudge -1", () => doNudge(-1));
|
|
1294
|
+
registerMomentaryBool(`${SW_NUDGE_PREFIX}.minus10`, "Nudge -10", () => doNudge(-10));
|
|
1295
|
+
// Tack helper: same two-write sequence as .actions.tack above.
|
|
1296
|
+
const doTack = (direction) => {
|
|
1297
|
+
if (!client)
|
|
1298
|
+
return;
|
|
1299
|
+
if (apProvider) {
|
|
1300
|
+
const iface = apProvider.toProviderInterface();
|
|
1301
|
+
iface.tack(direction, apProvider.deviceId).catch(() => { });
|
|
1302
|
+
}
|
|
1303
|
+
else {
|
|
1304
|
+
client.set("ap.tack.direction", direction);
|
|
1305
|
+
client.set("ap.tack.state", "begin");
|
|
1306
|
+
}
|
|
1307
|
+
};
|
|
1308
|
+
const doTackCancel = () => {
|
|
1309
|
+
if (!client)
|
|
1310
|
+
return;
|
|
1311
|
+
client.set("ap.tack.state", "none");
|
|
1312
|
+
};
|
|
1313
|
+
registerMomentaryBool(`${SW_TACK_PREFIX}.port`, "Tack Port", () => doTack("port"));
|
|
1314
|
+
registerMomentaryBool(`${SW_TACK_PREFIX}.starboard`, "Tack Starboard", () => doTack("starboard"));
|
|
1315
|
+
registerMomentaryBool(`${SW_TACK_PREFIX}.cancel`, "Tack Cancel", () => doTackCancel());
|
|
1316
|
+
// Rev34: mode switches under electrical.switches.pypilot.mode.* .
|
|
1317
|
+
// Unlike nudge/tack these are NOT momentary - they behave as a radio
|
|
1318
|
+
// group so KIP shows which mode is currently active. Only one is true
|
|
1319
|
+
// at a time; PUTting true on one sets pypilot's ap.mode and the others
|
|
1320
|
+
// flip to false when the mode confirmation comes back from pypilot.
|
|
1321
|
+
const SW_MODE_PREFIX = "electrical.switches.pypilot.mode";
|
|
1322
|
+
// Each entry maps the URL-safe SK path key to the pypilot mode string.
|
|
1323
|
+
// "true wind" has a space, so its path is trueWind (camelCase).
|
|
1324
|
+
const SW_MODE_MAP = [
|
|
1325
|
+
{ key: "compass", py: "compass", label: "Mode Compass" },
|
|
1326
|
+
{ key: "gps", py: "gps", label: "Mode GPS" },
|
|
1327
|
+
{ key: "wind", py: "wind", label: "Mode Wind" },
|
|
1328
|
+
{ key: "trueWind", py: "true wind", label: "Mode True Wind" },
|
|
1329
|
+
];
|
|
1330
|
+
const swModePathOf = (key) => `${SW_MODE_PREFIX}.${key}`;
|
|
1331
|
+
// emitModeSwitches broadcasts the current mode state as a radio group.
|
|
1332
|
+
// Called from pushAutopilotUpdate() and the keep-alive so KIP always
|
|
1333
|
+
// reflects the truth on the wire.
|
|
1334
|
+
const emitModeSwitches = (activePy) => {
|
|
1335
|
+
try {
|
|
1336
|
+
const values = SW_MODE_MAP.map((m) => ({
|
|
1337
|
+
path: swModePathOf(m.key),
|
|
1338
|
+
value: activePy === m.py,
|
|
1339
|
+
}));
|
|
1340
|
+
app.handleMessage(PLUGIN_ID, {
|
|
1341
|
+
context: "vessels." + app.selfId,
|
|
1342
|
+
updates: [{
|
|
1343
|
+
$source: SOURCE_LABEL,
|
|
1344
|
+
timestamp: new Date().toISOString(),
|
|
1345
|
+
values,
|
|
1346
|
+
}],
|
|
1347
|
+
});
|
|
1348
|
+
}
|
|
1349
|
+
catch { /* silent */ }
|
|
1350
|
+
};
|
|
1351
|
+
app._pypilotNewuiEmitModeSwitches = emitModeSwitches;
|
|
1352
|
+
for (const m of SW_MODE_MAP) {
|
|
1353
|
+
const skPath = swModePathOf(m.key);
|
|
1354
|
+
try {
|
|
1355
|
+
app.registerPutHandler("vessels.self", skPath, (_c, _p, value) => {
|
|
1356
|
+
const b = coerceBool(value);
|
|
1357
|
+
if (b === null)
|
|
1358
|
+
return bad("value must be boolean-like");
|
|
1359
|
+
if (!client?.connected)
|
|
1360
|
+
return noConn();
|
|
1361
|
+
// PUT false on a radio switch is a no-op: modes cannot be
|
|
1362
|
+
// "turned off" individually, only replaced by picking another.
|
|
1363
|
+
if (!b)
|
|
1364
|
+
return okLog(skPath, false);
|
|
1365
|
+
if (apProvider) {
|
|
1366
|
+
const iface = apProvider.toProviderInterface();
|
|
1367
|
+
iface.setMode(m.py, apProvider.deviceId).catch(() => { });
|
|
1368
|
+
}
|
|
1369
|
+
else {
|
|
1370
|
+
client.set("ap.mode", m.py);
|
|
1371
|
+
}
|
|
1372
|
+
// Optimistically flip the radio group now so the KIP UI feels
|
|
1373
|
+
// instant. When pypilot confirms via receiveValue('ap.mode',...),
|
|
1374
|
+
// pushAutopilotUpdate() re-emits the true state a fraction later.
|
|
1375
|
+
emitModeSwitches(m.py);
|
|
1376
|
+
return okLog(skPath, m.py);
|
|
1377
|
+
}, SOURCE_LABEL);
|
|
1378
|
+
putHandlersRegistered.add(skPath);
|
|
1379
|
+
// Initial value: false unless pypilot has already told us the mode.
|
|
1380
|
+
const currentPy = apProvider?.data?.mode ?? null;
|
|
1381
|
+
app.handleMessage(PLUGIN_ID, {
|
|
1382
|
+
context: "vessels." + app.selfId,
|
|
1383
|
+
updates: [{
|
|
1384
|
+
$source: SOURCE_LABEL,
|
|
1385
|
+
timestamp: new Date().toISOString(),
|
|
1386
|
+
values: [{ path: skPath, value: currentPy === m.py }],
|
|
1387
|
+
meta: [{ path: skPath, value: {
|
|
1388
|
+
supportsPut: true, type: "boolean", units: "bool",
|
|
1389
|
+
displayName: m.label,
|
|
1390
|
+
description: `PUT true to switch pypilot to ${m.py} mode. Radio-group behavior: only one of the mode.* switches is true at any time. PUT false is ignored.`,
|
|
1391
|
+
} }],
|
|
1392
|
+
}],
|
|
1393
|
+
});
|
|
1394
|
+
}
|
|
1395
|
+
catch (e) {
|
|
1396
|
+
app.debug(`[actions] mode switch ${skPath} register failed: ${e?.message || e}`);
|
|
1397
|
+
}
|
|
1398
|
+
}
|
|
1399
|
+
// Rev38: dynamic profile switches under electrical.switches.pypilot.profile.* .
|
|
1400
|
+
// Profiles are user-defined and change at runtime (add/remove/rename),
|
|
1401
|
+
// so we cannot enumerate them at boot. Instead we hook into the value
|
|
1402
|
+
// stream: when 'profiles' arrives (list of names), we register one
|
|
1403
|
+
// boolean radio switch per profile; when 'profile' arrives (active
|
|
1404
|
+
// name), we broadcast which switch is currently true. Only one is true.
|
|
1405
|
+
const SW_PROFILE_PREFIX = "electrical.switches.pypilot.profile";
|
|
1406
|
+
const profileSwitchKey = (s) => s.replace(/[^A-Za-z0-9_]/g, "_") || "unnamed";
|
|
1407
|
+
const profileSwitchPath = (s) => `${SW_PROFILE_PREFIX}.${profileSwitchKey(s)}`;
|
|
1408
|
+
let profilesList = [];
|
|
1409
|
+
let activeProfile = null;
|
|
1410
|
+
const registeredProfileSwitches = new Set();
|
|
1411
|
+
const emitProfileSwitches = () => {
|
|
1412
|
+
if (!profilesList.length)
|
|
1413
|
+
return;
|
|
1414
|
+
try {
|
|
1415
|
+
const values = profilesList.map((p) => ({
|
|
1416
|
+
path: profileSwitchPath(p),
|
|
1417
|
+
value: activeProfile != null && p === activeProfile,
|
|
1418
|
+
}));
|
|
1419
|
+
app.handleMessage(PLUGIN_ID, {
|
|
1420
|
+
context: "vessels." + app.selfId,
|
|
1421
|
+
updates: [{
|
|
1422
|
+
$source: SOURCE_LABEL,
|
|
1423
|
+
timestamp: new Date().toISOString(),
|
|
1424
|
+
values,
|
|
1425
|
+
}],
|
|
1426
|
+
});
|
|
1427
|
+
}
|
|
1428
|
+
catch { /* silent */ }
|
|
1429
|
+
};
|
|
1430
|
+
const ensureProfileSwitchHandler = (profileName) => {
|
|
1431
|
+
const skPath = profileSwitchPath(profileName);
|
|
1432
|
+
if (registeredProfileSwitches.has(skPath) || putHandlersRegistered.has(skPath))
|
|
1433
|
+
return;
|
|
1434
|
+
try {
|
|
1435
|
+
app.registerPutHandler("vessels.self", skPath, (_c, _p, value) => {
|
|
1436
|
+
const b = coerceBool(value);
|
|
1437
|
+
if (b === null)
|
|
1438
|
+
return bad("value must be boolean-like");
|
|
1439
|
+
if (!client?.connected)
|
|
1440
|
+
return noConn();
|
|
1441
|
+
// PUT false on a radio switch is a no-op: profiles cannot be
|
|
1442
|
+
// "turned off" individually, only replaced by picking another.
|
|
1443
|
+
if (!b)
|
|
1444
|
+
return okLog(skPath, false);
|
|
1445
|
+
if (!profilesList.includes(profileName)) {
|
|
1446
|
+
return bad(`profile "${profileName}" no longer exists`);
|
|
1447
|
+
}
|
|
1448
|
+
client.set("profile", profileName);
|
|
1449
|
+
// Optimistic radio flip; the confirmation delta will re-emit
|
|
1450
|
+
// authoritatively when pypilot echoes the new 'profile' value.
|
|
1451
|
+
activeProfile = profileName;
|
|
1452
|
+
emitProfileSwitches();
|
|
1453
|
+
return okLog(skPath, profileName);
|
|
1454
|
+
}, SOURCE_LABEL);
|
|
1455
|
+
putHandlersRegistered.add(skPath);
|
|
1456
|
+
registeredProfileSwitches.add(skPath);
|
|
1457
|
+
// Initial value + meta so KIP's picker sees the path immediately.
|
|
1458
|
+
app.handleMessage(PLUGIN_ID, {
|
|
1459
|
+
context: "vessels." + app.selfId,
|
|
1460
|
+
updates: [{
|
|
1461
|
+
$source: SOURCE_LABEL,
|
|
1462
|
+
timestamp: new Date().toISOString(),
|
|
1463
|
+
values: [{ path: skPath, value: activeProfile === profileName }],
|
|
1464
|
+
meta: [{ path: skPath, value: {
|
|
1465
|
+
supportsPut: true, type: "boolean", units: "bool",
|
|
1466
|
+
displayName: `Profile: ${profileName}`,
|
|
1467
|
+
description: `PUT true to activate pypilot profile "${profileName}". Radio-group behavior: only one profile.* switch is true at a time. PUT false is ignored.`,
|
|
1468
|
+
} }],
|
|
1469
|
+
}],
|
|
1470
|
+
});
|
|
1471
|
+
}
|
|
1472
|
+
catch (e) {
|
|
1473
|
+
app.debug(`[actions] profile switch ${skPath} register failed: ${e?.message || e}`);
|
|
1474
|
+
}
|
|
1475
|
+
};
|
|
1476
|
+
// Hook invoked from client.on('value') above. Handles both 'profiles'
|
|
1477
|
+
// (array of names) and 'profile' (currently active name) updates.
|
|
1478
|
+
app._pypilotNewuiProfileHook = (name, value) => {
|
|
1479
|
+
if (name === "profiles" && Array.isArray(value)) {
|
|
1480
|
+
const next = value.map(String);
|
|
1481
|
+
profilesList = next;
|
|
1482
|
+
for (const p of next)
|
|
1483
|
+
ensureProfileSwitchHandler(p);
|
|
1484
|
+
emitProfileSwitches();
|
|
1485
|
+
}
|
|
1486
|
+
else if (name === "profile" && typeof value === "string") {
|
|
1487
|
+
activeProfile = value;
|
|
1488
|
+
// If pypilot revealed a profile name we did not yet know about,
|
|
1489
|
+
// register it lazily so KIP still sees the switch immediately.
|
|
1490
|
+
if (!profilesList.includes(value)) {
|
|
1491
|
+
profilesList = [...profilesList, value];
|
|
1492
|
+
ensureProfileSwitchHandler(value);
|
|
1493
|
+
}
|
|
1494
|
+
emitProfileSwitches();
|
|
1495
|
+
}
|
|
1496
|
+
};
|
|
1497
|
+
// Helper for the keep-alive so it can re-emit the profile switches.
|
|
1498
|
+
app._pypilotNewuiEmitProfileSwitches = emitProfileSwitches;
|
|
1499
|
+
// Keep them alive: some KIP versions expire paths that stop receiving
|
|
1500
|
+
// updates. Republish the current values every 30 s so they never drop
|
|
1501
|
+
// out of the model. Rev27: ALSO republish the canonical Autopilot API
|
|
1502
|
+
// paths (state/mode/target/engaged/availableActions) so those show up
|
|
1503
|
+
// in the SK tree even when pypilot has not emitted an ap.enabled/mode
|
|
1504
|
+
// change since restart.
|
|
1505
|
+
const keepAlive = setInterval(() => {
|
|
1506
|
+
try {
|
|
1507
|
+
const engaged = apProvider?.data?.engaged ?? false;
|
|
1508
|
+
const values = [
|
|
1509
|
+
{ path: `${ACTIONS_PREFIX}.engage`, value: engaged },
|
|
1510
|
+
{ path: `${ACTIONS_PREFIX}.nudge`, value: 0 },
|
|
1511
|
+
{ path: `${ACTIONS_PREFIX}.tack`, value: apProvider?.data ? ((apProvider.data.options.actions.find((a) => a.id === "tack")?.available) ? "ready" : "none") : "none" },
|
|
1512
|
+
{ path: SW_ENGAGE, value: engaged },
|
|
1513
|
+
];
|
|
1514
|
+
// Rev33: keep the momentary boolean switches alive at value=false so
|
|
1515
|
+
// KIP does not drop them from its picker after a period of idleness.
|
|
1516
|
+
for (const p of momentaryPaths)
|
|
1517
|
+
values.push({ path: p, value: false });
|
|
1518
|
+
// Rev34: keep the mode radio switches alive with the current mode
|
|
1519
|
+
// reflected so KIP does not expire them and always shows the truth.
|
|
1520
|
+
const currentModePy = apProvider?.data?.mode ?? null;
|
|
1521
|
+
for (const m of SW_MODE_MAP) {
|
|
1522
|
+
values.push({ path: swModePathOf(m.key), value: currentModePy === m.py });
|
|
1523
|
+
}
|
|
1524
|
+
// Rev38: keep the profile radio switches alive too.
|
|
1525
|
+
try {
|
|
1526
|
+
const emitP = app._pypilotNewuiEmitProfileSwitches;
|
|
1527
|
+
if (emitP)
|
|
1528
|
+
emitP();
|
|
1529
|
+
}
|
|
1530
|
+
catch { /* silent */ }
|
|
1531
|
+
if (apProvider) {
|
|
1532
|
+
values.push({ path: "steering.autopilot.state", value: apProvider.data.state }, { path: "steering.autopilot.mode", value: apProvider.data.mode }, { path: "steering.autopilot.target", value: apProvider.data.target }, { path: "steering.autopilot.engaged", value: engaged }, { path: "steering.autopilot.availableActions",
|
|
1533
|
+
value: apProvider.data.options.actions.filter((a) => a.available).map((a) => a.id) });
|
|
1534
|
+
}
|
|
1535
|
+
app.handleMessage(PLUGIN_ID, {
|
|
1536
|
+
context: "vessels." + app.selfId,
|
|
1537
|
+
updates: [{
|
|
1538
|
+
$source: SOURCE_LABEL,
|
|
1539
|
+
timestamp: new Date().toISOString(),
|
|
1540
|
+
values,
|
|
1541
|
+
}],
|
|
1542
|
+
});
|
|
1543
|
+
}
|
|
1544
|
+
catch { /* silent */ }
|
|
1545
|
+
}, 30000);
|
|
1546
|
+
// Track it so plugin.stop() cleans up (we do not currently, but a leak
|
|
1547
|
+
// here would leave the interval running past a plugin restart).
|
|
1548
|
+
app._pypilotNewuiKeepAlive = keepAlive;
|
|
1549
|
+
}
|
|
1550
|
+
return plugin;
|
|
1551
|
+
};
|