@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,315 @@
1
+ /**
2
+ * MRR (MaYaRa Radar Recording) file format reader
3
+ *
4
+ * JavaScript port of mayara-server's file_format.rs
5
+ * Reads .mrr binary files containing recorded radar data.
6
+ */
7
+
8
+ const fs = require('fs')
9
+ const zlib = require('zlib')
10
+
11
+ // Constants matching Rust implementation
12
+ const MRR_MAGIC = Buffer.from('MRR1')
13
+ const MRR_FOOTER_MAGIC = Buffer.from('MRRF')
14
+ const MRR_VERSION = 1
15
+ const HEADER_SIZE = 256
16
+ const FOOTER_SIZE = 32
17
+ const INDEX_ENTRY_SIZE = 16
18
+ const FRAME_FLAG_HAS_STATE = 0x01
19
+
20
+ /**
21
+ * Read a little-endian uint16 from buffer
22
+ */
23
+ function readU16(buf, offset) {
24
+ return buf.readUInt16LE(offset)
25
+ }
26
+
27
+ /**
28
+ * Read a little-endian uint32 from buffer
29
+ */
30
+ function readU32(buf, offset) {
31
+ return buf.readUInt32LE(offset)
32
+ }
33
+
34
+ /**
35
+ * Read a little-endian uint64 from buffer (as BigInt, then convert to Number)
36
+ * Note: JavaScript Numbers can safely represent integers up to 2^53-1
37
+ */
38
+ function readU64(buf, offset) {
39
+ return Number(buf.readBigUInt64LE(offset))
40
+ }
41
+
42
+ /**
43
+ * MRR file header (256 bytes)
44
+ */
45
+ class MrrHeader {
46
+ constructor() {
47
+ this.version = MRR_VERSION
48
+ this.flags = 0
49
+ this.radarBrand = 0
50
+ this.spokesPerRev = 0
51
+ this.maxSpokeLen = 0
52
+ this.pixelValues = 0
53
+ this.startTimeMs = 0
54
+ this.capabilitiesOffset = 0
55
+ this.capabilitiesLen = 0
56
+ this.initialStateOffset = 0
57
+ this.initialStateLen = 0
58
+ this.framesOffset = 0
59
+ }
60
+
61
+ /**
62
+ * Parse header from buffer
63
+ * @param {Buffer} buf - Buffer containing at least HEADER_SIZE bytes
64
+ * @returns {MrrHeader}
65
+ */
66
+ static fromBuffer(buf) {
67
+ if (buf.length < HEADER_SIZE) {
68
+ throw new Error(`Buffer too small for header: ${buf.length} < ${HEADER_SIZE}`)
69
+ }
70
+
71
+ // Check magic
72
+ if (!buf.subarray(0, 4).equals(MRR_MAGIC)) {
73
+ throw new Error('Invalid MRR file: bad magic bytes')
74
+ }
75
+
76
+ const header = new MrrHeader()
77
+ header.version = readU16(buf, 4)
78
+
79
+ if (header.version > MRR_VERSION) {
80
+ throw new Error(`Unsupported MRR version: ${header.version}`)
81
+ }
82
+
83
+ header.flags = readU16(buf, 6)
84
+ header.radarBrand = readU32(buf, 8)
85
+ header.spokesPerRev = readU32(buf, 12)
86
+ header.maxSpokeLen = readU32(buf, 16)
87
+ header.pixelValues = readU32(buf, 20)
88
+ header.startTimeMs = readU64(buf, 24)
89
+ header.capabilitiesOffset = readU64(buf, 32)
90
+ header.capabilitiesLen = readU32(buf, 40)
91
+ header.initialStateOffset = readU64(buf, 44)
92
+ header.initialStateLen = readU32(buf, 52)
93
+ header.framesOffset = readU64(buf, 56)
94
+
95
+ return header
96
+ }
97
+ }
98
+
99
+ /**
100
+ * MRR file footer (32 bytes)
101
+ */
102
+ class MrrFooter {
103
+ constructor() {
104
+ this.indexOffset = 0
105
+ this.indexCount = 0
106
+ this.frameCount = 0
107
+ this.durationMs = 0
108
+ }
109
+
110
+ /**
111
+ * Parse footer from buffer
112
+ * @param {Buffer} buf - Buffer containing at least FOOTER_SIZE bytes
113
+ * @returns {MrrFooter}
114
+ */
115
+ static fromBuffer(buf) {
116
+ if (buf.length < FOOTER_SIZE) {
117
+ throw new Error(`Buffer too small for footer: ${buf.length} < ${FOOTER_SIZE}`)
118
+ }
119
+
120
+ // Check magic
121
+ if (!buf.subarray(0, 4).equals(MRR_FOOTER_MAGIC)) {
122
+ throw new Error('Invalid MRR footer: bad magic bytes')
123
+ }
124
+
125
+ const footer = new MrrFooter()
126
+ footer.indexOffset = readU64(buf, 4)
127
+ footer.indexCount = readU32(buf, 12)
128
+ footer.frameCount = readU32(buf, 16)
129
+ footer.durationMs = readU64(buf, 20)
130
+
131
+ return footer
132
+ }
133
+ }
134
+
135
+ /**
136
+ * MRR frame data
137
+ */
138
+ class MrrFrame {
139
+ constructor() {
140
+ this.timestampMs = 0
141
+ this.flags = 0
142
+ this.data = null // Buffer - protobuf RadarMessage
143
+ this.stateDelta = null // Buffer - optional JSON state delta
144
+ }
145
+
146
+ /**
147
+ * Parse frame from buffer at given offset
148
+ * @param {Buffer} buf - Full file buffer
149
+ * @param {number} offset - Start offset of frame
150
+ * @returns {{frame: MrrFrame, bytesRead: number}}
151
+ */
152
+ static fromBuffer(buf, offset) {
153
+ const frame = new MrrFrame()
154
+
155
+ // Timestamp (8 bytes)
156
+ frame.timestampMs = readU64(buf, offset)
157
+ offset += 8
158
+
159
+ // Flags (1 byte)
160
+ frame.flags = buf.readUInt8(offset)
161
+ offset += 1
162
+
163
+ // Data length (4 bytes)
164
+ const dataLen = readU32(buf, offset)
165
+ offset += 4
166
+
167
+ // Data
168
+ frame.data = buf.subarray(offset, offset + dataLen)
169
+ offset += dataLen
170
+
171
+ // State delta (if present)
172
+ if (frame.flags & FRAME_FLAG_HAS_STATE) {
173
+ const stateLen = readU32(buf, offset)
174
+ offset += 4
175
+ frame.stateDelta = buf.subarray(offset, offset + stateLen)
176
+ offset += stateLen
177
+ }
178
+
179
+ return { frame, bytesRead: offset }
180
+ }
181
+ }
182
+
183
+ /**
184
+ * MRR file reader
185
+ */
186
+ class MrrReader {
187
+ /**
188
+ * @param {string} filePath - Path to .mrr or .mrr.gz file
189
+ */
190
+ constructor(filePath) {
191
+ this.filePath = filePath
192
+ this.buffer = null
193
+ this.header = null
194
+ this.footer = null
195
+ this.capabilities = null
196
+ this.initialState = null
197
+ this.currentOffset = 0
198
+ this.currentFrame = 0
199
+ }
200
+
201
+ /**
202
+ * Load and parse the file
203
+ * Automatically decompresses .mrr.gz files
204
+ */
205
+ async load() {
206
+ // Read file
207
+ let data = fs.readFileSync(this.filePath)
208
+
209
+ // Decompress if gzipped
210
+ if (this.filePath.endsWith('.gz') || this.filePath.endsWith('.mrr.gz')) {
211
+ data = zlib.gunzipSync(data)
212
+ }
213
+
214
+ this.buffer = data
215
+
216
+ // Parse header
217
+ this.header = MrrHeader.fromBuffer(this.buffer)
218
+
219
+ // Parse footer (at end of file)
220
+ const footerBuf = this.buffer.subarray(this.buffer.length - FOOTER_SIZE)
221
+ this.footer = MrrFooter.fromBuffer(footerBuf)
222
+
223
+ // Read capabilities JSON
224
+ const capBuf = this.buffer.subarray(
225
+ this.header.capabilitiesOffset,
226
+ this.header.capabilitiesOffset + this.header.capabilitiesLen
227
+ )
228
+ this.capabilities = JSON.parse(capBuf.toString('utf8'))
229
+
230
+ // Read initial state JSON
231
+ const stateBuf = this.buffer.subarray(
232
+ this.header.initialStateOffset,
233
+ this.header.initialStateOffset + this.header.initialStateLen
234
+ )
235
+ this.initialState = JSON.parse(stateBuf.toString('utf8'))
236
+
237
+ // Position at first frame
238
+ this.currentOffset = this.header.framesOffset
239
+ this.currentFrame = 0
240
+ }
241
+
242
+ /**
243
+ * Get file metadata
244
+ */
245
+ getMetadata() {
246
+ return {
247
+ version: this.header.version,
248
+ radarBrand: this.header.radarBrand,
249
+ spokesPerRev: this.header.spokesPerRev,
250
+ maxSpokeLen: this.header.maxSpokeLen,
251
+ pixelValues: this.header.pixelValues,
252
+ startTimeMs: this.header.startTimeMs,
253
+ frameCount: this.footer.frameCount,
254
+ durationMs: this.footer.durationMs,
255
+ capabilities: this.capabilities,
256
+ initialState: this.initialState
257
+ }
258
+ }
259
+
260
+ /**
261
+ * Read the next frame
262
+ * @returns {MrrFrame|null} Frame or null if at end
263
+ */
264
+ readFrame() {
265
+ if (this.currentFrame >= this.footer.frameCount) {
266
+ return null
267
+ }
268
+
269
+ const { frame, bytesRead } = MrrFrame.fromBuffer(this.buffer, this.currentOffset)
270
+ this.currentOffset = bytesRead
271
+ this.currentFrame++
272
+
273
+ return frame
274
+ }
275
+
276
+ /**
277
+ * Reset to beginning
278
+ */
279
+ rewind() {
280
+ this.currentOffset = this.header.framesOffset
281
+ this.currentFrame = 0
282
+ }
283
+
284
+ /**
285
+ * Get current position info
286
+ */
287
+ getPosition() {
288
+ return {
289
+ frame: this.currentFrame,
290
+ totalFrames: this.footer.frameCount
291
+ }
292
+ }
293
+
294
+ /**
295
+ * Create an async iterator for frames
296
+ * Useful for playback with timing
297
+ */
298
+ *frames() {
299
+ this.rewind()
300
+ let frame
301
+ while ((frame = this.readFrame()) !== null) {
302
+ yield frame
303
+ }
304
+ }
305
+ }
306
+
307
+ module.exports = {
308
+ MrrReader,
309
+ MrrHeader,
310
+ MrrFooter,
311
+ MrrFrame,
312
+ HEADER_SIZE,
313
+ FOOTER_SIZE,
314
+ FRAME_FLAG_HAS_STATE
315
+ }
@@ -0,0 +1,10 @@
1
+ <!DOCTYPE html>
2
+ <html>
3
+ <head>
4
+ <meta http-equiv="refresh" content="0; url=playback.html">
5
+ <title>Redirecting...</title>
6
+ </head>
7
+ <body>
8
+ <p>Redirecting to <a href="playback.html">playback.html</a>...</p>
9
+ </body>
10
+ </html>