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 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
- 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).
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 heading readout, cardinals + degree ticks, hull-fixed
35
- green/red sailing wedges, AWA + TWA arrows (independent, toggleable), cyan
36
- target diamond, and 4 configurable corner tiles (long-press to reconfigure).
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 triad + Debug console
120
-
121
- When the autopilot process on the TinyPilot gets wedged you don't need to open
122
- an SSH session by hand. In **Setup → Emergency**, save the TinyPilot's SSH user
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.
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 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>" }` |
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
- this.client.set("ap.enabled", st.engaged);
107
- return st.engaged;
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 = "Rev67";
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
- app.autopilotUpdate(apProvider.deviceId, {
890
- state: apProvider.data.state,
891
- mode: apProvider.data.mode,
892
- target: apProvider.data.target,
893
- engaged: apProvider.data.engaged,
894
- actions: apProvider.data.options.actions,
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
- // ALSO emit canonical steering.autopilot.* deltas so anything subscribed
902
- // to the WebSocket stream (including our own webapp) sees the change.
903
- // autopilotUpdate() alone only feeds the REST endpoint - deltas would not
904
- // arrive to stream subscribers otherwise.
905
- const values = [
906
- { path: "steering.autopilot.state", value: apProvider.data.state },
907
- { path: "steering.autopilot.mode", value: apProvider.data.mode },
908
- { path: "steering.autopilot.target", value: apProvider.data.target },
909
- { path: "steering.autopilot.engaged", value: apProvider.data.engaged },
910
- { path: "steering.autopilot.availableActions",
911
- value: apProvider.data.options.actions.filter((a) => a.available).map((a) => a.id) },
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
- try {
929
- const emit = app._pypilotNewuiEmitModeSwitches;
930
- if (emit)
931
- emit(apProvider.data.mode);
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.4",
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": {