signalk-autopilot-furuno 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md ADDED
@@ -0,0 +1,42 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project will be documented in this file.
4
+
5
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
+ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
+
8
+ ## [Unreleased]
9
+
10
+ ### Added
11
+ - PGN 127237 (Heading/Track Control) listener for autopilot feedback and alarms
12
+ - Alarm monitoring for autopilot limit exceeded conditions:
13
+ - Rudder limit exceeded
14
+ - Off heading limit exceeded
15
+ - Off track limit exceeded
16
+ - Autopilot override active
17
+ - Commanded rudder angle logging from autopilot
18
+ - Debug logging for all received PGN 127237 messages to troubleshoot data reception
19
+
20
+ ### Changed
21
+ - Renamed 'route' mode to 'nav' mode for clearer navigation terminology
22
+ - Simplified autopilot modes to focus on core functionality (standby, auto, nav)
23
+ - `tack()` and `gybe()` methods now throw errors indicating they are not supported
24
+ - Added alarms array to autopilot state for Signal K alarm integration
25
+
26
+ ### Removed
27
+ - Removed 'wind' mode (not supported by Furuno NavPilot-711C)
28
+ - Removed 'fishingPattern' mode (not supported by Furuno NavPilot-711C)
29
+ - Removed `setWindAngle()`, `tack()`, and `gybe()` implementation from N2KCommands
30
+
31
+ ### Fixed
32
+ - Fixed Signal K AutopilotProvider API compliance by keeping `tack()` and `gybe()` method stubs
33
+
34
+ ## [0.0.1] - Initial Release
35
+
36
+ ### Added
37
+ - Initial implementation of Furuno NavPilot-711C autopilot provider
38
+ - Support for autopilot modes: standby, auto, nav
39
+ - NMEA2000 command interface via proprietary PGN 130850
40
+ - Signal K path subscriptions for heading, rudder angle, and cross-track error
41
+ - Autopilot detection and status reporting
42
+ - Configuration options for device ID, hull type, and detection timeout
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 SignalK Community
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,31 @@
1
+ # signalk-autopilot-furuno
2
+
3
+ Signal K Autopilot Provider plugin for the Furuno NavPilot (700/711C) via NMEA 2000.
4
+
5
+ Status: **feedback works; remote command is unverified.** This plugin reports the
6
+ NavPilot's live state to Signal K and can act as a feedback provider. Remote command
7
+ is disabled by default — see "Remote command" below.
8
+
9
+ ## What works
10
+
11
+ - **Mode feedback** from PGN 127237 `Steering Mode`:
12
+ `Main Steering` → standby, `Heading Control Standalone` → auto, `Track Control` → nav.
13
+ - **Rudder / heading feedback** from PGN 127245 / 127237.
14
+ - **Route following (nav mode):** the NavPilot follows an active route on its own from the
15
+ standard route PGNs (129285 / 129284 / 129283). To command a GOTO from Signal K, emit those
16
+ as an active route source — use https://github.com/dirkwa/signalk-to-nmea2000.
17
+
18
+ ## Remote command (experimental, off by default)
19
+
20
+ Remote command of this NavPilot over NMEA 2000 is **unverified**. The plugin can emit
21
+ Furuno-proprietary command PGNs (126720 for mode, 130827 for course), but they are unproven
22
+ and may do nothing, so they are gated behind the `experimentalCommands` setting and disabled by
23
+ default. The Simrad PGN 130850 an earlier version used is inert, and the standard PGN 126208 is
24
+ ignored by the pilot. "Adjust ±N°" is sent as an absolute course (there is no relative-course PGN).
25
+
26
+
27
+ # Known Limitations
28
+
29
+ ## WIND-Mode
30
+ - Furuno FAP-7002 does not support wind mode remote
31
+ https://www.furuno.it/docs/OPERATOR_MANUAL/OME45120D_TZT9F_12F_16F_19F.pdf
@@ -0,0 +1,84 @@
1
+
2
+ # TZ Pro 4.x sents in goto:
3
+
4
+ {
5
+ "pgn": 129285,
6
+ "prio": 6,
7
+ "src": 4,
8
+ "dst": 255,
9
+ "timestamp": "2025-11-15T22:32:21.673Z",
10
+ "description": "Navigation - Route/WP Information",
11
+ "fields": {
12
+ "startRps": null,
13
+ "nitems": 2,
14
+ "databaseId": 0,
15
+ "routeId": null,
16
+ "navigationDirectionInRoute": "Forward",
17
+ "supplementaryRouteWpDataAvailable": "Off",
18
+ "reserved": null,
19
+ "routeName": null,
20
+ "reserved9": null,
21
+ "list": [
22
+ {
23
+ "wpId": null,
24
+ "wpName": null,
25
+ "wpLatitude": -17.6814665,
26
+ "wpLongitude": 177.3838499
27
+ },
28
+ {
29
+ "wpId": 1,
30
+ "wpName": "WPT001",
31
+ "wpLatitude": -17.6936884,
32
+ "wpLongitude": 177.3707721
33
+ }
34
+ ]
35
+ },
36
+ "id": "navigationRouteWpInformation"
37
+ }
38
+
39
+ {
40
+ "pgn": 129284,
41
+ "prio": 3,
42
+ "src": 4,
43
+ "dst": 255,
44
+ "timestamp": "2025-11-15T22:32:31.125Z",
45
+ "description": "Navigation Data",
46
+ "fields": {
47
+ "sid": 127,
48
+ "distanceToWaypoint": 1934.78,
49
+ "courseBearingReference": "True",
50
+ "perpendicularCrossed": "No",
51
+ "arrivalCircleEntered": "No",
52
+ "calculationType": "Rhumbline",
53
+ "etaTime": null,
54
+ "etaDate": null,
55
+ "bearingOriginToDestinationWaypoint": 3.9396,
56
+ "bearingPositionToDestinationWaypoint": 3.9409,
57
+ "originWaypointNumber": null,
58
+ "destinationWaypointNumber": 1,
59
+ "destinationLatitude": -17.6936884,
60
+ "destinationLongitude": 177.3707721,
61
+ "waypointClosingVelocity": 0
62
+ },
63
+ "id": "navigationData"
64
+ }
65
+
66
+ {
67
+ "pgn": 129283,
68
+ "prio": 3,
69
+ "src": 4,
70
+ "dst": 255,
71
+ "timestamp": "2025-11-15T22:32:52.161Z",
72
+ "description": "Cross Track Error",
73
+ "fields": {
74
+ "sid": 147,
75
+ "xteMode": "Autonomous",
76
+ "reserved": null,
77
+ "navigationTerminated": "No",
78
+ "xte": -2.54,
79
+ "reserved6": null
80
+ },
81
+ "id": "crossTrackError"
82
+ }
83
+
84
+
@@ -0,0 +1,43 @@
1
+ Install master to RPI5, SK 2.18.0:
2
+ # Install Latest bleading edge
3
+
4
+ ## Prerequirment - patched virtual analyzer to see transmitted PGNs
5
+ ```
6
+ cd ~ && mkdir src && cd src
7
+ git clone https://github.com/dirkwa/visual-analyzer.git
8
+ cd visual-analyzer
9
+ npm pack
10
+
11
+ cd ~/.signalk
12
+ npm install ~/src/visual-analyzer/visual-analyzer-1.19.2.tgz
13
+ ```
14
+
15
+ ## Prerequirement - patched sifnalk-to-nmea2000
16
+ ```
17
+ cd ~/src
18
+ git clone https://github.com/dirkwa/signalk-to-nmea2000.git
19
+ cd signalk-to-nmea2000
20
+ npm pack
21
+
22
+ cd ~/.signalk
23
+ npm install ~/src/signalk-to-nmea2000/signalk-to-nmea2000-2.23.0.tgz
24
+ ```
25
+
26
+ ## signalk-autopilot-furuno
27
+ ```
28
+ cd ~/src
29
+ git clone https://github.com/dirkwa/signalk-autopilot-furuno.git
30
+ cd signalk-autopilot-furuno/
31
+ npm pack
32
+
33
+ cd ~/.signalk
34
+ npm install ~/src/signalk-autopilot-furuno/signalk-autopilot-furuno-0.0.1.tgz
35
+ ```
36
+
37
+ ## Restart Signal K
38
+ ```
39
+ sudo systemctl restart signalk.service && journalctl -u signalk -f | grep -i furuno
40
+ ```
41
+
42
+
43
+
package/index.js ADDED
@@ -0,0 +1,106 @@
1
+ /**
2
+ * Signal K Plugin for Furuno NavPilot-711C Autopilot
3
+ * Main entry point
4
+ */
5
+
6
+ const AutopilotProvider = require('./lib/AutopilotProvider')
7
+
8
+ module.exports = function(app) {
9
+ const plugin = {
10
+ id: 'signalk-autopilot-furuno',
11
+ name: 'Furuno NavPilot-711C Autopilot Provider',
12
+ description: 'Signal K Autopilot Provider for Furuno NavPilot-711C via NMEA2000'
13
+ }
14
+
15
+ let autopilotProvider = null
16
+
17
+ plugin.start = function(settings) {
18
+ try {
19
+ app.debug('Starting Furuno NavPilot-711C plugin')
20
+
21
+ autopilotProvider = new AutopilotProvider(app, settings)
22
+ autopilotProvider.start()
23
+
24
+ app.setPluginStatus('Started')
25
+ } catch (error) {
26
+ const errorMsg = 'Failed to start: ' + error.message
27
+ app.error(errorMsg)
28
+ app.setPluginError(errorMsg)
29
+ throw error
30
+ }
31
+ }
32
+
33
+ plugin.stop = function() {
34
+ try {
35
+ app.debug('Stopping Furuno NavPilot-711C plugin')
36
+
37
+ if (autopilotProvider) {
38
+ autopilotProvider.stop()
39
+ autopilotProvider = null
40
+ }
41
+
42
+ app.setPluginStatus('Stopped')
43
+ } catch (error) {
44
+ app.error('Error stopping: ' + error.message)
45
+ }
46
+ }
47
+
48
+ plugin.schema = {
49
+ type: 'object',
50
+ required: ['deviceId'],
51
+ properties: {
52
+ deviceId: {
53
+ type: 'string',
54
+ title: 'Autopilot Device ID',
55
+ description: 'Unique identifier for this autopilot',
56
+ default: '711c'
57
+ },
58
+ hullType: {
59
+ type: 'string',
60
+ title: 'Hull Type',
61
+ description: 'Type of vessel hull for autopilot tuning',
62
+ enum: [
63
+ 'sail',
64
+ 'sailSlowTurn',
65
+ 'sailCatamaran',
66
+ 'power',
67
+ 'powerSlowTurn',
68
+ 'powerFastTurn'
69
+ ],
70
+ default: 'power'
71
+ },
72
+ detectionTimeout: {
73
+ type: 'number',
74
+ title: 'Detection Timeout (seconds)',
75
+ description: 'How long to wait for autopilot detection before showing warning',
76
+ default: 10
77
+ },
78
+ connectionTimeout: {
79
+ type: 'number',
80
+ title: 'Connection Timeout (seconds)',
81
+ description:
82
+ 'If no autopilot data (PGN 127237) is received for this long, raise a ' +
83
+ '"NavPilot connection lost" notification.',
84
+ default: 5
85
+ },
86
+ experimentalCommands: {
87
+ type: 'boolean',
88
+ title: 'Enable experimental remote commands (UNVERIFIED)',
89
+ description:
90
+ 'Off by default. This NavPilot has no verified NMEA 2000 remote-control path; ' +
91
+ 'commands use Furuno proprietary PGNs (126720 mode / 130827 course) that ' +
92
+ 'are unproven and may do nothing. Leave disabled for a feedback-only provider.',
93
+ default: false
94
+ },
95
+ deviceAddress: {
96
+ type: 'number',
97
+ title: 'Autopilot N2K source address (optional)',
98
+ description:
99
+ 'Source address of the NavPilot on the bus, used as the command destination when ' +
100
+ 'experimental commands are enabled. Leave blank to broadcast.'
101
+ }
102
+ }
103
+ }
104
+
105
+ return plugin
106
+ }
@@ -0,0 +1,521 @@
1
+ /**
2
+ * AutopilotProvider - Implements Signal K Autopilot API
3
+ */
4
+
5
+ const SignalKPaths = require('./SignalKPaths')
6
+ const N2KCommands = require('./N2KCommands')
7
+
8
+ const PLUGIN_ID = 'signalk-autopilot-furuno'
9
+
10
+ // PGN 127237 "Steering Mode" reported by the NavPilot maps to the
11
+ // Signal K autopilot mode/engaged/state:
12
+ // Standby (hand steering) -> "Main Steering"
13
+ // Auto (heading hold) -> "Heading Control Standalone"
14
+ // Nav / GOTO -> "Track Control"
15
+ const FURUNO_STEERING_MODE = {
16
+ 'Main Steering': { mode: 'standby', engaged: false, state: 'disabled' },
17
+ 'Non-Follow-Up Device': { mode: 'standby', engaged: false, state: 'disabled' },
18
+ 'Follow-Up Device': { mode: 'standby', engaged: false, state: 'disabled' },
19
+ 'Heading Control Standalone': { mode: 'auto', engaged: true, state: 'enabled' },
20
+ 'Heading Control': { mode: 'auto', engaged: true, state: 'enabled' },
21
+ 'Track Control': { mode: 'nav', engaged: true, state: 'enabled' }
22
+ }
23
+
24
+ // Signal K server emits canboat Title-Case field names by default
25
+ // (useCamelCompat defaults to false), but a camelCase pipeline is possible.
26
+ // Read a PGN field tolerant of either spelling.
27
+ function field(fields, ...names) {
28
+ for (const n of names) {
29
+ if (fields[n] !== undefined) return fields[n]
30
+ }
31
+ return undefined
32
+ }
33
+
34
+ class AutopilotProvider {
35
+ constructor(app, settings) {
36
+ this.app = app
37
+ this.settings = settings
38
+ this.deviceId = settings.deviceId || '711c'
39
+
40
+ this.state = {
41
+ state: 'disabled',
42
+ mode: 'standby',
43
+ target: 0,
44
+ engaged: false,
45
+ heading: null,
46
+ rudderAngle: null,
47
+ xte: null,
48
+ alarms: []
49
+ }
50
+
51
+ this.signalKPaths = new SignalKPaths(app, this.onPathUpdate.bind(this))
52
+ this.n2kCommands = new N2KCommands(app, {
53
+ experimentalCommands: settings.experimentalCommands === true,
54
+ deviceAddress: settings.deviceAddress
55
+ })
56
+ this.detectionTimeout = null
57
+ this.detected = false
58
+ this.detectedHeading = false
59
+ this.detectedRudder = false
60
+ this.n2kListener = null
61
+
62
+ // Feedback bookkeeping
63
+ this.activeAlarms = {} // alarm/notification path -> boolean, for edge-triggered emits
64
+ this.lastN2K = null // ms timestamp of the last PGN 127237
65
+ this.connectionLost = false
66
+ this.staleTimer = null
67
+ // No 127237 for this long (ms) => NavPilot connection considered lost.
68
+ this.staleTimeout = (settings.connectionTimeout || 5) * 1000
69
+ }
70
+
71
+ start() {
72
+ this.app.debug('Registering autopilot provider: ' + this.deviceId)
73
+
74
+ // Register with Signal K
75
+ this.app.registerAutopilotProvider(this.getProvider(), [this.deviceId])
76
+
77
+ // Subscribe to Signal K paths
78
+ this.signalKPaths.subscribe()
79
+
80
+ // Initialize N2K commands
81
+ this.n2kCommands.initialize()
82
+
83
+ // Listen for PGN 127237 (Heading/Track Control) from autopilot
84
+ this.setupN2KListener()
85
+
86
+ // Send initial state
87
+ this.updateSignalK()
88
+
89
+ // Set detection timeout
90
+ const timeout = (this.settings.detectionTimeout || 10) * 1000
91
+ this.detectionTimeout = setTimeout(() => {
92
+ if (!this.detected) {
93
+ this.app.setPluginStatus('Warning - No autopilot data detected. Check NMEA2000 connection.')
94
+ this.app.debug('No heading or rudder data received after ' + (timeout/1000) + ' seconds')
95
+ }
96
+ }, timeout)
97
+
98
+ // Connection watchdog: NavPilot broadcasts 127237 continuously, so a gap
99
+ // means it is off / disconnected.
100
+ this.staleTimer = setInterval(() => this.checkConnection(), 1000)
101
+
102
+ this.app.setPluginStatus('Started - Waiting for autopilot data...')
103
+ }
104
+
105
+ stop() {
106
+ if (this.detectionTimeout) {
107
+ clearTimeout(this.detectionTimeout)
108
+ }
109
+ if (this.staleTimer) {
110
+ clearInterval(this.staleTimer)
111
+ this.staleTimer = null
112
+ }
113
+
114
+ if (this.n2kListener) {
115
+ this.app.removeListener('N2KAnalyzerOut', this.n2kListener)
116
+ this.n2kListener = null
117
+ }
118
+
119
+ this.signalKPaths.unsubscribe()
120
+ this.app.debug('AutopilotProvider stopped')
121
+ }
122
+
123
+ setupN2KListener() {
124
+ this.n2kListener = (n2k) => {
125
+ // Listen for PGN 127237 (Heading/Track Control) from autopilot
126
+ if (n2k && n2k.pgn === 127237 && n2k.fields) {
127
+ this.handlePGN127237(n2k.fields)
128
+ }
129
+ }
130
+
131
+ this.app.on('N2KAnalyzerOut', this.n2kListener)
132
+ this.app.debug('Listening for PGN 127237 (Heading/Track Control)')
133
+ }
134
+
135
+ handlePGN127237(fields) {
136
+ // Process autopilot feedback from PGN 127237
137
+ this.app.debug('Received PGN 127237: ' + JSON.stringify(fields))
138
+
139
+ this.markConnectionAlive()
140
+
141
+ let changed = false
142
+
143
+ // Mode feedback: reflect the NavPilot's actual Steering Mode into Signal K
144
+ // so the head and Signal K stay in sync (Auto -> Standby at the head, etc.).
145
+ const steeringMode = field(fields, 'Steering Mode', 'steeringMode')
146
+ const mapped = FURUNO_STEERING_MODE[steeringMode]
147
+ if (mapped) {
148
+ if (this.state.mode !== mapped.mode ||
149
+ this.state.engaged !== mapped.engaged ||
150
+ this.state.state !== mapped.state) {
151
+ this.app.debug(
152
+ 'Mode feedback: ' + steeringMode + ' -> mode=' + mapped.mode +
153
+ ' engaged=' + mapped.engaged + ' state=' + mapped.state
154
+ )
155
+ this.state.mode = mapped.mode
156
+ this.state.engaged = mapped.engaged
157
+ this.state.state = mapped.state
158
+ changed = true
159
+ }
160
+ } else if (steeringMode !== undefined) {
161
+ this.app.debug('Unmapped Steering Mode: ' + steeringMode)
162
+ }
163
+
164
+ // Target feedback: the pilot's own commanded course (Heading-To-Steer),
165
+ // so Signal K reflects what the NavPilot is actually steering to.
166
+ const hts = field(fields, 'Heading-To-Steer (Course)', 'headingToSteer')
167
+ if (hts !== null && hts !== undefined && hts !== this.state.target) {
168
+ this.state.target = hts
169
+ changed = true
170
+ }
171
+
172
+ if (changed) {
173
+ this.updateSignalK()
174
+ }
175
+
176
+ // Limit / override flags -> Signal K notifications (edge-triggered).
177
+ // Off-heading and off-track map to the standard autopilot alarm paths;
178
+ // rudder-limit and override have no standard path, so use a custom one.
179
+ this.setAlarm('heading',
180
+ field(fields, 'Off-Heading Limit Exceeded', 'offHeadingLimitExceeded') === 'Yes',
181
+ 'Off heading limit exceeded', 'alarm')
182
+ this.setAlarm('xte',
183
+ field(fields, 'Off-Track Limit Exceeded', 'offTrackLimitExceeded') === 'Yes',
184
+ 'Off track limit exceeded', 'alarm')
185
+ this.setNotification('rudderLimit',
186
+ field(fields, 'Rudder Limit Exceeded', 'rudderLimitExceeded') === 'Yes',
187
+ 'Rudder limit exceeded', 'alarm')
188
+ this.setNotification('override',
189
+ field(fields, 'Override', 'override') === 'Yes',
190
+ 'Autopilot override active', 'warn')
191
+
192
+ // Log commanded rudder angle if available
193
+ const commandedRudderAngle = field(fields, 'Commanded Rudder Angle', 'commandedRudderAngle')
194
+ if (commandedRudderAngle !== null && commandedRudderAngle !== undefined) {
195
+ const cmdRudderDeg = (commandedRudderAngle * 180 / Math.PI).toFixed(1)
196
+ this.app.debug('Autopilot commanding rudder: ' + cmdRudderDeg + '°')
197
+ }
198
+ }
199
+
200
+ // Emit a standard autopilot alarm (path is one of the SK autopilot alarm names:
201
+ // heading, xte, wind, waypointAdvance, waypointArrival, routeComplete).
202
+ setAlarm(apPath, active, message, level) {
203
+ if (this.activeAlarms[apPath] === active) return
204
+ this.activeAlarms[apPath] = active
205
+ this.app.autopilotUpdate(this.deviceId, {
206
+ alarm: {
207
+ path: apPath,
208
+ value: active
209
+ ? { state: level || 'alarm', message, method: ['visual', 'sound'] }
210
+ : { state: 'normal', message, method: [] }
211
+ }
212
+ })
213
+ this.app.debug((active ? 'ALARM ' : 'clear ') + apPath + ': ' + message)
214
+ }
215
+
216
+ // Emit a notification on a non-standard autopilot path (rudderLimit, override,
217
+ // connectionLost) that has no dedicated autopilot-API alarm name.
218
+ setNotification(subPath, active, message, level) {
219
+ if (this.activeAlarms[subPath] === active) return
220
+ this.activeAlarms[subPath] = active
221
+ this.app.handleMessage(PLUGIN_ID, {
222
+ updates: [
223
+ {
224
+ values: [
225
+ {
226
+ path: 'notifications.steering.autopilot.' + subPath,
227
+ value: active
228
+ ? { state: level || 'alarm', message, method: ['visual', 'sound'] }
229
+ : { state: 'normal', message, method: [] }
230
+ }
231
+ ]
232
+ }
233
+ ]
234
+ })
235
+ this.app.debug((active ? 'NOTIFY ' : 'clear ') + subPath + ': ' + message)
236
+ }
237
+
238
+ onPathUpdate(path, value) {
239
+ let wasDetected = this.detected
240
+
241
+ if (path === 'heading') {
242
+ this.state.heading = value
243
+ if (!this.detectedHeading) {
244
+ this.detectedHeading = true
245
+ this.app.debug('Heading data detected')
246
+ this.checkDetection()
247
+ }
248
+ } else if (path === 'rudderAngle') {
249
+ this.state.rudderAngle = value
250
+ if (!this.detectedRudder) {
251
+ this.detectedRudder = true
252
+ this.app.debug('Rudder angle data detected')
253
+ this.checkDetection()
254
+ }
255
+ } else if (path === 'xte') {
256
+ this.state.xte = value
257
+ }
258
+ // Heading/rudder/xte are not published as autopilot attributes (they are
259
+ // already on standard paths), so no autopilotUpdate() is needed here.
260
+ }
261
+
262
+ checkDetection() {
263
+ // Consider autopilot detected if we have heading data
264
+ // Rudder is nice to have but not required (some autopilots don't report it)
265
+ if (this.detectedHeading && !this.detected) {
266
+ this.detected = true
267
+
268
+ let status = 'Connected - Autopilot detected ('
269
+ if (this.detectedHeading) status += 'heading'
270
+ if (this.detectedRudder) status += ', rudder'
271
+ status += ')'
272
+
273
+ this.app.setPluginStatus(status)
274
+ this.app.debug('Autopilot fully detected')
275
+
276
+ if (this.detectionTimeout) {
277
+ clearTimeout(this.detectionTimeout)
278
+ this.detectionTimeout = null
279
+ }
280
+ }
281
+ }
282
+
283
+ updateSignalK() {
284
+ // Only the valid autopilot update attributes are published here. Heading and
285
+ // rudder angle are already on standard paths (navigation.*, steering.rudderAngle);
286
+ // alarms are emitted separately via setAlarm()/setNotification().
287
+ this.app.autopilotUpdate(this.deviceId, {
288
+ state: this.state.state,
289
+ mode: this.state.mode,
290
+ target: this.state.target,
291
+ engaged: this.state.engaged
292
+ })
293
+ }
294
+
295
+ // Record that the NavPilot is transmitting, and clear a connection-lost
296
+ // notification if one was raised.
297
+ markConnectionAlive() {
298
+ this.lastN2K = Date.now()
299
+ if (this.connectionLost) {
300
+ this.connectionLost = false
301
+ this.setNotification('connectionLost', false, 'NavPilot connection restored', 'alarm')
302
+ this.app.setPluginStatus('Connected - NavPilot data OK')
303
+ }
304
+ }
305
+
306
+ // Watchdog: if no PGN 127237 arrives within staleTimeout, flag the NavPilot as
307
+ // disconnected (it stops broadcasting when powered off / bus fault).
308
+ checkConnection() {
309
+ if (this.lastN2K === null || this.connectionLost) return
310
+ if (Date.now() - this.lastN2K > this.staleTimeout) {
311
+ this.connectionLost = true
312
+ this.setNotification('connectionLost', true, 'NavPilot connection lost', 'alarm')
313
+ this.app.setPluginStatus('Warning - NavPilot connection lost (no data)')
314
+ this.app.debug('No PGN 127237 for ' + (this.staleTimeout / 1000) + 's - connection lost')
315
+ }
316
+ }
317
+
318
+ // Signal K Autopilot Provider Interface
319
+ getProvider() {
320
+ return {
321
+ getData: async (apDeviceId) => {
322
+ this.validateDeviceId(apDeviceId)
323
+
324
+ return {
325
+ options: {
326
+ states: [
327
+ { name: 'enabled', engaged: true },
328
+ { name: 'disabled', engaged: false }
329
+ ],
330
+ modes: ['standby', 'auto', 'nav'],
331
+ // 'dodge' is the only supported action, and only when experimental
332
+ // commands are enabled (no verified command path otherwise).
333
+ actions: [
334
+ {
335
+ id: 'dodge',
336
+ name: 'Dodge',
337
+ available: this.n2kCommands.commandsEnabled && this.state.mode !== 'standby'
338
+ }
339
+ ]
340
+ },
341
+ state: this.state.state,
342
+ mode: this.state.mode,
343
+ target: this.state.target,
344
+ engaged: this.state.engaged
345
+ }
346
+ },
347
+
348
+ getState: async (apDeviceId) => {
349
+ this.validateDeviceId(apDeviceId)
350
+ return this.state.state
351
+ },
352
+
353
+ setState: async (newState, apDeviceId) => {
354
+ this.validateDeviceId(apDeviceId)
355
+
356
+ if (newState !== 'enabled' && newState !== 'disabled') {
357
+ throw new Error('Invalid state: ' + newState)
358
+ }
359
+
360
+ if (newState === 'disabled') {
361
+ this.requireCommands('Disable')
362
+ }
363
+
364
+ this.app.debug('setState: ' + this.state.state + ' -> ' + newState)
365
+ this.state.state = newState
366
+
367
+ if (newState === 'disabled') {
368
+ this.state.mode = 'standby'
369
+ this.state.engaged = false
370
+ this.n2kCommands.setMode('standby')
371
+ }
372
+
373
+ this.updateSignalK()
374
+ },
375
+
376
+ getMode: async (apDeviceId) => {
377
+ this.validateDeviceId(apDeviceId)
378
+ return this.state.mode
379
+ },
380
+
381
+ setMode: async (mode, apDeviceId) => {
382
+ this.validateDeviceId(apDeviceId)
383
+
384
+ const validModes = ['standby', 'auto', 'nav']
385
+ if (!validModes.includes(mode)) {
386
+ throw new Error('Invalid mode: ' + mode)
387
+ }
388
+ this.requireCommands('setMode')
389
+
390
+ this.app.debug('setMode: ' + this.state.mode + ' -> ' + mode)
391
+ this.state.mode = mode
392
+ this.state.engaged = mode !== 'standby'
393
+
394
+ if (mode !== 'standby') {
395
+ this.state.state = 'enabled'
396
+ }
397
+
398
+ this.n2kCommands.setMode(mode)
399
+ this.updateSignalK()
400
+ },
401
+
402
+ getTarget: async (apDeviceId) => {
403
+ this.validateDeviceId(apDeviceId)
404
+ return this.state.target
405
+ },
406
+
407
+ setTarget: async (value, apDeviceId) => {
408
+ this.validateDeviceId(apDeviceId)
409
+ this.requireCommands('setTarget')
410
+
411
+ const degrees = (value * 180 / Math.PI).toFixed(1)
412
+ this.app.debug('setTarget: ' + degrees + '°')
413
+
414
+ this.state.target = value
415
+
416
+ if (this.state.mode === 'auto') {
417
+ this.n2kCommands.setCourse(value)
418
+ }
419
+
420
+ this.updateSignalK()
421
+ },
422
+
423
+ adjustTarget: async (adjustment, apDeviceId) => {
424
+ this.validateDeviceId(apDeviceId)
425
+ this.requireCommands('adjustTarget')
426
+
427
+ const oldTarget = this.state.target
428
+ this.state.target += adjustment
429
+
430
+ // Normalize to 0-2π
431
+ while (this.state.target < 0) this.state.target += 2 * Math.PI
432
+ while (this.state.target >= 2 * Math.PI) this.state.target -= 2 * Math.PI
433
+
434
+ const oldDeg = (oldTarget * 180 / Math.PI).toFixed(1)
435
+ const newDeg = (this.state.target * 180 / Math.PI).toFixed(1)
436
+ const adjDeg = (adjustment * 180 / Math.PI).toFixed(1)
437
+ this.app.debug('adjustTarget: ' + oldDeg + '° -> ' + newDeg + '° (' + adjDeg + '°)')
438
+
439
+ // The NavPilot has no relative command: send the new absolute course.
440
+ if (this.state.mode === 'auto') {
441
+ this.n2kCommands.setCourse(this.state.target)
442
+ }
443
+
444
+ this.updateSignalK()
445
+ },
446
+
447
+ engage: async (apDeviceId) => {
448
+ this.validateDeviceId(apDeviceId)
449
+ this.requireCommands('engage')
450
+
451
+ this.app.debug('Engaging autopilot')
452
+ this.state.engaged = true
453
+ this.state.state = 'enabled'
454
+
455
+ if (this.state.mode === 'standby') {
456
+ this.state.mode = 'auto'
457
+ this.n2kCommands.setMode('auto')
458
+ }
459
+
460
+ this.updateSignalK()
461
+ },
462
+
463
+ disengage: async (apDeviceId) => {
464
+ this.validateDeviceId(apDeviceId)
465
+ this.requireCommands('disengage')
466
+
467
+ this.app.debug('Disengaging autopilot')
468
+ this.state.engaged = false
469
+ this.state.mode = 'standby'
470
+ this.n2kCommands.setMode('standby')
471
+
472
+ this.updateSignalK()
473
+ },
474
+
475
+ tack: async (direction, apDeviceId) => {
476
+ this.validateDeviceId(apDeviceId)
477
+ throw new Error('Tack not supported by Furuno NavPilot-711C')
478
+ },
479
+
480
+ gybe: async (direction, apDeviceId) => {
481
+ this.validateDeviceId(apDeviceId)
482
+ throw new Error('Gybe not supported by Furuno NavPilot-711C')
483
+ },
484
+
485
+ dodge: async (value, apDeviceId) => {
486
+ this.validateDeviceId(apDeviceId)
487
+ this.requireCommands('dodge')
488
+
489
+ const degrees = (value * 180 / Math.PI).toFixed(1)
490
+ this.app.debug('Dodge: ' + degrees + '°')
491
+
492
+ // No dodge PGN exists; send an absolute course offset from the current target.
493
+ const base = this.state.target != null ? this.state.target : (this.state.heading || 0)
494
+ let course = base + value
495
+ while (course < 0) course += 2 * Math.PI
496
+ while (course >= 2 * Math.PI) course -= 2 * Math.PI
497
+ this.n2kCommands.setCourse(course)
498
+ }
499
+ }
500
+ }
501
+
502
+ validateDeviceId(apDeviceId) {
503
+ if (apDeviceId !== this.deviceId && apDeviceId !== '_default') {
504
+ throw new Error('Unknown autopilot device: ' + apDeviceId)
505
+ }
506
+ }
507
+
508
+ // Remote command has no verified path on this hardware; refuse unless the
509
+ // user opted into the experimental (unverified) command support.
510
+ requireCommands(action) {
511
+ if (!this.n2kCommands.commandsEnabled) {
512
+ throw new Error(
513
+ action + ' is not available: this NavPilot has no verified NMEA 2000 ' +
514
+ 'remote-control path (feedback only). Enable "experimentalCommands" in the ' +
515
+ 'plugin settings to attempt it (unverified, may do nothing).'
516
+ )
517
+ }
518
+ }
519
+ }
520
+
521
+ module.exports = AutopilotProvider
@@ -0,0 +1,96 @@
1
+ /**
2
+ * N2KCommands - EXPERIMENTAL remote command support for the Furuno NavPilot.
3
+ *
4
+ * ⚠️ Remote command is UNVERIFIED on real hardware and disabled by default.
5
+ *
6
+ * The NavPilot uses Furuno-proprietary PGNs for control, NOT the Simrad PGN
7
+ * 130850 an earlier version of this plugin used (which encodes empty and is
8
+ * ignored), nor the standard PGN 126208 (which the pilot drops):
9
+ *
10
+ * - Mode: PGN 126720 (Furuno, mfr 1855) - proprietary selectors + mode value
11
+ * - Course: PGN 130827 (Furuno, mfr 1855) - absolute course to steer (0.0001 rad)
12
+ *
13
+ * "Adjust +N°" / dodge are not separate messages: send the new absolute course
14
+ * (currentCourse ± N).
15
+ *
16
+ * The mode value and 126720 field packing are not fully confirmed, canboatjs has
17
+ * no matching PGN definition yet (so these may encode empty), and the tested
18
+ * NavPilot exposes no remote-control enable. These commands are best-effort and
19
+ * may do nothing. Enable only for experimentation via `experimentalCommands`.
20
+ */
21
+
22
+ // Candidate NavPilot mode values. UNVERIFIED - the wire encoding is not confirmed.
23
+ const NAVPILOT_MODE = { standby: 0, auto: 1, nav: 2 }
24
+
25
+ class N2KCommands {
26
+ constructor(app, options = {}) {
27
+ this.app = app
28
+ this.enabled = options.experimentalCommands === true
29
+ // Destination = the pilot's N2K source address if known, else broadcast.
30
+ this.dst = options.deviceAddress != null ? options.deviceAddress : 255
31
+ }
32
+
33
+ initialize() {
34
+ this.app.debug('N2K command interface initialized (experimental=' + this.enabled + ')')
35
+ }
36
+
37
+ get commandsEnabled() {
38
+ return this.enabled
39
+ }
40
+
41
+ send(pgn, label) {
42
+ if (!this.enabled) {
43
+ this.app.debug('Command suppressed (experimentalCommands disabled): ' + label)
44
+ return false
45
+ }
46
+ try {
47
+ // Emit as a JSON PGN object (nmea2000JsonOut), not the string channel.
48
+ this.app.emit('nmea2000JsonOut', pgn)
49
+ this.app.debug('Sent experimental command [' + label + ']: ' + JSON.stringify(pgn))
50
+ return true
51
+ } catch (err) {
52
+ this.app.error('Failed to send ' + label + ': ' + err.message)
53
+ return false
54
+ }
55
+ }
56
+
57
+ // Mode command - PGN 126720 (Furuno proprietary). UNVERIFIED.
58
+ setMode(mode) {
59
+ const modeValue = NAVPILOT_MODE[mode]
60
+ if (modeValue === undefined) {
61
+ this.app.debug('Unknown mode: ' + mode)
62
+ return false
63
+ }
64
+ const pgn = {
65
+ pgn: 126720,
66
+ dst: this.dst,
67
+ fields: {
68
+ 'Manufacturer Code': 'Furuno',
69
+ 'Industry Code': 'Marine Industry',
70
+ // Furuno proprietary selectors (field 4 = 8, field 5 = 1) + mode (field 6).
71
+ 'Message ID': 8,
72
+ 'Command': 1,
73
+ 'Mode': modeValue
74
+ }
75
+ }
76
+ return this.send(pgn, 'mode=' + mode)
77
+ }
78
+
79
+ // Absolute course-to-steer command - PGN 130827 (Furuno proprietary). UNVERIFIED.
80
+ // courseRad is an absolute heading in radians.
81
+ setCourse(courseRad) {
82
+ const pgn = {
83
+ pgn: 130827,
84
+ dst: this.dst,
85
+ fields: {
86
+ 'Manufacturer Code': 'Furuno',
87
+ 'Industry Code': 'Marine Industry',
88
+ 'Message ID': 8,
89
+ 'Commanded Course': courseRad
90
+ }
91
+ }
92
+ return this.send(pgn, 'course=' + (courseRad * 180 / Math.PI).toFixed(1) + '°')
93
+ }
94
+ }
95
+
96
+ module.exports = N2KCommands
@@ -0,0 +1,98 @@
1
+ /**
2
+ * SignalKPaths - Subscribes to Signal K paths and monitors autopilot data
3
+ */
4
+
5
+ class SignalKPaths {
6
+ constructor(app, onUpdate) {
7
+ this.app = app
8
+ this.onUpdate = onUpdate
9
+ this.subscriptions = []
10
+ this.lastHeading = null
11
+ this.lastRudder = null
12
+ this.lastXTE = null
13
+ }
14
+
15
+ subscribe() {
16
+ this.app.debug('Subscribing to Signal K paths...')
17
+
18
+ // Subscribe to heading (try both magnetic and true)
19
+ this.subscribePath('navigation.headingMagnetic', 'heading')
20
+ this.subscribePath('navigation.headingTrue', 'heading')
21
+
22
+ // Subscribe to rudder angle
23
+ this.subscribePath('steering.rudderAngle', 'rudderAngle')
24
+
25
+ // Subscribe to XTE for route mode
26
+ this.subscribePath('navigation.courseGreatCircle.crossTrackError', 'xte')
27
+ }
28
+
29
+ subscribePath(path, dataType) {
30
+ try {
31
+ const unsubscribe = this.app.streambundle.getSelfBus(path).onValue((rawValue) => {
32
+ // Extract actual value (might be wrapped in {value: ...})
33
+ let value = rawValue
34
+ if (typeof rawValue === 'object' && rawValue !== null && 'value' in rawValue) {
35
+ value = rawValue.value
36
+ }
37
+
38
+ // Skip if value is null or undefined
39
+ if (value === null || value === undefined) {
40
+ return
41
+ }
42
+
43
+ // Throttle logging based on data type
44
+ let shouldLog = false
45
+
46
+ if (dataType === 'heading') {
47
+ // Only log heading on significant change (>10 degrees) or first time
48
+ if (this.lastHeading === null || Math.abs(value - this.lastHeading) > 0.174) {
49
+ const degrees = (value * 180 / Math.PI).toFixed(1)
50
+ this.app.debug('Heading updated: ' + degrees + '°')
51
+ this.lastHeading = value
52
+ shouldLog = true
53
+ }
54
+ } else if (dataType === 'rudderAngle') {
55
+ // Only log rudder on significant change (>5 degrees) or first time
56
+ if (this.lastRudder === null || Math.abs(value - this.lastRudder) > 0.087) {
57
+ const degrees = (value * 180 / Math.PI).toFixed(1)
58
+ this.app.debug('Rudder angle: ' + degrees + '°')
59
+ this.lastRudder = value
60
+ shouldLog = true
61
+ }
62
+ } else if (dataType === 'xte') {
63
+ // Only log XTE on significant change (>10m) or first time
64
+ if (this.lastXTE === null || Math.abs(value - this.lastXTE) > 10) {
65
+ this.app.debug('Cross track error: ' + value.toFixed(1) + 'm')
66
+ this.lastXTE = value
67
+ shouldLog = true
68
+ }
69
+ }
70
+
71
+ // Always call onUpdate, but only log occasionally
72
+ this.onUpdate(dataType, value)
73
+ })
74
+
75
+ this.subscriptions.push({ path, unsubscribe })
76
+ this.app.debug('Subscribed to: ' + path)
77
+ } catch (err) {
78
+ this.app.debug('Could not subscribe to ' + path + ': ' + err.message)
79
+ }
80
+ }
81
+
82
+ unsubscribe() {
83
+ this.subscriptions.forEach((sub) => {
84
+ try {
85
+ if (sub.unsubscribe && typeof sub.unsubscribe === 'function') {
86
+ sub.unsubscribe()
87
+ this.app.debug('Unsubscribed from: ' + sub.path)
88
+ }
89
+ } catch (err) {
90
+ this.app.debug('Error unsubscribing from ' + sub.path + ': ' + err.message)
91
+ }
92
+ })
93
+
94
+ this.subscriptions = []
95
+ }
96
+ }
97
+
98
+ module.exports = SignalKPaths
package/package.json ADDED
@@ -0,0 +1,39 @@
1
+ {
2
+ "name": "signalk-autopilot-furuno",
3
+ "version": "0.1.0",
4
+ "description": "Signal K Autopilot Provider plugin for Furuno NavPilot (700/711C) via NMEA2000",
5
+ "main": "index.js",
6
+ "files": [
7
+ "index.js",
8
+ "lib/",
9
+ "doc/",
10
+ "CHANGELOG.md"
11
+ ],
12
+ "scripts": {
13
+ "test": "node --test"
14
+ },
15
+ "keywords": [
16
+ "signalk-node-server-plugin",
17
+ "signalk-category-autopilot",
18
+ "autopilot",
19
+ "furuno",
20
+ "navpilot",
21
+ "navpilot-711c"
22
+ ],
23
+ "author": "Dirk Wahrheit",
24
+ "license": "MIT",
25
+ "repository": {
26
+ "type": "git",
27
+ "url": "git+https://github.com/dirkwa/signalk-autopilot-furuno.git"
28
+ },
29
+ "bugs": {
30
+ "url": "https://github.com/dirkwa/signalk-autopilot-furuno/issues"
31
+ },
32
+ "homepage": "https://github.com/dirkwa/signalk-autopilot-furuno#readme",
33
+ "engines": {
34
+ "node": ">=22.0.0"
35
+ },
36
+ "devDependencies": {
37
+ "@types/node": "^22.0.0"
38
+ }
39
+ }