signalk-pypilot-newui 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md ADDED
@@ -0,0 +1,186 @@
1
+ # Changelog
2
+
3
+ ## 1.0.0 — 2026-08-18 — Rev32..Rev62 first public release
4
+
5
+ The webapp went from a minimum viable control panel to a full-blown touch
6
+ visor + first-class Signal K bridge in this batch. Highlights:
7
+
8
+ ### UI - Control tab
9
+
10
+ - **Compass rose** (Rev47..Rev58): cardinals + 30° tick labels, boat-fixed
11
+ green/red sailing wedges reaching under the hull, AWA (amber) + TWA
12
+ (magenta dashed) arrows, cyan target diamond outside the ring, HDG at
13
+ the top rim, wind speed at the bottom.
14
+ - **4 configurable corner tiles** (Rev48): long-press to pick datum (HDG,
15
+ AWA/TWA/TWS/TWD, SOG, COG, TGT, depth, mode, polar-performance keys,
16
+ custom SK path).
17
+ - **Wind arrow independent toggles** (Rev58): AWA and TWA on/off separately,
18
+ persisted in localStorage.
19
+ - **Custom SK path** (Rev58): add any published Signal K path as a corner
20
+ tile with label + unit. Datalist auto-suggests paths seen on the bus.
21
+ - **Polar-performance opt-in** (Rev58): checkboxes for the 10 keys of
22
+ `signalk-polar-performance-plugin` + water temperature.
23
+ - **Optimistic AP engage** (Rev56): visual state flips instantly on click,
24
+ auto-retries on Tailscale flakes, rolls back with red-border flash on
25
+ hard failure. Guard blocks double-taps from firing opposite POSTs.
26
+ - **KIP-friendly Simple Switches** (Rev33..Rev38): `electrical.switches.pypilot.*`
27
+ for ap.state (momentary boolean), nudge (4 momentary), tack (3 momentary),
28
+ mode (radio), dynamic profile (radio).
29
+
30
+ ### UI - Tune tab
31
+
32
+ - **Locked-by-default gain sliders** (Rev42): unlock checkbox, auto-relocks
33
+ on tab exit.
34
+ - **±10 / ±1 fine-tune buttons** (Rev43) flanking each slider.
35
+ - **Orange "was" reference** shown when unlocked so the operator sees what
36
+ they are moving from.
37
+ - All `RangeSetting` config sliders from the pypilot catalog appear here
38
+ automatically (Rev45).
39
+
40
+ ### UI - Race tab (new, Rev35)
41
+
42
+ - T1/T2 countdown timer with audible beeps, screen flash, spoken seconds
43
+ ("10, 5, 4, 3, 2, 1, Start"). Runs in the background across tabs.
44
+ - SYNC rounds to the nearest minute mid-countdown; RESET returns to idle.
45
+
46
+ ### UI - Setup tab
47
+
48
+ - **Remote `RESTART pypilot`** (Rev55): SSH into the TinyPilot using the
49
+ user + password stored in plugin config (via `ssh2` npm), runs
50
+ `sudo systemctl restart pypilot pypilot_web`, reconnects local socket.
51
+ Falls back to reconnect-only if SSH fails or password not set.
52
+ - **`_pluginConfigMerge` helper** (Rev55): GETs the full plugin config from
53
+ `/skServer/plugins/<id>/config`, overlays only the patched fields, POSTs
54
+ back - so saving Nudge or Absorb no longer wipes the SSH credentials
55
+ (or vice versa) via full-replace.
56
+ - Pause/Resume buttons removed from the UI (backend endpoints kept).
57
+ - Absorb-provider hint reworded, security note added for the plain-text
58
+ plugin config storage.
59
+
60
+ ### UI - Paths & API tab
61
+
62
+ - KIP mini-table trimmed to the current recommended surface (Simple Switch
63
+ + Autopilot API v2 Put). Legacy `.actions.engage/nudge/tack/*Int` paths
64
+ hidden from the UI (backend handlers still registered for backwards
65
+ compatibility).
66
+ - Horizontal-scroll wrapper on both tables so long SK paths remain readable
67
+ on portrait phones.
68
+
69
+ ### UI - Info tab (new, Rev58..Rev59)
70
+
71
+ - Safety notice, quick-start (10 steps), About, Acknowledgements
72
+ (Sean D'Epagnier, Jean-Marc at Navitop, Signal K community, Panaaj),
73
+ Apache 2.0 license attribution, GitHub issue links, coexistence rules
74
+ with `pypilot-autopilot-provider`.
75
+ - **Config export/import** (Rev59): Web Share API to email / WhatsApp /
76
+ Signal / AirDrop, `.json` download, clipboard copy, file / text import,
77
+ reset. Payload includes corners, wind-arrow flags, polar toggles, custom
78
+ paths. Never includes SSH credentials.
79
+ - Version + Rev header, link to the classic pypilot_web UI on the TinyPilot.
80
+
81
+ ### i18n (Rev61)
82
+
83
+ - Full **English / Español / Deutsch / Français** with browser auto-detect
84
+ on first visit, override in Setup → Language.
85
+ - ~90 keys covering tabs, Setup, popup, Paths, Regatta, alerts, prompts,
86
+ confirms. EN + ES complete; DE + FR cover tabs + core Setup + calibration
87
+ and fall back to EN for the rest.
88
+ - Info tab uses `data-lang` sibling blocks (EN + ES fully written).
89
+ - `<html lang="…">` reflects the active language.
90
+
91
+ ### Layout / responsiveness (Rev49..Rev57)
92
+
93
+ - Landscape: rose stretches to fill the left column; control column becomes
94
+ a `1fr 1fr auto` grid so tack + AP rows fill the height instead of
95
+ clustering at the top. Text auto-shrinks via `clamp(min, min(vw, vh), max)`.
96
+ - Portrait: nav bar and swipe hint always visible; corner tiles inside the
97
+ rose square; long paths get horizontal scroll wrappers.
98
+ - **Swipe navigation** (Rev43..Rev57) between tabs: 80px threshold, 2.2×
99
+ H/V ratio, first-move axis lock so scrolling never triggers a stray
100
+ tab flip. Excluded over unlocked sliders and `.paths-scroll` wrappers.
101
+ - Nav-bar auto-hide toggle via yellow triangle in the swipe-hint strip.
102
+
103
+ ### Backend (src/index.ts)
104
+
105
+ - `PLUGIN_REVISION` bumped Rev32 → Rev61 (11 packaged Revs since 0.1.0).
106
+ - New `sshUser` + `sshPassword` plugin config fields (Rev55).
107
+ - New `POST /plugins/*/restart-pypilot` router using `ssh2` (Rev55).
108
+ - `/status` exposes `sshUser`, `sshPasswordSet` (never the password itself),
109
+ and the package `version` from `package.json` (Rev59).
110
+ - Wildcard subscription to `performance.*` for polar-performance keys +
111
+ `environment.water.temperature` (Rev58).
112
+ - Optimistic UI + retry helper in the frontend (Rev56); no backend change.
113
+
114
+ ### Screenshots + logo (Rev61)
115
+
116
+ - App Store screenshots in `public/screenshots/` (JPEG < 500 KB each,
117
+ 4 shots: Control / Tune / Info / Setup).
118
+ - Vector logo `public/Pypilot_New_UI.svg` (33 KB) wired as
119
+ `signalk.appIcon`.
120
+ - `signalk.screenshots[]` populated for the App Store card.
121
+
122
+ ### Docs
123
+
124
+ - README rewritten: full feature list, coexistence table, remote restart,
125
+ updated endpoint table, acknowledgements, safety, reporting.
126
+ - App Store `description` in `package.json` replaced with a
127
+ user-facing one-paragraph blurb.
128
+
129
+ ## 0.1.0 — 2026-07-21
130
+
131
+ ### Rev1..Rev6 first-boot iteration on the Tunatunes Pi
132
+
133
+ - Rev1 scaffold.
134
+ - Rev2 fix: factory function forgot to `return plugin` -> SK crashed with
135
+ `Cannot read properties of undefined (reading 'id')`.
136
+ - Rev3 fix: `publishMeta` sent `updates:[{meta:[...]}]` without `values`,
137
+ triggering `update.values is not iterable` in the SK delta iterator.
138
+ - Rev4 refactor: attach `meta` INLINE on the first `publishValue` of each
139
+ path so no delta ever carries an empty `values` array. Dropped the
140
+ separate `publishMeta` call.
141
+ - Rev5 diagnostic: catalog counts in `setPluginStatus`.
142
+ - Rev6 fix: pypilot exposes gains as `ap.pilot.<pilot>.<gain>` (SINGULAR),
143
+ not `ap.pilots.*` as the initial brief assumed. Confirmed by inspecting
144
+ `pypilot_values` on the socket.io wire. Corrected in publisher.ts,
145
+ index.ts (setupWatches + status), and public/app.js (renderGains).
146
+
147
+ ### Known limitation
148
+
149
+ `signalk-pushover-plugin@0.0.6` emits two `Unhandled rejection: TypeError:
150
+ update.values is not iterable` at every SK restart, even before our plugin
151
+ publishes anything. The stack is pushover's own (`index.js:193:71`) and
152
+ does not reference our plugin. Ignore, or file upstream against
153
+ pushover-plugin.
154
+
155
+ ### Backend
156
+ - src/pypilot-client.ts: socket.io client to `pypilot_web`, reconnect,
157
+ `pypilot_values` catalog discovery, watch/set primitives, ping/pong.
158
+ - src/publisher.ts: fixed + dynamic mapping table pypilot names to
159
+ `steering.autopilot.pypilot.*` (gains, servo, calibration, tack,
160
+ errors, warnings, runtime, version). Deg->rad and C->K conversions.
161
+ - src/scanner.ts: /24 LAN scan, TCP probe on ports 80 and 8000, HTTP
162
+ fingerprint on `<title>pypilot control</title>`.
163
+ - src/index.ts: plugin lifecycle + JSON schema + PUT handlers (with
164
+ reverse rad->deg on angle paths) + HTTP router (`/status`, `/paths`,
165
+ `/catalog`, `/scan`, `/raw`).
166
+
167
+ ### Reserved for pypilot-autopilot-provider (never republished)
168
+ `ap.enabled`, `ap.heading`, `ap.heading_command`, `ap.mode`. Both plugins
169
+ coexist.
170
+
171
+ - Backend: socket.io client to `pypilot_web`, catalog discovery via
172
+ `pypilot_values`, watch subscriptions, delta publisher with
173
+ configurable per-path enable/disable.
174
+ - Paths: full `steering.autopilot.pypilot.*` tree (gains, servo,
175
+ calibration, tack, errors, warnings, runtime, version).
176
+ - HTTP: `/scan` LAN discovery, `/status`, `/paths` KIP-friendly catalog,
177
+ `/raw` protected write.
178
+ - Webapp: mobile-first dark, four tabs (Control, Ajustes, Paths & API,
179
+ Setup). Control tab fully wired against the Autopilot API v1 of
180
+ Signal K (engage / disengage / mode / target / tack / heading nudges);
181
+ Ajustes tab with translucent-while-adjusting sliders for gains and
182
+ calibration; Paths & API tab with live path list + copy-for-KIP;
183
+ Setup tab with LAN scan.
184
+ - Coexists with the official `pypilot-autopilot-provider` — this plugin
185
+ never publishes `steering.autopilot.state / mode / target / engaged`
186
+ (those stay with the provider).
package/NOTICE ADDED
@@ -0,0 +1,30 @@
1
+ signalk-pypilot-newui
2
+ Copyright 2026 Aitonos
3
+
4
+ Licensed under the Apache License, Version 2.0 (the "License");
5
+ you may not use this project except in compliance with the License.
6
+ You may obtain a copy of the License at
7
+
8
+ http://www.apache.org/licenses/LICENSE-2.0
9
+
10
+
11
+ This project includes an "Autopilot Provider" adapter
12
+ (src/autopilot-provider.ts) whose method structure and command
13
+ translation semantics are derived from:
14
+
15
+ pypilot-autopilot-provider
16
+ Copyright (c) Panaaj
17
+ https://github.com/panaaj/pypilot-autopilot-provider
18
+ Apache License 2.0
19
+
20
+ The upstream project ships its own socket.io client. In our fork we
21
+ re-implemented the same command surface on top of the PypilotClient we
22
+ already carry (src/pypilot-client.ts) so that a Signal K server with
23
+ both plugins effectively active only ever opens ONE socket to the
24
+ pypilot_web process on the TinyPilot. This is a functional benefit for
25
+ Pi Zero W hardware; no derivative works of the upstream code itself are
26
+ distributed.
27
+
28
+ For the socket.io protocol layer talking to pypilot_web (watch=, name=value)
29
+ we independently confirmed it against the upstream pypilot sources
30
+ (https://github.com/pypilot/pypilot) which are LGPL-3.0.
package/README.md ADDED
@@ -0,0 +1,171 @@
1
+ # signalk-pypilot-newui
2
+
3
+ **PyPilot New-UI + SK Paths** — a modern, touch-first control panel for the
4
+ open-source [pypilot](https://github.com/pypilot/pypilot) autopilot, plus every
5
+ pypilot value exposed as a first-class Signal K path so tools like
6
+ [KIP](https://kip.signalk.org), WilhelmSK and freeboard can build custom gauges
7
+ and switches out of the box.
8
+
9
+ Runs alongside [`pypilot-autopilot-provider`](https://www.npmjs.com/package/pypilot-autopilot-provider)
10
+ by Panaaj — see [Working alongside pypilot-autopilot-provider](#working-alongside-pypilot-autopilot-provider).
11
+
12
+ ## What it does
13
+
14
+ **Touch webapp** (mobile / tablet / chart-plotter, dark theme):
15
+
16
+ - Compass rose with heading readout, cardinals + degree ticks, hull-fixed
17
+ green/red sailing wedges, AWA + TWA arrows (independent, toggleable), cyan
18
+ target diamond, and 4 configurable corner tiles (long-press to reconfigure).
19
+ - Big-touch AP engage / disengage with optimistic UI + auto-retry over
20
+ flaky links (Tailscale-safe).
21
+ - Tack port / starboard, mode selector (compass / GPS / wind / true wind / nav),
22
+ nudge ±1 / ±10 with configurable step size, current heading snap on engage.
23
+ - **Tune** tab with locked-by-default gain sliders (auto-relock on tab exit),
24
+ ±10 / ±1 buttons flanking each slider, orange "previous value" reference.
25
+ - **Race** timer T1/T2 with beeps, screen flash, spoken seconds countdown
26
+ ("10, 5, 4, 3, 2, 1, Start"). Runs in the background across tabs.
27
+ - **Setup** with LAN scan, manual host+port, KIP absorb-provider toggle,
28
+ SSH-based remote `RESTART pypilot` button (credentials stored in plugin
29
+ config), nudge step size, language, IMU / rudder calibration.
30
+ - **Paths & API** tab with live catalog of published paths, per-path publish
31
+ toggles, KIP copy-to-clipboard helpers.
32
+ - **Info** tab with quick-start, coexistence rules, credits, license, GitHub
33
+ link, config export / import (Web Share API → email / WhatsApp / Signal /
34
+ AirDrop, `.json` download, clipboard).
35
+ - Full i18n: **English / Español / Deutsch / Français** (auto-detected from
36
+ browser, override in Setup → Language).
37
+ - Regatta-safe swipe navigation between tabs with vertical-axis-lock so
38
+ scrolling never triggers a stray tab flip.
39
+ - Signal K polar-performance and any custom SK path bindable to a corner tile.
40
+
41
+ **~60 Signal K paths** under `steering.autopilot.pypilot.*` (opt-in per path):
42
+
43
+ - Gains per pilot (`P`, `I`, `D`, `DD`, `PR`, `FF`).
44
+ - Servo telemetry (voltage, current, controller / motor temperatures, flags,
45
+ amp-hours, clutch engaged, controller error).
46
+ - Rudder calibration (offset, scale, non-linearity, range, calibration state).
47
+ - IMU heading offset, errors, warnings.
48
+ - Active pilot, active profile, available pilots / profiles / modes.
49
+ - Tack state, tack direction, tack timeout.
50
+ - Runtime, version.
51
+
52
+ **KIP-ready Simple Switches** under `electrical.switches.pypilot.*`:
53
+
54
+ - `ap.state` — momentary boolean, engage / disengage.
55
+ - `nudge.{bigPort,smallPort,smallStar,bigStar}` — momentary, auto-reset.
56
+ - `tack.{port,starboard,cancel}` — momentary.
57
+ - `mode.{compass,gps,wind,trueWind,nav}` — radio (mutually exclusive).
58
+ - `profile.<name>` — radio, dynamic per pilot profile.
59
+
60
+ ## Installation
61
+
62
+ 1. Signal K Server → **App Store** → search **PyPilot New-UI**.
63
+ 2. Server → **Plugin Config** → **PyPilot New-UI + SK Paths**.
64
+ 3. Enter the `pypilot_web` host (IP or hostname) and port. On TinyPilot the
65
+ default is port `80`; on a classic pypilot install it is `8000`.
66
+ 4. The **Scan LAN** button in the webapp auto-detects hosts running
67
+ `pypilot_web` on ports 80 / 8000 of your subnet if you don't know the IP.
68
+ 5. Enable the plugin.
69
+
70
+ **Webapp URL**: `http://<sk-host>:3000/signalk-pypilot-newui/`
71
+
72
+ ## Working alongside `pypilot-autopilot-provider`
73
+
74
+ We build on top of the excellent
75
+ [`pypilot-autopilot-provider`](https://www.npmjs.com/package/pypilot-autopilot-provider)
76
+ by Panaaj — the plugin that gives Signal K the standard
77
+ **Autopilot API v2** surface (`/signalk/v2/api/vessels/self/autopilots/*`) so
78
+ WilhelmSK, freeboard and other clients can drive pypilot through the canonical
79
+ interface. Panaaj focuses tightly on that API and opens its own socket.io
80
+ connection to `pypilot_web`.
81
+
82
+ This plugin adds ~60 additional pypilot values on top (gains, servo telemetry,
83
+ calibration, warnings, profiles, tack detail, etc.), ships the touch webapp,
84
+ and can optionally register the Autopilot API v2 itself via the
85
+ **absorbProvider** option in Setup, so a single socket hits `pypilot_web`
86
+ instead of two — useful when pypilot runs on a Pi Zero W.
87
+
88
+ Signal K only allows one provider per pilot ID, so if you turn on
89
+ **absorbProvider** here you should disable Panaaj's plugin (otherwise both
90
+ register the same ID and behaviour is undefined). Valid setups:
91
+
92
+ | Panaaj `pypilot-autopilot-provider` | `signalk-pypilot-newui` absorb | Result |
93
+ |---|---|---|
94
+ | Enabled | Off (default) | Both run happily side by side. Panaaj owns the AP v2 API; this plugin adds the extra paths + UI. **2 sockets** to `pypilot_web`. |
95
+ | Disabled | **On** | Only this plugin. Same AP v2 API + all the extras. **1 socket**. Handy on Pi Zero W. |
96
+ | Enabled | On | Both try to register the same AP ID — leave one off. |
97
+ | Disabled | Off | WilhelmSK / freeboard / KIP won't see the AP. Only useful if you exclusively use this plugin's own webapp. |
98
+
99
+ Absorb mode lives in **Setup → Autopilot Provider (one-socket mode)**.
100
+
101
+ ## Remote `RESTART pypilot`
102
+
103
+ When the autopilot process on the TinyPilot gets wedged you don't need to open
104
+ an SSH session by hand. In **Setup → Emergency**, save the TinyPilot's SSH user
105
+ + password once; the **RESTART pypilot** button then runs
106
+ `sudo systemctl restart pypilot pypilot_web` over SSH and reconnects the local
107
+ socket. Signal K stores plugin configs in plain text under
108
+ `~/.signalk/plugin-config-data/` so use credentials that are only valid for
109
+ that isolated TinyPilot.
110
+
111
+ ## HTTP endpoints (plugin router)
112
+
113
+ | Method | Path | Purpose |
114
+ |---|---|---|
115
+ | `GET` | `/plugins/signalk-pypilot-newui/status` | Connection + last-seen catalog summary + Rev + version |
116
+ | `GET` | `/plugins/signalk-pypilot-newui/scan` | Scan LAN for `pypilot_web` hosts |
117
+ | `GET` | `/plugins/signalk-pypilot-newui/paths` | Live list of published SK paths with GET / PUT URLs and units |
118
+ | `GET` | `/plugins/signalk-pypilot-newui/catalog` | Raw pypilot catalog (all values + metadata) |
119
+ | `PUT` | `/plugins/signalk-pypilot-newui/raw` | Send raw `name=value` to pypilot (protected by `allowWrites`) |
120
+ | `POST` | `/plugins/signalk-pypilot-newui/restart-pypilot` | SSH remote restart, uses `sshUser` + `sshPassword` from plugin config |
121
+ | `POST` | `/plugins/signalk-pypilot-newui/pause` | Disconnect local socket (for scripts) |
122
+ | `POST` | `/plugins/signalk-pypilot-newui/resume` | Reconnect local socket (for scripts) |
123
+
124
+ ## Safety
125
+
126
+ - `allowDirectServo` defaults to **off** — the direct `servo.command` back-door
127
+ used by pypilot's own UI for manual steering is not exposed until you enable
128
+ it explicitly in the plugin config.
129
+ - All PUT handlers validate ranges from the pypilot catalog before sending.
130
+ - On plugin stop the socket closes cleanly; the visor auto-reconnects on
131
+ Signal K restart.
132
+ - This plugin is an aid to navigation and steering. It does not replace the
133
+ vigilance of the skipper, correct manoeuvring, direct observation of the
134
+ surroundings, or official charts and notices. Verify in safe waters before
135
+ relying on the autopilot for demanding manoeuvres.
136
+
137
+ ## Acknowledgements
138
+
139
+ - **Sean D'Epagnier** — creator of [pypilot](https://github.com/pypilot/pypilot),
140
+ the open-source autopilot that makes this plugin possible.
141
+ - **Jean-Marc** at [Navitop](https://www.navitop.fr/) — for the TinyPilot
142
+ hardware and marine integrations, and for making pypilot accessible to
143
+ European sailors.
144
+ - The [Signal K](https://signalk.org/) community and OpenPlotter for the open
145
+ ecosystem this plugin runs on.
146
+ - Panaaj — for
147
+ [`pypilot-autopilot-provider`](https://www.npmjs.com/package/pypilot-autopilot-provider),
148
+ the reference AP v2 provider this plugin builds on top of.
149
+
150
+ ## Reporting issues / feature requests
151
+
152
+ Open an issue on GitHub —
153
+ [github.com/Aitonos/signalk-pypilot-newui/issues](https://github.com/Aitonos/signalk-pypilot-newui/issues).
154
+ Include the Rev (visible in the app under **Info** → top header, or via
155
+ `GET /plugins/signalk-pypilot-newui/status`), what you expected, what happened,
156
+ and a screenshot if possible.
157
+
158
+ ## Development
159
+
160
+ ```
161
+ git clone https://github.com/Aitonos/signalk-pypilot-newui
162
+ cd signalk-pypilot-newui
163
+ npm install
164
+ npm run build
165
+ ```
166
+
167
+ Deploy from a Windows laptop to a Raspberry Pi: `.\deploy.ps1 -Restart`.
168
+
169
+ ## License
170
+
171
+ Apache-2.0, Aitonos. See `NOTICE` for third-party attributions.
@@ -0,0 +1,220 @@
1
+ "use strict";
2
+ // Autopilot Provider adapter. When props.absorbProvider is on, this plugin
3
+ // registers itself with the SK server as an autopilot provider, in addition
4
+ // to its own steering.autopilot.pypilot.* paths. Purpose: eliminate the
5
+ // duplicate socket to pypilot_web that occurs when both this plugin and the
6
+ // official pypilot-autopilot-provider are enabled.
7
+ //
8
+ // Translation semantics of engage / setMode / setTarget / tack / dodge come
9
+ // from the official pypilot-autopilot-provider (Apache-2.0, Panaaj). We
10
+ // re-implemented them here on top of our own PypilotClient so we keep a
11
+ // single socket to pypilot_web. See NOTICE + CHANGELOG for attribution.
12
+ Object.defineProperty(exports, "__esModule", { value: true });
13
+ exports.AutopilotProvider = void 0;
14
+ const DEG_TO_RAD = Math.PI / 180;
15
+ const RAD_TO_DEG = 180 / Math.PI;
16
+ class AutopilotProvider {
17
+ constructor(client,
18
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
19
+ app, opts) {
20
+ this.client = client;
21
+ this.app = app;
22
+ this.deviceId = "pypilot-newui";
23
+ this.pilotIds = ["pypilot-newui"];
24
+ this.data = {
25
+ state: "off-line",
26
+ mode: null,
27
+ target: null,
28
+ engaged: false,
29
+ options: {
30
+ states: [
31
+ { name: "enabled", engaged: true },
32
+ { name: "disabled", engaged: false },
33
+ ],
34
+ modes: [],
35
+ actions: [
36
+ { id: "tack", name: "Tack", available: false },
37
+ { id: "courseCurrentPoint", name: "To Destination", available: false },
38
+ ],
39
+ },
40
+ };
41
+ this.pypilotModes = [];
42
+ this.allowDodge = false;
43
+ if (opts?.allowDodge)
44
+ this.allowDodge = true;
45
+ }
46
+ // Called from PypilotClient 'value' event. Returns true if apData changed
47
+ // and an autopilotUpdate should be pushed.
48
+ receiveValue(name, value) {
49
+ let changed = false;
50
+ switch (name) {
51
+ case "ap.heading_command":
52
+ if (typeof value === "number") {
53
+ const rad = value * DEG_TO_RAD;
54
+ if (rad !== this.data.target) {
55
+ this.data.target = rad;
56
+ changed = true;
57
+ }
58
+ }
59
+ else if (value === false && this.data.target !== null) {
60
+ this.data.target = null;
61
+ changed = true;
62
+ }
63
+ break;
64
+ case "ap.mode":
65
+ if (typeof value === "string" && value !== this.data.mode) {
66
+ this.data.mode = value;
67
+ if (this.data.options.modes.length === 0) {
68
+ this.data.options.modes.push(value);
69
+ }
70
+ changed = true;
71
+ }
72
+ break;
73
+ case "ap.modes":
74
+ if (Array.isArray(value)) {
75
+ this.pypilotModes = value.map(String);
76
+ this.data.options.modes = [...this.pypilotModes];
77
+ }
78
+ break;
79
+ case "ap.enabled": {
80
+ const eng = !!value;
81
+ const st = eng ? "enabled" : "disabled";
82
+ if (this.data.state !== st || this.data.engaged !== eng) {
83
+ this.data.state = st;
84
+ this.data.engaged = eng;
85
+ changed = true;
86
+ }
87
+ break;
88
+ }
89
+ }
90
+ if (changed)
91
+ this.recomputeActions();
92
+ return changed;
93
+ }
94
+ markOffline() {
95
+ if (this.data.state !== "off-line" || this.data.engaged) {
96
+ this.data.state = "off-line";
97
+ this.data.engaged = false;
98
+ this.recomputeActions();
99
+ }
100
+ }
101
+ // ---- write path ----
102
+ async setState(state) {
103
+ const st = this.data.options.states.find((s) => s.name === state);
104
+ if (!st)
105
+ throw new Error(`Invalid state: ${state}`);
106
+ this.client.set("ap.enabled", st.engaged);
107
+ return st.engaged;
108
+ }
109
+ async setMode(mode) {
110
+ if (this.data.options.modes.length > 0 &&
111
+ !this.data.options.modes.includes(mode)) {
112
+ throw new Error(`Invalid mode: ${mode}`);
113
+ }
114
+ this.client.set("ap.mode", mode);
115
+ }
116
+ async setTarget(rad) {
117
+ const deg = rad * RAD_TO_DEG;
118
+ this.client.set("ap.heading_command", deg);
119
+ }
120
+ async adjustTarget(rad) {
121
+ if (this.data.engaged) {
122
+ const base = typeof this.data.target === "number" ? this.data.target : 0;
123
+ const newRad = base + rad;
124
+ this.client.set("ap.heading_command", newRad * RAD_TO_DEG);
125
+ }
126
+ else if (this.allowDodge) {
127
+ await this.dodge(rad);
128
+ }
129
+ else {
130
+ throw new Error("Adjust while disengaged requires allowDirectServo");
131
+ }
132
+ }
133
+ async tack(direction) {
134
+ // The upstream provider only writes ap.tack.direction and lets pypilot
135
+ // auto-begin. We explicitly send begin too so the tack starts even on
136
+ // pypilot versions that require it.
137
+ this.client.set("ap.tack.direction", direction);
138
+ this.client.set("ap.tack.state", "begin");
139
+ }
140
+ async engage() {
141
+ try {
142
+ await this.setNavMode();
143
+ }
144
+ catch {
145
+ await this.setState("enabled");
146
+ }
147
+ }
148
+ async disengage() {
149
+ await this.setState("disabled");
150
+ }
151
+ async setNavMode() {
152
+ const cdata = await this.app.getCourse?.();
153
+ if (cdata?.nextPoint &&
154
+ this.getAvailableActionIds().includes("courseCurrentPoint")) {
155
+ await this.setMode("nav");
156
+ setTimeout(() => this.setState("enabled").catch(() => { }), 500);
157
+ }
158
+ else {
159
+ throw new Error("Nav mode is not available");
160
+ }
161
+ }
162
+ async dodge(rad) {
163
+ if (!this.allowDodge) {
164
+ throw new Error("Dodge requires allowDirectServo=true in plugin config");
165
+ }
166
+ // Simple dodge: emit a servo.command pulse. Upstream watchdogs it every
167
+ // 200 ms for a couple of ticks; we only emit once and let pypilot's own
168
+ // servo watchdog return the rudder to neutral (6 s upstream default).
169
+ const sign = rad > 0 ? 1 : -1;
170
+ this.client.set("servo.command", -sign);
171
+ }
172
+ recomputeActions() {
173
+ for (const a of this.data.options.actions) {
174
+ if (a.id === "tack") {
175
+ a.available = this.data.engaged;
176
+ }
177
+ else if (a.id === "courseCurrentPoint") {
178
+ a.available = this.data.engaged && this.pypilotModes.includes("nav");
179
+ }
180
+ }
181
+ }
182
+ getAvailableActionIds() {
183
+ return this.data.options.actions
184
+ .filter((a) => a.available)
185
+ .map((a) => a.id);
186
+ }
187
+ // Object literal expected by app.registerAutopilotProvider(...).
188
+ toProviderInterface() {
189
+ const self = this;
190
+ return {
191
+ getData: async (_id) => self.data,
192
+ getState: async (_id) => self.data.state,
193
+ setState: async (state, _id) => {
194
+ await self.setState(state);
195
+ },
196
+ getMode: async (_id) => self.data.mode,
197
+ setMode: async (mode, _id) => self.setMode(mode),
198
+ getTarget: async (_id) => self.data.target,
199
+ setTarget: async (value, _id) => self.setTarget(value),
200
+ adjustTarget: async (value, _id) => self.adjustTarget(value),
201
+ engage: async (_id) => self.engage(),
202
+ disengage: async (_id) => self.disengage(),
203
+ courseCurrentPoint: async (_id) => self.setNavMode(),
204
+ courseNextPoint: async (_id) => {
205
+ throw new Error("Not implemented");
206
+ },
207
+ tack: async (direction, _id) => self.tack(direction),
208
+ gybe: async (_direction, _id) => {
209
+ throw new Error("Not implemented");
210
+ },
211
+ dodge: async (value, _id) => {
212
+ if (value)
213
+ await self.dodge(value);
214
+ else
215
+ throw new Error("Not implemented");
216
+ },
217
+ };
218
+ }
219
+ }
220
+ exports.AutopilotProvider = AutopilotProvider;