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 +42 -0
- package/LICENSE +21 -0
- package/README.md +31 -0
- package/doc/TZ_goto_mode.md +84 -0
- package/doc/install_beta.md +43 -0
- package/index.js +106 -0
- package/lib/AutopilotProvider.js +521 -0
- package/lib/N2KCommands.js +96 -0
- package/lib/SignalKPaths.js +98 -0
- package/package.json +39 -0
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
|
+
}
|