signalk-navico-autopilot-bridge 0.3.0-alpha

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Johan Sölve
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,379 @@
1
+ # signalk-navico-autopilot-bridge
2
+
3
+ > **Status: 0.3.0-alpha.** Sea-trialled on a real rig (B&G Vulcan 7 → SignalK V2
4
+ > → Raymarine EV-200): engaging and holding Auto, ±course nudges, the abort /
5
+ > failsafe path and following a route leg all worked on the water. **Wind-mode
6
+ > display and Tack do not work yet**, and some MFD display frames are still
7
+ > unverified. One trial only, in light conditions. See
8
+ > [Known limitations](#known-limitations) and the
9
+ > [Disclaimer](#disclaimer--no-warranty) before using it.
10
+
11
+ Emulate a **Simrad AC12/AC42 autopilot computer** so a **Navico MFD** (B&G
12
+ Vulcan/Zeus, Simrad, Lowrance) binds to it and exposes its own **autopilot
13
+ control view**. The button presses that view puts on the NMEA 2000 bus (Simnet
14
+ `130850`) are decoded and translated into the **SignalK Autopilot V2 API**, which
15
+ drives whichever pilot backs it through an Autopilot V2 provider — for example a
16
+ Raymarine EV-200 via
17
+ [`signalk-autopilot`](https://github.com/SignalK/signalk-autopilot).
18
+
19
+ In other words: **press the autopilot buttons on a Navico plotter, steer a
20
+ non-Navico pilot.** The bridge is Navico/Simrad/B&G-specific on the *input* side
21
+ (only Navico MFDs bind to a Simrad AC) and **provider-agnostic on the output side**
22
+ (anything that implements the SignalK V2 Autopilot API).
23
+
24
+ ## Disclaimer / no warranty
25
+
26
+ **An autopilot steers the boat. This software can move the rudder.** Read this
27
+ before you install it.
28
+
29
+ - **Alpha, experimental, unofficial.** This is a reverse-engineered emulation of
30
+ a proprietary Simrad device, built from bus captures — not from any
31
+ manufacturer specification. It is not affiliated with, endorsed by, or
32
+ supported by Navico, B&G, Simrad, Lowrance, Raymarine, or the SignalK project.
33
+ Behaviour may change or break with any firmware, plugin, or canboatjs update.
34
+ - **Not safety-rated.** It is **not** certified for marine navigation and must
35
+ **not** be relied on as a primary or sole means of steering or watchkeeping. A
36
+ competent helmsman must remain at the helm, keep a proper lookout, and be ready
37
+ to take manual control and drop the pilot to standby at all times.
38
+ - **It can fail silently or behave unexpectedly** — wrong mode, wrong course,
39
+ no response, or a course change at the wrong moment. Several features (Wind
40
+ display/adjust, Tack, route/track display) are unverified test candidates. Do
41
+ not trust it where a failure could cause a collision, grounding, injury, or
42
+ loss of life.
43
+ - **You are responsible.** By installing or running this software you accept full
44
+ responsibility for any consequences. Only ever use it with the **boat secured,
45
+ the engine off, and the pilot's own control head to hand**, until you have
46
+ personally sea-trialled every mode you intend to rely on.
47
+ - **No warranty.** Provided "AS IS", without warranty of any kind, express or
48
+ implied, including but not limited to fitness for a particular purpose. To the
49
+ maximum extent permitted by law the authors accept no liability for any damage,
50
+ injury, or loss arising from its use. See [License](#license).
51
+
52
+ ## Why an emulator (the "firehose")
53
+
54
+ A real AC is anything but quiet: it broadcasts a full set of proprietary
55
+ state/telemetry PGNs (`65340` Pilot State, `65305` Device Status, `65341` AP
56
+ Angle, `65302`/`65420`/`130860`/`128275`, …) at 1–2 Hz. A near-silent fake is
57
+ ranked below a live pilot and never selected, and the commissioning "press
58
+ standby" gate is satisfied by the AC's *own* continuous state broadcast. This
59
+ plugin reproduces that broadcast faithfully (byte templates taken verbatim from a
60
+ real AC42 in `canboat/samples/ac42-commissioning.raw`), which is what makes the
61
+ MFD bind to it and unlock the control view.
62
+
63
+ The emulator runs as a **second N2K device** on the bus (its own address claim,
64
+ default address 35), alongside the SignalK server's own canboat connection and any
65
+ real pilot. It opens its own SocketCAN socket — it does **not** route through
66
+ SignalK's N2K output, because the firehose must be sent *as the emulated AC*, not
67
+ as the server.
68
+
69
+ ## How it works
70
+
71
+ 1. **Firehose** — broadcasts the AC's proprietary AP state/telemetry so the MFD
72
+ binds and keeps the control view live. Mode bytes follow the live SignalK
73
+ autopilot state (read back over the loopback API).
74
+ 2. **Commissioning readback** — answers the MFD's `130845` dockside-config reads
75
+ with byte-exact values from the reference AC42, so the wizard fields populate.
76
+ The control view unlocks **without finishing the wizard**: the values you key
77
+ in (rudder calibration, drive voltage, rudder test, …) are not consumed by
78
+ anything that steers — the backing pilot has its own commissioning — so they
79
+ can be left at defaults or skipped. The one setting that matters is **boat
80
+ type**: set it to **Sail** so the MFD exposes wind mode and the tack buttons
81
+ (per the Vulcan manual those functions require a Sail boat type).
82
+ 3. **Input bridge** — reassembles incoming `130850` commands, decodes the Simnet
83
+ key byte, and (in `live` mode) calls the V2 Autopilot API.
84
+
85
+ The byte-level command and mode-display decoding lives in
86
+ [Protocol details](#protocol-details).
87
+
88
+ ## Setup
89
+
90
+ Do the steps below in order:
91
+
92
+ 1. [Install](#1-install) the plugin from source.
93
+ 2. [Configure](#2-configure-the-plugin) it (start in `dry-run`).
94
+ 3. [Enable the autopilot on the MFD](#3-enable-the-autopilot-on-the-mfd).
95
+ 4. [Run first commissioning](#4-first-commissioning) to bind the MFD and unlock
96
+ the control view.
97
+ 5. [Set source priorities](#5-source-priorities-required) so the emulator can't
98
+ shadow the real pilot.
99
+
100
+ ### 1. Install
101
+
102
+ Install it from the **SignalK app store**: in the SignalK admin UI open
103
+ **Appstore → Available**, search for *Navico autopilot bridge*, install it, and
104
+ restart the server. It is published as an **alpha** — read the
105
+ [Disclaimer](#disclaimer--no-warranty) first.
106
+
107
+ `@canboat/canboatjs` is a peerDependency already present in a SignalK server
108
+ install, so there is nothing else to install.
109
+
110
+ To install from source instead (e.g. for development):
111
+
112
+ ```sh
113
+ cd ~/.signalk/node_modules
114
+ git clone https://github.com/johansolve/signalk-navico-autopilot-bridge.git
115
+ ```
116
+
117
+ Restart the SignalK server, then enable and configure the plugin under
118
+ **Server → Plugin Config**.
119
+
120
+ ### 2. Configure the plugin
121
+
122
+ | option | default | notes |
123
+ |---|---|---|
124
+ | CAN interface | `can0` | SocketCAN interface |
125
+ | Emulated AC model | `AC42` | both `AC42` and `AC12` bind a Vulcan 7; the model only sets the reported product info, not whether it binds |
126
+ | Preferred N2K address | `35` | address the emulated AC claims |
127
+ | Broadcast AC autopilot state | `true` | the firehose; required for binding — leave on |
128
+ | Standard nav PGNs | `false` | duplicates other sources; A/B testing only |
129
+ | **Bridge mode** | **`dry-run`** | `off` / `dry-run` (decode+log) / `live` (steer) |
130
+ | Target autopilot id | `_default` | which `autopilots/<id>` the V2 API drives |
131
+ | SignalK host / port | `127.0.0.1` / `3000` | loopback API target |
132
+ | API token | — | leave empty — auto-requested, you approve it once (see [Access & token](#access--token)) |
133
+ | Commissioning mode | `false` | emulate a control head to open the first-commissioning gate (see below) |
134
+ | Commissioning head address | `44` | address the emulated head claims (commissioning only) |
135
+
136
+ #### ⚠️ Safety
137
+
138
+ - **Default is `dry-run`** — it decodes and logs commands but never steers. You
139
+ must set `live` deliberately.
140
+ - Only switch to `live` with a **competent helmsman at the helm and the pilot's
141
+ own control head to hand** to drop to standby. Do **first commissioning and any
142
+ untested mode** at the dock with the boat secured.
143
+ - Do **not** run this on a bus that already has a real Simrad/B&G AC — two devices
144
+ broadcasting AP state will confuse control heads.
145
+
146
+ #### Access & token
147
+
148
+ In `live` mode the bridge has to PUT commands to the Autopilot V2 API, which needs
149
+ a read/write token. You normally **leave the API token field empty** and let the
150
+ plugin obtain one through SignalK's standard device access-request flow:
151
+
152
+ 1. On first start in `live`, the plugin submits an access request (it asks for
153
+ `readwrite`) and prints `requesting device access` in its log.
154
+ 2. In the SignalK admin UI, open **Security → Access Requests**. A pending entry
155
+ appears, described as *"Navico autopilot bridge (needs readwrite to steer)"*.
156
+ **Approve** it with **read/write** permission.
157
+ 3. The granted token is stored in the plugin's data directory (`access.json`) and
158
+ reused across restarts, so you only approve once. The bridge now shows up under
159
+ **Security → Devices** and can be revoked there at any time.
160
+
161
+ If you **deny** the request the bridge stays read-only until you reconfigure and
162
+ restart it. If the request expires, or you later revoke the device, the plugin
163
+ automatically submits a fresh request to approve again.
164
+
165
+ To use a specific token instead, paste it into the **API token** field — it must
166
+ be a valid SignalK JWT; any non-JWT value is ignored and the auto-request is used.
167
+
168
+ ### 3. Enable the autopilot on the MFD
169
+
170
+ The MFD only shows its autopilot control view once the autopilot is enabled on
171
+ the MFD — this is a prerequisite. Per the Navico manual, *"a device connected to
172
+ the NMEA 2000 network should automatically be identified by the system. If not,
173
+ enable the feature from the advanced option in the System settings dialog."*
174
+
175
+ > **Not yet verified with the emulator.** On this boat the Autopilot feature was
176
+ > enabled **manually** from System settings when the project started. Whether a
177
+ > running emulator now triggers fully automatic identification, or the feature
178
+ > still has to be enabled by hand, is untested — if the control view does not
179
+ > appear, enable it manually from System settings (advanced option).
180
+
181
+ ### 4. First commissioning
182
+
183
+ A Navico MFD needs a control head on the bus to **start** its very first
184
+ commissioning of an AC. The plugin can emulate one (a B&G keypad on a second
185
+ address) so you can open the **"press standby"** gate without any physical Simrad
186
+ hardware. (See your Navico MFD's install manual — e.g. the B&G Vulcan's
187
+ *Software Setup → autopilot commissioning*.)
188
+
189
+ 1. Enable **Commissioning mode** in the plugin config and save. SignalK restarts
190
+ the plugin automatically when you save config — no manual restart needed.
191
+ 2. On the MFD, run the autopilot commissioning wizard. When it asks you to press
192
+ standby, the emulated head is already putting the standby command on the bus,
193
+ so the gate opens; the dockside config fields populate from the AC readback.
194
+ 3. Bail out of the wizard before the rudder test / sea-trial — the control view
195
+ unlocks anyway. Only **boat type** matters (set **Sail** for wind/tack); the
196
+ rudder calibration, drive voltage and rudder-test values are ignored.
197
+ 4. **Disable Commissioning mode** and save. It is not needed afterwards — the
198
+ emulated AC plus the MFD's own buttons run normal operation.
199
+
200
+ The emulated head only sends the keypad heartbeat (`65305`) and the standby
201
+ command (`130850` key `0x0006`); it never steers.
202
+
203
+ ### 5. Source priorities (required)
204
+
205
+ The emulator is a second "autopilot" on the same NMEA 2000 bus your SignalK server
206
+ already reads. Its firehose includes Simnet `65305`/`65341`, which canboat maps to
207
+ `steering.autopilot.state` and `steering.autopilot.target.headingMagnetic` — the
208
+ same paths your **real** pilot writes. With two sources on one path, SignalK's
209
+ source arbitration can pick the emulator's value (`65305` decodes to `heading`, not
210
+ `auto`), and the V2 provider's `putAdjustHeading` then rejects a course change with
211
+ **`400 "Autopilot not in auto or wind mode"`** because the path it reads is no
212
+ longer `auto`/`wind`.
213
+
214
+ Symptom: state changes (standby/auto/wind) work, but **a course change is refused
215
+ in auto** (and intermittently in wind), even though the pilot really is in auto.
216
+
217
+ Fix: pin the real pilot as the authoritative source for these paths in
218
+ `~/.signalk/settings.json` (`sourcePriorities`), with an **empty timeout** so the
219
+ emulator can never take over:
220
+
221
+ ```json
222
+ "sourcePriorities": {
223
+ "steering.autopilot.state": [
224
+ { "sourceRef": "<real-pilot-source>", "timeout": "" }
225
+ ],
226
+ "steering.autopilot.target.headingMagnetic": [
227
+ { "sourceRef": "<real-pilot-source>", "timeout": "" }
228
+ ]
229
+ }
230
+ ```
231
+
232
+ Find `<real-pilot-source>` by reading the path and picking the source in `values`
233
+ that reports the correct mode (not the emulator's `heading`):
234
+
235
+ ```sh
236
+ curl -s http://localhost:3000/signalk/v1/api/vessels/self/steering/autopilot/state
237
+ ```
238
+
239
+ A non-empty timeout (e.g. `10000`) is **not** enough: the real pilot may not
240
+ re-publish state every few seconds at rest, so the emulator's ~2 Hz firehose wins
241
+ again between updates. Use `""`. Restart the server after editing.
242
+
243
+ ## Protocol details
244
+
245
+ Reference for the reverse-engineered N2K layer; not needed to set the plugin up.
246
+
247
+ ### Command decoding
248
+
249
+ canboatjs 2.10 has an incomplete `130850` definition for this Simnet layout, so
250
+ the bridge decodes on the raw **key byte**, gated by group `0x0a`, not on
251
+ canboat's mislabeled `Event` field. Keys verified live against a Vulcan 7:
252
+
253
+ | key (byte 6) | command | maps to |
254
+ |---|---|---|
255
+ | `0x06` | Standby | `PUT state {standby}` |
256
+ | `0x09` | Auto | `PUT state {auto}` |
257
+ | `0x0f` | Wind | `PUT state {wind}` |
258
+ | `0x0a` | Nav / Track | `PUT state {route}` |
259
+ | `0x1a` | ChangeCourse / Tack | `PUT target/adjust`, or `POST tack/*` for ~90° |
260
+ | `0x0c` | No Drift | decoded, not fired (no V2 state) |
261
+ | `0x1c` | key-press envelope | ignored (precedes every command) |
262
+
263
+ **ChangeCourse** (`0x1a`): byte 8 = direction (`0x03` starboard/+, `0x02`
264
+ port/−), bytes 9–10 = magnitude LE16 at `0.0001 rad/bit` (10° = 1745, 1° = 174).
265
+ canboat's `Angle` reads bytes 8–9 (off-by-one) and is wrong for this command, so
266
+ the bridge decodes from the reassembled raw frame. The V2 `adjustTarget` floors
267
+ `radiansToDegrees`, and some providers accept only exactly ±10/±1, so the magnitude
268
+ is rounded to a whole degree `N` and sent as `(N+0.5)°` in radians, landing the
269
+ floor exactly on `N`.
270
+
271
+ **Tack** rides the same `0x1a` channel at the MFD's configured tack angle
272
+ (B&G Vulcan UI default 100°; htool saw 90°), not a separate key. A single
273
+ ChangeCourse well above the buttons' ±10 is treated as a tack — but **only in
274
+ wind mode**, where a tack crosses the wind (a gybe crosses dead downwind);
275
+ outside wind mode a big ChangeCourse is ignored. The magnitude is discarded: the
276
+ pilot mirrors the apparent wind angle itself. The tack **direction is derived
277
+ from SK's `environment.wind.angleApparent`** (positive to starboard → tack to
278
+ starboard), not the unverified MFD dir byte, and mapped to
279
+ `POST tack/port|tack/starboard`
280
+ (channel per [htool](https://github.com/htool/RaymarineAPtoFakeNavicoAutoPilot)).
281
+ Test candidate — depends on the backing provider supporting tack, and the
282
+ turn-direction convention still needs on-board verification.
283
+
284
+ ### Mode display (firehose)
285
+
286
+ The MFD's displayed mode is driven by the firehose, not by the button press. Per
287
+ htool, `65305` `00,1d,..` sets the displayed mode and `00,0a,..` the state; the
288
+ plugin sends distinct per-mode `65340`/`65302`/`65305` frames (auto `10,01`, wind
289
+ `10,03`, nav `10,06`) plus a mode-change announce. **Test candidates** — htool had
290
+ not fully verified the wind/route overlay and some frames are his guesses.
291
+
292
+ ### Set heading (127237)
293
+
294
+ When engaged the emulator broadcasts `127237` Heading/Track Control with the
295
+ locked heading in the *Heading-To-Steer* field (Steering Mode = Heading Control,
296
+ Heading Reference = Magnetic), so the MFD shows the set heading. A real AC
297
+ re-broadcasts this; without it the MFD shows "- - -". It is sent at **5 Hz**
298
+ because the boat's other devices broadcast `127237` with an *empty*
299
+ Heading-To-Steer at 10–20 Hz, which otherwise blanks the value and makes the
300
+ display flicker. The value is `steering.autopilot.target.headingMagnetic` (the
301
+ locked heading), falling back to `navigation.headingMagnetic`. It is sent as
302
+ Magnetic; the MFD converts to its configured heading reference (e.g. True) using
303
+ the bus magnetic variation, so set the MFD's heading units to match the rest of
304
+ the boat.
305
+
306
+ ## Verified behaviour (Vulcan 7 → EV-200)
307
+
308
+ - MFD binds to the emulator and raises a *lost-autopilot* alarm the instant the
309
+ firehose stops.
310
+ - "Press standby" commissioning gate opens; dockside config fields populate.
311
+ - Control view reachable and live **without finishing the wizard**.
312
+ - Standby / Auto / Wind / Nav(Track) / ±course button presses decode and, in
313
+ `live`, drive the EV-200 (clutch engages, rudder moves, P70 and the Vulcan
314
+ overlay reflect the mode). The overlay label followed the pilot in every mode
315
+ during the trial.
316
+ - **Set heading** displays on the MFD (both the overlay and the AP view) via the
317
+ populated `127237`; ±course on the Vulcan changes it and is confirmed on the
318
+ MFD without touching the pilot's own head.
319
+
320
+ ### Sea trial (on the water, light wind, calm sea, single trial)
321
+
322
+ - **Auto holds course** with way on; the abort path is sound: **P70 standby frees
323
+ the helm immediately**, and standby from the Vulcan drops the pilot.
324
+ - **±1° / ±10°** nudges alter heading by the right amount and direction; a
325
+ cumulative ~60° alteration came round without an accidental tack.
326
+ - **Nav** steered along a route leg toward the waypoint and corrected cross-track.
327
+ - **Wind** engaged and **held the apparent wind angle** — but see the display /
328
+ Tack limitation below.
329
+
330
+ ## Known limitations
331
+
332
+ This is an alpha; these are open:
333
+
334
+ - **Route/Track display crashes the MFD's AP view.** Opening the dedicated
335
+ autopilot view from scratch while in Nav crashes a Vulcan 7 (it recovers and
336
+ rebinds). The cause is confirmed to be the unverified route display frames
337
+ (`65302`/`65305`, htool guesses): substituting the proven auto frames stops the
338
+ crash, but showing "Auto" while tracking is poor UX so the route frames are kept.
339
+ Steering in Nav works, and *switching* to Nav while the AP view is already open
340
+ works — only opening the view from scratch in Nav crashes it. A correct fix needs
341
+ a capture of a real Simrad AC in route mode. Until then, avoid opening the AP view
342
+ while in Nav.
343
+ - **Wind-mode display and Tack do not work, although the pilot does hold wind.**
344
+ On the sea trial the EV-200 **engaged Wind and held the apparent wind angle**,
345
+ but the MFD shows no commanded wind angle and no true wind angle (TWA),
346
+ ±wind-angle adjust from the Vulcan has no effect, and the **Tack button is
347
+ greyed out** — because the emulator's `65341` always carries heading, not a wind
348
+ reference. Tack is decoded (~90° ChangeCourse → V2 tack endpoint) but cannot be
349
+ triggered from the MFD until the wind angle is reported. Needs a capture of a real
350
+ Simrad AC in wind mode to get the right frame/field.
351
+ - **Some mode-display frames are still guesses.** The per-mode firehose frames
352
+ (from htool) made the Vulcan overlay follow standby/auto/wind/route correctly
353
+ throughout the sea trial, but some are unverified. If one is wrong the pilot is
354
+ still in the correct mode (confirm on its own control head); only the Navico
355
+ MFD's mode label would be off.
356
+ - **No Drift (`0x0c`) has no V2 equivalent** — the V2 states are only
357
+ standby/auto/wind/route. Logged, never fired.
358
+ - **Only one sea trial, in light conditions.** Auto course-hold, ±course, the
359
+ abort path and route-leg tracking are proven on the water — but in light wind
360
+ (~10 kn), calm sea, at ~4 kn with a single crew. Holding quality in stronger
361
+ wind and sea, and waypoint advance along a multi-leg route, are not yet proven.
362
+ (Auto behaviour may also differ on other pilots.)
363
+ - **Output is via loopback HTTP** with a configured token. An in-process V2 call
364
+ would remove the token requirement but there is no clean documented path for a
365
+ non-provider plugin to set V2 state; this is a candidate for a later version.
366
+
367
+ ## Scope
368
+
369
+ This emulates the **AC (the commanded device)** to capture an MFD's autopilot
370
+ control and re-target it. It does **not** implement a real Simrad pilot's steering
371
+ or active-controller takeover logic — it accepts and translates commands. The
372
+ commissioning values an MFD writes are never consumed by anything that steers;
373
+ the value here is the discovery/command protocol, not the commissioning data.
374
+
375
+ ## License
376
+
377
+ MIT. The software is provided "as is", without warranty of any kind and without
378
+ liability on the part of the authors — see the
379
+ [Disclaimer](#disclaimer--no-warranty).
package/index.js ADDED
@@ -0,0 +1,206 @@
1
+ 'use strict'
2
+
3
+ const fs = require('fs')
4
+ const path = require('path')
5
+ const ACEmulator = require('./lib/ac-emulator')
6
+ const ControlHead = require('./lib/control-head')
7
+ const AccessRequest = require('./lib/access-request')
8
+
9
+ module.exports = function (app) {
10
+ let emulator = null
11
+ let head = null
12
+ let accessReq = null
13
+
14
+ // Persist the granted device token (and its clientId) outside the plugin
15
+ // config so an admin approval survives restarts without rewriting config.
16
+ function tokenFile () {
17
+ try { return path.join(app.getDataDirPath(), 'access.json') } catch (e) { return null }
18
+ }
19
+ function readSaved () {
20
+ const f = tokenFile()
21
+ if (!f) { return {} }
22
+ try { return JSON.parse(fs.readFileSync(f, 'utf8')) } catch (e) { return {} }
23
+ }
24
+ function writeSaved (obj) {
25
+ const f = tokenFile()
26
+ if (!f) { return }
27
+ try { fs.writeFileSync(f, JSON.stringify(obj, null, 2)) } catch (e) { app.debug('could not save token: ' + (e && e.message)) }
28
+ }
29
+
30
+ const plugin = {
31
+ id: 'signalk-navico-autopilot-bridge',
32
+ name: 'Navico autopilot bridge (Simrad AC emulator)',
33
+ description:
34
+ 'Emulates a Simrad AC12/AC42 autopilot computer so a Navico MFD (B&G ' +
35
+ 'Vulcan/Zeus, Simrad, Lowrance) binds to it and sends its autopilot ' +
36
+ 'control-view button presses as Simnet 130850 commands. Those are decoded ' +
37
+ 'and translated into the SignalK Autopilot V2 API, driving whichever pilot ' +
38
+ 'backs it (e.g. a Raymarine EV-200 via signalk-autopilot). ' +
39
+ 'ALPHA: see README for verified behaviour and known limitations.'
40
+ }
41
+
42
+ plugin.schema = {
43
+ type: 'object',
44
+ required: ['canInterface', 'acModel', 'preferredAddress', 'bridge'],
45
+ properties: {
46
+ canInterface: {
47
+ type: 'string',
48
+ title: 'CAN interface',
49
+ description: 'SocketCAN interface the NMEA 2000 bus is on.',
50
+ default: 'can0'
51
+ },
52
+ acModel: {
53
+ type: 'string',
54
+ title: 'Emulated AC model',
55
+ description: 'Identity broadcast to the MFD. Both AC42 and AC12 bind a ' +
56
+ 'Vulcan 7; the model only sets the reported product info, not whether ' +
57
+ 'it binds. AC42 matches the reference capture.',
58
+ enum: ['AC42', 'AC12'],
59
+ default: 'AC42'
60
+ },
61
+ preferredAddress: {
62
+ type: 'number',
63
+ title: 'Preferred N2K source address',
64
+ description: 'Address the emulated AC claims on the bus.',
65
+ default: 35
66
+ },
67
+ enableFirehose: {
68
+ type: 'boolean',
69
+ title: 'Broadcast AC autopilot state (required)',
70
+ description: 'Send the full Simrad AP state/telemetry broadcast a real ' +
71
+ 'AC emits. Required for the MFD to bind and for the control view to ' +
72
+ 'unlock. Leave on.',
73
+ default: true
74
+ },
75
+ enableStdPgns: {
76
+ type: 'boolean',
77
+ title: 'Also send standard nav PGNs (advanced, A/B only)',
78
+ description: 'Emit 127245/127237/127250 as the real AC also does. These ' +
79
+ 'DUPLICATE other bus sources (rudder/heading/track) and can cause ' +
80
+ 'conflicting data — only enable for protocol A/B testing.',
81
+ default: false
82
+ },
83
+ bridge: {
84
+ type: 'string',
85
+ title: 'Bridge mode',
86
+ description: 'off = ignore incoming commands; dry-run = decode and log ' +
87
+ 'only (no steering); live = translate commands to the autopilot. ' +
88
+ 'Default dry-run for safety — set live deliberately.',
89
+ enum: ['off', 'dry-run', 'live'],
90
+ default: 'dry-run'
91
+ },
92
+ autopilotId: {
93
+ type: 'string',
94
+ title: 'Target autopilot id (V2 API)',
95
+ description: 'Which autopilots/<id> the V2 API drives. Usually _default.',
96
+ default: '_default'
97
+ },
98
+ skHost: {
99
+ type: 'string',
100
+ title: 'SignalK host',
101
+ description: 'Host for the loopback V2 API calls.',
102
+ default: '127.0.0.1'
103
+ },
104
+ skPort: {
105
+ type: 'number',
106
+ title: 'SignalK port',
107
+ description: 'Port for the loopback V2 API calls.',
108
+ default: 3000
109
+ },
110
+ token: {
111
+ type: 'string',
112
+ title: 'API token (optional manual override)',
113
+ description: 'Normally leave EMPTY. In live mode the plugin requests a ' +
114
+ 'readwrite token automatically via an access request you approve under ' +
115
+ 'Security → Access Requests, and stores it. Set this only to force a ' +
116
+ 'specific token (must be a valid JWT; a non-JWT value is ignored).'
117
+ },
118
+ enableCommissioningHead: {
119
+ type: 'boolean',
120
+ title: 'Commissioning mode (emulate a control head)',
121
+ description: 'Emulate a B&G keypad on a second address to open the MFD ' +
122
+ '"press standby" gate for FIRST commissioning. Enable only while ' +
123
+ 'commissioning, then turn it off — not needed for normal operation.',
124
+ default: false
125
+ },
126
+ headAddress: {
127
+ type: 'number',
128
+ title: 'Commissioning head N2K address',
129
+ description: 'Source address the emulated control head claims (only used ' +
130
+ 'when commissioning mode is on).',
131
+ default: 44
132
+ }
133
+ }
134
+ }
135
+
136
+ // A SignalK token is a JWT (three dot-separated parts). Ignore anything else
137
+ // pasted into the config field so a stray value can't shadow a valid token.
138
+ function validJwt (t) { return typeof t === 'string' && t.split('.').length === 3 }
139
+
140
+ plugin.start = function (options) {
141
+ const o = options || {}
142
+ try {
143
+ // Token precedence: a VALID config token > previously granted token.
144
+ const saved = readSaved()
145
+ let configToken = null
146
+ if (o.token) {
147
+ if (validJwt(o.token)) { configToken = o.token } else { app.error('Configured token is not a JWT — ignoring it (using the granted device token if present)') }
148
+ }
149
+ const token = configToken || saved.token || null
150
+ emulator = new ACEmulator(app, Object.assign({}, o, { token }))
151
+ emulator.start()
152
+
153
+ // No token and we intend to steer -> request device access; an admin
154
+ // approves it under Security -> Access Requests, then we store + use it.
155
+ if (!token && o.bridge === 'live') {
156
+ accessReq = new AccessRequest({
157
+ host: o.skHost || '127.0.0.1',
158
+ port: o.skPort || 3000,
159
+ description: 'Navico autopilot bridge (needs readwrite to steer)',
160
+ clientId: saved.clientId,
161
+ debug: app.debug
162
+ })
163
+ writeSaved({ clientId: accessReq.clientId, token: saved.token || null })
164
+ app.debug('no token configured -- requesting device access (approve under Security -> Access Requests)')
165
+ accessReq.start((newToken) => {
166
+ if (emulator) { emulator.setToken(newToken) }
167
+ writeSaved({ clientId: accessReq.clientId, token: newToken })
168
+ app.setPluginStatus('Access approved -- token stored, bridge can steer')
169
+ })
170
+ }
171
+
172
+ if (o.enableCommissioningHead) {
173
+ head = new ControlHead(app, {
174
+ canInterface: o.canInterface || 'can0',
175
+ headAddress: (typeof o.headAddress === 'number') ? o.headAddress : 44,
176
+ acAddress: (typeof o.preferredAddress === 'number') ? o.preferredAddress : 35
177
+ })
178
+ head.start()
179
+ }
180
+ app.setPluginStatus('Starting Simrad ' + (o.acModel || 'AC42') +
181
+ ' emulator on ' + (o.canInterface || 'can0') +
182
+ (o.enableCommissioningHead ? ' + commissioning head' : '') + '…')
183
+ } catch (e) {
184
+ app.setPluginError('Failed to start: ' + (e && e.message))
185
+ app.error(e)
186
+ }
187
+ }
188
+
189
+ plugin.stop = function () {
190
+ if (accessReq) {
191
+ accessReq.stop()
192
+ accessReq = null
193
+ }
194
+ if (head) {
195
+ head.stop()
196
+ head = null
197
+ }
198
+ if (emulator) {
199
+ emulator.stop()
200
+ emulator = null
201
+ }
202
+ app.setPluginStatus('Stopped')
203
+ }
204
+
205
+ return plugin
206
+ }