@marineyachtradar/signalk-plugin 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/LICENSE +21 -0
- package/README.md +119 -0
- package/build.js +202 -0
- package/package.json +44 -0
- package/plugin/index.js +306 -0
- package/plugin/mayara-client.js +197 -0
- package/plugin/radar-provider.js +308 -0
- package/plugin/spoke-forwarder.js +143 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 MarineYachtRadar
|
|
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,119 @@
|
|
|
1
|
+
# MaYaRa Server SignalK Plugin
|
|
2
|
+
|
|
3
|
+
A native SignalK plugin that connects to a remote [mayara-server](https://github.com/MarineYachtRadar/mayara-server) and exposes its radar(s) via SignalK's Radar API.
|
|
4
|
+
|
|
5
|
+
## Overview
|
|
6
|
+
|
|
7
|
+
This plugin acts as a thin proxy layer between SignalK and mayara-server. All radar logic (protocol handling, signal processing) runs on mayara-server - this plugin simply forwards control commands and streams radar data.
|
|
8
|
+
|
|
9
|
+
```
|
|
10
|
+
┌─────────────────────────────────────────────────────────────────┐
|
|
11
|
+
│ SignalK Server │
|
|
12
|
+
│ ┌───────────────────────────────────────────────────────────┐ │
|
|
13
|
+
│ │ mayara-server-signalk-plugin │ │
|
|
14
|
+
│ │ │ │
|
|
15
|
+
│ │ HTTP Client ──────► RadarProvider ◄────── SpokeForwarder │ │
|
|
16
|
+
│ │ │ │ │ │ │
|
|
17
|
+
│ └──────┼────────────────────┼─────────────────────┼─────────┘ │
|
|
18
|
+
│ │ radarApi.register() binaryStreamManager │
|
|
19
|
+
│ │ │ │ │
|
|
20
|
+
│ ┌──────┼────────────────────┼─────────────────────┼─────────┐ │
|
|
21
|
+
│ │ │ SignalK Radar API v2 │ │ │
|
|
22
|
+
│ │ │ /signalk/v2/api/vessels/self/radars/* │ │ │
|
|
23
|
+
│ └──────┼──────────────────────────────────────────┼─────────┘ │
|
|
24
|
+
└─────────┼──────────────────────────────────────────┼────────────┘
|
|
25
|
+
│ HTTP WebSocket│
|
|
26
|
+
▼ ▼
|
|
27
|
+
┌─────────────────────────────────────────────────────────────────┐
|
|
28
|
+
│ mayara-server │
|
|
29
|
+
│ /v2/api/radars/* /v2/api/radars/*/spokes │
|
|
30
|
+
└─────────────────────────────────────────────────────────────────┘
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
## Requirements
|
|
34
|
+
|
|
35
|
+
- **SignalK Server** >= 3.0.0 (with Radar API support)
|
|
36
|
+
- **mayara-server** running and accessible on the network
|
|
37
|
+
|
|
38
|
+
## Installation
|
|
39
|
+
|
|
40
|
+
### From npm (recommended)
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
npm install @marineyachtradar/signalk-plugin
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
### From source
|
|
47
|
+
|
|
48
|
+
```bash
|
|
49
|
+
git clone https://github.com/MarineYachtRadar/mayara-server-signalk-plugin
|
|
50
|
+
cd mayara-server-signalk-plugin
|
|
51
|
+
npm install
|
|
52
|
+
npm run build
|
|
53
|
+
npm link
|
|
54
|
+
# In your SignalK server directory:
|
|
55
|
+
npm link @marineyachtradar/signalk-plugin
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
## Configuration
|
|
59
|
+
|
|
60
|
+
Enable the plugin in SignalK Admin UI and configure:
|
|
61
|
+
|
|
62
|
+
| Setting | Description | Default |
|
|
63
|
+
|---------|-------------|---------|
|
|
64
|
+
| **Host** | IP address or hostname of mayara-server | `localhost` |
|
|
65
|
+
| **Port** | HTTP port of mayara-server REST API | `6502` |
|
|
66
|
+
| **Use HTTPS/WSS** | Enable secure connections (requires TLS on mayara-server) | `false` |
|
|
67
|
+
| **Discovery Poll Interval** | How often to poll for new/disconnected radars (seconds) | `10` |
|
|
68
|
+
| **Reconnect Interval** | How often to retry when mayara-server is unreachable (seconds) | `5` |
|
|
69
|
+
|
|
70
|
+
## Features
|
|
71
|
+
|
|
72
|
+
- **Multi-radar support**: Automatically discovers and manages all radars connected to mayara-server
|
|
73
|
+
- **Full Radar API**: Implements SignalK's RadarProviderMethods interface
|
|
74
|
+
- Power control (off/standby/transmit)
|
|
75
|
+
- Range selection
|
|
76
|
+
- Gain, sea clutter, and rain clutter adjustment
|
|
77
|
+
- ARPA target acquisition and tracking
|
|
78
|
+
- **Binary spoke streaming**: Uses SignalK's built-in binaryStreamManager for efficient data delivery
|
|
79
|
+
- **Auto-reconnection**: Handles network disconnections gracefully
|
|
80
|
+
- **Integrated GUI**: Includes the MaYaRa radar display webapp
|
|
81
|
+
|
|
82
|
+
## API Endpoints
|
|
83
|
+
|
|
84
|
+
Once configured, the plugin exposes radars at:
|
|
85
|
+
|
|
86
|
+
- `GET /signalk/v2/api/vessels/self/radars` - List all radars
|
|
87
|
+
- `GET /signalk/v2/api/vessels/self/radars/{id}` - Radar info
|
|
88
|
+
- `GET /signalk/v2/api/vessels/self/radars/{id}/capabilities` - Capability manifest
|
|
89
|
+
- `GET /signalk/v2/api/vessels/self/radars/{id}/state` - Current state
|
|
90
|
+
- `PUT /signalk/v2/api/vessels/self/radars/{id}/controls/{control}` - Set control
|
|
91
|
+
- `GET /signalk/v2/api/vessels/self/radars/{id}/targets` - ARPA targets
|
|
92
|
+
- `WS /signalk/v2/api/vessels/self/radars/{id}/stream` - Binary spoke data
|
|
93
|
+
|
|
94
|
+
## GUI
|
|
95
|
+
|
|
96
|
+
The radar display is available at:
|
|
97
|
+
```
|
|
98
|
+
http://your-signalk-server:3000/@marineyachtradar/signalk-plugin/
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
## Development
|
|
102
|
+
|
|
103
|
+
```bash
|
|
104
|
+
# Use local mayara-gui instead of npm version
|
|
105
|
+
npm run build -- --local-gui
|
|
106
|
+
|
|
107
|
+
# Link for development
|
|
108
|
+
npm link
|
|
109
|
+
cd ~/.signalk
|
|
110
|
+
npm link @marineyachtradar/signalk-plugin
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
## License
|
|
114
|
+
|
|
115
|
+
Apache-2.0 - See [LICENSE](LICENSE)
|
|
116
|
+
|
|
117
|
+
## Contributing
|
|
118
|
+
|
|
119
|
+
See [CONTRIBUTING.md](CONTRIBUTING.md)
|
package/build.js
ADDED
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Build script for mayara-server-signalk-plugin
|
|
4
|
+
*
|
|
5
|
+
* Downloads @marineyachtradar/mayara-gui from npm and copies GUI files to public/
|
|
6
|
+
*
|
|
7
|
+
* Usage: node build.js [options]
|
|
8
|
+
* --local-gui Use local mayara-gui instead of npm (for development)
|
|
9
|
+
* --pack Create a .tgz tarball with public/ included (for manual install)
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
const fs = require('fs')
|
|
13
|
+
const path = require('path')
|
|
14
|
+
const { execSync } = require('child_process')
|
|
15
|
+
|
|
16
|
+
const args = process.argv.slice(2)
|
|
17
|
+
const useLocalGui = args.includes('--local-gui')
|
|
18
|
+
const createPack = args.includes('--pack')
|
|
19
|
+
|
|
20
|
+
// Paths (relative to this script's directory)
|
|
21
|
+
const scriptDir = __dirname
|
|
22
|
+
const publicDest = path.join(scriptDir, 'public')
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Recursively copy directory contents
|
|
26
|
+
*/
|
|
27
|
+
function copyDir(src, dest) {
|
|
28
|
+
if (!fs.existsSync(src)) {
|
|
29
|
+
console.error(`Source directory not found: ${src}`)
|
|
30
|
+
process.exit(1)
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Remove destination if it exists
|
|
34
|
+
if (fs.existsSync(dest)) {
|
|
35
|
+
fs.rmSync(dest, { recursive: true })
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Create destination directory
|
|
39
|
+
fs.mkdirSync(dest, { recursive: true })
|
|
40
|
+
|
|
41
|
+
// Copy all files and subdirectories
|
|
42
|
+
const entries = fs.readdirSync(src, { withFileTypes: true })
|
|
43
|
+
for (const entry of entries) {
|
|
44
|
+
const srcPath = path.join(src, entry.name)
|
|
45
|
+
const destPath = path.join(dest, entry.name)
|
|
46
|
+
if (entry.isDirectory()) {
|
|
47
|
+
copyDir(srcPath, destPath)
|
|
48
|
+
} else {
|
|
49
|
+
fs.copyFileSync(srcPath, destPath)
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Download GUI from npm and copy to public/
|
|
56
|
+
*/
|
|
57
|
+
function setupGuiFromNpm() {
|
|
58
|
+
console.log('Copying GUI from node_modules...\n')
|
|
59
|
+
|
|
60
|
+
// Dependencies should already be installed by npm install
|
|
61
|
+
// (this script runs as postinstall, so node_modules exists)
|
|
62
|
+
const guiSource = path.join(scriptDir, 'node_modules', '@marineyachtradar', 'mayara-gui')
|
|
63
|
+
|
|
64
|
+
if (!fs.existsSync(guiSource)) {
|
|
65
|
+
console.error('Error: @marineyachtradar/mayara-gui not found in node_modules')
|
|
66
|
+
console.error('Make sure it is listed in package.json dependencies')
|
|
67
|
+
process.exit(1)
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Remove old public dir
|
|
71
|
+
if (fs.existsSync(publicDest)) {
|
|
72
|
+
fs.rmSync(publicDest, { recursive: true })
|
|
73
|
+
}
|
|
74
|
+
fs.mkdirSync(publicDest, { recursive: true })
|
|
75
|
+
|
|
76
|
+
// Copy GUI files (exclude package.json, node_modules, etc.)
|
|
77
|
+
const guiPatterns = [
|
|
78
|
+
{ ext: '.html' },
|
|
79
|
+
{ ext: '.js' },
|
|
80
|
+
{ ext: '.css' },
|
|
81
|
+
{ ext: '.ico' },
|
|
82
|
+
{ ext: '.svg' },
|
|
83
|
+
{ dir: 'assets' },
|
|
84
|
+
{ dir: 'proto' },
|
|
85
|
+
{ dir: 'protobuf' }
|
|
86
|
+
]
|
|
87
|
+
|
|
88
|
+
const entries = fs.readdirSync(guiSource, { withFileTypes: true })
|
|
89
|
+
for (const entry of entries) {
|
|
90
|
+
const srcPath = path.join(guiSource, entry.name)
|
|
91
|
+
const destPath = path.join(publicDest, entry.name)
|
|
92
|
+
|
|
93
|
+
if (entry.isDirectory()) {
|
|
94
|
+
// Copy known directories
|
|
95
|
+
if (guiPatterns.some(p => p.dir === entry.name)) {
|
|
96
|
+
copyDir(srcPath, destPath)
|
|
97
|
+
}
|
|
98
|
+
} else {
|
|
99
|
+
// Copy files matching extensions
|
|
100
|
+
if (guiPatterns.some(p => p.ext && entry.name.endsWith(p.ext))) {
|
|
101
|
+
fs.copyFileSync(srcPath, destPath)
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const fileCount = fs.readdirSync(publicDest, { recursive: true }).length
|
|
107
|
+
console.log(`Copied ${fileCount} GUI files to public/\n`)
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Copy GUI from local sibling directory (for development)
|
|
112
|
+
*/
|
|
113
|
+
function setupGuiFromLocal() {
|
|
114
|
+
const localGuiPath = path.join(scriptDir, '..', 'mayara-gui')
|
|
115
|
+
console.log(`Copying GUI from local ${localGuiPath}...\n`)
|
|
116
|
+
|
|
117
|
+
// Remove old public dir
|
|
118
|
+
if (fs.existsSync(publicDest)) {
|
|
119
|
+
fs.rmSync(publicDest, { recursive: true })
|
|
120
|
+
}
|
|
121
|
+
fs.mkdirSync(publicDest, { recursive: true })
|
|
122
|
+
|
|
123
|
+
// Copy GUI files (exclude package.json, node_modules, .git, etc.)
|
|
124
|
+
const guiPatterns = [
|
|
125
|
+
{ ext: '.html' },
|
|
126
|
+
{ ext: '.js' },
|
|
127
|
+
{ ext: '.css' },
|
|
128
|
+
{ ext: '.ico' },
|
|
129
|
+
{ ext: '.svg' },
|
|
130
|
+
{ dir: 'assets' },
|
|
131
|
+
{ dir: 'proto' },
|
|
132
|
+
{ dir: 'protobuf' }
|
|
133
|
+
]
|
|
134
|
+
|
|
135
|
+
const entries = fs.readdirSync(localGuiPath, { withFileTypes: true })
|
|
136
|
+
for (const entry of entries) {
|
|
137
|
+
const srcPath = path.join(localGuiPath, entry.name)
|
|
138
|
+
const destPath = path.join(publicDest, entry.name)
|
|
139
|
+
|
|
140
|
+
if (entry.isDirectory()) {
|
|
141
|
+
// Copy known directories
|
|
142
|
+
if (guiPatterns.some(p => p.dir === entry.name)) {
|
|
143
|
+
copyDir(srcPath, destPath)
|
|
144
|
+
}
|
|
145
|
+
} else {
|
|
146
|
+
// Copy files matching extensions
|
|
147
|
+
if (guiPatterns.some(p => p.ext && entry.name.endsWith(p.ext))) {
|
|
148
|
+
fs.copyFileSync(srcPath, destPath)
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const fileCount = fs.readdirSync(publicDest, { recursive: true }).length
|
|
154
|
+
console.log(`Copied ${fileCount} files from local mayara-gui/ to public/\n`)
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function main() {
|
|
158
|
+
console.log('=== MaYaRa SignalK Plugin Build ===\n')
|
|
159
|
+
|
|
160
|
+
// Get GUI assets
|
|
161
|
+
console.log('Setting up GUI assets...\n')
|
|
162
|
+
if (useLocalGui) {
|
|
163
|
+
setupGuiFromLocal()
|
|
164
|
+
} else {
|
|
165
|
+
setupGuiFromNpm()
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// Create tarball if --pack flag is set
|
|
169
|
+
if (createPack) {
|
|
170
|
+
console.log('Creating tarball with public/ included...\n')
|
|
171
|
+
|
|
172
|
+
// Temporarily remove public/ from .npmignore
|
|
173
|
+
const npmignorePath = path.join(scriptDir, '.npmignore')
|
|
174
|
+
const npmignoreContent = fs.readFileSync(npmignorePath, 'utf8')
|
|
175
|
+
const npmignoreWithoutPublic = npmignoreContent.replace(/^public\/\n?/m, '')
|
|
176
|
+
fs.writeFileSync(npmignorePath, npmignoreWithoutPublic)
|
|
177
|
+
|
|
178
|
+
// Also temporarily add public/ to files in package.json
|
|
179
|
+
const pkgPath = path.join(scriptDir, 'package.json')
|
|
180
|
+
const pkgContent = fs.readFileSync(pkgPath, 'utf8')
|
|
181
|
+
const pkg = JSON.parse(pkgContent)
|
|
182
|
+
const originalFiles = [...pkg.files]
|
|
183
|
+
pkg.files.push('public/**/*')
|
|
184
|
+
fs.writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + '\n')
|
|
185
|
+
|
|
186
|
+
try {
|
|
187
|
+
// Run npm pack
|
|
188
|
+
execSync('npm pack', { stdio: 'inherit', cwd: scriptDir })
|
|
189
|
+
console.log('\nTarball created successfully!')
|
|
190
|
+
} finally {
|
|
191
|
+
// Restore .npmignore
|
|
192
|
+
fs.writeFileSync(npmignorePath, npmignoreContent)
|
|
193
|
+
// Restore package.json
|
|
194
|
+
pkg.files = originalFiles
|
|
195
|
+
fs.writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + '\n')
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
console.log('\n=== Build complete ===')
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
main()
|
package/package.json
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@marineyachtradar/signalk-plugin",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "MaYaRa Radar - Connect SignalK to mayara-server",
|
|
5
|
+
"main": "plugin/index.js",
|
|
6
|
+
"scripts": {
|
|
7
|
+
"build": "node build.js",
|
|
8
|
+
"postinstall": "node build.js"
|
|
9
|
+
},
|
|
10
|
+
"keywords": [
|
|
11
|
+
"signalk-node-server-plugin",
|
|
12
|
+
"signalk-webapp",
|
|
13
|
+
"signalk-category-instruments",
|
|
14
|
+
"radar",
|
|
15
|
+
"marine",
|
|
16
|
+
"mayara"
|
|
17
|
+
],
|
|
18
|
+
"signalk": {
|
|
19
|
+
"displayName": "MaYaRa Radar (Server)",
|
|
20
|
+
"appIcon": "assets/mayara_logo.png",
|
|
21
|
+
"webapp": {
|
|
22
|
+
"name": "MaYaRa Radar",
|
|
23
|
+
"description": "Marine radar display connected to mayara-server",
|
|
24
|
+
"location": "/plugins/@marineyachtradar/signalk-plugin/"
|
|
25
|
+
}
|
|
26
|
+
},
|
|
27
|
+
"files": [
|
|
28
|
+
"plugin/**/*",
|
|
29
|
+
"build.js"
|
|
30
|
+
],
|
|
31
|
+
"engines": {
|
|
32
|
+
"signalk": ">=2.0.0"
|
|
33
|
+
},
|
|
34
|
+
"dependencies": {
|
|
35
|
+
"@marineyachtradar/mayara-gui": "^0.6.0",
|
|
36
|
+
"ws": "^8.14.0"
|
|
37
|
+
},
|
|
38
|
+
"repository": {
|
|
39
|
+
"type": "git",
|
|
40
|
+
"url": "git+https://github.com/MarineYachtRadar/mayara-server-signalk-plugin"
|
|
41
|
+
},
|
|
42
|
+
"author": "MarineYachtRadar Contributors",
|
|
43
|
+
"license": "Apache-2.0"
|
|
44
|
+
}
|
package/plugin/index.js
ADDED
|
@@ -0,0 +1,306 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MaYaRa Radar SignalK Plugin
|
|
3
|
+
*
|
|
4
|
+
* Connects to a remote mayara-server and exposes its radar(s) via SignalK's Radar API.
|
|
5
|
+
* The plugin acts as a thin proxy layer - all radar logic runs on mayara-server.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
const MayaraClient = require('./mayara-client')
|
|
9
|
+
const createRadarProvider = require('./radar-provider')
|
|
10
|
+
const SpokeForwarder = require('./spoke-forwarder')
|
|
11
|
+
|
|
12
|
+
module.exports = function (app) {
|
|
13
|
+
let client = null
|
|
14
|
+
let provider = null
|
|
15
|
+
let spokeForwarders = new Map() // radarId -> SpokeForwarder
|
|
16
|
+
let discoveryInterval = null
|
|
17
|
+
let reconnectInterval = null
|
|
18
|
+
let isConnected = false
|
|
19
|
+
let knownRadars = new Set()
|
|
20
|
+
|
|
21
|
+
const plugin = {
|
|
22
|
+
id: 'mayara-server-signalk-plugin',
|
|
23
|
+
name: 'MaYaRa Radar (Server)',
|
|
24
|
+
description: 'Connect SignalK to mayara-server for multi-brand marine radar integration',
|
|
25
|
+
|
|
26
|
+
schema: () => ({
|
|
27
|
+
type: 'object',
|
|
28
|
+
title: 'MaYaRa Server Connection',
|
|
29
|
+
required: ['host', 'port'],
|
|
30
|
+
properties: {
|
|
31
|
+
host: {
|
|
32
|
+
type: 'string',
|
|
33
|
+
title: 'mayara-server Host',
|
|
34
|
+
description: 'IP address or hostname of mayara-server',
|
|
35
|
+
default: 'localhost'
|
|
36
|
+
},
|
|
37
|
+
port: {
|
|
38
|
+
type: 'number',
|
|
39
|
+
title: 'mayara-server Port',
|
|
40
|
+
description: 'HTTP port of mayara-server REST API',
|
|
41
|
+
default: 6502,
|
|
42
|
+
minimum: 1,
|
|
43
|
+
maximum: 65535
|
|
44
|
+
},
|
|
45
|
+
secure: {
|
|
46
|
+
type: 'boolean',
|
|
47
|
+
title: 'Use HTTPS/WSS',
|
|
48
|
+
description: 'Use secure connections (requires TLS on mayara-server)',
|
|
49
|
+
default: false
|
|
50
|
+
},
|
|
51
|
+
discoveryPollInterval: {
|
|
52
|
+
type: 'number',
|
|
53
|
+
title: 'Discovery Poll Interval (seconds)',
|
|
54
|
+
description: 'How often to poll for new/disconnected radars',
|
|
55
|
+
default: 10,
|
|
56
|
+
minimum: 5,
|
|
57
|
+
maximum: 60
|
|
58
|
+
},
|
|
59
|
+
reconnectInterval: {
|
|
60
|
+
type: 'number',
|
|
61
|
+
title: 'Reconnect Interval (seconds)',
|
|
62
|
+
description: 'How often to retry connection when mayara-server is unreachable',
|
|
63
|
+
default: 5,
|
|
64
|
+
minimum: 1,
|
|
65
|
+
maximum: 30
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
}),
|
|
69
|
+
|
|
70
|
+
start: function (settings) {
|
|
71
|
+
app.debug('Starting mayara-server-signalk-plugin')
|
|
72
|
+
|
|
73
|
+
// Initialize client to mayara-server
|
|
74
|
+
client = new MayaraClient({
|
|
75
|
+
host: settings.host || 'localhost',
|
|
76
|
+
port: settings.port || 6502,
|
|
77
|
+
secure: settings.secure || false,
|
|
78
|
+
debug: app.debug.bind(app)
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
// Create RadarProvider implementation
|
|
82
|
+
provider = createRadarProvider(client, app)
|
|
83
|
+
|
|
84
|
+
// Check if radar API is available
|
|
85
|
+
if (!app.radarApi) {
|
|
86
|
+
app.setPluginError('SignalK Radar API not available (requires SignalK >= 2.0.0)')
|
|
87
|
+
return
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Register with SignalK Radar API
|
|
91
|
+
try {
|
|
92
|
+
app.radarApi.register(plugin.id, {
|
|
93
|
+
name: plugin.name,
|
|
94
|
+
methods: provider
|
|
95
|
+
})
|
|
96
|
+
app.debug('Registered as radar provider')
|
|
97
|
+
} catch (err) {
|
|
98
|
+
app.setPluginError(`Failed to register radar provider: ${err.message}`)
|
|
99
|
+
return
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Start connection and discovery
|
|
103
|
+
connectAndDiscover(settings)
|
|
104
|
+
},
|
|
105
|
+
|
|
106
|
+
stop: function () {
|
|
107
|
+
app.debug('Stopping mayara-server-signalk-plugin')
|
|
108
|
+
|
|
109
|
+
// Unregister from radar API
|
|
110
|
+
if (app.radarApi) {
|
|
111
|
+
try {
|
|
112
|
+
app.radarApi.unRegister(plugin.id)
|
|
113
|
+
} catch (err) {
|
|
114
|
+
app.debug(`Error unregistering: ${err.message}`)
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// Clear intervals
|
|
119
|
+
if (discoveryInterval) {
|
|
120
|
+
clearInterval(discoveryInterval)
|
|
121
|
+
discoveryInterval = null
|
|
122
|
+
}
|
|
123
|
+
if (reconnectInterval) {
|
|
124
|
+
clearInterval(reconnectInterval)
|
|
125
|
+
reconnectInterval = null
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// Stop all spoke forwarders
|
|
129
|
+
for (const forwarder of spokeForwarders.values()) {
|
|
130
|
+
forwarder.stop()
|
|
131
|
+
}
|
|
132
|
+
spokeForwarders.clear()
|
|
133
|
+
knownRadars.clear()
|
|
134
|
+
|
|
135
|
+
// Close client
|
|
136
|
+
if (client) {
|
|
137
|
+
client.close()
|
|
138
|
+
client = null
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
isConnected = false
|
|
142
|
+
app.setPluginStatus('Stopped')
|
|
143
|
+
},
|
|
144
|
+
|
|
145
|
+
registerWithRouter: function (router) {
|
|
146
|
+
// Health check endpoint
|
|
147
|
+
router.get('/status', (req, res) => {
|
|
148
|
+
res.json({
|
|
149
|
+
connected: isConnected,
|
|
150
|
+
radars: Array.from(knownRadars),
|
|
151
|
+
spokeForwarders: Array.from(spokeForwarders.keys()).map(id => ({
|
|
152
|
+
radarId: id,
|
|
153
|
+
connected: spokeForwarders.get(id)?.isConnected() || false
|
|
154
|
+
}))
|
|
155
|
+
})
|
|
156
|
+
})
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
async function connectAndDiscover(settings) {
|
|
161
|
+
try {
|
|
162
|
+
// Try to connect to mayara-server
|
|
163
|
+
const radars = await client.getRadars()
|
|
164
|
+
isConnected = true
|
|
165
|
+
|
|
166
|
+
const radarIds = Object.keys(radars)
|
|
167
|
+
app.setPluginStatus(`Connected - ${radarIds.length} radar(s) found`)
|
|
168
|
+
|
|
169
|
+
// Update known radars and start spoke forwarders
|
|
170
|
+
await updateRadars(radarIds, settings)
|
|
171
|
+
|
|
172
|
+
// Start discovery polling
|
|
173
|
+
const pollMs = (settings.discoveryPollInterval || 10) * 1000
|
|
174
|
+
discoveryInterval = setInterval(() => {
|
|
175
|
+
pollForRadarChanges(settings)
|
|
176
|
+
}, pollMs)
|
|
177
|
+
|
|
178
|
+
} catch (err) {
|
|
179
|
+
isConnected = false
|
|
180
|
+
app.setPluginError(`Cannot connect to mayara-server: ${err.message}`)
|
|
181
|
+
|
|
182
|
+
// Schedule reconnect
|
|
183
|
+
const reconnectMs = (settings.reconnectInterval || 5) * 1000
|
|
184
|
+
reconnectInterval = setInterval(async () => {
|
|
185
|
+
try {
|
|
186
|
+
const radars = await client.getRadars()
|
|
187
|
+
isConnected = true
|
|
188
|
+
|
|
189
|
+
const radarIds = Object.keys(radars)
|
|
190
|
+
app.setPluginStatus(`Connected - ${radarIds.length} radar(s) found`)
|
|
191
|
+
|
|
192
|
+
// Clear reconnect timer
|
|
193
|
+
clearInterval(reconnectInterval)
|
|
194
|
+
reconnectInterval = null
|
|
195
|
+
|
|
196
|
+
// Update radars
|
|
197
|
+
await updateRadars(radarIds, settings)
|
|
198
|
+
|
|
199
|
+
// Start discovery polling
|
|
200
|
+
const pollMs = (settings.discoveryPollInterval || 10) * 1000
|
|
201
|
+
discoveryInterval = setInterval(() => {
|
|
202
|
+
pollForRadarChanges(settings)
|
|
203
|
+
}, pollMs)
|
|
204
|
+
|
|
205
|
+
} catch (e) {
|
|
206
|
+
// Still disconnected, keep trying
|
|
207
|
+
app.debug(`Reconnect failed: ${e.message}`)
|
|
208
|
+
}
|
|
209
|
+
}, reconnectMs)
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
async function updateRadars(radarIds, settings) {
|
|
214
|
+
const currentIds = new Set(radarIds)
|
|
215
|
+
|
|
216
|
+
// Add new radars
|
|
217
|
+
for (const radarId of currentIds) {
|
|
218
|
+
if (!knownRadars.has(radarId)) {
|
|
219
|
+
app.debug(`New radar discovered: ${radarId}`)
|
|
220
|
+
knownRadars.add(radarId)
|
|
221
|
+
|
|
222
|
+
// Start spoke forwarder for this radar
|
|
223
|
+
if (app.binaryStreamManager) {
|
|
224
|
+
const forwarder = new SpokeForwarder({
|
|
225
|
+
radarId: radarId,
|
|
226
|
+
url: client.getSpokeStreamUrl(radarId),
|
|
227
|
+
binaryStreamManager: app.binaryStreamManager,
|
|
228
|
+
debug: app.debug.bind(app),
|
|
229
|
+
reconnectInterval: (settings.reconnectInterval || 5) * 1000
|
|
230
|
+
})
|
|
231
|
+
spokeForwarders.set(radarId, forwarder)
|
|
232
|
+
forwarder.start()
|
|
233
|
+
} else {
|
|
234
|
+
app.debug('binaryStreamManager not available - spoke streaming disabled')
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// Remove disconnected radars
|
|
240
|
+
for (const radarId of knownRadars) {
|
|
241
|
+
if (!currentIds.has(radarId)) {
|
|
242
|
+
app.debug(`Radar disconnected: ${radarId}`)
|
|
243
|
+
knownRadars.delete(radarId)
|
|
244
|
+
|
|
245
|
+
// Stop spoke forwarder
|
|
246
|
+
const forwarder = spokeForwarders.get(radarId)
|
|
247
|
+
if (forwarder) {
|
|
248
|
+
forwarder.stop()
|
|
249
|
+
spokeForwarders.delete(radarId)
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
async function pollForRadarChanges(settings) {
|
|
256
|
+
try {
|
|
257
|
+
const radars = await client.getRadars()
|
|
258
|
+
const radarIds = Object.keys(radars)
|
|
259
|
+
|
|
260
|
+
await updateRadars(radarIds, settings)
|
|
261
|
+
|
|
262
|
+
app.setPluginStatus(`Connected - ${radarIds.length} radar(s)`)
|
|
263
|
+
|
|
264
|
+
} catch (err) {
|
|
265
|
+
// Lost connection
|
|
266
|
+
isConnected = false
|
|
267
|
+
app.setPluginError(`Lost connection: ${err.message}`)
|
|
268
|
+
|
|
269
|
+
// Stop discovery polling
|
|
270
|
+
if (discoveryInterval) {
|
|
271
|
+
clearInterval(discoveryInterval)
|
|
272
|
+
discoveryInterval = null
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
// Start reconnect timer
|
|
276
|
+
const reconnectMs = (settings.reconnectInterval || 5) * 1000
|
|
277
|
+
reconnectInterval = setInterval(async () => {
|
|
278
|
+
try {
|
|
279
|
+
const radars = await client.getRadars()
|
|
280
|
+
isConnected = true
|
|
281
|
+
|
|
282
|
+
const radarIds = Object.keys(radars)
|
|
283
|
+
app.setPluginStatus(`Connected - ${radarIds.length} radar(s) found`)
|
|
284
|
+
|
|
285
|
+
// Clear reconnect timer
|
|
286
|
+
clearInterval(reconnectInterval)
|
|
287
|
+
reconnectInterval = null
|
|
288
|
+
|
|
289
|
+
// Update radars
|
|
290
|
+
await updateRadars(radarIds, settings)
|
|
291
|
+
|
|
292
|
+
// Restart discovery polling
|
|
293
|
+
const pollMs = (settings.discoveryPollInterval || 10) * 1000
|
|
294
|
+
discoveryInterval = setInterval(() => {
|
|
295
|
+
pollForRadarChanges(settings)
|
|
296
|
+
}, pollMs)
|
|
297
|
+
|
|
298
|
+
} catch (e) {
|
|
299
|
+
// Still disconnected
|
|
300
|
+
}
|
|
301
|
+
}, reconnectMs)
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
return plugin
|
|
306
|
+
}
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HTTP client for mayara-server REST API
|
|
3
|
+
*
|
|
4
|
+
* Provides methods to communicate with mayara-server's /v2/api/radars/* endpoints.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
const http = require('http')
|
|
8
|
+
const https = require('https')
|
|
9
|
+
|
|
10
|
+
class MayaraClient {
|
|
11
|
+
constructor({ host, port, secure = false, timeout = 10000, debug = () => {} }) {
|
|
12
|
+
this.host = host
|
|
13
|
+
this.port = port
|
|
14
|
+
this.secure = secure
|
|
15
|
+
this.timeout = timeout
|
|
16
|
+
this.debug = debug
|
|
17
|
+
this.baseUrl = `${secure ? 'https' : 'http'}://${host}:${port}`
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Make an HTTP request to mayara-server
|
|
22
|
+
* @param {string} method - HTTP method (GET, PUT, POST, DELETE)
|
|
23
|
+
* @param {string} path - API path (e.g., /v2/api/radars)
|
|
24
|
+
* @param {object|null} body - Request body (for PUT/POST)
|
|
25
|
+
* @returns {Promise<any>} - Parsed JSON response
|
|
26
|
+
*/
|
|
27
|
+
async request(method, path, body = null) {
|
|
28
|
+
return new Promise((resolve, reject) => {
|
|
29
|
+
const options = {
|
|
30
|
+
hostname: this.host,
|
|
31
|
+
port: this.port,
|
|
32
|
+
path: path,
|
|
33
|
+
method: method,
|
|
34
|
+
headers: {
|
|
35
|
+
'Accept': 'application/json',
|
|
36
|
+
'Content-Type': 'application/json'
|
|
37
|
+
},
|
|
38
|
+
timeout: this.timeout
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const transport = this.secure ? https : http
|
|
42
|
+
|
|
43
|
+
const req = transport.request(options, (res) => {
|
|
44
|
+
let data = ''
|
|
45
|
+
res.on('data', chunk => data += chunk)
|
|
46
|
+
res.on('end', () => {
|
|
47
|
+
if (res.statusCode >= 200 && res.statusCode < 300) {
|
|
48
|
+
try {
|
|
49
|
+
resolve(data ? JSON.parse(data) : null)
|
|
50
|
+
} catch (e) {
|
|
51
|
+
resolve(data)
|
|
52
|
+
}
|
|
53
|
+
} else {
|
|
54
|
+
reject(new Error(`HTTP ${res.statusCode}: ${data}`))
|
|
55
|
+
}
|
|
56
|
+
})
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
req.on('error', reject)
|
|
60
|
+
req.on('timeout', () => {
|
|
61
|
+
req.destroy()
|
|
62
|
+
reject(new Error('Request timeout'))
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
if (body) {
|
|
66
|
+
req.write(JSON.stringify(body))
|
|
67
|
+
}
|
|
68
|
+
req.end()
|
|
69
|
+
})
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// ============================================
|
|
73
|
+
// Radar Discovery
|
|
74
|
+
// ============================================
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Get list of all radars
|
|
78
|
+
* @returns {Promise<object>} - Object keyed by radar ID
|
|
79
|
+
*/
|
|
80
|
+
async getRadars() {
|
|
81
|
+
return this.request('GET', '/v2/api/radars')
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// ============================================
|
|
85
|
+
// Capabilities & State
|
|
86
|
+
// ============================================
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Get capability manifest for a radar
|
|
90
|
+
* @param {string} radarId - The radar ID
|
|
91
|
+
* @returns {Promise<object>} - CapabilityManifest
|
|
92
|
+
*/
|
|
93
|
+
async getCapabilities(radarId) {
|
|
94
|
+
return this.request('GET', `/v2/api/radars/${radarId}/capabilities`)
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Get current state for a radar
|
|
99
|
+
* @param {string} radarId - The radar ID
|
|
100
|
+
* @returns {Promise<object>} - RadarState
|
|
101
|
+
*/
|
|
102
|
+
async getState(radarId) {
|
|
103
|
+
return this.request('GET', `/v2/api/radars/${radarId}/state`)
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// ============================================
|
|
107
|
+
// Controls
|
|
108
|
+
// ============================================
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Set a single control value
|
|
112
|
+
* @param {string} radarId - The radar ID
|
|
113
|
+
* @param {string} controlId - The control ID (e.g., "power", "gain")
|
|
114
|
+
* @param {any} value - The value to set
|
|
115
|
+
* @returns {Promise<object>} - Result
|
|
116
|
+
*/
|
|
117
|
+
async setControl(radarId, controlId, value) {
|
|
118
|
+
return this.request('PUT', `/v2/api/radars/${radarId}/controls/${controlId}`, { value })
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Set multiple controls at once
|
|
123
|
+
* @param {string} radarId - The radar ID
|
|
124
|
+
* @param {object} controls - Object with controlId: value pairs
|
|
125
|
+
* @returns {Promise<object>} - Result
|
|
126
|
+
*/
|
|
127
|
+
async setControls(radarId, controls) {
|
|
128
|
+
return this.request('PUT', `/v2/api/radars/${radarId}/controls`, controls)
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// ============================================
|
|
132
|
+
// ARPA Targets
|
|
133
|
+
// ============================================
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Get all tracked ARPA targets
|
|
137
|
+
* @param {string} radarId - The radar ID
|
|
138
|
+
* @returns {Promise<object>} - TargetListResponse
|
|
139
|
+
*/
|
|
140
|
+
async getTargets(radarId) {
|
|
141
|
+
return this.request('GET', `/v2/api/radars/${radarId}/targets`)
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Manually acquire a target at bearing/distance
|
|
146
|
+
* @param {string} radarId - The radar ID
|
|
147
|
+
* @param {number} bearing - Bearing in degrees (0-360)
|
|
148
|
+
* @param {number} distance - Distance in meters
|
|
149
|
+
* @returns {Promise<object>} - Result with targetId
|
|
150
|
+
*/
|
|
151
|
+
async acquireTarget(radarId, bearing, distance) {
|
|
152
|
+
return this.request('POST', `/v2/api/radars/${radarId}/targets`, { bearing, distance })
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Cancel tracking of a target
|
|
157
|
+
* @param {string} radarId - The radar ID
|
|
158
|
+
* @param {number} targetId - The target ID to cancel
|
|
159
|
+
* @returns {Promise<object>} - Result
|
|
160
|
+
*/
|
|
161
|
+
async cancelTarget(radarId, targetId) {
|
|
162
|
+
return this.request('DELETE', `/v2/api/radars/${radarId}/targets/${targetId}`)
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// ============================================
|
|
166
|
+
// WebSocket URLs
|
|
167
|
+
// ============================================
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Get WebSocket URL for spoke streaming
|
|
171
|
+
* @param {string} radarId - The radar ID
|
|
172
|
+
* @returns {string} - WebSocket URL
|
|
173
|
+
*/
|
|
174
|
+
getSpokeStreamUrl(radarId) {
|
|
175
|
+
const wsProtocol = this.secure ? 'wss' : 'ws'
|
|
176
|
+
return `${wsProtocol}://${this.host}:${this.port}/v2/api/radars/${radarId}/spokes`
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Get WebSocket URL for target streaming
|
|
181
|
+
* @param {string} radarId - The radar ID
|
|
182
|
+
* @returns {string} - WebSocket URL
|
|
183
|
+
*/
|
|
184
|
+
getTargetStreamUrl(radarId) {
|
|
185
|
+
const wsProtocol = this.secure ? 'wss' : 'ws'
|
|
186
|
+
return `${wsProtocol}://${this.host}:${this.port}/v2/api/radars/${radarId}/targets/stream`
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Close any persistent connections (none for HTTP client)
|
|
191
|
+
*/
|
|
192
|
+
close() {
|
|
193
|
+
// No persistent connections to close for HTTP client
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
module.exports = MayaraClient
|
|
@@ -0,0 +1,308 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* RadarProvider implementation for SignalK Radar API
|
|
3
|
+
*
|
|
4
|
+
* Implements RadarProviderMethods interface by proxying calls to mayara-server.
|
|
5
|
+
* See: signalk-server/packages/server-api/src/radarapi.ts
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Create a RadarProvider that proxies to mayara-server
|
|
10
|
+
* @param {MayaraClient} client - The mayara-server HTTP client
|
|
11
|
+
* @param {object} app - SignalK app object
|
|
12
|
+
* @returns {object} - RadarProviderMethods implementation
|
|
13
|
+
*/
|
|
14
|
+
function createRadarProvider(client, app) {
|
|
15
|
+
const debug = app.debug.bind(app)
|
|
16
|
+
|
|
17
|
+
return {
|
|
18
|
+
// ============================================
|
|
19
|
+
// Required Methods
|
|
20
|
+
// ============================================
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Get list of radar IDs this provider manages
|
|
24
|
+
* @returns {Promise<string[]>} - Array of radar IDs
|
|
25
|
+
*/
|
|
26
|
+
async getRadars() {
|
|
27
|
+
try {
|
|
28
|
+
const radars = await client.getRadars()
|
|
29
|
+
// mayara-server returns object keyed by ID
|
|
30
|
+
return Object.keys(radars)
|
|
31
|
+
} catch (err) {
|
|
32
|
+
debug(`getRadars error: ${err.message}`)
|
|
33
|
+
return []
|
|
34
|
+
}
|
|
35
|
+
},
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Get detailed info for a specific radar
|
|
39
|
+
* @param {string} radarId - The radar ID
|
|
40
|
+
* @returns {Promise<RadarInfo|null>} - Radar info or null
|
|
41
|
+
*/
|
|
42
|
+
async getRadarInfo(radarId) {
|
|
43
|
+
try {
|
|
44
|
+
const state = await client.getState(radarId)
|
|
45
|
+
if (!state) return null
|
|
46
|
+
|
|
47
|
+
const capabilities = await client.getCapabilities(radarId)
|
|
48
|
+
|
|
49
|
+
// Build RadarInfo from state and capabilities
|
|
50
|
+
return {
|
|
51
|
+
id: radarId,
|
|
52
|
+
name: capabilities?.model
|
|
53
|
+
? `${capabilities.make || ''} ${capabilities.model}`.trim()
|
|
54
|
+
: radarId,
|
|
55
|
+
brand: capabilities?.make || 'Unknown',
|
|
56
|
+
status: state.status || 'standby',
|
|
57
|
+
spokesPerRevolution: capabilities?.characteristics?.spokesPerRevolution || 2048,
|
|
58
|
+
maxSpokeLen: capabilities?.characteristics?.maxSpokeLength || 512,
|
|
59
|
+
range: state.controls?.range || 1852,
|
|
60
|
+
controls: {
|
|
61
|
+
gain: state.controls?.gain || { auto: true, value: 50 },
|
|
62
|
+
sea: state.controls?.sea || { auto: true, value: 50 },
|
|
63
|
+
rain: state.controls?.rain || { value: 0 }
|
|
64
|
+
},
|
|
65
|
+
// streamUrl undefined = use SignalK's built-in stream endpoint
|
|
66
|
+
streamUrl: undefined
|
|
67
|
+
}
|
|
68
|
+
} catch (err) {
|
|
69
|
+
debug(`getRadarInfo error for ${radarId}: ${err.message}`)
|
|
70
|
+
return null
|
|
71
|
+
}
|
|
72
|
+
},
|
|
73
|
+
|
|
74
|
+
// ============================================
|
|
75
|
+
// Capability and State Methods
|
|
76
|
+
// ============================================
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Get capability manifest for a radar
|
|
80
|
+
* @param {string} radarId - The radar ID
|
|
81
|
+
* @returns {Promise<CapabilityManifest|null>}
|
|
82
|
+
*/
|
|
83
|
+
async getCapabilities(radarId) {
|
|
84
|
+
try {
|
|
85
|
+
return await client.getCapabilities(radarId)
|
|
86
|
+
} catch (err) {
|
|
87
|
+
debug(`getCapabilities error for ${radarId}: ${err.message}`)
|
|
88
|
+
return null
|
|
89
|
+
}
|
|
90
|
+
},
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Get current radar state
|
|
94
|
+
* @param {string} radarId - The radar ID
|
|
95
|
+
* @returns {Promise<RadarState|null>}
|
|
96
|
+
*/
|
|
97
|
+
async getState(radarId) {
|
|
98
|
+
try {
|
|
99
|
+
return await client.getState(radarId)
|
|
100
|
+
} catch (err) {
|
|
101
|
+
debug(`getState error for ${radarId}: ${err.message}`)
|
|
102
|
+
return null
|
|
103
|
+
}
|
|
104
|
+
},
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Get a single control value
|
|
108
|
+
* @param {string} radarId - The radar ID
|
|
109
|
+
* @param {string} controlId - The control ID
|
|
110
|
+
* @returns {Promise<any|null>}
|
|
111
|
+
*/
|
|
112
|
+
async getControl(radarId, controlId) {
|
|
113
|
+
try {
|
|
114
|
+
const state = await client.getState(radarId)
|
|
115
|
+
return state?.controls?.[controlId] ?? null
|
|
116
|
+
} catch (err) {
|
|
117
|
+
debug(`getControl error for ${radarId}/${controlId}: ${err.message}`)
|
|
118
|
+
return null
|
|
119
|
+
}
|
|
120
|
+
},
|
|
121
|
+
|
|
122
|
+
// ============================================
|
|
123
|
+
// Control Methods
|
|
124
|
+
// ============================================
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Set radar power state
|
|
128
|
+
* @param {string} radarId - The radar ID
|
|
129
|
+
* @param {string} state - Power state (off, standby, transmit)
|
|
130
|
+
* @returns {Promise<boolean>}
|
|
131
|
+
*/
|
|
132
|
+
async setPower(radarId, state) {
|
|
133
|
+
try {
|
|
134
|
+
await client.setControl(radarId, 'power', state)
|
|
135
|
+
return true
|
|
136
|
+
} catch (err) {
|
|
137
|
+
debug(`setPower error for ${radarId}: ${err.message}`)
|
|
138
|
+
return false
|
|
139
|
+
}
|
|
140
|
+
},
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Set radar range
|
|
144
|
+
* @param {string} radarId - The radar ID
|
|
145
|
+
* @param {number} range - Range in meters
|
|
146
|
+
* @returns {Promise<boolean>}
|
|
147
|
+
*/
|
|
148
|
+
async setRange(radarId, range) {
|
|
149
|
+
try {
|
|
150
|
+
await client.setControl(radarId, 'range', range)
|
|
151
|
+
return true
|
|
152
|
+
} catch (err) {
|
|
153
|
+
debug(`setRange error for ${radarId}: ${err.message}`)
|
|
154
|
+
return false
|
|
155
|
+
}
|
|
156
|
+
},
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Set radar gain
|
|
160
|
+
* @param {string} radarId - The radar ID
|
|
161
|
+
* @param {object} gain - { auto: boolean, value?: number }
|
|
162
|
+
* @returns {Promise<boolean>}
|
|
163
|
+
*/
|
|
164
|
+
async setGain(radarId, gain) {
|
|
165
|
+
try {
|
|
166
|
+
const value = {
|
|
167
|
+
mode: gain.auto ? 'auto' : 'manual',
|
|
168
|
+
value: gain.value ?? 50
|
|
169
|
+
}
|
|
170
|
+
await client.setControl(radarId, 'gain', value)
|
|
171
|
+
return true
|
|
172
|
+
} catch (err) {
|
|
173
|
+
debug(`setGain error for ${radarId}: ${err.message}`)
|
|
174
|
+
return false
|
|
175
|
+
}
|
|
176
|
+
},
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Set sea clutter
|
|
180
|
+
* @param {string} radarId - The radar ID
|
|
181
|
+
* @param {object} sea - { auto: boolean, value?: number }
|
|
182
|
+
* @returns {Promise<boolean>}
|
|
183
|
+
*/
|
|
184
|
+
async setSea(radarId, sea) {
|
|
185
|
+
try {
|
|
186
|
+
const value = {
|
|
187
|
+
mode: sea.auto ? 'auto' : 'manual',
|
|
188
|
+
value: sea.value ?? 50
|
|
189
|
+
}
|
|
190
|
+
await client.setControl(radarId, 'sea', value)
|
|
191
|
+
return true
|
|
192
|
+
} catch (err) {
|
|
193
|
+
debug(`setSea error for ${radarId}: ${err.message}`)
|
|
194
|
+
return false
|
|
195
|
+
}
|
|
196
|
+
},
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Set rain clutter
|
|
200
|
+
* @param {string} radarId - The radar ID
|
|
201
|
+
* @param {object} rain - { auto: boolean, value?: number }
|
|
202
|
+
* @returns {Promise<boolean>}
|
|
203
|
+
*/
|
|
204
|
+
async setRain(radarId, rain) {
|
|
205
|
+
try {
|
|
206
|
+
const value = {
|
|
207
|
+
mode: rain.auto ? 'auto' : 'manual',
|
|
208
|
+
value: rain.value ?? 0
|
|
209
|
+
}
|
|
210
|
+
await client.setControl(radarId, 'rain', value)
|
|
211
|
+
return true
|
|
212
|
+
} catch (err) {
|
|
213
|
+
debug(`setRain error for ${radarId}: ${err.message}`)
|
|
214
|
+
return false
|
|
215
|
+
}
|
|
216
|
+
},
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* Set a single control value
|
|
220
|
+
* @param {string} radarId - The radar ID
|
|
221
|
+
* @param {string} controlId - The control ID
|
|
222
|
+
* @param {any} value - The value to set
|
|
223
|
+
* @returns {Promise<{success: boolean, error?: string}>}
|
|
224
|
+
*/
|
|
225
|
+
async setControl(radarId, controlId, value) {
|
|
226
|
+
try {
|
|
227
|
+
await client.setControl(radarId, controlId, value)
|
|
228
|
+
return { success: true }
|
|
229
|
+
} catch (err) {
|
|
230
|
+
debug(`setControl error for ${radarId}/${controlId}: ${err.message}`)
|
|
231
|
+
return { success: false, error: err.message }
|
|
232
|
+
}
|
|
233
|
+
},
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* Set multiple controls at once
|
|
237
|
+
* @param {string} radarId - The radar ID
|
|
238
|
+
* @param {object} controls - Partial controls to update
|
|
239
|
+
* @returns {Promise<boolean>}
|
|
240
|
+
*/
|
|
241
|
+
async setControls(radarId, controls) {
|
|
242
|
+
try {
|
|
243
|
+
await client.setControls(radarId, controls)
|
|
244
|
+
return true
|
|
245
|
+
} catch (err) {
|
|
246
|
+
debug(`setControls error for ${radarId}: ${err.message}`)
|
|
247
|
+
return false
|
|
248
|
+
}
|
|
249
|
+
},
|
|
250
|
+
|
|
251
|
+
// ============================================
|
|
252
|
+
// ARPA Target Methods
|
|
253
|
+
// ============================================
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* Get all tracked ARPA targets
|
|
257
|
+
* @param {string} radarId - The radar ID
|
|
258
|
+
* @returns {Promise<TargetListResponse|null>}
|
|
259
|
+
*/
|
|
260
|
+
async getTargets(radarId) {
|
|
261
|
+
try {
|
|
262
|
+
return await client.getTargets(radarId)
|
|
263
|
+
} catch (err) {
|
|
264
|
+
debug(`getTargets error for ${radarId}: ${err.message}`)
|
|
265
|
+
return null
|
|
266
|
+
}
|
|
267
|
+
},
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* Manually acquire a target
|
|
271
|
+
* @param {string} radarId - The radar ID
|
|
272
|
+
* @param {number} bearing - Bearing in degrees (0-360)
|
|
273
|
+
* @param {number} distance - Distance in meters
|
|
274
|
+
* @returns {Promise<{success: boolean, targetId?: number, error?: string}>}
|
|
275
|
+
*/
|
|
276
|
+
async acquireTarget(radarId, bearing, distance) {
|
|
277
|
+
try {
|
|
278
|
+
const result = await client.acquireTarget(radarId, bearing, distance)
|
|
279
|
+
return { success: true, targetId: result.targetId }
|
|
280
|
+
} catch (err) {
|
|
281
|
+
debug(`acquireTarget error for ${radarId}: ${err.message}`)
|
|
282
|
+
return { success: false, error: err.message }
|
|
283
|
+
}
|
|
284
|
+
},
|
|
285
|
+
|
|
286
|
+
/**
|
|
287
|
+
* Cancel tracking of a target
|
|
288
|
+
* @param {string} radarId - The radar ID
|
|
289
|
+
* @param {number} targetId - The target ID
|
|
290
|
+
* @returns {Promise<boolean>}
|
|
291
|
+
*/
|
|
292
|
+
async cancelTarget(radarId, targetId) {
|
|
293
|
+
try {
|
|
294
|
+
await client.cancelTarget(radarId, targetId)
|
|
295
|
+
return true
|
|
296
|
+
} catch (err) {
|
|
297
|
+
debug(`cancelTarget error for ${radarId}/${targetId}: ${err.message}`)
|
|
298
|
+
return false
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
// Note: handleStreamConnection is NOT implemented here.
|
|
303
|
+
// Spoke streaming is handled by SpokeForwarder which uses
|
|
304
|
+
// app.binaryStreamManager.emitData() directly.
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
module.exports = createRadarProvider
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SpokeForwarder - WebSocket client that forwards spoke data to SignalK's binaryStreamManager
|
|
3
|
+
*
|
|
4
|
+
* Connects to mayara-server's spoke WebSocket endpoint and forwards binary data
|
|
5
|
+
* to SignalK's built-in binary stream infrastructure.
|
|
6
|
+
*
|
|
7
|
+
* SignalK clients connect to /signalk/v2/api/vessels/self/radars/{id}/stream
|
|
8
|
+
* and receive the forwarded data automatically.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
const WebSocket = require('ws')
|
|
12
|
+
|
|
13
|
+
class SpokeForwarder {
|
|
14
|
+
/**
|
|
15
|
+
* Create a SpokeForwarder
|
|
16
|
+
* @param {object} options - Configuration options
|
|
17
|
+
* @param {string} options.radarId - The radar ID
|
|
18
|
+
* @param {string} options.url - WebSocket URL for mayara-server spoke stream
|
|
19
|
+
* @param {object} options.binaryStreamManager - SignalK's binaryStreamManager
|
|
20
|
+
* @param {function} options.debug - Debug logging function
|
|
21
|
+
* @param {number} options.reconnectInterval - Reconnect interval in ms (default: 5000)
|
|
22
|
+
*/
|
|
23
|
+
constructor({ radarId, url, binaryStreamManager, debug, reconnectInterval = 5000 }) {
|
|
24
|
+
this.radarId = radarId
|
|
25
|
+
this.url = url
|
|
26
|
+
this.binaryStreamManager = binaryStreamManager
|
|
27
|
+
this.debug = debug || (() => {})
|
|
28
|
+
this.reconnectInterval = reconnectInterval
|
|
29
|
+
|
|
30
|
+
this.ws = null
|
|
31
|
+
this.reconnectTimer = null
|
|
32
|
+
this.closed = false
|
|
33
|
+
this.connected = false
|
|
34
|
+
|
|
35
|
+
// Stream ID for binaryStreamManager (matches SignalK radar stream pattern)
|
|
36
|
+
this.streamId = `radars/${radarId}`
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Start the forwarder - connect to mayara-server
|
|
41
|
+
*/
|
|
42
|
+
start() {
|
|
43
|
+
if (this.closed) return
|
|
44
|
+
this.connect()
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Connect to mayara-server spoke WebSocket
|
|
49
|
+
*/
|
|
50
|
+
connect() {
|
|
51
|
+
if (this.closed) return
|
|
52
|
+
|
|
53
|
+
this.debug(`Connecting to spoke stream: ${this.url}`)
|
|
54
|
+
|
|
55
|
+
try {
|
|
56
|
+
this.ws = new WebSocket(this.url)
|
|
57
|
+
this.ws.binaryType = 'arraybuffer'
|
|
58
|
+
|
|
59
|
+
this.ws.on('open', () => {
|
|
60
|
+
this.connected = true
|
|
61
|
+
this.debug(`Connected to spoke stream for ${this.radarId}`)
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
this.ws.on('message', (data) => {
|
|
65
|
+
// Forward binary spoke data to SignalK's binaryStreamManager
|
|
66
|
+
if (this.binaryStreamManager && data instanceof ArrayBuffer) {
|
|
67
|
+
const buffer = Buffer.from(data)
|
|
68
|
+
this.binaryStreamManager.emitData(this.streamId, buffer)
|
|
69
|
+
} else if (this.binaryStreamManager && Buffer.isBuffer(data)) {
|
|
70
|
+
this.binaryStreamManager.emitData(this.streamId, data)
|
|
71
|
+
}
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
this.ws.on('error', (err) => {
|
|
75
|
+
this.debug(`Spoke stream error for ${this.radarId}: ${err.message}`)
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
this.ws.on('close', (code, reason) => {
|
|
79
|
+
this.connected = false
|
|
80
|
+
this.debug(`Spoke stream closed for ${this.radarId}: ${code} ${reason}`)
|
|
81
|
+
|
|
82
|
+
if (!this.closed) {
|
|
83
|
+
this.scheduleReconnect()
|
|
84
|
+
}
|
|
85
|
+
})
|
|
86
|
+
} catch (err) {
|
|
87
|
+
this.debug(`Failed to connect to spoke stream for ${this.radarId}: ${err.message}`)
|
|
88
|
+
if (!this.closed) {
|
|
89
|
+
this.scheduleReconnect()
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Schedule a reconnection attempt
|
|
96
|
+
*/
|
|
97
|
+
scheduleReconnect() {
|
|
98
|
+
if (this.closed || this.reconnectTimer) return
|
|
99
|
+
|
|
100
|
+
this.debug(`Scheduling reconnect for ${this.radarId} in ${this.reconnectInterval}ms`)
|
|
101
|
+
|
|
102
|
+
this.reconnectTimer = setTimeout(() => {
|
|
103
|
+
this.reconnectTimer = null
|
|
104
|
+
if (!this.closed) {
|
|
105
|
+
this.connect()
|
|
106
|
+
}
|
|
107
|
+
}, this.reconnectInterval)
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Check if the forwarder is connected
|
|
112
|
+
* @returns {boolean}
|
|
113
|
+
*/
|
|
114
|
+
isConnected() {
|
|
115
|
+
return this.connected
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Stop the forwarder and close the WebSocket
|
|
120
|
+
*/
|
|
121
|
+
stop() {
|
|
122
|
+
this.closed = true
|
|
123
|
+
|
|
124
|
+
if (this.reconnectTimer) {
|
|
125
|
+
clearTimeout(this.reconnectTimer)
|
|
126
|
+
this.reconnectTimer = null
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
if (this.ws) {
|
|
130
|
+
try {
|
|
131
|
+
this.ws.close()
|
|
132
|
+
} catch (err) {
|
|
133
|
+
// Ignore close errors
|
|
134
|
+
}
|
|
135
|
+
this.ws = null
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
this.connected = false
|
|
139
|
+
this.debug(`Stopped spoke forwarder for ${this.radarId}`)
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
module.exports = SpokeForwarder
|