esp32tool 1.5.0 → 1.6.1
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/README.md +2 -0
- package/apple-touch-icon.png +0 -0
- package/css/style.css +388 -2
- package/dist/esp_loader.js +3 -13
- package/dist/web/index.js +1 -1
- package/icons/icon-128.png +0 -0
- package/icons/icon-144.png +0 -0
- package/icons/icon-152.png +0 -0
- package/icons/icon-192.png +0 -0
- package/icons/icon-384.png +0 -0
- package/icons/icon-512.png +0 -0
- package/icons/icon-72.png +0 -0
- package/icons/icon-96.png +0 -0
- package/index.html +7 -4
- package/js/modules/esptool.js +1 -1
- package/js/nvs-editor.js +732 -0
- package/js/script.js +142 -4
- package/package.json +8 -5
- package/screenshots/desktop.png +0 -0
- package/screenshots/mobile.png +0 -0
- package/src/esp_loader.ts +3 -16
- package/sw.js +2 -1
package/js/nvs-editor.js
ADDED
|
@@ -0,0 +1,732 @@
|
|
|
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
|
+
// ─────── UI ───────
|
|
391
|
+
|
|
392
|
+
_buildUI() {
|
|
393
|
+
const sizeStr = this.partitionSize >= 1024 * 1024
|
|
394
|
+
? (this.partitionSize / (1024 * 1024)).toFixed(1) + ' MB'
|
|
395
|
+
: this.partitionSize >= 1024
|
|
396
|
+
? (this.partitionSize / 1024).toFixed(1) + ' KB'
|
|
397
|
+
: this.partitionSize + ' B';
|
|
398
|
+
|
|
399
|
+
const totalItems = this.pages.reduce((s, p) => s + p.items.length, 0);
|
|
400
|
+
|
|
401
|
+
this.container.innerHTML = `
|
|
402
|
+
<div class="nvseditor-toolbar">
|
|
403
|
+
<h3>NVS Editor</h3>
|
|
404
|
+
<span class="nvs-info">
|
|
405
|
+
Partition: <b>${this._esc(this.partitionName)}</b> |
|
|
406
|
+
Offset: 0x${this.baseAddress.toString(16).toUpperCase()} |
|
|
407
|
+
Size: ${sizeStr} |
|
|
408
|
+
${this.pages.length} page(s), ${totalItems} entries
|
|
409
|
+
</span>
|
|
410
|
+
<span class="spacer"></span>
|
|
411
|
+
<div class="nvseditor-filter">
|
|
412
|
+
<input id="nvsFilter" type="text" placeholder="Filter by namespace or key..." />
|
|
413
|
+
</div>
|
|
414
|
+
<button id="nvsRefresh" title="Re-parse data">Refresh</button>
|
|
415
|
+
<button id="nvsWrite" class="primary" disabled>Write to Flash</button>
|
|
416
|
+
<button id="nvsClose">Close</button>
|
|
417
|
+
</div>
|
|
418
|
+
<div class="nvseditor-body">
|
|
419
|
+
<div class="nvseditor-progress-overlay hidden" id="nvsProgress">
|
|
420
|
+
<div class="progress-text" id="nvsProgressText">Loading...</div>
|
|
421
|
+
<div class="progress-bar-outer">
|
|
422
|
+
<div class="progress-bar-inner" id="nvsProgressBar"></div>
|
|
423
|
+
</div>
|
|
424
|
+
</div>
|
|
425
|
+
<div class="nvseditor-content" id="nvsContent"></div>
|
|
426
|
+
</div>
|
|
427
|
+
<div class="nvseditor-statusbar">
|
|
428
|
+
<span id="nvsStatus">${totalItems} entries in ${this.pages.length} page(s)</span>
|
|
429
|
+
</div>
|
|
430
|
+
<div id="nvsHexEditorContainer" class="hexeditor-container hidden"></div>`;
|
|
431
|
+
|
|
432
|
+
this._hexEditorContainer = this.container.querySelector('#nvsHexEditorContainer');
|
|
433
|
+
|
|
434
|
+
this._progressOverlay = this.container.querySelector('#nvsProgress');
|
|
435
|
+
this._progressText = this.container.querySelector('#nvsProgressText');
|
|
436
|
+
this._progressBarInner = this.container.querySelector('#nvsProgressBar');
|
|
437
|
+
|
|
438
|
+
// Close
|
|
439
|
+
this.container.querySelector('#nvsClose').addEventListener('click', () => {
|
|
440
|
+
if (this.modified) {
|
|
441
|
+
if (!confirm('You have unsaved modifications. Close anyway?')) return;
|
|
442
|
+
}
|
|
443
|
+
this.close();
|
|
444
|
+
});
|
|
445
|
+
|
|
446
|
+
// Write
|
|
447
|
+
const butWrite = this.container.querySelector('#nvsWrite');
|
|
448
|
+
butWrite.addEventListener('click', async () => {
|
|
449
|
+
if (!this.onWriteFlash) return;
|
|
450
|
+
butWrite.disabled = true;
|
|
451
|
+
try {
|
|
452
|
+
this.showProgress('Writing NVS to flash...', 0);
|
|
453
|
+
await this.onWriteFlash(this.data);
|
|
454
|
+
this.originalData = new Uint8Array(this.data);
|
|
455
|
+
this.modified = false;
|
|
456
|
+
butWrite.disabled = true;
|
|
457
|
+
this.showProgress('Write complete!', 100);
|
|
458
|
+
setTimeout(() => this.hideProgress(), 1000);
|
|
459
|
+
} catch (e) {
|
|
460
|
+
alert('Write failed: ' + e);
|
|
461
|
+
this.hideProgress();
|
|
462
|
+
} finally {
|
|
463
|
+
butWrite.disabled = this.modified === false;
|
|
464
|
+
}
|
|
465
|
+
});
|
|
466
|
+
|
|
467
|
+
// Refresh
|
|
468
|
+
this.container.querySelector('#nvsRefresh').addEventListener('click', () => {
|
|
469
|
+
this.pages = this._parse();
|
|
470
|
+
this._renderContent();
|
|
471
|
+
});
|
|
472
|
+
|
|
473
|
+
// Filter
|
|
474
|
+
this.container.querySelector('#nvsFilter').addEventListener('input', (e) => {
|
|
475
|
+
this._filterText = e.target.value.toLowerCase();
|
|
476
|
+
this._renderContent();
|
|
477
|
+
});
|
|
478
|
+
|
|
479
|
+
this._renderContent();
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
_esc(s) {
|
|
483
|
+
const d = document.createElement('span');
|
|
484
|
+
d.textContent = s;
|
|
485
|
+
return d.innerHTML;
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
_renderContent() {
|
|
489
|
+
const content = this.container.querySelector('#nvsContent');
|
|
490
|
+
if (!content) return;
|
|
491
|
+
|
|
492
|
+
const filter = this._filterText;
|
|
493
|
+
|
|
494
|
+
let html = '';
|
|
495
|
+
|
|
496
|
+
for (const page of this.pages) {
|
|
497
|
+
// Group items by namespace
|
|
498
|
+
const nsGroups = new Map();
|
|
499
|
+
const nsDefs = [];
|
|
500
|
+
|
|
501
|
+
for (const item of page.items) {
|
|
502
|
+
if (item.nsIndex === 0) {
|
|
503
|
+
nsDefs.push(item);
|
|
504
|
+
continue;
|
|
505
|
+
}
|
|
506
|
+
const ns = item.namespace || `ns_${item.nsIndex}`;
|
|
507
|
+
if (!nsGroups.has(ns)) nsGroups.set(ns, []);
|
|
508
|
+
nsGroups.get(ns).push(item);
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
// Apply filter
|
|
512
|
+
let hasVisibleItems = false;
|
|
513
|
+
if (filter) {
|
|
514
|
+
for (const [ns, items] of nsGroups) {
|
|
515
|
+
const filtered = items.filter(it =>
|
|
516
|
+
ns.toLowerCase().includes(filter) ||
|
|
517
|
+
it.key.toLowerCase().includes(filter) ||
|
|
518
|
+
String(it.value).toLowerCase().includes(filter)
|
|
519
|
+
);
|
|
520
|
+
if (filtered.length > 0) hasVisibleItems = true;
|
|
521
|
+
}
|
|
522
|
+
// Also check namespace defs
|
|
523
|
+
for (const nd of nsDefs) {
|
|
524
|
+
if (nd.key.toLowerCase().includes(filter)) hasVisibleItems = true;
|
|
525
|
+
}
|
|
526
|
+
if (!hasVisibleItems) continue;
|
|
527
|
+
} else {
|
|
528
|
+
hasVisibleItems = true;
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
const stateClass = page.state === 'ACTIVE' ? 'state-active' :
|
|
532
|
+
page.state === 'FULL' ? 'state-full' :
|
|
533
|
+
page.state === 'FREEING' ? 'state-freeing' : 'state-other';
|
|
534
|
+
|
|
535
|
+
html += `<div class="nvs-page">
|
|
536
|
+
<div class="nvs-page-header ${stateClass}">
|
|
537
|
+
<span class="nvs-page-state">${page.state}</span>
|
|
538
|
+
<span>Page @ 0x${page.offset.toString(16).toUpperCase()}</span>
|
|
539
|
+
<span>Seq: ${page.seq}</span>
|
|
540
|
+
<span>Version: ${page.version === 0xFF ? 'v1' : page.version === 0xFE ? 'v2' : page.version}</span>
|
|
541
|
+
<span>${page.items.length} entries</span>
|
|
542
|
+
</div>`;
|
|
543
|
+
|
|
544
|
+
// Render namespace groups
|
|
545
|
+
for (const [ns, items] of nsGroups) {
|
|
546
|
+
const filteredItems = filter
|
|
547
|
+
? items.filter(it =>
|
|
548
|
+
ns.toLowerCase().includes(filter) ||
|
|
549
|
+
it.key.toLowerCase().includes(filter) ||
|
|
550
|
+
String(it.value).toLowerCase().includes(filter))
|
|
551
|
+
: items;
|
|
552
|
+
if (filteredItems.length === 0) continue;
|
|
553
|
+
|
|
554
|
+
html += `<div class="nvs-namespace">
|
|
555
|
+
<div class="nvs-namespace-header">
|
|
556
|
+
<span class="nvs-ns-icon">📁</span>
|
|
557
|
+
<span class="nvs-ns-name">${this._esc(ns)}</span>
|
|
558
|
+
<span class="nvs-ns-count">${filteredItems.length} item(s)</span>
|
|
559
|
+
</div>
|
|
560
|
+
<table class="nvs-table">
|
|
561
|
+
<thead><tr>
|
|
562
|
+
<th>Key</th><th>Type</th><th>Value</th><th>CRC</th><th>Offset</th><th>Actions</th>
|
|
563
|
+
</tr></thead>
|
|
564
|
+
<tbody>`;
|
|
565
|
+
|
|
566
|
+
for (const item of filteredItems) {
|
|
567
|
+
const crcOk = item.headerCrcValid !== false;
|
|
568
|
+
const dataCrcOk = item.dataCrcValid !== undefined ? item.dataCrcValid : true;
|
|
569
|
+
const crcClass = (crcOk && dataCrcOk) ? 'crc-ok' : 'crc-bad';
|
|
570
|
+
const crcText = (crcOk && dataCrcOk) ? '✓' : '✗';
|
|
571
|
+
|
|
572
|
+
let displayValue = String(item.value ?? '');
|
|
573
|
+
if (displayValue.length > 120) displayValue = displayValue.substring(0, 120) + '…';
|
|
574
|
+
|
|
575
|
+
const editable = true;
|
|
576
|
+
|
|
577
|
+
html += `<tr>
|
|
578
|
+
<td class="nvs-key" title="${this._esc(item.key)}">${this._esc(item.key)}</td>
|
|
579
|
+
<td class="nvs-type">${this._esc(item.typeName)}</td>
|
|
580
|
+
<td class="nvs-value" title="${this._esc(String(item.value ?? ''))}">${this._esc(displayValue)}</td>
|
|
581
|
+
<td class="nvs-crc ${crcClass}">${crcText}</td>
|
|
582
|
+
<td class="nvs-offset">0x${(this.baseAddress + item.offset).toString(16).toUpperCase()}</td>
|
|
583
|
+
<td class="nvs-actions">
|
|
584
|
+
${editable ? `<button class="nvs-btn-edit" data-offset="${item.offset}" title="Edit value">✎</button>` : ''}
|
|
585
|
+
<button class="nvs-btn-delete" data-offset="${item.offset}" title="Delete entry">✕</button>
|
|
586
|
+
</td>
|
|
587
|
+
</tr>`;
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
html += `</tbody></table></div>`;
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
html += `</div>`;
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
if (html === '') {
|
|
597
|
+
html = '<div class="nvs-empty">No NVS entries found' + (filter ? ' matching filter' : '') + '</div>';
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
content.innerHTML = html;
|
|
601
|
+
|
|
602
|
+
// Bind edit buttons
|
|
603
|
+
content.querySelectorAll('.nvs-btn-edit').forEach(btn => {
|
|
604
|
+
btn.addEventListener('click', () => {
|
|
605
|
+
const off = parseInt(btn.dataset.offset, 10);
|
|
606
|
+
this._editItem(off);
|
|
607
|
+
});
|
|
608
|
+
});
|
|
609
|
+
|
|
610
|
+
// Bind delete buttons
|
|
611
|
+
content.querySelectorAll('.nvs-btn-delete').forEach(btn => {
|
|
612
|
+
btn.addEventListener('click', () => {
|
|
613
|
+
const off = parseInt(btn.dataset.offset, 10);
|
|
614
|
+
this._deleteItemUI(off);
|
|
615
|
+
});
|
|
616
|
+
});
|
|
617
|
+
|
|
618
|
+
this._updateWriteButton();
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
_findItem(offset) {
|
|
622
|
+
for (const page of this.pages) {
|
|
623
|
+
for (const item of page.items) {
|
|
624
|
+
if (item.offset === offset) return item;
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
return null;
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
_editItem(offset) {
|
|
631
|
+
const item = this._findItem(offset);
|
|
632
|
+
if (!item) return;
|
|
633
|
+
this._editItemInHexEditor(item);
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
/**
|
|
637
|
+
* Open the HexEditor for any NVS entry.
|
|
638
|
+
* Primitive types (U8..I64, Blob Index) → 8-byte data field at off+24.
|
|
639
|
+
* String / Blob → multi-span payload at off+32.
|
|
640
|
+
*/
|
|
641
|
+
_editItemInHexEditor(item) {
|
|
642
|
+
const off = item.offset;
|
|
643
|
+
const isPrimitive = !(item.datatype === 0x21 || item.datatype === 0x42);
|
|
644
|
+
|
|
645
|
+
let dataOffset, dataSize, maxSize;
|
|
646
|
+
if (isPrimitive) {
|
|
647
|
+
// Primitive types store value in 8 bytes at header offset 24
|
|
648
|
+
dataOffset = off + 24;
|
|
649
|
+
dataSize = 8;
|
|
650
|
+
maxSize = 8;
|
|
651
|
+
} else {
|
|
652
|
+
// String / Blob: payload after the 32-byte header
|
|
653
|
+
dataOffset = off + 32;
|
|
654
|
+
dataSize = item.size || (item.rawValue ? item.rawValue.length : 0);
|
|
655
|
+
maxSize = (item.span - 1) * 32;
|
|
656
|
+
if (dataSize <= 0) { alert('No data to edit'); return; }
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
const entryData = this.data.slice(dataOffset, dataOffset + dataSize);
|
|
660
|
+
|
|
661
|
+
if (!this._hexEditorInstance) {
|
|
662
|
+
this._hexEditorInstance = new HexEditor(this._hexEditorContainer);
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
this._hexEditorContainer.classList.remove('hidden');
|
|
666
|
+
this._hexEditorInstance.open(entryData, 0);
|
|
667
|
+
|
|
668
|
+
// Relabel button and show entry info
|
|
669
|
+
const writeBtn = this._hexEditorContainer.querySelector('#hexedWrite');
|
|
670
|
+
if (writeBtn) writeBtn.textContent = 'Apply Changes';
|
|
671
|
+
|
|
672
|
+
this._hexEditorInstance.onWriteFlash = async (editedData, modifiedOffsets) => {
|
|
673
|
+
if (modifiedOffsets.size === 0) return;
|
|
674
|
+
|
|
675
|
+
if (editedData.length > maxSize) {
|
|
676
|
+
alert('Edited data exceeds available slot size (' + maxSize + ' bytes)');
|
|
677
|
+
return;
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
if (isPrimitive) {
|
|
681
|
+
// Write the 8 data bytes back into the header
|
|
682
|
+
this.data.set(editedData.slice(0, 8), dataOffset);
|
|
683
|
+
} else {
|
|
684
|
+
// Clear payload area, then write
|
|
685
|
+
this.data.fill(0xFF, dataOffset, dataOffset + maxSize);
|
|
686
|
+
this.data.set(editedData, dataOffset);
|
|
687
|
+
// Update size field
|
|
688
|
+
this.data[off + 24] = editedData.length & 0xFF;
|
|
689
|
+
this.data[off + 25] = (editedData.length >> 8) & 0xFF;
|
|
690
|
+
// Update data CRC
|
|
691
|
+
const crc = NVSEditor.crc32(editedData);
|
|
692
|
+
const dv = new DataView(this.data.buffer, off + 28, 4);
|
|
693
|
+
dv.setUint32(0, crc, true);
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
// Recalculate header CRC
|
|
697
|
+
const hcrc = NVSEditor.crc32Header(this.data, off);
|
|
698
|
+
const hdv = new DataView(this.data.buffer, off + 4, 4);
|
|
699
|
+
hdv.setUint32(0, hcrc, true);
|
|
700
|
+
|
|
701
|
+
this.modified = true;
|
|
702
|
+
|
|
703
|
+
this._hexEditorInstance.showProgress('Applied to NVS!', 100);
|
|
704
|
+
await new Promise(r => setTimeout(r, 500));
|
|
705
|
+
this._hexEditorInstance.hideProgress();
|
|
706
|
+
};
|
|
707
|
+
|
|
708
|
+
this._hexEditorInstance.onClose = () => {
|
|
709
|
+
this._hexEditorContainer.classList.add('hidden');
|
|
710
|
+
this._hexEditorInstance = null;
|
|
711
|
+
this.pages = this._parse();
|
|
712
|
+
this._renderContent();
|
|
713
|
+
};
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
_deleteItemUI(offset) {
|
|
717
|
+
const item = this._findItem(offset);
|
|
718
|
+
if (!item) return;
|
|
719
|
+
|
|
720
|
+
const ns = item.namespace || `ns_${item.nsIndex}`;
|
|
721
|
+
if (!confirm(`Delete ${ns}.${item.key}?`)) return;
|
|
722
|
+
|
|
723
|
+
this._deleteEntry(item);
|
|
724
|
+
this.pages = this._parse();
|
|
725
|
+
this._renderContent();
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
_updateWriteButton() {
|
|
729
|
+
const btn = this.container.querySelector('#nvsWrite');
|
|
730
|
+
if (btn) btn.disabled = !this.modified;
|
|
731
|
+
}
|
|
732
|
+
}
|