signalk-pypilot-newui 2.0.4 → 2.1.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 +110 -0
- package/README.md +42 -31
- package/dist/autopilot-provider.js +103 -2
- package/dist/index.js +56 -27
- package/package.json +1 -1
- package/public/app.js +477 -52
- package/public/index.html +261 -94
- package/public/screenshots/01-control-rose.jpg +0 -0
- package/public/style.css +134 -2
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,115 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 2.1.0 — 2026-08-25 — Rev68..Rev87
|
|
4
|
+
|
|
5
|
+
Twenty in-session Revs of iterative visual + reliability work rolled up
|
|
6
|
+
into a proper minor release. Themes: a full redesign of the compass-rose
|
|
7
|
+
autopilot overlays ("Gota Chain"), a hard-earned fix for AP mutations
|
|
8
|
+
racing each other across the SignalK bus, and a permanent seamless-auth
|
|
9
|
+
story so a Signal K restart never again silently disables the visor's
|
|
10
|
+
mutation buttons.
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
- **Gota Chain overlay** — the target diamond and the AWA/TWA wind
|
|
15
|
+
arrows are now three concentric SVG pieces that interlock into one
|
|
16
|
+
clean droplet when `target ≡ AWA ≡ TWA`. Each piece carries its own
|
|
17
|
+
letter (A / T) and its own color ring-tick that marks the exact
|
|
18
|
+
angle on the compass. When any of the three drifts, its V-hembra
|
|
19
|
+
opens as a visible hollow. Iterated over 12 Revs (Rev68..Rev82)
|
|
20
|
+
with Carlos on Tunatunes.
|
|
21
|
+
- **COG line** — chain of yellow chevrons from the bow toward the
|
|
22
|
+
course-over-ground direction. Visible only when `SOG > 0.15 kn`.
|
|
23
|
+
- **Current vector** — two parallel wavy blue lines with three
|
|
24
|
+
triangles pointing at the boat. Computed as `SOG − SOW` (added
|
|
25
|
+
new `navigation.speedThroughWater` subscription). Visible when
|
|
26
|
+
`|current| > 0.2 kn`.
|
|
27
|
+
- **4-card overlay selector** — long-press any rose corner OR the
|
|
28
|
+
boat sprite to open the popup with the four toggle cards (AWA,
|
|
29
|
+
TWA, COG, Current), each with a large preview and a data-
|
|
30
|
+
requirement line ("Needs a wind sensor", "Only shows when SOG >
|
|
31
|
+
0.15 kn", etc). Toggles persist in `localStorage`.
|
|
32
|
+
- **Rose cardinals colour-coded**: N orange, E green, S blue, W red.
|
|
33
|
+
All at the same radius `r = 70`. Thin black stroke around each
|
|
34
|
+
letter for legibility on any background.
|
|
35
|
+
- **Seamless auth via SK Access Request** — the visor now uses a
|
|
36
|
+
device-scoped, permanent SK token instead of a short-lived JWT.
|
|
37
|
+
On first install the user approves once from `SK admin → Security
|
|
38
|
+
→ Access Requests`; from then on the token survives every SK
|
|
39
|
+
server restart, every plugin redeploy, and every browser reload,
|
|
40
|
+
so mutations (engage / mode / target) NEVER silently 401 again.
|
|
41
|
+
- **`.\deploy.ps1 -FrontOnly`** — new script flag that syncs
|
|
42
|
+
`public/` only (no build, no npm install, no `systemctl restart
|
|
43
|
+
signalk`). Use it when the diff is HTML / CSS / JS only —
|
|
44
|
+
`Ctrl+F5` in the browser picks it up. Avoids the plugin-socket
|
|
45
|
+
reconnect to `pypilot_web` on the Pi Zero W that pushed the
|
|
46
|
+
TinyPilot to hang after a big batch of deploys.
|
|
47
|
+
|
|
48
|
+
### Fixed
|
|
49
|
+
|
|
50
|
+
- **AP mutation race (`DIA jumps to a stale target for a beat`).**
|
|
51
|
+
Frontend used to `Promise.all([engage, target])` and the backend's
|
|
52
|
+
`pushAutopilotUpdate` published ALL SK autopilot fields on every
|
|
53
|
+
notify — so whichever POST landed first emitted a delta that
|
|
54
|
+
carried the OTHER field's STALE value (typically an old target
|
|
55
|
+
from the previous session, e.g. `115°` when the intent was `75°`).
|
|
56
|
+
Fixed by refactoring `AutopilotProvider.notifyChanged(fields)` to
|
|
57
|
+
accept `"engaged" | "target" | "all"` and `pushAutopilotUpdate` to
|
|
58
|
+
only emit the paths that actually changed. Parallel POSTs no
|
|
59
|
+
longer clobber each other with stale copies.
|
|
60
|
+
- **Wind-mode target capture on engage.** `doEngage` always sent
|
|
61
|
+
`state.heading` as the target regardless of mode; pypilot in wind
|
|
62
|
+
mode interpreted that compass value as a wind angle and steered
|
|
63
|
+
the boat toward nonsense. Now captures `state.windAngle` (apparent)
|
|
64
|
+
or `state.windAngleTrue` (true wind) when the active mode contains
|
|
65
|
+
"wind"; falls back to heading only for compass / gps / nav.
|
|
66
|
+
- **AP button "click missed" and engage / disengage bounce**
|
|
67
|
+
hardened: switched from `click` to `pointerdown` (fires once per
|
|
68
|
+
press on both touch and mouse without waiting for the
|
|
69
|
+
browser-synthesised `click`), swallow the trailing `click` with
|
|
70
|
+
`preventDefault + stopPropagation`, `touch-action: manipulation`
|
|
71
|
+
on the button, plus an 800 ms cooldown gate that survives the
|
|
72
|
+
~200 ms POST round-trip. Ghost taps from `touchend` no longer
|
|
73
|
+
produce a phantom second engage.
|
|
74
|
+
- **Wind speed chip tap regressions.** Chip was picked up by the
|
|
75
|
+
corner-selector `forEach` (auto-attached its 500 ms long-press
|
|
76
|
+
timer → opened the wrong popup) AND the `<main>` tab-swipe
|
|
77
|
+
handler treated any tap-with-drift on it as a horizontal swipe
|
|
78
|
+
→ the chip appeared to slide right. Renamed the chip class to
|
|
79
|
+
`.wind-chip` so the forEach ignores it and replaced the shared
|
|
80
|
+
handler with a bespoke `pointerdown/up` short-tap detector that
|
|
81
|
+
stops touch propagation.
|
|
82
|
+
- **HDG chip / compass labels covered by DIA** on engage: reordered
|
|
83
|
+
the SVG stack so the compass card and HDG chip render AFTER the
|
|
84
|
+
gota pieces, never behind them.
|
|
85
|
+
- **DIA arc rendering asymmetry** ("bump on one side") when
|
|
86
|
+
rotated: `stroke-linejoin` switched from `round` to `miter`,
|
|
87
|
+
raya `stroke-linecap` switched from `round` to `butt` to remove
|
|
88
|
+
the semi-circle that overshot the ring line.
|
|
89
|
+
|
|
90
|
+
### Changed
|
|
91
|
+
|
|
92
|
+
- **Wind speed chip moved from an SVG group to a positioned HTML
|
|
93
|
+
`.wind-chip` div** overlaid on the rose bottom-center. Text
|
|
94
|
+
baseline now aligns with the two bottom corner cells so all
|
|
95
|
+
three values (BL / BC / BR) read as one row.
|
|
96
|
+
- **SVG stacking order** finalised (bottom → top): ring · wind
|
|
97
|
+
sectors · boat sprite · TWA · AWA · DIA · raya · AWA / TWA
|
|
98
|
+
colour ring-ticks · COG line · current vector · compass card ·
|
|
99
|
+
HDG chip. Wind chip is a separate HTML overlay above the SVG.
|
|
100
|
+
- **Restart engine** already switched to `sv restart` (piCore
|
|
101
|
+
runit) in 2.0.4; the docs, i18n text and Info-tab quick-start
|
|
102
|
+
now match — the earlier "three escalated restarts" wording was
|
|
103
|
+
outdated (Emergency section has been 2 buttons since Rev66:
|
|
104
|
+
`RESTART pypilot` + `reboot Pi`; the safe `restart pypilot_web`
|
|
105
|
+
lives in the Debug console).
|
|
106
|
+
- **Info-tab quick-start** rewritten in EN + ES to describe the
|
|
107
|
+
Gota Chain, the 4-card overlay selector, the new cardinal
|
|
108
|
+
colors, and boat-long-press as an alternative selector opener.
|
|
109
|
+
- **README** overview and "Remote restart / reboot" section
|
|
110
|
+
rewritten to match the current 2-button + Debug-console layout,
|
|
111
|
+
and to describe the Gota Chain + optional overlays.
|
|
112
|
+
|
|
3
113
|
## 2.0.4 — 2026-08-21 — Rev67
|
|
4
114
|
|
|
5
115
|
Reintroduces (with proper QA) the UI polish + reliability work that was
|
package/README.md
CHANGED
|
@@ -22,18 +22,28 @@ dashboards wired to 1.0.0 paths, see the full migration table in
|
|
|
22
22
|
- `.calibration.rudderRange` (rad) -> `.rudder.range` (deg)
|
|
23
23
|
- `.gains.<pilot>.<gain>` -> `.ap.pilot.<pilot>.<gain>`
|
|
24
24
|
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
25
|
+
Setup -> Emergency ships **two remote restart buttons** (`RESTART pypilot`
|
|
26
|
+
and `reboot Pi`), both guarded by a helm-manned confirmation modal.
|
|
27
|
+
`restart pypilot_web` (the safe web-only restart that does not interrupt
|
|
28
|
+
steering) lives as a preset in the **Debug console** — an SSH-based
|
|
29
|
+
whitelist of preset diagnostic commands (logs, dmesg, top, uptime, df,
|
|
30
|
+
pypilot version, restart pypilot_web).
|
|
29
31
|
|
|
30
32
|
## What it does
|
|
31
33
|
|
|
32
34
|
**Touch webapp** (mobile / tablet / chart-plotter, dark theme):
|
|
33
35
|
|
|
34
|
-
- Compass rose with
|
|
35
|
-
|
|
36
|
-
|
|
36
|
+
- Compass rose with HDG readout, colour-coded cardinals (N orange, E green,
|
|
37
|
+
S blue, W red) + degree ticks, hull-fixed green/red sailing wedges, and 4
|
|
38
|
+
configurable corner tiles (long-press to reconfigure).
|
|
39
|
+
- **Gota Chain** overlay: three concentric arrow pieces on the rose — cyan
|
|
40
|
+
target diamond, amber `A` (AWA), sea-green `T` (TWA). Interlock into one
|
|
41
|
+
clean droplet when all three angles coincide; the V-hembra of any piece
|
|
42
|
+
that drifts opens up as a visible hollow, and a colour ring-tick marks
|
|
43
|
+
the exact angle on the compass. Optional **COG line** from the bow
|
|
44
|
+
(yellow chevrons) and **current vector** (SOG − SOW, blue wavy arrow)
|
|
45
|
+
can be toggled from a 4-card selector opened by long-press on any
|
|
46
|
+
corner or on the boat sprite.
|
|
37
47
|
- Big-touch AP engage / disengage with optimistic UI + auto-retry over
|
|
38
48
|
flaky links (Tailscale-safe).
|
|
39
49
|
- Tack port / starboard, mode selector (compass / GPS / wind / true wind / nav),
|
|
@@ -116,28 +126,29 @@ register the same ID and behaviour is undefined). Valid setups:
|
|
|
116
126
|
|
|
117
127
|
Absorb mode lives in **Setup → Autopilot Provider (one-socket mode)**.
|
|
118
128
|
|
|
119
|
-
## Remote restart / reboot
|
|
120
|
-
|
|
121
|
-
When the autopilot process on the TinyPilot gets wedged you don't need to
|
|
122
|
-
an SSH session by hand. In **Setup → Emergency**, save the TinyPilot's
|
|
123
|
-
+ password once. From then on you get
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
1. **
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
129
|
+
## Remote restart / reboot + Debug console
|
|
130
|
+
|
|
131
|
+
When the autopilot process on the TinyPilot gets wedged you don't need to
|
|
132
|
+
open an SSH session by hand. In **Setup → Emergency**, save the TinyPilot's
|
|
133
|
+
SSH user + password once. From then on you get **two big restart buttons**,
|
|
134
|
+
each guarded by a helm-manned confirmation modal that asks whether someone
|
|
135
|
+
is at the helm before executing:
|
|
136
|
+
|
|
137
|
+
1. **RESTART pypilot** (~10-15 s) — `sv restart` of the pypilot core + web
|
|
138
|
+
(runit under piCore). The AP drops the heading briefly and re-engages
|
|
139
|
+
when it comes back up.
|
|
140
|
+
2. **reboot Pi** (~35-60 s) — full `sudo reboot` of the Raspberry Pi. No
|
|
141
|
+
autopilot for almost a minute. Last-resort when the box is in a bad
|
|
142
|
+
state.
|
|
143
|
+
|
|
144
|
+
The safe **restart pypilot_web** (web server only, does not interrupt
|
|
145
|
+
steering) lives as a preset button in the **Debug console** below,
|
|
146
|
+
alongside a closed whitelist of preset diagnostic commands over the same
|
|
147
|
+
SSH session: `logs pypilot`, `logs pypilot_web`, `dmesg`, `top`, `uptime`,
|
|
148
|
+
`df`, `pypilot --version`. Output goes into a scrollable textarea, a
|
|
149
|
+
"Follow logs" toggle polls `journalctl --since '30 s ago'` every 3
|
|
150
|
+
seconds, and Copy / Share buttons let you paste the dump straight into a
|
|
151
|
+
GitHub issue or a WhatsApp / email conversation with your friendly rigger.
|
|
141
152
|
|
|
142
153
|
The endpoint (`POST /plugins/*/debug-cmd`) refuses any command that is
|
|
143
154
|
not in the whitelist, so a leaked JWT cannot be used to run arbitrary
|
|
@@ -156,8 +167,8 @@ that isolated TinyPilot.
|
|
|
156
167
|
| `GET` | `/plugins/signalk-pypilot-newui/paths` | Live list of published SK paths with GET / PUT URLs and units |
|
|
157
168
|
| `GET` | `/plugins/signalk-pypilot-newui/catalog` | Raw pypilot catalog (all values + metadata) |
|
|
158
169
|
| `PUT` | `/plugins/signalk-pypilot-newui/raw` | Send raw `name=value` to pypilot (protected by `allowWrites`) |
|
|
159
|
-
| `POST` | `/plugins/signalk-pypilot-newui/restart-pypilot` | SSH
|
|
160
|
-
| `POST` | `/plugins/signalk-pypilot-newui/debug-cmd` | Run one of a closed whitelist of preset diagnostic / restart commands over SSH (
|
|
170
|
+
| `POST` | `/plugins/signalk-pypilot-newui/restart-pypilot` | SSH `sv restart` of pypilot core+web on the TinyPilot, uses `sshUser` + `sshPassword` from plugin config |
|
|
171
|
+
| `POST` | `/plugins/signalk-pypilot-newui/debug-cmd` | Run one of a closed whitelist of preset diagnostic / restart commands over SSH (`restart.web`, `reboot.pi`, `logs.pypilot`, `logs.web`, `dmesg`, `top`, `uptime`, `df`, `pypilot.version`). Body: `{ "preset": "<name>" }` |
|
|
161
172
|
| `POST` | `/plugins/signalk-pypilot-newui/pause` | Disconnect local socket (for scripts) |
|
|
162
173
|
| `POST` | `/plugins/signalk-pypilot-newui/resume` | Reconnect local socket (for scripts) |
|
|
163
174
|
|
|
@@ -13,6 +13,23 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
13
13
|
exports.AutopilotProvider = void 0;
|
|
14
14
|
const DEG_TO_RAD = Math.PI / 180;
|
|
15
15
|
const RAD_TO_DEG = 180 / Math.PI;
|
|
16
|
+
// Rev68: after a local target write we ignore pypilot echoes that do not
|
|
17
|
+
// match, for up to this many ms. 2 s covers the worst RTT + pypilot apply
|
|
18
|
+
// latency observed on Tunatunes (~500-800 ms). See receiveValue for why.
|
|
19
|
+
const TARGET_PENDING_MS = 2500;
|
|
20
|
+
// Tolerance for treating an echoed heading_command as "the value we asked
|
|
21
|
+
// for" vs "stale echo of the previous value". Pypilot stores heading_command
|
|
22
|
+
// as float and echoes verbatim in practice, but allow a small margin for
|
|
23
|
+
// rounding on the wire.
|
|
24
|
+
const TARGET_ECHO_TOL_RAD = 2 * DEG_TO_RAD;
|
|
25
|
+
function shortestArcRad(a, b) {
|
|
26
|
+
let d = a - b;
|
|
27
|
+
while (d > Math.PI)
|
|
28
|
+
d -= 2 * Math.PI;
|
|
29
|
+
while (d <= -Math.PI)
|
|
30
|
+
d += 2 * Math.PI;
|
|
31
|
+
return d;
|
|
32
|
+
}
|
|
16
33
|
class AutopilotProvider {
|
|
17
34
|
constructor(client,
|
|
18
35
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
@@ -40,8 +57,23 @@ class AutopilotProvider {
|
|
|
40
57
|
};
|
|
41
58
|
this.pypilotModes = [];
|
|
42
59
|
this.allowDodge = false;
|
|
60
|
+
// Rev68: echo cancellation for local target writes. See setTarget /
|
|
61
|
+
// adjustTarget / receiveValue.
|
|
62
|
+
this.pendingTarget = null;
|
|
63
|
+
// Rev70: same treatment for the engaged flag. Without it, a pypilot
|
|
64
|
+
// ap.enabled=false echo can arrive between our POST /engage and the
|
|
65
|
+
// real echo, flip data.engaged to false, push a SK delta, and the
|
|
66
|
+
// JS button turns off; user re-taps and the AP ends up bouncing.
|
|
67
|
+
this.pendingEngaged = null;
|
|
43
68
|
if (opts?.allowDodge)
|
|
44
69
|
this.allowDodge = true;
|
|
70
|
+
this.onDataChanged = opts?.onDataChanged;
|
|
71
|
+
}
|
|
72
|
+
notifyChanged(fields) {
|
|
73
|
+
try {
|
|
74
|
+
this.onDataChanged?.(fields || "all");
|
|
75
|
+
}
|
|
76
|
+
catch { /* silent */ }
|
|
45
77
|
}
|
|
46
78
|
// Called from PypilotClient 'value' event. Returns true if apData changed
|
|
47
79
|
// and an autopilotUpdate should be pushed.
|
|
@@ -51,6 +83,28 @@ class AutopilotProvider {
|
|
|
51
83
|
case "ap.heading_command":
|
|
52
84
|
if (typeof value === "number") {
|
|
53
85
|
const rad = value * DEG_TO_RAD;
|
|
86
|
+
// Rev68: echo cancellation. When JS pushes a nudge or engage the
|
|
87
|
+
// provider assigns data.target OPTIMISTICALLY and marks pending.
|
|
88
|
+
// Pypilot needs 200-800 ms to apply the set and echo the new
|
|
89
|
+
// heading_command; meanwhile its 2 Hz watch tick may push the
|
|
90
|
+
// OLD value on the wire. Without gating, that stale echo would
|
|
91
|
+
// overwrite data.target and the diamond would jump back to the
|
|
92
|
+
// previous target, then jump forward again when the real echo
|
|
93
|
+
// finally landed. Bug reported by Carlos on Rev67 v2.0.4:
|
|
94
|
+
// "aparece en mal sitio, nudge lo mueve pero se mueve solo
|
|
95
|
+
// luego, hace rebotes".
|
|
96
|
+
if (this.pendingTarget && Date.now() < this.pendingTarget.until) {
|
|
97
|
+
const diff = Math.abs(shortestArcRad(rad, this.pendingTarget.value));
|
|
98
|
+
if (diff <= TARGET_ECHO_TOL_RAD) {
|
|
99
|
+
// Real echo landed - clear pending, keep our value (may be
|
|
100
|
+
// slightly more precise than the echo). No change signal
|
|
101
|
+
// needed since data.target already equals the pending value.
|
|
102
|
+
this.pendingTarget = null;
|
|
103
|
+
}
|
|
104
|
+
// Whether match or stale, do not overwrite data.target inside
|
|
105
|
+
// the pending window.
|
|
106
|
+
break;
|
|
107
|
+
}
|
|
54
108
|
if (rad !== this.data.target) {
|
|
55
109
|
this.data.target = rad;
|
|
56
110
|
changed = true;
|
|
@@ -58,6 +112,7 @@ class AutopilotProvider {
|
|
|
58
112
|
}
|
|
59
113
|
else if (value === false && this.data.target !== null) {
|
|
60
114
|
this.data.target = null;
|
|
115
|
+
this.pendingTarget = null;
|
|
61
116
|
changed = true;
|
|
62
117
|
}
|
|
63
118
|
break;
|
|
@@ -78,6 +133,21 @@ class AutopilotProvider {
|
|
|
78
133
|
break;
|
|
79
134
|
case "ap.enabled": {
|
|
80
135
|
const eng = !!value;
|
|
136
|
+
// Rev70: echo cancellation on the engaged flag. See notes on
|
|
137
|
+
// pendingEngaged. Without this, when the JS button POSTs an
|
|
138
|
+
// engage, pypilot's 2 Hz watch tick may push ap.enabled=false
|
|
139
|
+
// (its OLD stored value) between our set and pypilot's real
|
|
140
|
+
// apply. That stale echo would flip data.engaged back to false,
|
|
141
|
+
// publish a SK delta, and the JS button would grey out
|
|
142
|
+
// momentarily - Carlos re-taps and the whole AP bounces
|
|
143
|
+
// engage-disengage-engage. Ignore echoes that don't match the
|
|
144
|
+
// pending intent while the pending window is open.
|
|
145
|
+
if (this.pendingEngaged && Date.now() < this.pendingEngaged.until) {
|
|
146
|
+
if (eng === this.pendingEngaged.value) {
|
|
147
|
+
this.pendingEngaged = null;
|
|
148
|
+
}
|
|
149
|
+
break;
|
|
150
|
+
}
|
|
81
151
|
const st = eng ? "enabled" : "disabled";
|
|
82
152
|
if (this.data.state !== st || this.data.engaged !== eng) {
|
|
83
153
|
this.data.state = st;
|
|
@@ -95,6 +165,8 @@ class AutopilotProvider {
|
|
|
95
165
|
if (this.data.state !== "off-line" || this.data.engaged) {
|
|
96
166
|
this.data.state = "off-line";
|
|
97
167
|
this.data.engaged = false;
|
|
168
|
+
this.pendingEngaged = null;
|
|
169
|
+
this.pendingTarget = null;
|
|
98
170
|
this.recomputeActions();
|
|
99
171
|
}
|
|
100
172
|
}
|
|
@@ -103,8 +175,25 @@ class AutopilotProvider {
|
|
|
103
175
|
const st = this.data.options.states.find((s) => s.name === state);
|
|
104
176
|
if (!st)
|
|
105
177
|
throw new Error(`Invalid state: ${state}`);
|
|
106
|
-
|
|
107
|
-
|
|
178
|
+
// Rev70: optimistic + echo-cancellation on ap.enabled. Same rationale
|
|
179
|
+
// as setTarget: SK subscribers see the ordered engaged state in the
|
|
180
|
+
// same tick as the write, and receiveValue() ignores stale pypilot
|
|
181
|
+
// echoes while pending.
|
|
182
|
+
const eng = st.engaged;
|
|
183
|
+
const apSt = eng ? "enabled" : "disabled";
|
|
184
|
+
this.data.state = apSt;
|
|
185
|
+
this.data.engaged = eng;
|
|
186
|
+
this.pendingEngaged = { value: eng, until: Date.now() + TARGET_PENDING_MS };
|
|
187
|
+
this.recomputeActions();
|
|
188
|
+
this.client.set("ap.enabled", eng);
|
|
189
|
+
// Rev84: publish ONLY the engaged/state/actions delta - do NOT
|
|
190
|
+
// include target. If setTarget was called in parallel, its own
|
|
191
|
+
// notifyChanged("target") will publish the new target value
|
|
192
|
+
// independently. Publishing target here would carry the STALE
|
|
193
|
+
// apProvider.data.target and clobber JS state.target if this
|
|
194
|
+
// delta happens to land second.
|
|
195
|
+
this.notifyChanged("engaged");
|
|
196
|
+
return eng;
|
|
108
197
|
}
|
|
109
198
|
async setMode(mode) {
|
|
110
199
|
if (this.data.options.modes.length > 0 &&
|
|
@@ -115,13 +204,25 @@ class AutopilotProvider {
|
|
|
115
204
|
}
|
|
116
205
|
async setTarget(rad) {
|
|
117
206
|
const deg = rad * RAD_TO_DEG;
|
|
207
|
+
// Rev68: optimistic + echo-cancellation. Assign our authoritative value
|
|
208
|
+
// immediately so downstream SK deltas (and every subscribed UI) snap to
|
|
209
|
+
// the ordered target in the same tick as the write; mark a pending
|
|
210
|
+
// window during which receiveValue() filters stale pypilot echoes.
|
|
211
|
+
this.data.target = rad;
|
|
212
|
+
this.pendingTarget = { value: rad, until: Date.now() + TARGET_PENDING_MS };
|
|
118
213
|
this.client.set("ap.heading_command", deg);
|
|
214
|
+
// Rev84: publish ONLY the target delta - see setState notes.
|
|
215
|
+
this.notifyChanged("target");
|
|
119
216
|
}
|
|
120
217
|
async adjustTarget(rad) {
|
|
121
218
|
if (this.data.engaged) {
|
|
122
219
|
const base = typeof this.data.target === "number" ? this.data.target : 0;
|
|
123
220
|
const newRad = base + rad;
|
|
221
|
+
this.data.target = newRad;
|
|
222
|
+
this.pendingTarget = { value: newRad, until: Date.now() + TARGET_PENDING_MS };
|
|
124
223
|
this.client.set("ap.heading_command", newRad * RAD_TO_DEG);
|
|
224
|
+
// Rev84: publish ONLY the target delta.
|
|
225
|
+
this.notifyChanged("target");
|
|
125
226
|
}
|
|
126
227
|
else if (this.allowDodge) {
|
|
127
228
|
await this.dodge(rad);
|
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 = "
|
|
9
|
+
const PLUGIN_REVISION = "Rev87";
|
|
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
|
|
@@ -216,6 +216,14 @@ module.exports = function (app) {
|
|
|
216
216
|
try {
|
|
217
217
|
apProvider = new autopilot_provider_1.AutopilotProvider(client, app, {
|
|
218
218
|
allowDodge: !!props.allowDirectServo,
|
|
219
|
+
// Rev68: setTarget/adjustTarget assign optimistically and need
|
|
220
|
+
// to push a canonical steering.autopilot.target delta right
|
|
221
|
+
// then so JS + KIP + WilhelmSK snap without waiting for the
|
|
222
|
+
// pypilot echo round-trip.
|
|
223
|
+
// Rev84: forward the field mask ("engaged" / "target" /
|
|
224
|
+
// "all") so pushAutopilotUpdate filters the emitted delta
|
|
225
|
+
// paths accordingly.
|
|
226
|
+
onDataChanged: (fields) => pushAutopilotUpdate(fields),
|
|
219
227
|
});
|
|
220
228
|
app.registerAutopilotProvider(apProvider.toProviderInterface(), apProvider.pilotIds);
|
|
221
229
|
// Watches for ap.enabled/mode/heading_command/modes are registered
|
|
@@ -880,36 +888,54 @@ module.exports = function (app) {
|
|
|
880
888
|
sshPassword: typeof options.sshPassword === "string" ? options.sshPassword : "",
|
|
881
889
|
};
|
|
882
890
|
}
|
|
883
|
-
function pushAutopilotUpdate() {
|
|
891
|
+
function pushAutopilotUpdate(fields = "all") {
|
|
884
892
|
if (!apProvider)
|
|
885
893
|
return;
|
|
886
894
|
try {
|
|
887
|
-
// Push structured update through the Autopilot API (WilhelmSK etc.)
|
|
895
|
+
// Push structured update through the Autopilot API (WilhelmSK etc.).
|
|
896
|
+
// The App API accepts partial updates - only include changed fields
|
|
897
|
+
// so a target-only or engaged-only notify does not overwrite the
|
|
898
|
+
// sibling field's provider-visible value with a stale copy.
|
|
888
899
|
if (typeof app.autopilotUpdate === "function") {
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
900
|
+
const apUpdate = {};
|
|
901
|
+
if (fields === "all" || fields === "engaged") {
|
|
902
|
+
apUpdate.state = apProvider.data.state;
|
|
903
|
+
apUpdate.engaged = apProvider.data.engaged;
|
|
904
|
+
apUpdate.actions = apProvider.data.options.actions;
|
|
905
|
+
}
|
|
906
|
+
if (fields === "all" || fields === "target") {
|
|
907
|
+
apUpdate.target = apProvider.data.target;
|
|
908
|
+
}
|
|
909
|
+
if (fields === "all") {
|
|
910
|
+
apUpdate.mode = apProvider.data.mode;
|
|
911
|
+
}
|
|
912
|
+
app.autopilotUpdate(apProvider.deviceId, apUpdate);
|
|
896
913
|
}
|
|
897
914
|
}
|
|
898
915
|
catch (e) {
|
|
899
916
|
app.debug(`[absorb] autopilotUpdate failed: ${e?.message || e}`);
|
|
900
917
|
}
|
|
901
|
-
//
|
|
902
|
-
//
|
|
903
|
-
//
|
|
904
|
-
//
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
918
|
+
// Rev84: canonical SK deltas now filtered by the same field mask.
|
|
919
|
+
// setTarget publishes ONLY steering.autopilot.target, setState
|
|
920
|
+
// publishes ONLY state/engaged/availableActions. This kills the
|
|
921
|
+
// race where a parallel /engage + /target pair produced two deltas
|
|
922
|
+
// each carrying both fields; whichever landed second would clobber
|
|
923
|
+
// the sibling with a stale copy (the "DIA jumps to 115° then back
|
|
924
|
+
// to 75°" Carlos reported on Rev82). "all" is still used by the
|
|
925
|
+
// 30-s keep-alive and startup push.
|
|
926
|
+
const values = [];
|
|
927
|
+
if (fields === "all" || fields === "engaged") {
|
|
928
|
+
values.push({ path: "steering.autopilot.state", value: apProvider.data.state }, { path: "steering.autopilot.engaged", value: apProvider.data.engaged }, { path: "steering.autopilot.availableActions",
|
|
929
|
+
value: apProvider.data.options.actions.filter((a) => a.available).map((a) => a.id) });
|
|
930
|
+
}
|
|
931
|
+
if (fields === "all" || fields === "target") {
|
|
932
|
+
values.push({ path: "steering.autopilot.target", value: apProvider.data.target });
|
|
933
|
+
}
|
|
934
|
+
if (fields === "all") {
|
|
935
|
+
values.push({ path: "steering.autopilot.mode", value: apProvider.data.mode });
|
|
936
|
+
}
|
|
937
|
+
if (values.length === 0)
|
|
938
|
+
return;
|
|
913
939
|
try {
|
|
914
940
|
app.handleMessage(PLUGIN_ID, {
|
|
915
941
|
context: "vessels." + app.selfId,
|
|
@@ -925,12 +951,15 @@ module.exports = function (app) {
|
|
|
925
951
|
}
|
|
926
952
|
// Rev34: mirror the active mode into the mode.* radio switch group
|
|
927
953
|
// so KIP's Simple Switch widgets reflect pypilot's current mode.
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
emit
|
|
954
|
+
// Only when publishing the "all" or a mode-carrying update.
|
|
955
|
+
if (fields === "all") {
|
|
956
|
+
try {
|
|
957
|
+
const emit = app._pypilotNewuiEmitModeSwitches;
|
|
958
|
+
if (emit)
|
|
959
|
+
emit(apProvider.data.mode);
|
|
960
|
+
}
|
|
961
|
+
catch { /* silent */ }
|
|
932
962
|
}
|
|
933
|
-
catch { /* silent */ }
|
|
934
963
|
}
|
|
935
964
|
function setupWatches(c, catalog) {
|
|
936
965
|
// High-rate: engage/mode changes and gains that the user might slide.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "signalk-pypilot-newui",
|
|
3
|
-
"version": "2.0
|
|
3
|
+
"version": "2.1.0",
|
|
4
4
|
"description": "Signal K friendly UI for the pypilot autopilot, with the capability to spread its functions and buttons to other programs like KIP, WilhelmSK and freeboard. Touch-first mobile visor + full pypilot data (servo, gains, calibration, tack, warnings) as first-class Signal K paths and Simple Switches. Coexists with pypilot-autopilot-provider.",
|
|
5
5
|
"main": "./dist/index.js",
|
|
6
6
|
"scripts": {
|