@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 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
+ }
@@ -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