signalk-pypilot-newui 2.0.3 → 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,196 @@
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
+
113
+ ## 2.0.4 — 2026-08-21 — Rev67
114
+
115
+ Reintroduces (with proper QA) the UI polish + reliability work that was
116
+ withdrawn in 2.0.3 when 2.0.2 hit regressions. Every item below was
117
+ tested on Carlos's Tunatunes Pi Zero W setup before publish.
118
+
119
+ ### Fixed
120
+
121
+ - **Restart engine now uses `sv` (runit), not `systemctl`.** piCore does
122
+ not ship systemd; the previous `sudo systemctl restart pypilot pypilot_web`
123
+ was silently falling through to `|| true` on every install. Only the
124
+ `pkill -9` had any effect, and runit's `runsv` was auto-relaunching
125
+ the killed processes into a slightly inconsistent state that
126
+ accumulated zombies + I2C contention until the Pi Zero locked up.
127
+ Fixed to `sudo sv restart /etc/sv/pypilot /etc/sv/pypilot_web /etc/sv/pypilot_hat`
128
+ with a selective `pkill -9 -f 'python.*pypilot --version'` safety net.
129
+ - **`pypilot --version` preset in Debug console wrapped in `timeout 3`.**
130
+ On piCore that command starts the full pypilot process and never
131
+ exits cleanly; each click was leaving a zombie holding I2C and TCP
132
+ resources. Falls back to reading the packaged version file.
133
+ - **Target diamond drifted after tapping AP on laggy links.** The
134
+ optimistic-target assign now happens INSTANTLY on click (same tick),
135
+ not after the parallel promise resolves - so the diamond snaps to
136
+ the captured heading immediately, without a jump to the stale value
137
+ then back. Nudge also updates `state.target` optimistically so the
138
+ diamond moves in the same tick as the button press.
139
+ - **socket.io auto-reconnect no longer gives up.** `reconnectionAttempts`
140
+ changed from 30 (which could exhaust on a long outage) to Infinity;
141
+ backoff capped at 30 s. Restarts done via `/debug-cmd` (`restart.web`
142
+ / `reboot.pi`) now schedule an explicit `pause+resume` after the
143
+ operation.
144
+ - **Reconnection watchdog** (every 60 s, was 30 s): if the socket has
145
+ been disconnected > 90 s, force a pause+resume once. Max 3 forced
146
+ reconnects in a rolling 15-min window so a permanently-dead
147
+ pypilot_web is not turned into a local DDoS. Watchdog interval
148
+ loosened per Carlos: "con la Pi Zero debemos ser suuuuuper ratas".
149
+
150
+ ### Changed - Emergency section simplified
151
+
152
+ The 3-level escalated restart triad shipped in 2.0.2 has been trimmed
153
+ to 2 levels in Setup -> Emergency: **RESTART pypilot** (level 2, ~10-15 s
154
+ outage) + **reboot Pi** (level 3, ~35-60 s). The old level 1
155
+ (`restart pypilot_web`) rarely helps in practice - when the Pi Zero is
156
+ saturated a full reboot is what actually helps - so it moved to the
157
+ Debug console below as an advanced preset for the ~5 % edge cases.
158
+
159
+ ### Compass rose
160
+
161
+ - HDG in a small dark chip that straddles the top of the ring, narrowed
162
+ from 48 to 40 units wide so the enlarged target diamond can peek out
163
+ on all four sides even when target is dead ahead.
164
+ - Target diamond enlarged (28 wide, 30 tall) so its top point asoma
165
+ above the HDG chip and its bottom point below it.
166
+ - Wind arrows both solid (no dashed shaft on TWA), amber AWA (big head)
167
+ + sea-green (#5ce8c0) TWA (small head), each with its own rotating
168
+ text label placed at very different radii so they never overlap even
169
+ when both arrows point in the same direction.
170
+ - Wind speed at the bottom of the rose now inside a small dark chip
171
+ (same look as the HDG chip on top). Short-tap on the chip OR on a
172
+ corner mapped to "wind" flips AWS/TWS. Long-press opens the popup.
173
+ - Corner options `AWA` and `TWA` now show angle AND matching wind speed
174
+ (AWA/AWS and TWA/TWS) as a combined 2-line cell, consistent with the
175
+ `wind` combo.
176
+
177
+ ### Rudder angle indicator in the swipe-hint bar
178
+
179
+ - Default range **±70°** (was ±45°), matching typical TinyPilot configs.
180
+ - Explicit numeric labels at both ends showing the actual range from
181
+ the pypilot catalog.
182
+ - Intermediate tick marks every 10° plus numeric mid-labels at 20°, 40°
183
+ and 60° each side, so the operator reads the rudder angle at a glance.
184
+
185
+ ### Internal
186
+
187
+ - Debug console preset `pypilot.version` now `timeout 3 python3 -c
188
+ 'import pypilot; print(pypilot.__version__)'` with fallback.
189
+ - Merge-based `_pluginConfigMerge()` helper preserved.
190
+ - Per-cell state for the long-press / short-tap handler on the corner
191
+ dashboard (Rev65's shared timer flags were race-flipping and killing
192
+ the tap toggle intermittently).
193
+
3
194
  ## 2.0.3 — 2026-08-20 — Rev66 - rollback of 2.0.2
4
195
 
5
196
  Codebase is functionally identical to 2.0.1. Version 2.0.2 introduced
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);