@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.
- package/LICENSE +21 -0
- package/README.md +150 -0
- package/build.js +248 -0
- package/package.json +46 -0
- package/plugin/index.js +557 -0
- package/plugin/mrr-reader.js +315 -0
- package/plugin/public/assets/MaYaRa_RED.png +0 -0
- package/plugin/public/index.html +10 -0
- package/plugin/public/playback.html +572 -0
|
@@ -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
|
+
}
|
|
Binary file
|