@marineyachtradar/signalk-playback-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.
@@ -0,0 +1,557 @@
1
+ /**
2
+ * MaYaRa Radar Playback SignalK Plugin
3
+ *
4
+ * Plays .mrr radar recordings through SignalK's Radar API.
5
+ * This is a developer tool for testing SignalK Radar API consumers
6
+ * with consistent recorded data.
7
+ *
8
+ * Does NOT require mayara-server - reads .mrr files directly.
9
+ */
10
+
11
+ const fs = require('fs')
12
+ const path = require('path')
13
+ const { MrrReader } = require('./mrr-reader')
14
+
15
+ module.exports = function (app) {
16
+ let player = null
17
+ let recordingsDir = null
18
+
19
+ /**
20
+ * Create RadarProvider methods for SignalK Radar API
21
+ * Returns playback radar info when a recording is loaded
22
+ */
23
+ function createRadarProvider() {
24
+ return {
25
+ async getRadars() {
26
+ if (player && player.radarId) {
27
+ return [player.radarId]
28
+ }
29
+ return []
30
+ },
31
+
32
+ async getRadarInfo(radarId) {
33
+ if (!player || player.radarId !== radarId) return null
34
+
35
+ return {
36
+ id: player.radarId,
37
+ name: `Playback: ${player.filename}`,
38
+ brand: 'Playback',
39
+ model: 'Recording',
40
+ status: player.playing ? 'transmit' : 'standby',
41
+ spokesPerRevolution: player.spokesPerRev || 2048,
42
+ maxSpokeLen: player.maxSpokeLen || 512,
43
+ range: player.range || 1852,
44
+ controls: {
45
+ power: player.playing ? 2 : 1,
46
+ range: player.range || 1852
47
+ },
48
+ isPlayback: true
49
+ }
50
+ },
51
+
52
+ async getCapabilities(radarId) {
53
+ if (!player || player.radarId !== radarId) return null
54
+
55
+ return {
56
+ id: player.radarId,
57
+ make: 'Playback',
58
+ model: 'Recording',
59
+ characteristics: {
60
+ spokesPerRevolution: player.spokesPerRev || 2048,
61
+ maxSpokeLength: player.maxSpokeLen || 512,
62
+ pixelValues: player.pixelValues || 64
63
+ },
64
+ // Controls must be an array for buildControlsFromCapabilities()
65
+ controls: [
66
+ {
67
+ id: 'power',
68
+ type: 'enum',
69
+ values: ['off', 'standby', 'transmit'],
70
+ readOnly: true
71
+ },
72
+ {
73
+ id: 'range',
74
+ type: 'number',
75
+ min: 50,
76
+ max: 96000,
77
+ readOnly: true
78
+ }
79
+ ],
80
+ isPlayback: true
81
+ }
82
+ },
83
+
84
+ async getState(radarId) {
85
+ if (!player || player.radarId !== radarId) return null
86
+
87
+ return {
88
+ status: player.playing ? 'transmit' : 'standby',
89
+ controls: {
90
+ power: player.playing ? 2 : 1,
91
+ range: player.range || 1852
92
+ }
93
+ }
94
+ },
95
+
96
+ async getControl(radarId, controlId) {
97
+ if (!player || player.radarId !== radarId) return null
98
+ const state = await this.getState(radarId)
99
+ return state?.controls?.[controlId] ?? null
100
+ },
101
+
102
+ // Control methods - playback is read-only
103
+ async setPower(radarId, state) {
104
+ app.debug(`setPower ignored for playback radar (read-only)`)
105
+ return false
106
+ },
107
+
108
+ async setRange(radarId, range) {
109
+ app.debug(`setRange ignored for playback radar (read-only)`)
110
+ return false
111
+ },
112
+
113
+ async setControl(radarId, controlId, value) {
114
+ app.debug(`setControl ignored for playback radar (read-only)`)
115
+ return false
116
+ },
117
+
118
+ async setControls(radarId, controls) {
119
+ app.debug(`setControls ignored for playback radar (read-only)`)
120
+ return false
121
+ },
122
+
123
+ // ARPA not supported for playback
124
+ async getTargets(radarId) {
125
+ return { targets: [] }
126
+ },
127
+
128
+ async acquireTarget(radarId, bearing, distance) {
129
+ return { success: false, error: 'Not supported for playback' }
130
+ },
131
+
132
+ async cancelTarget(radarId, targetId) {
133
+ return false
134
+ }
135
+ }
136
+ }
137
+
138
+ const plugin = {
139
+ id: 'mayara-server-signalk-playbackrecordings-plugin',
140
+ name: 'MaYaRa Radar Playback',
141
+ description: 'Play .mrr radar recordings through SignalK Radar API (Developer Tool)',
142
+
143
+ schema: () => ({
144
+ type: 'object',
145
+ title: 'MaYaRa Radar Playback Settings',
146
+ properties: {
147
+ recordingsDir: {
148
+ type: 'string',
149
+ title: 'Recordings Directory',
150
+ description: 'Directory containing .mrr files (leave empty for plugin data directory)',
151
+ default: ''
152
+ }
153
+ }
154
+ }),
155
+
156
+ start: function (settings) {
157
+ app.debug('Starting mayara-playback plugin')
158
+
159
+ // Set recordings directory
160
+ recordingsDir = settings.recordingsDir || path.join(app.getDataDirPath(), 'recordings')
161
+
162
+ // Ensure directory exists
163
+ if (!fs.existsSync(recordingsDir)) {
164
+ fs.mkdirSync(recordingsDir, { recursive: true })
165
+ app.debug(`Created recordings directory: ${recordingsDir}`)
166
+ }
167
+
168
+ // Register with SignalK Radar API
169
+ if (app.radarApi) {
170
+ try {
171
+ app.radarApi.register(plugin.id, {
172
+ name: plugin.name,
173
+ methods: createRadarProvider()
174
+ })
175
+ app.debug('Registered as radar provider with SignalK Radar API')
176
+ } catch (err) {
177
+ app.error(`Failed to register radar provider: ${err.message}`)
178
+ }
179
+ } else {
180
+ app.debug('SignalK Radar API not available - spoke streaming will not work')
181
+ }
182
+
183
+ app.setPluginStatus('Ready - No recording loaded')
184
+ },
185
+
186
+ stop: function () {
187
+ app.debug('Stopping mayara-playback plugin')
188
+
189
+ // Unregister from radar API
190
+ if (app.radarApi) {
191
+ try {
192
+ app.radarApi.unRegister(plugin.id)
193
+ app.debug('Unregistered from radar API')
194
+ } catch (err) {
195
+ app.debug(`Error unregistering: ${err.message}`)
196
+ }
197
+ }
198
+
199
+ if (player) {
200
+ player.stop()
201
+ player = null
202
+ }
203
+
204
+ app.setPluginStatus('Stopped')
205
+ },
206
+
207
+ registerWithRouter: function (router) {
208
+ // List available recordings
209
+ router.get('/recordings', (req, res) => {
210
+ try {
211
+ const files = listRecordings()
212
+ res.json({ recordings: files })
213
+ } catch (err) {
214
+ res.status(500).json({ error: err.message })
215
+ }
216
+ })
217
+
218
+ // Upload a recording
219
+ router.post('/recordings/upload', async (req, res) => {
220
+ try {
221
+ // Handle multipart form data
222
+ const chunks = []
223
+ req.on('data', chunk => chunks.push(chunk))
224
+ req.on('end', () => {
225
+ const body = Buffer.concat(chunks)
226
+
227
+ // Extract filename from Content-Disposition header or generate one
228
+ let filename = `upload_${Date.now()}.mrr`
229
+ const contentDisp = req.headers['content-disposition']
230
+ if (contentDisp) {
231
+ const match = contentDisp.match(/filename="?([^";\s]+)"?/)
232
+ if (match) filename = match[1]
233
+ }
234
+
235
+ // Save file
236
+ const filePath = path.join(recordingsDir, filename)
237
+ fs.writeFileSync(filePath, body)
238
+
239
+ app.debug(`Uploaded recording: ${filename} (${body.length} bytes)`)
240
+ res.json({ filename, size: body.length })
241
+ })
242
+ } catch (err) {
243
+ res.status(500).json({ error: err.message })
244
+ }
245
+ })
246
+
247
+ // Delete a recording
248
+ router.delete('/recordings/:filename', (req, res) => {
249
+ try {
250
+ const filePath = path.join(recordingsDir, req.params.filename)
251
+ if (!fs.existsSync(filePath)) {
252
+ return res.status(404).json({ error: 'Recording not found' })
253
+ }
254
+ fs.unlinkSync(filePath)
255
+ res.json({ ok: true })
256
+ } catch (err) {
257
+ res.status(500).json({ error: err.message })
258
+ }
259
+ })
260
+
261
+ // Load a recording for playback
262
+ router.post('/playback/load', async (req, res) => {
263
+ try {
264
+ const { filename } = req.body
265
+ if (!filename) {
266
+ return res.status(400).json({ error: 'filename required' })
267
+ }
268
+
269
+ const filePath = path.join(recordingsDir, filename)
270
+ if (!fs.existsSync(filePath)) {
271
+ return res.status(404).json({ error: 'Recording not found' })
272
+ }
273
+
274
+ // Stop existing player and wait for cleanup
275
+ if (player) {
276
+ app.debug(`Stopping existing playback before loading new: ${player.filename}`)
277
+ player.stop()
278
+ player = null
279
+ // Small delay to allow old playback to fully stop
280
+ await new Promise(resolve => setTimeout(resolve, 100))
281
+ }
282
+
283
+ // Create new player
284
+ player = new MrrPlayer(app, filePath)
285
+ await player.load()
286
+
287
+ app.setPluginStatus(`Loaded: ${filename}`)
288
+ res.json({
289
+ radarId: player.radarId,
290
+ filename: filename,
291
+ durationMs: player.durationMs,
292
+ frameCount: player.frameCount
293
+ })
294
+ } catch (err) {
295
+ app.error(`Load failed: ${err.message}`)
296
+ res.status(500).json({ error: err.message })
297
+ }
298
+ })
299
+
300
+ // Play
301
+ router.post('/playback/play', (req, res) => {
302
+ if (!player) {
303
+ return res.status(400).json({ error: 'No recording loaded' })
304
+ }
305
+ player.play()
306
+ app.setPluginStatus(`Playing: ${player.filename}`)
307
+ res.json({ ok: true })
308
+ })
309
+
310
+ // Pause
311
+ router.post('/playback/pause', (req, res) => {
312
+ if (!player) {
313
+ return res.status(400).json({ error: 'No recording loaded' })
314
+ }
315
+ player.pause()
316
+ app.setPluginStatus(`Paused: ${player.filename}`)
317
+ res.json({ ok: true })
318
+ })
319
+
320
+ // Stop
321
+ router.post('/playback/stop', (req, res) => {
322
+ if (!player) {
323
+ return res.status(400).json({ error: 'No recording loaded' })
324
+ }
325
+ player.stop()
326
+ player = null
327
+ app.setPluginStatus('Ready - No recording loaded')
328
+ res.json({ ok: true })
329
+ })
330
+
331
+ // Get status
332
+ router.get('/playback/status', (req, res) => {
333
+ if (!player) {
334
+ return res.json({ state: 'idle', loopPlayback: true })
335
+ }
336
+ res.json(player.getStatus())
337
+ })
338
+
339
+ // Settings (loop)
340
+ router.put('/playback/settings', (req, res) => {
341
+ if (!player) {
342
+ return res.status(400).json({ error: 'No recording loaded' })
343
+ }
344
+ const { loopPlayback } = req.body
345
+ if (typeof loopPlayback === 'boolean') {
346
+ player.loop = loopPlayback
347
+ }
348
+ res.json({ ok: true })
349
+ })
350
+ }
351
+ }
352
+
353
+ /**
354
+ * List recordings in the recordings directory
355
+ */
356
+ function listRecordings() {
357
+ if (!fs.existsSync(recordingsDir)) {
358
+ return []
359
+ }
360
+
361
+ const files = fs.readdirSync(recordingsDir)
362
+ .filter(f => f.endsWith('.mrr') || f.endsWith('.mrr.gz'))
363
+ .map(filename => {
364
+ const filePath = path.join(recordingsDir, filename)
365
+ const stats = fs.statSync(filePath)
366
+
367
+ // Try to read metadata
368
+ let metadata = {}
369
+ try {
370
+ const reader = new MrrReader(filePath)
371
+ // Sync load for listing (could optimize later)
372
+ const data = fs.readFileSync(filePath)
373
+ const zlib = require('zlib')
374
+ const buf = filename.endsWith('.gz') ? zlib.gunzipSync(data) : data
375
+
376
+ // Quick parse just header and footer
377
+ const { MrrHeader, MrrFooter, HEADER_SIZE, FOOTER_SIZE } = require('./mrr-reader')
378
+ const header = MrrHeader.fromBuffer(buf)
379
+ const footer = MrrFooter.fromBuffer(buf.subarray(buf.length - FOOTER_SIZE))
380
+
381
+ metadata = {
382
+ durationMs: footer.durationMs,
383
+ frameCount: footer.frameCount,
384
+ spokesPerRev: header.spokesPerRev,
385
+ radarBrand: header.radarBrand
386
+ }
387
+ } catch (e) {
388
+ app.debug(`Could not read metadata for ${filename}: ${e.message}`)
389
+ }
390
+
391
+ return {
392
+ filename,
393
+ size: stats.size,
394
+ modifiedMs: stats.mtimeMs,
395
+ ...metadata
396
+ }
397
+ })
398
+
399
+ // Sort by modification time, newest first
400
+ files.sort((a, b) => b.modifiedMs - a.modifiedMs)
401
+
402
+ return files
403
+ }
404
+
405
+ return plugin
406
+ }
407
+
408
+ /**
409
+ * MRR Playback Player
410
+ * Reads frames from .mrr file and emits them through SignalK Radar API
411
+ */
412
+ class MrrPlayer {
413
+ constructor(app, filePath) {
414
+ this.app = app
415
+ this.filePath = filePath
416
+ this.filename = path.basename(filePath)
417
+ this.reader = new MrrReader(filePath)
418
+ this.radarId = null
419
+ this.durationMs = 0
420
+ this.frameCount = 0
421
+ this.playing = false
422
+ this.loop = true // Default to looping
423
+ this.currentFrame = 0
424
+ this.positionMs = 0
425
+ this.playbackTimer = null
426
+ this.frames = [] // Pre-loaded frames for proper timing
427
+ // Metadata for RadarProvider
428
+ this.spokesPerRev = 2048
429
+ this.maxSpokeLen = 512
430
+ this.pixelValues = 64
431
+ this.range = 1852
432
+ }
433
+
434
+ async load() {
435
+ await this.reader.load()
436
+
437
+ const meta = this.reader.getMetadata()
438
+ this.durationMs = meta.durationMs
439
+ this.frameCount = meta.frameCount
440
+
441
+ // Store metadata for RadarProvider to access
442
+ this.spokesPerRev = meta.spokesPerRev || 2048
443
+ this.maxSpokeLen = meta.maxSpokeLen || 512
444
+ this.pixelValues = meta.pixelValues || 64
445
+ this.range = meta.initialState?.range || 1852
446
+
447
+ // Pre-load all frames for proper timing (avoids hacky read-ahead)
448
+ this.frames = []
449
+ for (const frame of this.reader.frames()) {
450
+ this.frames.push(frame)
451
+ }
452
+ this.app.debug(`Pre-loaded ${this.frames.length} frames`)
453
+
454
+ // Generate radar ID from filename
455
+ const baseName = this.filename.replace(/\.mrr(\.gz)?$/, '')
456
+ this.radarId = `playback-${baseName}`
457
+
458
+ this.app.debug(`Loaded ${this.filename}: ${this.frameCount} frames, ${this.durationMs}ms, ${this.spokesPerRev} spokes/rev`)
459
+ }
460
+
461
+ play() {
462
+ if (this.playing) return
463
+
464
+ this.playing = true
465
+ this.scheduleNextFrame()
466
+ }
467
+
468
+ pause() {
469
+ this.playing = false
470
+ if (this.playbackTimer) {
471
+ clearTimeout(this.playbackTimer)
472
+ this.playbackTimer = null
473
+ }
474
+ }
475
+
476
+ stop() {
477
+ this.pause()
478
+
479
+ // Unregister radar
480
+ if (this.app.radarApi && this.radarId) {
481
+ try {
482
+ // Unregister from SignalK
483
+ this.app.debug(`Unregistering radar: ${this.radarId}`)
484
+ } catch (err) {
485
+ this.app.debug(`Error unregistering: ${err.message}`)
486
+ }
487
+ }
488
+
489
+ // Reset position
490
+ this.currentFrame = 0
491
+ this.positionMs = 0
492
+ }
493
+
494
+ getStatus() {
495
+ return {
496
+ state: this.playing ? 'playing' : (this.currentFrame > 0 ? 'paused' : 'loaded'),
497
+ radarId: this.radarId,
498
+ filename: this.filename,
499
+ positionMs: this.positionMs,
500
+ durationMs: this.durationMs,
501
+ frame: this.currentFrame,
502
+ frameCount: this.frameCount,
503
+ loopPlayback: this.loop
504
+ }
505
+ }
506
+
507
+ scheduleNextFrame() {
508
+ if (!this.playing) return
509
+
510
+ // Check if we've reached the end
511
+ if (this.currentFrame >= this.frames.length) {
512
+ // End of recording
513
+ if (this.loop) {
514
+ this.currentFrame = 0
515
+ this.positionMs = 0
516
+ this.scheduleNextFrame()
517
+ } else {
518
+ this.playing = false
519
+ this.app.setPluginStatus(`Finished: ${this.filename}`)
520
+ }
521
+ return
522
+ }
523
+
524
+ const frame = this.frames[this.currentFrame]
525
+
526
+ // Emit frame through SignalK binary stream
527
+ // Stream ID must be "radars/{radarId}" to match WebSocket endpoint
528
+ if (this.app.binaryStreamManager) {
529
+ try {
530
+ const streamId = `radars/${this.radarId}`
531
+ this.app.binaryStreamManager.emitData(streamId, frame.data)
532
+ } catch (err) {
533
+ this.app.debug(`Error emitting frame: ${err.message}`)
534
+ }
535
+ }
536
+
537
+ this.positionMs = frame.timestampMs
538
+ this.currentFrame++
539
+
540
+ // Schedule next frame based on timestamp delta
541
+ if (this.currentFrame < this.frames.length) {
542
+ const nextFrame = this.frames[this.currentFrame]
543
+ const deltaMs = nextFrame.timestampMs - frame.timestampMs
544
+ this.playbackTimer = setTimeout(() => this.scheduleNextFrame(), Math.max(1, deltaMs))
545
+ } else if (this.loop) {
546
+ // Last frame, schedule loop restart
547
+ this.playbackTimer = setTimeout(() => {
548
+ this.currentFrame = 0
549
+ this.positionMs = 0
550
+ this.scheduleNextFrame()
551
+ }, 100)
552
+ } else {
553
+ this.playing = false
554
+ this.app.setPluginStatus(`Finished: ${this.filename}`)
555
+ }
556
+ }
557
+ }