signalk-pypilot-newui 1.0.0 → 2.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 CHANGED
@@ -1,5 +1,96 @@
1
1
  # Changelog
2
2
 
3
+ ## 2.0.0 — 2026-08-20 — Rev63
4
+
5
+ **Breaking release addressing feedback from Sean D'Epagnier (pypilot author,
6
+ [#1](https://github.com/Aitonos/signalk-pypilot-newui/issues/1) and
7
+ [#2](https://github.com/Aitonos/signalk-pypilot-newui/issues/2)).**
8
+
9
+ ### Breaking - path standardisation (issue #1)
10
+
11
+ Every pypilot key now maps 1:1 to `steering.autopilot.pypilot.<key>`
12
+ verbatim. The old `FIXED_MAPPINGS` table with hand-picked renames and
13
+ C -> K / deg -> rad conversions is gone. Signal K infers units cleanly
14
+ enough from the path type, consumers pick their display unit, and we
15
+ surface pypilot's own `meta.units` verbatim.
16
+
17
+ **If you have KIP widgets or WilhelmSK dashboards wired to 1.0.0 paths,
18
+ rewire them per the table below**:
19
+
20
+ | Old (1.0.0) | New (2.0.0) |
21
+ |-------------------------------------------------------------------|----------------------------------------------------------|
22
+ | `steering.autopilot.pypilot.availableModes` | `steering.autopilot.pypilot.ap.modes` |
23
+ | `steering.autopilot.pypilot.pilot` | `steering.autopilot.pypilot.ap.pilot` |
24
+ | `steering.autopilot.pypilot.gains.<pilot>.<gain>` | `steering.autopilot.pypilot.ap.pilot.<pilot>.<gain>` |
25
+ | `steering.autopilot.pypilot.servo.controllerTemperature` (K) | `steering.autopilot.pypilot.servo.controller_temp` (C) |
26
+ | `steering.autopilot.pypilot.servo.motorTemperature` (K) | `steering.autopilot.pypilot.servo.motor_temp` (C) |
27
+ | `steering.autopilot.pypilot.servo.ampHours` | `steering.autopilot.pypilot.servo.amp_hours` |
28
+ | `steering.autopilot.pypilot.calibration.imuHeadingOffset` (rad) | `steering.autopilot.pypilot.imu.heading_offset` (deg) |
29
+ | `steering.autopilot.pypilot.calibration.rudderRange` (rad) | `steering.autopilot.pypilot.rudder.range` (deg) |
30
+ | `steering.autopilot.pypilot.calibration.rudderOffset` | `steering.autopilot.pypilot.rudder.offset` |
31
+ | `steering.autopilot.pypilot.calibration.rudderScale` | `steering.autopilot.pypilot.rudder.scale` |
32
+ | `steering.autopilot.pypilot.calibration.rudderNonlinearity` | `steering.autopilot.pypilot.rudder.nonlinearity` |
33
+ | `steering.autopilot.pypilot.calibration.state` | `steering.autopilot.pypilot.rudder.calibration_state` |
34
+ | `steering.autopilot.pypilot.tack.state` | `steering.autopilot.pypilot.ap.tack.state` |
35
+ | `steering.autopilot.pypilot.tack.direction` | `steering.autopilot.pypilot.ap.tack.direction` |
36
+ | `steering.autopilot.pypilot.tack.timeout` | `steering.autopilot.pypilot.ap.tack.timeout` |
37
+ | `steering.autopilot.pypilot.errors.imu` | `steering.autopilot.pypilot.imu.error` |
38
+ | `steering.autopilot.pypilot.warnings.imu` | `steering.autopilot.pypilot.imu.warning` |
39
+ | `steering.autopilot.pypilot.errors.controller` | `steering.autopilot.pypilot.servo.controller` |
40
+
41
+ `steering.autopilot.pypilot.profile`, `.profiles`, `.availablePilots`,
42
+ `.version`, `.runtime`, `.servo.voltage`, `.servo.current`,
43
+ `.servo.engaged`, `.servo.flags`, `.servo.controller` are unchanged
44
+ (their pypilot keys already matched the SK-side name).
45
+
46
+ ### Fixed (issue #2) - `pypilot_values` is not one-shot
47
+
48
+ Sean noted that `pypilot_values` can arrive multiple times, and on
49
+ subsequent deliveries it carries only the newly-appeared keys (typically
50
+ during boot while calibration is still loading). Our code assumed
51
+ one-shot AND replaced `this.catalog` wholesale on each delivery.
52
+
53
+ - Comment in `pypilot-client.ts` fixed.
54
+ - Catalog is now MERGED, not replaced.
55
+ - `catalog` event carries `{ isDelta, newKeys }` so downstream handlers
56
+ can register put-handlers only for the new keys and avoid re-emitting
57
+ meta for paths already published.
58
+
59
+ ### Added - Debug console in Setup
60
+
61
+ New closed-whitelist SSH endpoint `POST /plugins/*/debug-cmd` accepting
62
+ one of the following presets (nothing arbitrary):
63
+
64
+ - `logs.pypilot` - last 200 lines of `journalctl -u pypilot`
65
+ - `logs.pypilot_web` - last 200 lines of the web unit
66
+ - `logs.follow` - last 30 s (polled every 3 s from the UI when
67
+ "Follow logs" is on)
68
+ - `dmesg.tail` - last 100 lines of `dmesg`
69
+ - `top.snapshot` - one-shot `top -bn1`
70
+ - `uptime`, `df`, `pypilot.version`
71
+ - `restart.web` - restart only `pypilot_web` (level 1 of the triad)
72
+ - `reboot.pi` - full `sudo reboot` (level 3 of the triad)
73
+
74
+ UI in Setup: preset buttons, scrollable output textarea, follow-logs
75
+ toggle (3 s polling), copy-to-clipboard, share-to-anywhere (Web Share
76
+ API), clear.
77
+
78
+ ### Changed - three escalated restart levels in Setup / Emergency
79
+
80
+ The old single "RESTART pypilot" button becomes a triad, colour-coded
81
+ from green (safest) to red (loudest):
82
+
83
+ - **1. restart pypilot_web** (~3-5 s) - AP keeps steering.
84
+ - **2. RESTART pypilot** (~10-15 s) - the previous behaviour: AP drops
85
+ the heading briefly and re-engages after reconnect.
86
+ - **3. reboot Pi** (~35-60 s) - full Raspberry Pi reboot, no AP for
87
+ almost a minute.
88
+
89
+ All three go through a shared **helm-manned confirmation modal** that
90
+ states the outage duration and scope in the current UI language and
91
+ asks the operator to confirm someone is at the helm before executing.
92
+ Cancel button, click outside and Escape all decline safely.
93
+
3
94
  ## 1.0.0 — 2026-08-18 — Rev32..Rev62 first public release
4
95
 
5
96
  The webapp went from a minimum viable control panel to a full-blown touch
package/README.md CHANGED
@@ -9,6 +9,24 @@ and switches out of the box.
9
9
  Runs alongside [`pypilot-autopilot-provider`](https://www.npmjs.com/package/pypilot-autopilot-provider)
10
10
  by Panaaj — see [Working alongside pypilot-autopilot-provider](#working-alongside-pypilot-autopilot-provider).
11
11
 
12
+ ## Upgrading from 1.0.0 to 2.0.0
13
+
14
+ 2.0.0 is a **breaking release** addressing feedback from Sean D'Epagnier
15
+ (pypilot author). Every pypilot key now maps 1:1 to
16
+ `steering.autopilot.pypilot.<key>` verbatim - no more hand-picked renames
17
+ or C -> K / deg -> rad conversions. If you have KIP widgets or WilhelmSK
18
+ dashboards wired to 1.0.0 paths, see the full migration table in
19
+ [`CHANGELOG.md`](./CHANGELOG.md) - most notable examples:
20
+
21
+ - `.servo.controllerTemperature` (K) -> `.servo.controller_temp` (C)
22
+ - `.calibration.rudderRange` (rad) -> `.rudder.range` (deg)
23
+ - `.gains.<pilot>.<gain>` -> `.ap.pilot.<pilot>.<gain>`
24
+
25
+ Also new in 2.0.0: three escalated **restart / reboot levels** in
26
+ Setup -> Emergency, all guarded by a helm-manned confirmation modal, and
27
+ a **Debug console** with an SSH-based whitelist of preset diagnostic
28
+ commands (logs, dmesg, top, uptime, df, pypilot version).
29
+
12
30
  ## What it does
13
31
 
14
32
  **Touch webapp** (mobile / tablet / chart-plotter, dark theme):
@@ -98,13 +116,34 @@ register the same ID and behaviour is undefined). Valid setups:
98
116
 
99
117
  Absorb mode lives in **Setup → Autopilot Provider (one-socket mode)**.
100
118
 
101
- ## Remote `RESTART pypilot`
119
+ ## Remote restart / reboot triad + Debug console
102
120
 
103
121
  When the autopilot process on the TinyPilot gets wedged you don't need to open
104
122
  an SSH session by hand. In **Setup → Emergency**, save the TinyPilot's SSH user
105
- + password once; the **RESTART pypilot** button then runs
106
- `sudo systemctl restart pypilot pypilot_web` over SSH and reconnects the local
107
- socket. Signal K stores plugin configs in plain text under
123
+ + password once. From then on you get three escalated actions, colour-coded
124
+ from safest to loudest, each guarded by a helm-manned confirmation modal that
125
+ asks whether someone is at the helm before executing:
126
+
127
+ 1. **restart pypilot_web** (~3-5 s) - only the web server restarts. The AP
128
+ core keeps steering. Use when the visor gets stuck but the pilot is fine.
129
+ 2. **RESTART pypilot** (~10-15 s) - restarts pypilot core + web. The AP drops
130
+ the heading briefly and re-engages when it comes back up.
131
+ 3. **reboot Pi** (~35-60 s) - full `sudo reboot` of the Raspberry Pi. No
132
+ autopilot for almost a minute. Last-resort when the box is in a bad state.
133
+
134
+ Underneath the triad there is a **Debug console** that runs a closed
135
+ whitelist of preset diagnostic commands over the same SSH session:
136
+ `logs pypilot`, `logs pypilot_web`, `dmesg`, `top`, `uptime`, `df`,
137
+ `pypilot --version`. Output goes into a scrollable textarea, a "Follow
138
+ logs" toggle polls `journalctl --since '30 s ago'` every 3 seconds, and
139
+ Copy / Share buttons let you paste the dump straight into a GitHub
140
+ issue or a WhatsApp / email conversation with your friendly rigger.
141
+
142
+ The endpoint (`POST /plugins/*/debug-cmd`) refuses any command that is
143
+ not in the whitelist, so a leaked JWT cannot be used to run arbitrary
144
+ shell on the TinyPilot.
145
+
146
+ Signal K stores plugin configs in plain text under
108
147
  `~/.signalk/plugin-config-data/` so use credentials that are only valid for
109
148
  that isolated TinyPilot.
110
149
 
@@ -117,7 +156,8 @@ that isolated TinyPilot.
117
156
  | `GET` | `/plugins/signalk-pypilot-newui/paths` | Live list of published SK paths with GET / PUT URLs and units |
118
157
  | `GET` | `/plugins/signalk-pypilot-newui/catalog` | Raw pypilot catalog (all values + metadata) |
119
158
  | `PUT` | `/plugins/signalk-pypilot-newui/raw` | Send raw `name=value` to pypilot (protected by `allowWrites`) |
120
- | `POST` | `/plugins/signalk-pypilot-newui/restart-pypilot` | SSH remote restart, uses `sshUser` + `sshPassword` from plugin config |
159
+ | `POST` | `/plugins/signalk-pypilot-newui/restart-pypilot` | SSH remote restart (level 2 of the triad), uses `sshUser` + `sshPassword` from plugin config |
160
+ | `POST` | `/plugins/signalk-pypilot-newui/debug-cmd` | Run one of a closed whitelist of preset diagnostic / restart commands over SSH (levels 1 and 3 of the triad + logs / dmesg / top / etc). Body: `{ "preset": "<name>" }` |
121
161
  | `POST` | `/plugins/signalk-pypilot-newui/pause` | Disconnect local socket (for scripts) |
122
162
  | `POST` | `/plugins/signalk-pypilot-newui/resume` | Reconnect local socket (for scripts) |
123
163
 
package/dist/index.js CHANGED
@@ -6,7 +6,7 @@ const scanner_1 = require("./scanner");
6
6
  const autopilot_provider_1 = require("./autopilot-provider");
7
7
  // Rev counter bumped on every build so the user can distinguish deploys
8
8
  // from the webapp header (feedback_revision_bump_each_build).
9
- const PLUGIN_REVISION = "Rev62";
9
+ const PLUGIN_REVISION = "Rev63";
10
10
  // Rev59: read package.json once at load time so /status can report the
11
11
  // npm package version alongside the internal Rev counter.
12
12
  // eslint-disable-next-line @typescript-eslint/no-require-imports
@@ -164,13 +164,23 @@ module.exports = function (app) {
164
164
  client.on("pong", (latency) => {
165
165
  lastPingLatencyMs = latency;
166
166
  });
167
- client.on("catalog", (catalog) => {
167
+ client.on("catalog", (catalog, info) => {
168
+ const isDelta = !!info?.isDelta;
168
169
  lastCatalog = catalog;
169
- metaSent = new Set(); // resend meta on catalog refresh
170
+ // Rev63 / 2.0.0 (issue #2): only reset the meta-sent tracker on the
171
+ // INITIAL catalog. A delta catalog carries only newly-appeared keys
172
+ // (typically calibration finishing to load a few seconds after
173
+ // core), and we do NOT want to re-emit meta for the paths we
174
+ // already published - it would flood the SK bus with duplicate
175
+ // meta blobs. `metaSent` naturally excludes the new keys because
176
+ // they were never added to it, so those will emit meta on first
177
+ // publish as expected.
178
+ if (!isDelta)
179
+ metaSent = new Set();
170
180
  const catalogKeys = Object.keys(catalog);
171
181
  const gainKeys = catalogKeys.filter((k) => k.startsWith("ap.pilot.") && catalog[k].AutopilotGain);
172
182
  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})`);
183
+ app.setPluginStatus(`${PLUGIN_REVISION} - connected, catalog ${catalogKeys.length} vars (ap.pilots.*=${apKeys.length}, AutopilotGain=${gainKeys.length})${isDelta ? " [+" + info.newKeys.length + " new]" : ""}`);
174
184
  setupWatches(client, catalog);
175
185
  registerPutHandlers(catalog);
176
186
  publishCatalogDerived(catalog);
@@ -290,15 +300,7 @@ module.exports = function (app) {
290
300
  for (const [name, meta] of Object.entries(lastCatalog)) {
291
301
  if (publisher_1.RESERVED_PYPILOT_KEYS.has(name))
292
302
  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
- }
303
+ const mapping = (0, publisher_1.mappingFor)(name, meta);
302
304
  items.push({
303
305
  pypilotName: name,
304
306
  skPath: mapping.skPath,
@@ -634,6 +636,106 @@ module.exports = function (app) {
634
636
  return res.status(500).json({ error: e?.message || String(e) });
635
637
  }
636
638
  });
639
+ // Rev63 / 2.0.0: Debug console. A whitelist of preset commands
640
+ // executed over the same ssh2 session as /restart-pypilot. The
641
+ // whitelist is CLOSED - the endpoint refuses any command not in
642
+ // DEBUG_PRESETS below, so a leaked JWT cannot be used to run
643
+ // arbitrary shell on the TinyPilot. Requires allowWrites.
644
+ const DEBUG_PRESETS = {
645
+ "logs.pypilot": "journalctl -u pypilot -n 200 --no-pager 2>&1 || (test -f /var/log/pypilot.log && tail -n 200 /var/log/pypilot.log) || echo 'no journal / no logfile'",
646
+ "logs.pypilot_web": "journalctl -u pypilot_web -n 200 --no-pager 2>&1 || echo 'no pypilot_web unit'",
647
+ "logs.follow": "journalctl -u pypilot --since '30 seconds ago' --no-pager 2>&1",
648
+ "dmesg.tail": "dmesg 2>&1 | tail -100",
649
+ "top.snapshot": "top -bn1 2>&1 | head -20",
650
+ "uptime": "uptime && cat /proc/loadavg",
651
+ "df": "df -h 2>&1",
652
+ "pypilot.version": "pypilot --version 2>&1 || (test -f /home/tc/pypilot/version && cat /home/tc/pypilot/version) || echo 'unknown'",
653
+ "restart.web": "sudo systemctl restart pypilot_web 2>&1 && echo OK",
654
+ "reboot.pi": "sudo reboot",
655
+ };
656
+ router.post("/debug-cmd", async (req, res) => {
657
+ if (!props.allowWrites)
658
+ return res.status(403).json({ error: "allowWrites disabled" });
659
+ const preset = (req.body && req.body.preset) || "";
660
+ const cmd = DEBUG_PRESETS[preset];
661
+ if (!cmd) {
662
+ return res.status(400).json({
663
+ error: "unknown preset",
664
+ available: Object.keys(DEBUG_PRESETS),
665
+ });
666
+ }
667
+ const sshUser = props.sshUser || "tc";
668
+ const sshPassword = props.sshPassword || "";
669
+ if (!sshPassword) {
670
+ return res.status(202).json({
671
+ ok: false,
672
+ preset,
673
+ hint: "SSH password not set - open Setup > Emergency and save it.",
674
+ });
675
+ }
676
+ try {
677
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
678
+ const { Client } = require("ssh2");
679
+ const conn = new Client();
680
+ const t0 = Date.now();
681
+ const result = await new Promise((resolve) => {
682
+ let settled = false;
683
+ const finish = (r) => {
684
+ if (settled)
685
+ return;
686
+ settled = true;
687
+ try {
688
+ conn.end();
689
+ }
690
+ catch { }
691
+ resolve(r);
692
+ };
693
+ conn.on("ready", () => {
694
+ conn.exec(cmd, { pty: true }, (err, stream) => {
695
+ if (err) {
696
+ finish({ ok: false, stdout: err.message });
697
+ return;
698
+ }
699
+ let out = "";
700
+ stream.on("close", (code) => finish({ ok: code === 0, stdout: out, code }));
701
+ stream.on("data", (data) => { out += data.toString(); });
702
+ stream.stderr.on("data", (data) => { out += data.toString(); });
703
+ setTimeout(() => { try {
704
+ stream.write(sshPassword + "\n");
705
+ }
706
+ catch { } }, 300);
707
+ });
708
+ });
709
+ conn.on("error", (err) => finish({ ok: false, stdout: `ssh: ${err?.message || err}` }));
710
+ conn.on("timeout", () => finish({ ok: false, stdout: "ssh timeout" }));
711
+ try {
712
+ conn.connect({
713
+ host: props.host, port: 22,
714
+ username: sshUser, password: sshPassword,
715
+ readyTimeout: 8000, tryKeyboard: true,
716
+ });
717
+ conn.on("keyboard-interactive", (_n, _i, _l, _p, finish2) => {
718
+ finish2([sshPassword]);
719
+ });
720
+ }
721
+ catch (e) {
722
+ finish({ ok: false, stdout: e?.message || String(e) });
723
+ }
724
+ });
725
+ // For reboot.pi the ssh session drops as the box goes down, so we
726
+ // report success even on a non-zero exit if we at least connected.
727
+ return res.json({
728
+ ok: preset === "reboot.pi" ? true : result.ok,
729
+ preset,
730
+ elapsedMs: Date.now() - t0,
731
+ code: result.code,
732
+ stdout: result.stdout.slice(0, 32000),
733
+ });
734
+ }
735
+ catch (e) {
736
+ return res.status(500).json({ error: e?.message || String(e), preset });
737
+ }
738
+ });
637
739
  },
638
740
  };
639
741
  // ---- helpers ----
@@ -805,17 +907,11 @@ module.exports = function (app) {
805
907
  return;
806
908
  }
807
909
  }
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);
910
+ // Rev63 / 2.0.0: every pypilot key derives to steering.autopilot.pypilot.<key>
911
+ // verbatim (Sean D'Epagnier's suggestion in #1). No conversions, no rename,
912
+ // no hardcoded table - Signal K + downstream consumers handle units.
913
+ const mapping = (0, publisher_1.mappingFor)(name, lastCatalog[name]);
914
+ const skValue = value;
819
915
  const updateEntry = {
820
916
  $source: SOURCE_LABEL,
821
917
  timestamp: new Date().toISOString(),
@@ -891,13 +987,10 @@ module.exports = function (app) {
891
987
  if (!name) {
892
988
  return { state: "COMPLETED", statusCode: 404, message: "unknown path" };
893
989
  }
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);
990
+ // Rev63 / 2.0.0: values are surfaced verbatim, so PUT writes verbatim
991
+ // too. No reverse conversion needed - what the consumer sends is what
992
+ // pypilot receives.
993
+ client.set(name, value);
901
994
  return { state: "COMPLETED", statusCode: 200 };
902
995
  };
903
996
  try {
@@ -908,22 +1001,16 @@ module.exports = function (app) {
908
1001
  app.debug(`[put] register failed for ${skPath}: ${e?.message || e}`);
909
1002
  }
910
1003
  };
911
- for (const [name, mapping] of Object.entries(publisher_1.FIXED_MAPPINGS)) {
912
- if (mapping.putKind !== "plain")
1004
+ // Register a PUT handler for every catalog key we consider writeable.
1005
+ // With verbatim mapping (Rev63) this collapses to a single loop.
1006
+ for (const [name, meta] of Object.entries(catalog)) {
1007
+ if (publisher_1.RESERVED_PYPILOT_KEYS.has(name))
913
1008
  continue;
914
- if (!catalog[name])
1009
+ const mapping = (0, publisher_1.mappingFor)(name, meta);
1010
+ if (mapping.putKind !== "plain")
915
1011
  continue;
916
1012
  registerOne(mapping.skPath);
917
1013
  }
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
1014
  }
928
1015
  // Rev24: dedicated ACTION paths for KIP-friendly PUT buttons.
929
1016
  // KIP has widgets that PUT a fixed value on click - perfect for +10/+1/AP/Tack buttons.