esp32tool 1.5.0 → 1.6.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,809 @@
1
+ /**
2
+ * ESP32Tool NVS (Non-Volatile Storage) Editor
3
+ *
4
+ * A full-screen editor for viewing and editing NVS partition contents.
5
+ * Features:
6
+ * - Parse and display NVS pages, namespaces and key/value entries
7
+ * - Inline editing of values (primitives, strings, blobs)
8
+ * - Delete entries
9
+ * - Add new entries and namespaces
10
+ * - Write modified NVS data back to flash
11
+ * - Filter / search entries
12
+ * - Page state indicator
13
+ */
14
+
15
+ import { HexEditor } from './hex-editor.js';
16
+
17
+ export class NVSEditor {
18
+ /**
19
+ * @param {HTMLElement} container - The container element (#nvseditor-container)
20
+ */
21
+ constructor(container) {
22
+ this.container = container;
23
+ /** @type {Uint8Array|null} raw NVS partition data */
24
+ this.data = null;
25
+ /** @type {Uint8Array|null} original snapshot for diff */
26
+ this.originalData = null;
27
+ this.baseAddress = 0; // flash offset of the NVS partition
28
+ this.partitionSize = 0;
29
+ this.partitionName = '';
30
+
31
+ /** Parsed pages with items */
32
+ this.pages = [];
33
+ /** Whether data has been modified */
34
+ this.modified = false;
35
+
36
+ // Callbacks
37
+ this.onClose = null;
38
+ /** @type {((data: Uint8Array) => Promise<void>)|null} */
39
+ this.onWriteFlash = null;
40
+
41
+ // DOM cache
42
+ this._progressOverlay = null;
43
+ this._progressText = null;
44
+ this._progressBarInner = null;
45
+
46
+ // Filter state
47
+ this._filterText = '';
48
+
49
+ // Sub hex-editor for large entries
50
+ this._hexEditorInstance = null;
51
+ this._hexEditorContainer = null;
52
+ }
53
+
54
+ // ─────── CRC32 helpers (same as esp32-parser NVSParser) ───────
55
+
56
+ static crc32Byte(crc, d) {
57
+ for (let i = 0; i < 8; i++) {
58
+ const bit = d & 1;
59
+ crc ^= bit;
60
+ crc = (crc & 1) ? (crc >>> 1) ^ 0xEDB88320 : crc >>> 1;
61
+ d >>>= 1;
62
+ }
63
+ return crc >>> 0;
64
+ }
65
+
66
+ static crc32(data, offset = 0, length = null) {
67
+ let crc = 0;
68
+ const len = length ?? data.length - offset;
69
+ for (let i = 0; i < len; i++) {
70
+ crc = NVSEditor.crc32Byte(crc, data[offset + i]);
71
+ }
72
+ return (~crc) >>> 0;
73
+ }
74
+
75
+ static crc32Header(data, offset = 0) {
76
+ const buf = new Uint8Array(0x20 - 4);
77
+ buf.set(data.subarray(offset, offset + 4), 0);
78
+ buf.set(data.subarray(offset + 8, offset + 8 + 0x18), 4);
79
+ return NVSEditor.crc32(buf, 0, 0x1C);
80
+ }
81
+
82
+ static bytesToHex(bytes, separator = '') {
83
+ return Array.from(bytes)
84
+ .map(b => b.toString(16).padStart(2, '0').toUpperCase())
85
+ .join(separator);
86
+ }
87
+
88
+ // ─────── Public API ───────
89
+
90
+ /** Show a progress overlay (before open()) */
91
+ initProgressUI() {
92
+ this.container.innerHTML = `
93
+ <div class="nvseditor-body" style="flex:1;display:flex;align-items:center;justify-content:center;">
94
+ <div class="nvseditor-progress-overlay" id="nvsProgress">
95
+ <div class="progress-text" id="nvsProgressText">Initiating...</div>
96
+ <div class="progress-bar-outer">
97
+ <div class="progress-bar-inner" id="nvsProgressBar"></div>
98
+ </div>
99
+ </div>
100
+ </div>`;
101
+ this._progressOverlay = this.container.querySelector('#nvsProgress');
102
+ this._progressText = this.container.querySelector('#nvsProgressText');
103
+ this._progressBarInner = this.container.querySelector('#nvsProgressBar');
104
+ }
105
+
106
+ showProgress(text, percent) {
107
+ if (this._progressOverlay) {
108
+ this._progressOverlay.classList.remove('hidden');
109
+ this._progressText.textContent = text;
110
+ this._progressBarInner.style.width = percent + '%';
111
+ }
112
+ }
113
+
114
+ hideProgress() {
115
+ if (this._progressOverlay) {
116
+ this._progressOverlay.classList.add('hidden');
117
+ }
118
+ }
119
+
120
+ /**
121
+ * Open the NVS editor with data.
122
+ * @param {Uint8Array} data - raw NVS partition bytes
123
+ * @param {number} baseAddress - flash offset of the partition
124
+ * @param {string} name - partition name
125
+ */
126
+ open(data, baseAddress, name) {
127
+ this.data = new Uint8Array(data);
128
+ this.originalData = new Uint8Array(data);
129
+ this.baseAddress = baseAddress;
130
+ this.partitionSize = data.length;
131
+ this.partitionName = name || 'nvs';
132
+ this.modified = false;
133
+ this._filterText = '';
134
+
135
+ this.pages = this._parse();
136
+ this._buildUI();
137
+
138
+ this.container.classList.remove('hidden');
139
+ document.body.classList.add('nvseditor-active');
140
+ }
141
+
142
+ close() {
143
+ if (this._hexEditorInstance) {
144
+ this._hexEditorInstance.onClose = null; // prevent re-render on already-closing editor
145
+ try { this._hexEditorInstance.close(); } catch (_) {}
146
+ this._hexEditorInstance = null;
147
+ }
148
+ this.container.classList.add('hidden');
149
+ document.body.classList.remove('nvseditor-active');
150
+ this.container.innerHTML = '';
151
+ if (this.onClose) this.onClose();
152
+ }
153
+
154
+ // ─────── NVS parsing (synchronous, operates on Uint8Array) ───────
155
+
156
+ _readString(offset, maxLen) {
157
+ let r = '';
158
+ for (let i = 0; i < maxLen; i++) {
159
+ const b = this.data[offset + i];
160
+ if (b === 0) break;
161
+ if (b >= 32 && b <= 126) r += String.fromCharCode(b);
162
+ else return r;
163
+ }
164
+ return r;
165
+ }
166
+
167
+ _u8(off) { return this.data[off]; }
168
+ _u16(off) { return this.data[off] | (this.data[off + 1] << 8); }
169
+ _u32(off) { return (this.data[off] | (this.data[off + 1] << 8) | (this.data[off + 2] << 16) | (this.data[off + 3] << 24)) >>> 0; }
170
+ _i32(off) { return this._u32(off) | 0; }
171
+ _u64(off) {
172
+ const lo = this._u32(off);
173
+ const hi = this._u32(off + 4);
174
+ return (BigInt(hi) << 32n) | BigInt(lo);
175
+ }
176
+ _i64(off) { return BigInt.asIntN(64, this._u64(off)); }
177
+
178
+ _getNVSTypeName(dt) {
179
+ const m = {
180
+ 0x01: 'U8', 0x02: 'U16', 0x04: 'U32', 0x08: 'U64',
181
+ 0x11: 'I8', 0x12: 'I16', 0x14: 'I32', 0x18: 'I64',
182
+ 0x21: 'String', 0x42: 'Blob', 0x48: 'Blob Index'
183
+ };
184
+ return m[dt] || `0x${dt.toString(16)}`;
185
+ }
186
+
187
+ _getNVSItemState(bitmap, index) {
188
+ const bmpIdx = Math.floor(index / 4);
189
+ const bmpBit = (index % 4) * 2;
190
+ return (bitmap[bmpIdx] >> bmpBit) & 3;
191
+ }
192
+
193
+ _setNVSItemState(bitmap, index, state) {
194
+ const bmpIdx = Math.floor(index / 4);
195
+ const bmpBit = (index % 4) * 2;
196
+ bitmap[bmpIdx] &= ~(3 << bmpBit);
197
+ bitmap[bmpIdx] |= (state << bmpBit);
198
+ }
199
+
200
+ _parse() {
201
+ const NVS_SECTOR_SIZE = 4096;
202
+ const MAX_ENTRY_COUNT = 126;
203
+ const NVS_PAGE_STATE = {
204
+ UNINIT: 0xFFFFFFFF, ACTIVE: 0xFFFFFFFE,
205
+ FULL: 0xFFFFFFFC, FREEING: 0xFFFFFFF8, CORRUPT: 0xFFFFFFF0
206
+ };
207
+
208
+ const pages = [];
209
+ const namespaces = new Map();
210
+ namespaces.set(0, '');
211
+
212
+ // ── Pass 1: collect all namespace definitions ──────────────────────────
213
+ for (let secOff = 0; secOff < this.data.length; secOff += NVS_SECTOR_SIZE) {
214
+ if (secOff + 64 > this.data.length) break;
215
+ const state = this._u32(secOff);
216
+ if (state === NVS_PAGE_STATE.UNINIT || state === NVS_PAGE_STATE.CORRUPT) continue;
217
+ const stateBitmap = this.data.slice(secOff + 32, secOff + 64);
218
+ for (let entry = 0; entry < MAX_ENTRY_COUNT; entry++) {
219
+ if (this._getNVSItemState(stateBitmap, entry) !== 2) continue;
220
+ const eOff = secOff + 64 + entry * 32;
221
+ if (eOff + 32 > this.data.length) break;
222
+ const span = this._u8(eOff + 2);
223
+ if (this._u8(eOff) === 0 && this._u8(eOff + 1) !== 0xFF && this._u8(eOff + 1) !== 0x00) {
224
+ namespaces.set(this._u8(eOff + 24), this._readString(eOff + 8, 16));
225
+ }
226
+ if (span > 1) entry += span - 1;
227
+ }
228
+ }
229
+
230
+ for (let secOff = 0; secOff < this.data.length; secOff += NVS_SECTOR_SIZE) {
231
+ if (secOff + 64 > this.data.length) break;
232
+ const state = this._u32(secOff);
233
+
234
+ let stateName = 'UNKNOWN';
235
+ if (state === NVS_PAGE_STATE.UNINIT) { stateName = 'UNINIT'; }
236
+ else if (state === NVS_PAGE_STATE.ACTIVE) { stateName = 'ACTIVE'; }
237
+ else if (state === NVS_PAGE_STATE.FULL) { stateName = 'FULL'; }
238
+ else if (state === NVS_PAGE_STATE.FREEING) { stateName = 'FREEING'; }
239
+ else if (state === NVS_PAGE_STATE.CORRUPT) { stateName = 'CORRUPT'; }
240
+
241
+ if (stateName === 'UNINIT' || stateName === 'CORRUPT') continue;
242
+
243
+ const seq = this._u32(secOff + 4);
244
+ const version = this._u8(secOff + 8);
245
+ const crc32 = this._u32(secOff + 28);
246
+ const stateBitmap = this.data.slice(secOff + 32, secOff + 64);
247
+
248
+ const page = { offset: secOff, state: stateName, seq, version, crc32, items: [] };
249
+
250
+ for (let entry = 0; entry < MAX_ENTRY_COUNT; entry++) {
251
+ const itemState = this._getNVSItemState(stateBitmap, entry);
252
+ if (itemState !== 2) continue; // only WRITTEN entries
253
+
254
+ const eOff = secOff + 64 + entry * 32;
255
+ if (eOff + 32 > this.data.length) break;
256
+
257
+ const nsIndex = this._u8(eOff);
258
+ const datatype = this._u8(eOff + 1);
259
+ const span = this._u8(eOff + 2);
260
+ const chunkIndex = this._u8(eOff + 3);
261
+
262
+ if (span === 0 || span > 126) continue;
263
+ if (datatype === 0xFF || datatype === 0x00) continue;
264
+ if (nsIndex === 0xFF) continue;
265
+
266
+ const crc = this._u32(eOff + 4);
267
+ const key = this._readString(eOff + 8, 16);
268
+
269
+ if (nsIndex !== 0 && (!key || key.length === 0)) continue;
270
+
271
+ const headerCrcCalc = NVSEditor.crc32Header(this.data, eOff);
272
+
273
+ const item = {
274
+ nsIndex, datatype, span, chunkIndex,
275
+ crc32: crc >>> 0,
276
+ headerCrcCalc: headerCrcCalc >>> 0,
277
+ headerCrcValid: (crc >>> 0) === (headerCrcCalc >>> 0),
278
+ key,
279
+ value: null,
280
+ typeName: this._getNVSTypeName(datatype),
281
+ offset: eOff,
282
+ entrySize: 32,
283
+ pageOffset: secOff
284
+ };
285
+
286
+ // Namespace definition
287
+ if (nsIndex === 0) {
288
+ const namespaceIndex = this._u8(eOff + 24);
289
+ item.value = namespaceIndex;
290
+ item.namespace = key;
291
+ namespaces.set(namespaceIndex, key);
292
+ } else {
293
+ switch (datatype) {
294
+ case 0x01: item.value = this._u8(eOff + 24); break;
295
+ case 0x02: item.value = this._u16(eOff + 24); break;
296
+ case 0x04: item.value = this._u32(eOff + 24); break;
297
+ case 0x08: item.value = this._u64(eOff + 24).toString(); break;
298
+ case 0x11: item.value = (this._u8(eOff + 24) > 127 ? this._u8(eOff + 24) - 256 : this._u8(eOff + 24)); break;
299
+ case 0x12: { const v = this._u16(eOff + 24); item.value = v > 32767 ? v - 65536 : v; break; }
300
+ case 0x14: item.value = this._i32(eOff + 24); break;
301
+ case 0x18: item.value = this._i64(eOff + 24).toString(); break;
302
+ case 0x21: { // String
303
+ const strSize = this._u16(eOff + 24);
304
+ const strCrc = this._u32(eOff + 28);
305
+ if (strSize > 0 && strSize < 4096 && eOff + 32 + strSize <= this.data.length) {
306
+ const strData = this.data.slice(eOff + 32, eOff + 32 + strSize);
307
+ const allErased = strData.every(b => b === 0xFF);
308
+ // Find first NUL byte and decode as UTF-8
309
+ let nullIndex = strData.length;
310
+ for (let i = 0; i < strData.length; i++) {
311
+ if (strData[i] === 0) { nullIndex = i; break; }
312
+ }
313
+ const sv = nullIndex > 0 ? new TextDecoder('utf-8').decode(strData.subarray(0, nullIndex)) : '';
314
+ item.value = allErased ? '<erased>' : sv;
315
+ item.rawValue = strData;
316
+ item.dataCrcStored = strCrc >>> 0;
317
+ item.dataCrcCalc = NVSEditor.crc32(strData, 0, strSize) >>> 0;
318
+ item.dataCrcValid = item.dataCrcCalc === item.dataCrcStored;
319
+ item.size = strSize;
320
+ item.entrySize = 32 + strSize;
321
+ } else {
322
+ item.value = '<invalid string>';
323
+ item.size = 0;
324
+ }
325
+ break;
326
+ }
327
+ case 0x42: { // Blob
328
+ const blobSize = this._u16(eOff + 24);
329
+ const blobCrc = this._u32(eOff + 28);
330
+ if (blobSize > 0 && blobSize < 4096 && eOff + 32 + blobSize <= this.data.length) {
331
+ const blobData = this.data.slice(eOff + 32, eOff + 32 + blobSize);
332
+ const allErased = blobData.every(b => b === 0xFF);
333
+ item.value = allErased ? '<erased>' : NVSEditor.bytesToHex(blobData, ' ');
334
+ item.rawValue = blobData;
335
+ item.dataCrcStored = blobCrc >>> 0;
336
+ item.dataCrcCalc = NVSEditor.crc32(blobData, 0, blobSize) >>> 0;
337
+ item.dataCrcValid = item.dataCrcCalc === item.dataCrcStored;
338
+ item.size = blobSize;
339
+ item.entrySize = 32 + blobSize;
340
+ } else {
341
+ item.value = '<invalid blob>';
342
+ item.size = 0;
343
+ }
344
+ break;
345
+ }
346
+ case 0x48: { // Blob Index
347
+ item.totalSize = this._u32(eOff + 24);
348
+ item.chunkCount = this._u8(eOff + 28);
349
+ item.chunkStart = this._u8(eOff + 29);
350
+ item.value = `${item.chunkCount} chunks, ${item.totalSize} bytes total`;
351
+ break;
352
+ }
353
+ }
354
+ }
355
+
356
+ page.items.push(item);
357
+ if (span > 1) entry += span - 1;
358
+ }
359
+
360
+ pages.push(page);
361
+ }
362
+ // Resolve all item namespaces after both passes
363
+ for (const page of pages)
364
+ for (const it of page.items)
365
+ if (it.nsIndex && it.nsIndex !== 0)
366
+ it.namespace = namespaces.get(it.nsIndex) || `ns_${it.nsIndex}`;
367
+
368
+ return pages;
369
+ }
370
+
371
+ // ─────── NVS write helpers ───────
372
+
373
+ /** Delete an NVS entry by zeroing it and updating the bitmap */
374
+ _deleteEntry(item) {
375
+ const pageOff = item.pageOffset;
376
+ const entryIdx = (item.offset - pageOff - 64) / 32;
377
+
378
+ const stateBitmap = this.data.slice(pageOff + 32, pageOff + 64);
379
+
380
+ for (let s = 0; s < item.span; s++) {
381
+ const off = item.offset + s * 32;
382
+ this.data.fill(0xFF, off, off + 32);
383
+ this._setNVSItemState(stateBitmap, entryIdx + s, 0); // ERASED = 0
384
+ }
385
+ // Write back bitmap
386
+ this.data.set(stateBitmap, pageOff + 32);
387
+ this.modified = true;
388
+ }
389
+
390
+ static _parseIntStrict(s, label, min, max) {
391
+ const n = Number(s);
392
+ if (!Number.isFinite(n) || !Number.isInteger(n) || n < min || n > max)
393
+ throw new Error(`${label} must be an integer in [${min}, ${max}], got: ${s}`);
394
+ return n;
395
+ }
396
+
397
+ /** Write a primitive value back to an existing entry at item.offset */
398
+ _writeValue(item, newValue) {
399
+ const off = item.offset;
400
+ switch (item.datatype) {
401
+ case 0x01: { const v = NVSEditor._parseIntStrict(newValue, 'U8', 0, 255); this.data[off + 24] = v; break; }
402
+ case 0x02: { const v = parseInt(newValue, 10); if (!Number.isFinite(v) || v < 0 || v > 65535) throw new Error('U16 must be 0-65535'); this.data[off + 24] = v & 0xFF; this.data[off + 25] = (v >> 8) & 0xFF; break; }
403
+ case 0x04: { const v = parseInt(newValue, 10); if (!Number.isFinite(v) || v < 0 || v > 4294967295) throw new Error('U32 must be 0-4294967295'); const dv = new DataView(this.data.buffer, off + 24, 4); dv.setUint32(0, v >>> 0, true); break; }
404
+ case 0x08: { let v; try { v = BigInt(newValue); } catch(e) { throw new Error('U64 must be valid BigInt'); } if (v < 0n || v > 0xFFFFFFFFFFFFFFFFn) throw new Error('U64 out of range'); const dv = new DataView(this.data.buffer, off + 24, 8); dv.setBigUint64(0, v, true); break; }
405
+ case 0x11: { const v = parseInt(newValue, 10); if (!Number.isFinite(v) || v < -128 || v > 127) throw new Error('I8 must be -128 to 127'); this.data[off + 24] = v < 0 ? v + 256 : v; break; }
406
+ case 0x12: { const v = parseInt(newValue, 10); if (!Number.isFinite(v) || v < -32768 || v > 32767) throw new Error('I16 must be -32768 to 32767'); const dv = new DataView(this.data.buffer, off + 24, 2); dv.setInt16(0, v, true); break; }
407
+ case 0x14: { const v = parseInt(newValue, 10); if (!Number.isFinite(v) || v < -2147483648 || v > 2147483647) throw new Error('I32 must be -2147483648 to 2147483647'); const dv = new DataView(this.data.buffer, off + 24, 4); dv.setInt32(0, v, true); break; }
408
+ case 0x18: { let v; try { v = BigInt(newValue); } catch(e) { throw new Error('I64 must be valid BigInt'); } if (v < -0x8000000000000000n || v > 0x7FFFFFFFFFFFFFFFn) throw new Error('I64 out of range'); const dv = new DataView(this.data.buffer, off + 24, 8); dv.setBigInt64(0, v, true); break; }
409
+ case 0x21: { // String – rewrite in-place with trailing NUL
410
+ const enc = new TextEncoder().encode(newValue);
411
+ const maxPayload = (item.span - 1) * 32;
412
+ // Create NUL-terminated buffer
413
+ const encWithNul = new Uint8Array(enc.length + 1);
414
+ encWithNul.set(enc, 0);
415
+ encWithNul[enc.length] = 0;
416
+ if (encWithNul.length > maxPayload) { alert('String too long for existing slot (max ' + maxPayload + ' bytes)'); return; }
417
+ // Update size (includes NUL)
418
+ this.data[off + 24] = encWithNul.length & 0xFF;
419
+ this.data[off + 25] = (encWithNul.length >> 8) & 0xFF;
420
+ // Clear old data
421
+ this.data.fill(0xFF, off + 32, off + 32 + maxPayload);
422
+ // Write new data with NUL
423
+ this.data.set(encWithNul, off + 32);
424
+ // Update data CRC over NUL-terminated buffer
425
+ const crc = NVSEditor.crc32(encWithNul);
426
+ const dv = new DataView(this.data.buffer, off + 28, 4);
427
+ dv.setUint32(0, crc, true);
428
+ break;
429
+ }
430
+ case 0x42: { // Blob – rewrite hex bytes in-place
431
+ const hexBytes = newValue.split(/[\s,]+/).filter(b => b).map(b => parseInt(b, 16));
432
+ if (hexBytes.some(b => isNaN(b) || b < 0 || b > 255)) { alert('Invalid hex bytes'); return; }
433
+ const blobData = new Uint8Array(hexBytes);
434
+ const maxPayload = (item.span - 1) * 32;
435
+ if (blobData.length > maxPayload) { alert('Blob too long for existing slot (max ' + maxPayload + ' bytes)'); return; }
436
+ // Update size
437
+ this.data[off + 24] = blobData.length & 0xFF;
438
+ this.data[off + 25] = (blobData.length >> 8) & 0xFF;
439
+ // Clear old data
440
+ this.data.fill(0xFF, off + 32, off + 32 + maxPayload);
441
+ // Write new data
442
+ this.data.set(blobData, off + 32);
443
+ // Update data CRC
444
+ const crc = NVSEditor.crc32(blobData);
445
+ const dv = new DataView(this.data.buffer, off + 28, 4);
446
+ dv.setUint32(0, crc, true);
447
+ break;
448
+ }
449
+ case 0x48: { // Blob Index – edit totalSize
450
+ const v = parseInt(newValue, 10);
451
+ if (!Number.isFinite(v) || v < 0 || v > 0xFFFFFFFF) { alert('Invalid total size (must be 0-4294967295)'); return; }
452
+ const dv = new DataView(this.data.buffer, off + 24, 4);
453
+ dv.setUint32(0, v, true);
454
+ break;
455
+ }
456
+ default:
457
+ alert('Editing this type is not supported');
458
+ return;
459
+ }
460
+ // Recalculate header CRC
461
+ const hcrc = NVSEditor.crc32Header(this.data, off);
462
+ const dv = new DataView(this.data.buffer, off + 4, 4);
463
+ dv.setUint32(0, hcrc, true);
464
+ this.modified = true;
465
+ }
466
+
467
+ // ─────── UI ───────
468
+
469
+ _buildUI() {
470
+ const sizeStr = this.partitionSize >= 1024 * 1024
471
+ ? (this.partitionSize / (1024 * 1024)).toFixed(1) + ' MB'
472
+ : this.partitionSize >= 1024
473
+ ? (this.partitionSize / 1024).toFixed(1) + ' KB'
474
+ : this.partitionSize + ' B';
475
+
476
+ const totalItems = this.pages.reduce((s, p) => s + p.items.length, 0);
477
+
478
+ this.container.innerHTML = `
479
+ <div class="nvseditor-toolbar">
480
+ <h3>NVS Editor</h3>
481
+ <span class="nvs-info">
482
+ Partition: <b>${this._esc(this.partitionName)}</b> |
483
+ Offset: 0x${this.baseAddress.toString(16).toUpperCase()} |
484
+ Size: ${sizeStr} |
485
+ ${this.pages.length} page(s), ${totalItems} entries
486
+ </span>
487
+ <span class="spacer"></span>
488
+ <div class="nvseditor-filter">
489
+ <input id="nvsFilter" type="text" placeholder="Filter by namespace or key..." />
490
+ </div>
491
+ <button id="nvsRefresh" title="Re-parse data">Refresh</button>
492
+ <button id="nvsWrite" class="primary" disabled>Write to Flash</button>
493
+ <button id="nvsClose">Close</button>
494
+ </div>
495
+ <div class="nvseditor-body">
496
+ <div class="nvseditor-progress-overlay hidden" id="nvsProgress">
497
+ <div class="progress-text" id="nvsProgressText">Loading...</div>
498
+ <div class="progress-bar-outer">
499
+ <div class="progress-bar-inner" id="nvsProgressBar"></div>
500
+ </div>
501
+ </div>
502
+ <div class="nvseditor-content" id="nvsContent"></div>
503
+ </div>
504
+ <div class="nvseditor-statusbar">
505
+ <span id="nvsStatus">${totalItems} entries in ${this.pages.length} page(s)</span>
506
+ </div>
507
+ <div id="nvsHexEditorContainer" class="hexeditor-container hidden"></div>`;
508
+
509
+ this._hexEditorContainer = this.container.querySelector('#nvsHexEditorContainer');
510
+
511
+ this._progressOverlay = this.container.querySelector('#nvsProgress');
512
+ this._progressText = this.container.querySelector('#nvsProgressText');
513
+ this._progressBarInner = this.container.querySelector('#nvsProgressBar');
514
+
515
+ // Close
516
+ this.container.querySelector('#nvsClose').addEventListener('click', () => {
517
+ if (this.modified) {
518
+ if (!confirm('You have unsaved modifications. Close anyway?')) return;
519
+ }
520
+ this.close();
521
+ });
522
+
523
+ // Write
524
+ const butWrite = this.container.querySelector('#nvsWrite');
525
+ butWrite.addEventListener('click', async () => {
526
+ if (!this.onWriteFlash) return;
527
+ butWrite.disabled = true;
528
+ try {
529
+ this.showProgress('Writing NVS to flash...', 0);
530
+ await this.onWriteFlash(this.data);
531
+ this.originalData = new Uint8Array(this.data);
532
+ this.modified = false;
533
+ butWrite.disabled = true;
534
+ this.showProgress('Write complete!', 100);
535
+ setTimeout(() => this.hideProgress(), 1000);
536
+ } catch (e) {
537
+ alert('Write failed: ' + e);
538
+ this.hideProgress();
539
+ } finally {
540
+ butWrite.disabled = this.modified === false;
541
+ }
542
+ });
543
+
544
+ // Refresh
545
+ this.container.querySelector('#nvsRefresh').addEventListener('click', () => {
546
+ this.pages = this._parse();
547
+ this._renderContent();
548
+ });
549
+
550
+ // Filter
551
+ this.container.querySelector('#nvsFilter').addEventListener('input', (e) => {
552
+ this._filterText = e.target.value.toLowerCase();
553
+ this._renderContent();
554
+ });
555
+
556
+ this._renderContent();
557
+ }
558
+
559
+ _esc(s) {
560
+ const d = document.createElement('span');
561
+ d.textContent = s;
562
+ return d.innerHTML;
563
+ }
564
+
565
+ _renderContent() {
566
+ const content = this.container.querySelector('#nvsContent');
567
+ if (!content) return;
568
+
569
+ const filter = this._filterText;
570
+
571
+ let html = '';
572
+
573
+ for (const page of this.pages) {
574
+ // Group items by namespace
575
+ const nsGroups = new Map();
576
+ const nsDefs = [];
577
+
578
+ for (const item of page.items) {
579
+ if (item.nsIndex === 0) {
580
+ nsDefs.push(item);
581
+ continue;
582
+ }
583
+ const ns = item.namespace || `ns_${item.nsIndex}`;
584
+ if (!nsGroups.has(ns)) nsGroups.set(ns, []);
585
+ nsGroups.get(ns).push(item);
586
+ }
587
+
588
+ // Apply filter
589
+ let hasVisibleItems = false;
590
+ if (filter) {
591
+ for (const [ns, items] of nsGroups) {
592
+ const filtered = items.filter(it =>
593
+ ns.toLowerCase().includes(filter) ||
594
+ it.key.toLowerCase().includes(filter) ||
595
+ String(it.value).toLowerCase().includes(filter)
596
+ );
597
+ if (filtered.length > 0) hasVisibleItems = true;
598
+ }
599
+ // Also check namespace defs
600
+ for (const nd of nsDefs) {
601
+ if (nd.key.toLowerCase().includes(filter)) hasVisibleItems = true;
602
+ }
603
+ if (!hasVisibleItems) continue;
604
+ } else {
605
+ hasVisibleItems = true;
606
+ }
607
+
608
+ const stateClass = page.state === 'ACTIVE' ? 'state-active' :
609
+ page.state === 'FULL' ? 'state-full' :
610
+ page.state === 'FREEING' ? 'state-freeing' : 'state-other';
611
+
612
+ html += `<div class="nvs-page">
613
+ <div class="nvs-page-header ${stateClass}">
614
+ <span class="nvs-page-state">${page.state}</span>
615
+ <span>Page @ 0x${page.offset.toString(16).toUpperCase()}</span>
616
+ <span>Seq: ${page.seq}</span>
617
+ <span>Version: ${page.version}</span>
618
+ <span>${page.items.length} entries</span>
619
+ </div>`;
620
+
621
+ // Render namespace groups
622
+ for (const [ns, items] of nsGroups) {
623
+ const filteredItems = filter
624
+ ? items.filter(it =>
625
+ ns.toLowerCase().includes(filter) ||
626
+ it.key.toLowerCase().includes(filter) ||
627
+ String(it.value).toLowerCase().includes(filter))
628
+ : items;
629
+ if (filteredItems.length === 0) continue;
630
+
631
+ html += `<div class="nvs-namespace">
632
+ <div class="nvs-namespace-header">
633
+ <span class="nvs-ns-icon">📁</span>
634
+ <span class="nvs-ns-name">${this._esc(ns)}</span>
635
+ <span class="nvs-ns-count">${filteredItems.length} item(s)</span>
636
+ </div>
637
+ <table class="nvs-table">
638
+ <thead><tr>
639
+ <th>Key</th><th>Type</th><th>Value</th><th>CRC</th><th>Offset</th><th>Actions</th>
640
+ </tr></thead>
641
+ <tbody>`;
642
+
643
+ for (const item of filteredItems) {
644
+ const crcOk = item.headerCrcValid !== false;
645
+ const dataCrcOk = item.dataCrcValid !== undefined ? item.dataCrcValid : true;
646
+ const crcClass = (crcOk && dataCrcOk) ? 'crc-ok' : 'crc-bad';
647
+ const crcText = (crcOk && dataCrcOk) ? '✓' : '✗';
648
+
649
+ let displayValue = String(item.value ?? '');
650
+ if (displayValue.length > 120) displayValue = displayValue.substring(0, 120) + '…';
651
+
652
+ const editable = true;
653
+
654
+ html += `<tr>
655
+ <td class="nvs-key" title="${this._esc(item.key)}">${this._esc(item.key)}</td>
656
+ <td class="nvs-type">${this._esc(item.typeName)}</td>
657
+ <td class="nvs-value" title="${this._esc(String(item.value ?? ''))}">${this._esc(displayValue)}</td>
658
+ <td class="nvs-crc ${crcClass}">${crcText}</td>
659
+ <td class="nvs-offset">0x${(this.baseAddress + item.offset).toString(16).toUpperCase()}</td>
660
+ <td class="nvs-actions">
661
+ ${editable ? `<button class="nvs-btn-edit" data-offset="${item.offset}" title="Edit value">✎</button>` : ''}
662
+ <button class="nvs-btn-delete" data-offset="${item.offset}" title="Delete entry">✕</button>
663
+ </td>
664
+ </tr>`;
665
+ }
666
+
667
+ html += `</tbody></table></div>`;
668
+ }
669
+
670
+ html += `</div>`;
671
+ }
672
+
673
+ if (html === '') {
674
+ html = '<div class="nvs-empty">No NVS entries found' + (filter ? ' matching filter' : '') + '</div>';
675
+ }
676
+
677
+ content.innerHTML = html;
678
+
679
+ // Bind edit buttons
680
+ content.querySelectorAll('.nvs-btn-edit').forEach(btn => {
681
+ btn.addEventListener('click', () => {
682
+ const off = parseInt(btn.dataset.offset, 10);
683
+ this._editItem(off);
684
+ });
685
+ });
686
+
687
+ // Bind delete buttons
688
+ content.querySelectorAll('.nvs-btn-delete').forEach(btn => {
689
+ btn.addEventListener('click', () => {
690
+ const off = parseInt(btn.dataset.offset, 10);
691
+ this._deleteItemUI(off);
692
+ });
693
+ });
694
+
695
+ this._updateWriteButton();
696
+ }
697
+
698
+ _findItem(offset) {
699
+ for (const page of this.pages) {
700
+ for (const item of page.items) {
701
+ if (item.offset === offset) return item;
702
+ }
703
+ }
704
+ return null;
705
+ }
706
+
707
+ _editItem(offset) {
708
+ const item = this._findItem(offset);
709
+ if (!item) return;
710
+ this._editItemInHexEditor(item);
711
+ }
712
+
713
+ /**
714
+ * Open the HexEditor for any NVS entry.
715
+ * Primitive types (U8..I64, Blob Index) → 8-byte data field at off+24.
716
+ * String / Blob → multi-span payload at off+32.
717
+ */
718
+ _editItemInHexEditor(item) {
719
+ const off = item.offset;
720
+ const isPrimitive = !(item.datatype === 0x21 || item.datatype === 0x42);
721
+
722
+ let dataOffset, dataSize, maxSize;
723
+ if (isPrimitive) {
724
+ // Primitive types store value in 8 bytes at header offset 24
725
+ dataOffset = off + 24;
726
+ dataSize = 8;
727
+ maxSize = 8;
728
+ } else {
729
+ // String / Blob: payload after the 32-byte header
730
+ dataOffset = off + 32;
731
+ dataSize = item.size || (item.rawValue ? item.rawValue.length : 0);
732
+ maxSize = (item.span - 1) * 32;
733
+ if (dataSize <= 0) { alert('No data to edit'); return; }
734
+ }
735
+
736
+ const entryData = this.data.slice(dataOffset, dataOffset + dataSize);
737
+
738
+ if (!this._hexEditorInstance) {
739
+ this._hexEditorInstance = new HexEditor(this._hexEditorContainer);
740
+ }
741
+
742
+ this._hexEditorContainer.classList.remove('hidden');
743
+ this._hexEditorInstance.open(entryData, 0);
744
+
745
+ // Relabel button and show entry info
746
+ const writeBtn = this._hexEditorContainer.querySelector('#hexedWrite');
747
+ if (writeBtn) writeBtn.textContent = 'Apply Changes';
748
+
749
+ this._hexEditorInstance.onWriteFlash = async (editedData, modifiedOffsets) => {
750
+ if (modifiedOffsets.size === 0) return;
751
+
752
+ if (editedData.length > maxSize) {
753
+ alert('Edited data exceeds available slot size (' + maxSize + ' bytes)');
754
+ return;
755
+ }
756
+
757
+ if (isPrimitive) {
758
+ // Write the 8 data bytes back into the header
759
+ this.data.set(editedData.slice(0, 8), dataOffset);
760
+ } else {
761
+ // Clear payload area, then write
762
+ this.data.fill(0xFF, dataOffset, dataOffset + maxSize);
763
+ this.data.set(editedData, dataOffset);
764
+ // Update size field
765
+ this.data[off + 24] = editedData.length & 0xFF;
766
+ this.data[off + 25] = (editedData.length >> 8) & 0xFF;
767
+ // Update data CRC
768
+ const crc = NVSEditor.crc32(editedData);
769
+ const dv = new DataView(this.data.buffer, off + 28, 4);
770
+ dv.setUint32(0, crc, true);
771
+ }
772
+
773
+ // Recalculate header CRC
774
+ const hcrc = NVSEditor.crc32Header(this.data, off);
775
+ const hdv = new DataView(this.data.buffer, off + 4, 4);
776
+ hdv.setUint32(0, hcrc, true);
777
+
778
+ this.modified = true;
779
+
780
+ this._hexEditorInstance.showProgress('Applied to NVS!', 100);
781
+ await new Promise(r => setTimeout(r, 500));
782
+ this._hexEditorInstance.hideProgress();
783
+ };
784
+
785
+ this._hexEditorInstance.onClose = () => {
786
+ this._hexEditorContainer.classList.add('hidden');
787
+ this._hexEditorInstance = null;
788
+ this.pages = this._parse();
789
+ this._renderContent();
790
+ };
791
+ }
792
+
793
+ _deleteItemUI(offset) {
794
+ const item = this._findItem(offset);
795
+ if (!item) return;
796
+
797
+ const ns = item.namespace || `ns_${item.nsIndex}`;
798
+ if (!confirm(`Delete ${ns}.${item.key}?`)) return;
799
+
800
+ this._deleteEntry(item);
801
+ this.pages = this._parse();
802
+ this._renderContent();
803
+ }
804
+
805
+ _updateWriteButton() {
806
+ const btn = this.container.querySelector('#nvsWrite');
807
+ if (btn) btn.disabled = !this.modified;
808
+ }
809
+ }