signalk-pypilot-newui 2.7.5 → 2.8.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 +146 -0
- package/dist/alarms.js +112 -18
- package/dist/autopilot-provider.js +83 -7
- package/dist/config-backup.js +133 -0
- package/dist/doctor.js +145 -10
- package/dist/episodes.js +251 -0
- package/dist/index.js +897 -17
- package/dist/kpis.js +21 -4
- package/dist/mode-reanchor.js +69 -0
- package/dist/nav-bearing.js +79 -0
- package/dist/prechecks.js +17 -2
- package/dist/profile-advisor.js +137 -0
- package/dist/profile-change-log.js +111 -0
- package/dist/profile-metadata.js +105 -0
- package/dist/roll-ff.js +122 -0
- package/dist/servo-error-log.js +207 -0
- package/dist/servo-health.js +10 -0
- package/dist/test/alarms-trim.test.js +177 -0
- package/dist/test/config-backup.test.js +137 -0
- package/dist/test/episodes.test.js +240 -0
- package/dist/test/mode-reanchor.test.js +149 -0
- package/dist/test/nav-bearing.test.js +108 -0
- package/dist/test/profile-advisor.test.js +190 -0
- package/dist/test/profile-change-log.test.js +147 -0
- package/dist/test/profile-metadata.test.js +130 -0
- package/dist/test/roll-ff.test.js +105 -0
- package/dist/test/servo-error-log.test.js +162 -0
- package/dist/test/tuning-knobs.test.js +205 -0
- package/dist/trip-recorder.js +9 -0
- package/dist/tuning-knobs.js +161 -0
- package/package.json +2 -2
- package/public/app.js +74 -10
- package/public/index.html +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,151 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 2.8.0 — 2026-09-23 — Sea-trial critical fixes + intelligence layer
|
|
4
|
+
|
|
5
|
+
### Fixed (critical, from real sea trial)
|
|
6
|
+
|
|
7
|
+
- **pypilot-disconnected alarm no longer panics on a 1 s hiccup.**
|
|
8
|
+
The banner used to fire after just 1 second of socket silence at
|
|
9
|
+
"alarm" severity (audio channel forced). In a marine environment
|
|
10
|
+
where a 4G router routinely blips for a few seconds on the SIM,
|
|
11
|
+
this generated constant false positives with unavoidable sound.
|
|
12
|
+
New default: **15 s sustain, "warn" severity** (silent banner). The
|
|
13
|
+
window is now runtime-configurable via
|
|
14
|
+
`POST /supervisor/config` field `alarmPypilotDiscSec` (range
|
|
15
|
+
3..300 s). Applies to `notifications.autopilot.pypilot-disconnected`.
|
|
16
|
+
|
|
17
|
+
- **Wind-mode nudge buttons now steer the bow to the labelled side,
|
|
18
|
+
independent of AWA sign.** Before, `modeSign = -Math.sign(AWA)`
|
|
19
|
+
meant a `+10` on port-side ceñida (AWA=-45°) inverted the sign and
|
|
20
|
+
moved the target the wrong way, so the bow swung to the opposite
|
|
21
|
+
side of the button label. Fixed to `modeSign = -1` always in wind
|
|
22
|
+
mode; the four cases (AWA±45 × button±10) now all match the
|
|
23
|
+
physical expectation of "swing the bow to the labelled side".
|
|
24
|
+
|
|
25
|
+
- **Mode change compass↔wind no longer strands `heading_command` in
|
|
26
|
+
the previous space.** pypilot's `ap.heading_command` lives in
|
|
27
|
+
different value spaces per mode (compass=heading, wind=AWA,
|
|
28
|
+
true wind=TWA). Before, changing modes left the target number
|
|
29
|
+
unchanged, so a `heading_command=90°` in compass became a 90° AWA
|
|
30
|
+
target in wind mode and the pilot swung hard to reach it. The
|
|
31
|
+
provider now **re-anchors** `heading_command` to the current
|
|
32
|
+
measurement in the destination space (`wind.direction` /
|
|
33
|
+
`wind.true_direction` / `ap.heading`) right after the mode delta
|
|
34
|
+
is confirmed. Same-space transitions (compass↔gps↔nav,
|
|
35
|
+
wind↔true wind) skip the re-anchor. Decision extracted to
|
|
36
|
+
`src/mode-reanchor.ts` with 24 unit tests.
|
|
37
|
+
|
|
38
|
+
- **Profile selection cannot be overwritten to "default" when opening
|
|
39
|
+
the visor on a fresh device.** The `<select id="profile-select">`
|
|
40
|
+
starts with the DOM-default first option ("default") until the
|
|
41
|
+
authoritative delta lands. A stray change event fired during that
|
|
42
|
+
gap could write "default" to pypilot, overriding the sailor's
|
|
43
|
+
active profile. The visor now refuses to send profile changes
|
|
44
|
+
until it has received at least one authoritative `profile` delta
|
|
45
|
+
from the backend, and skips identity writes.
|
|
46
|
+
|
|
47
|
+
- **Gust temp-heavy strategy respects manual profile changes.** If
|
|
48
|
+
the sailor picks a different profile during the 30 s temp-heavy
|
|
49
|
+
window, the automatic restore no longer overwrites that choice.
|
|
50
|
+
It fires an advisory notification instead.
|
|
51
|
+
|
|
52
|
+
- **Runaway pilot defence.** `adjustTarget` clamp tightened from
|
|
53
|
+
573° to 90°. A single adjustTarget above 90° is either a caller
|
|
54
|
+
bug or a corrupt payload; refused at the boundary before it
|
|
55
|
+
becomes an untraceable pypilot sweep. Nudges (max 100° per press)
|
|
56
|
+
and tacks (go through `setTarget` absolute) are unaffected.
|
|
57
|
+
|
|
58
|
+
### New — Doctor rule set
|
|
59
|
+
|
|
60
|
+
- **Rule 5: step-response episode analysis.** When ≥3 correction
|
|
61
|
+
episodes closed during the Doctor session's window, high average
|
|
62
|
+
overshoot suggests raising D (+15% or +25% if overshoot > 35%),
|
|
63
|
+
and repeated timeouts flag "AP cannot reach the target — hardware
|
|
64
|
+
authority".
|
|
65
|
+
- **Rule 6: downwind roll advisory.** With Roll FF disabled and a
|
|
66
|
+
heel RMS > 5° on downwind engaged samples, Doctor suggests
|
|
67
|
+
activating the Roll feed-forward slider.
|
|
68
|
+
|
|
69
|
+
### New — Tuning & profiles
|
|
70
|
+
|
|
71
|
+
- **Three-slider tuning panel (backend).** New endpoints
|
|
72
|
+
`GET/POST /tuning/knobs` map Aggressivity + Understeer/Oversteer +
|
|
73
|
+
Balance-Heading/Rate onto raw P/I/D/DD via `src/tuning-knobs.ts`.
|
|
74
|
+
Identity at (50, 0, 0); all factors clamped to [0.3×, 3.0×]
|
|
75
|
+
baseline. 26 unit tests.
|
|
76
|
+
- **Profile metadata.** `GET /profiles/metadata` + `PUT/DELETE
|
|
77
|
+
/profiles/metadata/:name` for tagging each pypilot profile with a
|
|
78
|
+
condition (light/medium/heavy/motor/custom) and free-text notes.
|
|
79
|
+
Persisted via `savePluginOptions`.
|
|
80
|
+
- **Profile advisor.** Watches the 1 min KPI window and notifies
|
|
81
|
+
when the boat has been tracking sustainedly badly ("consider a
|
|
82
|
+
more aggressive profile") or over-tightly with high servo duty
|
|
83
|
+
("consider a less aggressive profile"). Never applies anything;
|
|
84
|
+
emits a `notifications.autopilot.pypilot.profileAdvisor` visual
|
|
85
|
+
banner with 5 min cooldown.
|
|
86
|
+
- **Profile change audit log.** New endpoint
|
|
87
|
+
`GET /profile-change-log` shows the last 50 profile transitions
|
|
88
|
+
with attributed source (user / auto-profile / gust-heavy /
|
|
89
|
+
gust-heavy-restore / doctor / external). Answers "who changed my
|
|
90
|
+
profile?" for surprise switches.
|
|
91
|
+
|
|
92
|
+
### New — Correction episodes + config backup
|
|
93
|
+
|
|
94
|
+
- **Correction episode metrics.** `src/episodes.ts` detects each
|
|
95
|
+
target-step correction and records step-response metrics
|
|
96
|
+
(rise time, overshoot %, settling time, steady-state error).
|
|
97
|
+
Exposed at `GET /episodes` with a good/acceptable/poor quality
|
|
98
|
+
band. Feeds Rule 5 of the Doctor.
|
|
99
|
+
- **Config backup / restore.** `GET /config/export` downloads a
|
|
100
|
+
JSON bundle of plugin options + persistent pypilot settings
|
|
101
|
+
(P/I/D/DD, tack params, servo max_current, etc.). `POST
|
|
102
|
+
/config/import` accepts a bundle and applies it, filtering runtime
|
|
103
|
+
telemetry keys defensively.
|
|
104
|
+
|
|
105
|
+
### New — Physical control terms
|
|
106
|
+
|
|
107
|
+
- **Roll feed-forward (off by default).** `src/roll-ff.ts` filters
|
|
108
|
+
the dynamic component of `navigation.attitude.roll` and computes a
|
|
109
|
+
small pre-emptive shift of the commanded heading, gated to
|
|
110
|
+
|TWA|>90°. Currently PUBLISHED at
|
|
111
|
+
`steering.autopilot.pypilot.tuning.rollFf.*` for observation; not
|
|
112
|
+
applied to the pilot yet — a later Rev flips that switch after sea
|
|
113
|
+
trial validates the term. Slider lives at `POST
|
|
114
|
+
/supervisor/config` field `rollFfGain` (0..2).
|
|
115
|
+
- **Gust heel-confirmation gate.** The Rev167 gust supervisor now
|
|
116
|
+
cross-checks the AWS jump against a >2° heel change during the
|
|
117
|
+
same 5 s window. A sensor spike that did not physically load the
|
|
118
|
+
boat is suppressed. Falls open when heel data is absent.
|
|
119
|
+
|
|
120
|
+
### New — Alarms & telemetry
|
|
121
|
+
|
|
122
|
+
- **Persistent servo error log.** JSONL under the plugin dataDir
|
|
123
|
+
records every servo-* alarm transition with a 30 s peak summary
|
|
124
|
+
of pre-fault telemetry (max A, max °C, min V, max heading error).
|
|
125
|
+
Exposed at `GET /servo-error-log`.
|
|
126
|
+
- **Pre-fault trim warnings.** New "info"-severity rules
|
|
127
|
+
`servo-current-trim` (>75% of overcurrent limit) and
|
|
128
|
+
`rudder-range-trim` (>40° absolute) fire *before* the fault-severity
|
|
129
|
+
rules to give the sailor time to trim.
|
|
130
|
+
- **Configurable alarm thresholds.** `alarmLowVoltageV` (8..14 V),
|
|
131
|
+
`alarmServoTempC` (40..85 °C), `alarmServoMotorTempC` (40..90 °C)
|
|
132
|
+
now exposed via `/supervisor/config` and threaded through the
|
|
133
|
+
alarm rules per install.
|
|
134
|
+
|
|
135
|
+
### New — NAV mode observability
|
|
136
|
+
|
|
137
|
+
- **APB source preview.** `GET /nav/apb-preview` shows the current
|
|
138
|
+
active-waypoint bearing computed both ways — direct `bearingTrue`
|
|
139
|
+
and plotter-provided `steerTo` — plus their divergence. Read-only
|
|
140
|
+
for now; a later Rev may switch to plugin-side NAV mode after sea
|
|
141
|
+
trial validates the preference.
|
|
142
|
+
|
|
143
|
+
### Under the hood
|
|
144
|
+
|
|
145
|
+
- 8 new source modules with 177 unit tests (up from 5). All
|
|
146
|
+
additive — no existing endpoint or SK path changed contract.
|
|
147
|
+
- Rev sequence 281..297 on branch `feat/blind-batch-sep16`.
|
|
148
|
+
|
|
3
149
|
## 2.7.5 — 2026-09-15 — Hotfix on 2.7.4 (visor did not load)
|
|
4
150
|
|
|
5
151
|
**Deprecates 2.7.4.** The restore verdict fix that landed in 2.7.4
|
package/dist/alarms.js
CHANGED
|
@@ -15,12 +15,15 @@
|
|
|
15
15
|
// the emitted notification timestamp, so KIP/WilhelmSK never
|
|
16
16
|
// collapse two consecutive alerts into one.
|
|
17
17
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
18
|
-
exports.AlarmEngine = exports.DEFAULT_RULES = exports.RULE_PYPILOT_DISC_SEC = exports.RULE_LOW_VOLTAGE_V = exports.RULE_SERVO_TEMP_C = exports.RULE_SERVO_OVERCURR_A = exports.RULE_UNABLE_DUTY_MIN = exports.RULE_UNABLE_ERR_RAD = exports.RULE_HEADING_ERR_RAD = void 0;
|
|
18
|
+
exports.AlarmEngine = exports.DEFAULT_RULES = exports.RULE_PYPILOT_DISC_SEC = exports.RULE_RUDDER_TRIM_RAD = exports.RULE_SERVO_TRIM_FRAC = exports.RULE_LOW_VOLTAGE_V = exports.RULE_SERVO_TEMP_C = exports.RULE_SERVO_OVERCURR_A = exports.RULE_UNABLE_DUTY_MIN = exports.RULE_UNABLE_ERR_RAD = exports.RULE_HEADING_ERR_RAD = exports.DEFAULT_MOTOR_TEMP_C = void 0;
|
|
19
19
|
const SEV_TO_SK = {
|
|
20
20
|
info: "nominal",
|
|
21
21
|
warn: "warn",
|
|
22
22
|
alarm: "alarm",
|
|
23
23
|
};
|
|
24
|
+
/** Canonical defaults, exposed for the /alarms/thresholds GET and for
|
|
25
|
+
* the visor to know the safe range. */
|
|
26
|
+
exports.DEFAULT_MOTOR_TEMP_C = 70;
|
|
24
27
|
// -------- Built-in rules --------
|
|
25
28
|
// Thresholds picked to be sensible defaults for a cruising sailboat.
|
|
26
29
|
// A future Rev exposes them via plugin schema.
|
|
@@ -30,12 +33,20 @@ exports.RULE_UNABLE_DUTY_MIN = 0.7; // 70% duty
|
|
|
30
33
|
exports.RULE_SERVO_OVERCURR_A = 5.0;
|
|
31
34
|
exports.RULE_SERVO_TEMP_C = 60;
|
|
32
35
|
exports.RULE_LOW_VOLTAGE_V = 11.0;
|
|
33
|
-
//
|
|
34
|
-
//
|
|
35
|
-
//
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
36
|
+
// Rev287 (E1): trim warnings fire BEFORE the corresponding fault
|
|
37
|
+
// so the sailor sees the drift toward the limit and can trim sails
|
|
38
|
+
// or ease pressure before the fault-severity rule kicks in.
|
|
39
|
+
exports.RULE_SERVO_TRIM_FRAC = 0.75; // 75% of overcurrent
|
|
40
|
+
exports.RULE_RUDDER_TRIM_RAD = 40 * Math.PI / 180; // 40 deg absolute
|
|
41
|
+
// Rev292 (Carlos, navigating): the previous 1 s default was too eager
|
|
42
|
+
// in a marine environment where a 4G router routinely blips for a few
|
|
43
|
+
// seconds on the SIM. The severity was also cranked to "alarm" which
|
|
44
|
+
// forced the audio channel and made the pop-up unavoidable. New
|
|
45
|
+
// default: 15 s sustain (comfortable with the 8 s pong-aware healthy
|
|
46
|
+
// check → total ~23 s to fire) and "warn" severity so the visor banner
|
|
47
|
+
// shows but does not force sound. Sailors who want stricter behaviour
|
|
48
|
+
// can drop sustainSec via /supervisor/config.
|
|
49
|
+
exports.RULE_PYPILOT_DISC_SEC = 15;
|
|
39
50
|
exports.DEFAULT_RULES = [
|
|
40
51
|
{
|
|
41
52
|
id: "heading-deviation",
|
|
@@ -114,19 +125,68 @@ exports.DEFAULT_RULES = [
|
|
|
114
125
|
},
|
|
115
126
|
message: (c) => `Servo overcurrent ${(c.sample?.servoCurrent ?? 0).toFixed(1)} A`,
|
|
116
127
|
},
|
|
128
|
+
{
|
|
129
|
+
// Rev287 (E1): pre-fault trim warning. Fires when the servo has
|
|
130
|
+
// been drawing >75% of the overcurrent limit sustained a few
|
|
131
|
+
// seconds. Info severity - it precedes the fault-severity rule
|
|
132
|
+
// above by design so the sailor has time to react.
|
|
133
|
+
id: "servo-current-trim",
|
|
134
|
+
label: "Servo current approaching limit",
|
|
135
|
+
severity: "info",
|
|
136
|
+
defaultEnabled: true,
|
|
137
|
+
sustainSec: 5,
|
|
138
|
+
description: `Servo drawing over ${(exports.RULE_SERVO_TRIM_FRAC * 100).toFixed(0)}% of the overcurrent limit — ease pressure or trim sails.`,
|
|
139
|
+
evaluate: (c) => {
|
|
140
|
+
if (!c.sample || typeof c.sample.servoCurrent !== "number")
|
|
141
|
+
return false;
|
|
142
|
+
const thr = exports.RULE_SERVO_OVERCURR_A * exports.RULE_SERVO_TRIM_FRAC;
|
|
143
|
+
const a = c.sample.servoCurrent;
|
|
144
|
+
// Don't double-fire with the fault-severity rule; back off when
|
|
145
|
+
// already in the overcurrent band.
|
|
146
|
+
if (a > exports.RULE_SERVO_OVERCURR_A)
|
|
147
|
+
return false;
|
|
148
|
+
return a > thr;
|
|
149
|
+
},
|
|
150
|
+
message: (c) => `Servo current ${(c.sample?.servoCurrent ?? 0).toFixed(1)} A (limit ${exports.RULE_SERVO_OVERCURR_A.toFixed(1)} A)`,
|
|
151
|
+
},
|
|
152
|
+
{
|
|
153
|
+
// Rev287 (E1): rudder pre-fault trim warning. Fires when the rudder
|
|
154
|
+
// has been sustainedly close to its stop, which usually precedes a
|
|
155
|
+
// servo out-of-range fault. Info severity.
|
|
156
|
+
id: "rudder-range-trim",
|
|
157
|
+
label: "Rudder near limit",
|
|
158
|
+
severity: "info",
|
|
159
|
+
defaultEnabled: true,
|
|
160
|
+
sustainSec: 3,
|
|
161
|
+
description: `Rudder deflection above ${(exports.RULE_RUDDER_TRIM_RAD * 180 / Math.PI).toFixed(0)}° — servo is near its end of travel.`,
|
|
162
|
+
evaluate: (c) => {
|
|
163
|
+
if (!c.sample || typeof c.sample.rudder !== "number")
|
|
164
|
+
return false;
|
|
165
|
+
return Math.abs(c.sample.rudder) > exports.RULE_RUDDER_TRIM_RAD;
|
|
166
|
+
},
|
|
167
|
+
message: (c) => {
|
|
168
|
+
const r = (c.sample?.rudder ?? 0) * 180 / Math.PI;
|
|
169
|
+
const side = r >= 0 ? "stbd" : "port";
|
|
170
|
+
return `Rudder ${Math.abs(r).toFixed(0)}° ${side} (limit ${(exports.RULE_RUDDER_TRIM_RAD * 180 / Math.PI).toFixed(0)}°)`;
|
|
171
|
+
},
|
|
172
|
+
},
|
|
117
173
|
{
|
|
118
174
|
id: "servo-temp-high",
|
|
119
175
|
label: "Servo temperature high",
|
|
120
176
|
severity: "warn",
|
|
121
177
|
defaultEnabled: true,
|
|
122
178
|
sustainSec: 10,
|
|
123
|
-
description: `Servo controller temperature above ${exports.RULE_SERVO_TEMP_C} °C.`,
|
|
179
|
+
description: `Servo controller temperature above the configured threshold (default ${exports.RULE_SERVO_TEMP_C} °C).`,
|
|
124
180
|
evaluate: (c) => {
|
|
125
181
|
if (!c.sample || typeof c.sample.servoTemp !== "number")
|
|
126
182
|
return false;
|
|
127
|
-
|
|
183
|
+
const thr = c.thresholds?.servoTempC ?? exports.RULE_SERVO_TEMP_C;
|
|
184
|
+
return c.sample.servoTemp > thr;
|
|
185
|
+
},
|
|
186
|
+
message: (c) => {
|
|
187
|
+
const thr = c.thresholds?.servoTempC ?? exports.RULE_SERVO_TEMP_C;
|
|
188
|
+
return `Servo temp ${(c.sample?.servoTemp ?? 0).toFixed(0)} °C (limit ${thr} °C)`;
|
|
128
189
|
},
|
|
129
|
-
message: (c) => `Servo temp ${(c.sample?.servoTemp ?? 0).toFixed(0)} °C`,
|
|
130
190
|
},
|
|
131
191
|
{
|
|
132
192
|
// Rev156 (Carlos): coil temperature complements servo-temp-high,
|
|
@@ -138,13 +198,17 @@ exports.DEFAULT_RULES = [
|
|
|
138
198
|
severity: "warn",
|
|
139
199
|
defaultEnabled: true,
|
|
140
200
|
sustainSec: 15,
|
|
141
|
-
description:
|
|
201
|
+
description: `Motor coil temperature above the configured threshold (default ${exports.DEFAULT_MOTOR_TEMP_C} °C).`,
|
|
142
202
|
evaluate: (c) => {
|
|
143
203
|
if (!c.sample || typeof c.sample.servoMotorTemp !== "number")
|
|
144
204
|
return false;
|
|
145
|
-
|
|
205
|
+
const thr = c.thresholds?.servoMotorTempC ?? exports.DEFAULT_MOTOR_TEMP_C;
|
|
206
|
+
return c.sample.servoMotorTemp > thr;
|
|
207
|
+
},
|
|
208
|
+
message: (c) => {
|
|
209
|
+
const thr = c.thresholds?.servoMotorTempC ?? exports.DEFAULT_MOTOR_TEMP_C;
|
|
210
|
+
return `Servo motor temp ${(c.sample?.servoMotorTemp ?? 0).toFixed(0)} °C (limit ${thr} °C)`;
|
|
146
211
|
},
|
|
147
|
-
message: (c) => `Servo motor temp ${(c.sample?.servoMotorTemp ?? 0).toFixed(0)} °C`,
|
|
148
212
|
},
|
|
149
213
|
{
|
|
150
214
|
// Rev156 (Carlos): ServoHealth already grades the drive as
|
|
@@ -175,13 +239,17 @@ exports.DEFAULT_RULES = [
|
|
|
175
239
|
severity: "warn",
|
|
176
240
|
defaultEnabled: true,
|
|
177
241
|
sustainSec: 10,
|
|
178
|
-
description: `Servo battery voltage below ${exports.RULE_LOW_VOLTAGE_V} V.`,
|
|
242
|
+
description: `Servo battery voltage below the configured threshold (default ${exports.RULE_LOW_VOLTAGE_V} V).`,
|
|
179
243
|
evaluate: (c) => {
|
|
180
244
|
if (!c.sample || typeof c.sample.servoVoltage !== "number")
|
|
181
245
|
return false;
|
|
182
|
-
|
|
246
|
+
const thr = c.thresholds?.lowVoltageV ?? exports.RULE_LOW_VOLTAGE_V;
|
|
247
|
+
return c.sample.servoVoltage < thr;
|
|
248
|
+
},
|
|
249
|
+
message: (c) => {
|
|
250
|
+
const thr = c.thresholds?.lowVoltageV ?? exports.RULE_LOW_VOLTAGE_V;
|
|
251
|
+
return `Low voltage ${(c.sample?.servoVoltage ?? 0).toFixed(1)} V (limit ${thr} V)`;
|
|
183
252
|
},
|
|
184
|
-
message: (c) => `Low voltage ${(c.sample?.servoVoltage ?? 0).toFixed(1)} V`,
|
|
185
253
|
},
|
|
186
254
|
{
|
|
187
255
|
id: "sensor-lost",
|
|
@@ -210,10 +278,21 @@ exports.DEFAULT_RULES = [
|
|
|
210
278
|
{
|
|
211
279
|
id: "pypilot-disconnected",
|
|
212
280
|
label: "Pypilot desconectado",
|
|
213
|
-
|
|
281
|
+
// Rev292 (Carlos, navigating): dropped from "alarm" to "warn". A
|
|
282
|
+
// pypilot socket that stops sending pings does NOT put the boat in
|
|
283
|
+
// immediate danger — the sailor can still steer manually while the
|
|
284
|
+
// sailors's phone / router / SIM recovers. Reserve "alarm" (audio
|
|
285
|
+
// channel) for rules that actually mean "act now" (unable-to-steer,
|
|
286
|
+
// servo-overcurrent).
|
|
287
|
+
severity: "warn",
|
|
214
288
|
defaultEnabled: true,
|
|
289
|
+
// sustainSec is now dynamic: threshold override wins; falls back
|
|
290
|
+
// to the module default (15 s in Rev292+). We keep the field here
|
|
291
|
+
// for the description; the actual sustain used is stateful because
|
|
292
|
+
// AlarmEngine reads it once at register time — see the sustain
|
|
293
|
+
// override handled by evalWithSustainOverride below.
|
|
215
294
|
sustainSec: exports.RULE_PYPILOT_DISC_SEC,
|
|
216
|
-
description: `pypilot_web socket has been down for ${exports.RULE_PYPILOT_DISC_SEC}
|
|
295
|
+
description: `pypilot_web socket has been down for the configured window (default ${exports.RULE_PYPILOT_DISC_SEC} s).`,
|
|
217
296
|
evaluate: (c) => !c.connected,
|
|
218
297
|
message: (c) => {
|
|
219
298
|
if (c.disconnectedSinceMs == null)
|
|
@@ -315,6 +394,21 @@ class AlarmEngine {
|
|
|
315
394
|
}
|
|
316
395
|
return changed;
|
|
317
396
|
}
|
|
397
|
+
/** Rev292: change the sustain window of one rule at runtime, e.g.
|
|
398
|
+
* when the sailor updates alarmPypilotDiscSec in /supervisor/config.
|
|
399
|
+
* Returns true on success, false if the rule id is unknown. Values
|
|
400
|
+
* outside a sane band are clamped, never rejected. */
|
|
401
|
+
setRuleSustain(ruleId, sec) {
|
|
402
|
+
const rule = this.rules.get(ruleId);
|
|
403
|
+
if (!rule)
|
|
404
|
+
return false;
|
|
405
|
+
const clamped = Math.max(1, Math.min(600, Math.floor(sec)));
|
|
406
|
+
// Mutate the RuleDef in place. The RuleDef object was captured by
|
|
407
|
+
// reference in this.rules.set(), so subsequent ticks pick up the
|
|
408
|
+
// new value on their next evaluate.
|
|
409
|
+
rule.sustainSec = clamped;
|
|
410
|
+
return true;
|
|
411
|
+
}
|
|
318
412
|
fire(s, rule, ctx) {
|
|
319
413
|
s.active = true;
|
|
320
414
|
s.activeSinceMs = ctx.nowMs;
|
|
@@ -11,17 +11,20 @@
|
|
|
11
11
|
// single socket to pypilot_web. See NOTICE + CHANGELOG for attribution.
|
|
12
12
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
13
13
|
exports.AutopilotProvider = void 0;
|
|
14
|
+
const mode_reanchor_1 = require("./mode-reanchor");
|
|
14
15
|
const DEG_TO_RAD = Math.PI / 180;
|
|
15
16
|
const RAD_TO_DEG = 180 / Math.PI;
|
|
16
17
|
// Rev68: after a local target write we ignore pypilot echoes that do not
|
|
17
18
|
// match, for up to this many ms. 2 s covers the worst RTT + pypilot apply
|
|
18
19
|
// latency observed on Tunatunes (~500-800 ms). See receiveValue for why.
|
|
19
20
|
const TARGET_PENDING_MS = 2500;
|
|
20
|
-
//
|
|
21
|
-
//
|
|
22
|
-
//
|
|
23
|
-
//
|
|
24
|
-
|
|
21
|
+
// Rev280 (audit T02): tightened from 2° to 0.1°. Pypilot echoes
|
|
22
|
+
// `heading_command` verbatim; the previous 2° window was wide enough
|
|
23
|
+
// that a stale echo of the previous value (e.g. 100°) matched a fresh
|
|
24
|
+
// request of 101°, so the visor kept snapping the target back to the
|
|
25
|
+
// value the user had just replaced. 0.1° covers float32 → double
|
|
26
|
+
// rounding on the wire without accidentally consuming a stale echo.
|
|
27
|
+
const TARGET_ECHO_TOL_RAD = 0.1 * DEG_TO_RAD;
|
|
25
28
|
// Rev272 (audit R07): bounded-cost normalisation. The previous while
|
|
26
29
|
// loop could spin forever on pathological inputs (e.g. |a-b| >= 1e15
|
|
27
30
|
// where 2π is below the mantissa's precision, so subtracting a full
|
|
@@ -159,6 +162,15 @@ class AutopilotProvider {
|
|
|
159
162
|
if (Array.isArray(value)) {
|
|
160
163
|
this.pypilotModes = value.map(String);
|
|
161
164
|
this.data.options.modes = [...this.pypilotModes];
|
|
165
|
+
// Rev280 (audit T16): the "courseCurrentPoint" action's
|
|
166
|
+
// availability depends on pypilotModes.includes("nav"), so
|
|
167
|
+
// a late-arriving ap.modes payload (typical: pypilot
|
|
168
|
+
// publishes it once its config loader has finished, a few
|
|
169
|
+
// seconds after the initial catalog) must trigger a fresh
|
|
170
|
+
// recomputeActions and a delta push. Previously the action
|
|
171
|
+
// stayed unavailable until the next engage/disengage.
|
|
172
|
+
this.recomputeActions();
|
|
173
|
+
changed = true;
|
|
162
174
|
}
|
|
163
175
|
break;
|
|
164
176
|
case "ap.enabled": {
|
|
@@ -288,6 +300,22 @@ class AutopilotProvider {
|
|
|
288
300
|
!this.data.options.modes.includes(mode)) {
|
|
289
301
|
throw new Error(`Invalid mode: ${mode}`);
|
|
290
302
|
}
|
|
303
|
+
// Rev292 (Carlos, navigating - bug C "piloto girando loco"):
|
|
304
|
+
// snapshot the OLD mode so we can decide whether the transition
|
|
305
|
+
// needs a heading_command re-anchor. Pypilot's ap.heading_command
|
|
306
|
+
// lives in different spaces depending on the mode:
|
|
307
|
+
// compass/gps/nav -> compass heading in degrees 0..360
|
|
308
|
+
// wind -> AWA in degrees -180..+180
|
|
309
|
+
// true wind -> TWA in degrees -180..+180
|
|
310
|
+
// A stale value from the previous mode (say a 90° heading) becomes
|
|
311
|
+
// a 90° AWA when we cross into wind mode, and pypilot promptly
|
|
312
|
+
// tries to swing the boat toward it — the "piloto se vuelve loco"
|
|
313
|
+
// symptom Carlos reported on Rev280 sea trial. The fix: right
|
|
314
|
+
// after ap.mode changes, force heading_command to the CURRENT
|
|
315
|
+
// measurement in the destination space, so the pilot has a
|
|
316
|
+
// sane target and never chases a value that meant something else.
|
|
317
|
+
const oldMode = String(this.data.mode || "").toLowerCase();
|
|
318
|
+
const newMode = String(mode || "").toLowerCase();
|
|
291
319
|
// Rev279 (audit follow-up B): bump modeGen so a stale mode retry
|
|
292
320
|
// aborts before overwriting a newer setMode / setState intent.
|
|
293
321
|
const gen = ++this.modeGen;
|
|
@@ -302,6 +330,47 @@ class AutopilotProvider {
|
|
|
302
330
|
if (this.modeGen !== gen) {
|
|
303
331
|
throw new Error("mode change superseded after write");
|
|
304
332
|
}
|
|
333
|
+
// Rev292: re-anchor. Only when the mode's target-space actually
|
|
334
|
+
// changed (compass↔gps↔nav share the compass space and don't need
|
|
335
|
+
// it; wind↔true wind share the wind space; only compass-family ↔
|
|
336
|
+
// wind-family crossings trigger the re-anchor).
|
|
337
|
+
if (this.data.engaged) {
|
|
338
|
+
try {
|
|
339
|
+
this._reAnchorTargetAfterModeChange(oldMode, newMode);
|
|
340
|
+
}
|
|
341
|
+
catch (e) {
|
|
342
|
+
// Never fail the mode change on the re-anchor path — worst
|
|
343
|
+
// case we leave the stale target and the sailor corrects with
|
|
344
|
+
// a nudge. Log so the diagnostic captures it.
|
|
345
|
+
// eslint-disable-next-line no-console
|
|
346
|
+
console.log(`[apProvider.setMode] re-anchor failed: ${e?.message || e}`);
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
/** Rev292: after a compass↔wind (or vice-versa) mode change, force
|
|
351
|
+
* heading_command to the current measurement in the destination
|
|
352
|
+
* space so pypilot never starts a mode with a target that meant
|
|
353
|
+
* something else in the previous mode. Rev295: decision extracted
|
|
354
|
+
* to src/mode-reanchor.ts (pure, unit-tested). This method only
|
|
355
|
+
* glues that decision to the pypilot socket. */
|
|
356
|
+
_reAnchorTargetAfterModeChange(oldMode, newMode) {
|
|
357
|
+
const values = this.client.getValues?.() || {};
|
|
358
|
+
const d = (0, mode_reanchor_1.decideReAnchor)(oldMode, newMode, values);
|
|
359
|
+
// eslint-disable-next-line no-console
|
|
360
|
+
console.log(`[apProvider.setMode] re-anchor: ${d.reason}`);
|
|
361
|
+
if (!d.shouldReAnchor || d.valueDeg == null)
|
|
362
|
+
return;
|
|
363
|
+
// Push directly to pypilot without going through setTarget() —
|
|
364
|
+
// setTarget bumps targetGen and could race with the modeGen we
|
|
365
|
+
// just claimed. Also bypass echo cancellation: the anchor value is
|
|
366
|
+
// what we WANT to see, so the echo does match.
|
|
367
|
+
try {
|
|
368
|
+
this.client.set("ap.heading_command", d.valueDeg);
|
|
369
|
+
}
|
|
370
|
+
catch (e) {
|
|
371
|
+
// eslint-disable-next-line no-console
|
|
372
|
+
console.log(`[apProvider.setMode] re-anchor write failed: ${e?.message || e}`);
|
|
373
|
+
}
|
|
305
374
|
}
|
|
306
375
|
async setTarget(rad) {
|
|
307
376
|
// Rev272 (audit R07): reject at the boundary. Downstream callers
|
|
@@ -341,8 +410,15 @@ class AutopilotProvider {
|
|
|
341
410
|
}
|
|
342
411
|
async adjustTarget(rad) {
|
|
343
412
|
// Rev272 (audit R07): reject non-finite deltas at the boundary.
|
|
344
|
-
|
|
345
|
-
|
|
413
|
+
// Rev292 (Carlos, navigating - bug C "piloto girando loco"):
|
|
414
|
+
// tighten the ceiling. `adjustTarget` is called from user nudges
|
|
415
|
+
// (max nudgeBig = 100°, so ~1.75 rad in extreme configurations)
|
|
416
|
+
// and never for tacks (those go through this.tack() → setTarget
|
|
417
|
+
// absolute). A single adjustTarget above 90° is either a bug in
|
|
418
|
+
// the caller or a corrupt payload — refuse it before it reaches
|
|
419
|
+
// pypilot, where it becomes an untraceable heading sweep.
|
|
420
|
+
if (!Number.isFinite(rad) || Math.abs(rad) > Math.PI / 2) {
|
|
421
|
+
throw new Error(`Invalid adjust rad: ${rad} (max ±π/2 = ±90°)`);
|
|
346
422
|
}
|
|
347
423
|
// Rev272 (audit R06): serialise on the shared chain. base must be
|
|
348
424
|
// read AFTER any pending adjust has committed data.target,
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// Config backup + restore helpers.
|
|
3
|
+
//
|
|
4
|
+
// Two independent things travel in one bundle:
|
|
5
|
+
// 1. This plugin's own SK options (host, port, propose*, thresholds).
|
|
6
|
+
// 2. The pypilot core settings that the boat spent effort tuning
|
|
7
|
+
// (P/I/D/DD/FF per pilot, tack angle/rate/delay/threshold, servo
|
|
8
|
+
// max_current, wind offset, compass alignment, profile names).
|
|
9
|
+
//
|
|
10
|
+
// Runtime values (headings, currents, temps) are NOT included. The
|
|
11
|
+
// filter distinguishes them by name: any key whose "info.persistent"
|
|
12
|
+
// flag is true in the pypilot catalog, or that matches an explicit
|
|
13
|
+
// whitelist, is treated as a persistent setting.
|
|
14
|
+
//
|
|
15
|
+
// Restore applies the pypilot settings back to the core one at a time
|
|
16
|
+
// via client.set(). The plugin's own props are handed back to the
|
|
17
|
+
// caller so it can savePluginOptions() them.
|
|
18
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
19
|
+
exports.isRuntimeKey = isRuntimeKey;
|
|
20
|
+
exports.captureBundle = captureBundle;
|
|
21
|
+
exports.validateBundle = validateBundle;
|
|
22
|
+
exports.applyBundleToPypilot = applyBundleToPypilot;
|
|
23
|
+
/** Extra pypilot keys we always include even if their catalog entry
|
|
24
|
+
* does not flag them as persistent. Empirically these have proved
|
|
25
|
+
* worth carrying across a restore. */
|
|
26
|
+
const EXTRA_PERSISTENT_KEYS = [
|
|
27
|
+
"ap.mode",
|
|
28
|
+
"profile",
|
|
29
|
+
"profiles",
|
|
30
|
+
"servo.max_current",
|
|
31
|
+
"servo.min_speed",
|
|
32
|
+
"servo.max_speed",
|
|
33
|
+
];
|
|
34
|
+
/** Runtime keys we NEVER include even if the catalog claims persistent. */
|
|
35
|
+
const RUNTIME_BLOCKLIST_SUFFIXES = [
|
|
36
|
+
".command",
|
|
37
|
+
".position",
|
|
38
|
+
".voltage",
|
|
39
|
+
".current",
|
|
40
|
+
".temperature",
|
|
41
|
+
".rate",
|
|
42
|
+
".heading",
|
|
43
|
+
".heel",
|
|
44
|
+
".pitch",
|
|
45
|
+
".roll",
|
|
46
|
+
".fault",
|
|
47
|
+
".flags",
|
|
48
|
+
];
|
|
49
|
+
/** Returns true if the value of a pypilot key looks like a runtime
|
|
50
|
+
* telemetry number (which we should never include in a config backup)
|
|
51
|
+
* rather than a persistent setting. */
|
|
52
|
+
function isRuntimeKey(key) {
|
|
53
|
+
for (const suf of RUNTIME_BLOCKLIST_SUFFIXES) {
|
|
54
|
+
if (key.endsWith(suf))
|
|
55
|
+
return true;
|
|
56
|
+
}
|
|
57
|
+
return false;
|
|
58
|
+
}
|
|
59
|
+
/** Build a bundle from the current in-memory state. `catalog` comes
|
|
60
|
+
* from client.getCatalog(); `values` from client.getValues(); `props`
|
|
61
|
+
* from the plugin's own options. */
|
|
62
|
+
function captureBundle(args) {
|
|
63
|
+
const pypilotSettings = {};
|
|
64
|
+
for (const [key, entry] of Object.entries(args.catalog)) {
|
|
65
|
+
const persistent = entry?.info?.persistent === true;
|
|
66
|
+
const extra = EXTRA_PERSISTENT_KEYS.includes(key);
|
|
67
|
+
if (!persistent && !extra)
|
|
68
|
+
continue;
|
|
69
|
+
if (isRuntimeKey(key))
|
|
70
|
+
continue;
|
|
71
|
+
const v = args.values[key];
|
|
72
|
+
if (v === undefined)
|
|
73
|
+
continue;
|
|
74
|
+
// Skip nested objects and arrays — pypilot settings are scalars.
|
|
75
|
+
if (typeof v === "object" && v !== null)
|
|
76
|
+
continue;
|
|
77
|
+
pypilotSettings[key] = v;
|
|
78
|
+
}
|
|
79
|
+
return {
|
|
80
|
+
version: 1,
|
|
81
|
+
capturedTs: Date.now(),
|
|
82
|
+
revision: args.revision,
|
|
83
|
+
note: args.note,
|
|
84
|
+
pluginOptions: JSON.parse(JSON.stringify(args.props)),
|
|
85
|
+
pypilotSettings,
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
/** Validate that a bundle looks well-formed. Returns null on success,
|
|
89
|
+
* an error message otherwise. Kept liberal — we accept older
|
|
90
|
+
* bundles even if some fields are missing, as long as the essentials
|
|
91
|
+
* parse. */
|
|
92
|
+
function validateBundle(b) {
|
|
93
|
+
if (!b || typeof b !== "object")
|
|
94
|
+
return "not an object";
|
|
95
|
+
const bb = b;
|
|
96
|
+
if (bb.version !== 1)
|
|
97
|
+
return `unsupported version: ${bb.version}`;
|
|
98
|
+
if (typeof bb.capturedTs !== "number")
|
|
99
|
+
return "capturedTs missing";
|
|
100
|
+
if (!bb.pluginOptions || typeof bb.pluginOptions !== "object")
|
|
101
|
+
return "pluginOptions missing";
|
|
102
|
+
if (!bb.pypilotSettings || typeof bb.pypilotSettings !== "object")
|
|
103
|
+
return "pypilotSettings missing";
|
|
104
|
+
for (const [k, v] of Object.entries(bb.pypilotSettings)) {
|
|
105
|
+
if (v && typeof v === "object")
|
|
106
|
+
return `pypilotSettings.${k} is not a scalar`;
|
|
107
|
+
}
|
|
108
|
+
return null;
|
|
109
|
+
}
|
|
110
|
+
/** Apply the pypilot settings from a bundle to the running core via
|
|
111
|
+
* the given setter. Skips keys that no longer exist in the catalog
|
|
112
|
+
* (a firmware downgrade would remove them). Returns an audit list. */
|
|
113
|
+
function applyBundleToPypilot(bundle, catalog, set) {
|
|
114
|
+
const out = [];
|
|
115
|
+
for (const [key, value] of Object.entries(bundle.pypilotSettings)) {
|
|
116
|
+
if (isRuntimeKey(key)) {
|
|
117
|
+
out.push({ key, status: "skipped-runtime" });
|
|
118
|
+
continue;
|
|
119
|
+
}
|
|
120
|
+
if (!(key in catalog)) {
|
|
121
|
+
out.push({ key, status: "skipped-unknown" });
|
|
122
|
+
continue;
|
|
123
|
+
}
|
|
124
|
+
try {
|
|
125
|
+
set(key, value);
|
|
126
|
+
out.push({ key, status: "applied" });
|
|
127
|
+
}
|
|
128
|
+
catch (e) {
|
|
129
|
+
out.push({ key, status: "error", error: String(e?.message || e) });
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
return out;
|
|
133
|
+
}
|