esp-nvs-utils 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,249 @@
1
+ //#region src/crc.ts
2
+ /**
3
+ * Pure CRC-32 implementation compatible with zlib.crc32 (polynomial 0xEDB88320).
4
+ * Browser-safe: no Node.js dependencies.
5
+ */
6
+ const CRC32_TABLE = (() => {
7
+ const table = new Uint32Array(256);
8
+ for (let i = 0; i < 256; i++) {
9
+ let c = i;
10
+ for (let j = 0; j < 8; j++) c = c & 1 ? 3988292384 ^ c >>> 1 : c >>> 1;
11
+ table[i] = c;
12
+ }
13
+ return table;
14
+ })();
15
+ /**
16
+ * Compute CRC-32 of data, matching Python's `zlib.crc32(data, seed)`.
17
+ * The default seed of 0xFFFFFFFF matches the NVS usage pattern.
18
+ */
19
+ function crc32(data, seed = 4294967295) {
20
+ let crc = seed >>> 0;
21
+ for (let i = 0; i < data.length; i++) crc = CRC32_TABLE[(crc ^ data[i]) & 255] ^ crc >>> 8;
22
+ return crc >>> 0;
23
+ }
24
+ //#endregion
25
+ //#region src/constants.ts
26
+ /** NVS page size in bytes. */
27
+ const PAGE_SIZE = 4096;
28
+ /** Page state: page is currently being written. */
29
+ const PAGE_STATE_ACTIVE = 4294967294;
30
+ /** Page state: page is full, no more entries can be written. */
31
+ const PAGE_STATE_FULL = 4294967292;
32
+ /** Map from NVS encoding string to type code. */
33
+ const ENCODING_TO_TYPE_CODE = {
34
+ u8: 1,
35
+ i8: 17,
36
+ u16: 2,
37
+ i16: 18,
38
+ u32: 4,
39
+ i32: 20,
40
+ u64: 8,
41
+ i64: 24,
42
+ string: 33,
43
+ blob_data: 66
44
+ };
45
+ //#endregion
46
+ //#region src/decoder.ts
47
+ function readKey(entry) {
48
+ let end = 8;
49
+ while (end < 24 && entry[end] !== 0) end++;
50
+ return new TextDecoder().decode(entry.subarray(8, end));
51
+ }
52
+ function entryBitmapState(bitmap, idx) {
53
+ const bitNum = idx * 2;
54
+ const byteIdx = bitNum >> 3;
55
+ const bitOffset = bitNum & 7;
56
+ return bitmap[byteIdx] >> bitOffset & 3;
57
+ }
58
+ function parsePrimitive(typeCode, inlineData) {
59
+ const view = new DataView(inlineData.buffer, inlineData.byteOffset, inlineData.byteLength);
60
+ switch (typeCode) {
61
+ case 1: return view.getUint8(0);
62
+ case 17: return view.getInt8(0);
63
+ case 2: return view.getUint16(0, true);
64
+ case 18: return view.getInt16(0, true);
65
+ case 4: return view.getUint32(0, true);
66
+ case 20: return view.getInt32(0, true);
67
+ case 8: return view.getBigUint64(0, true);
68
+ case 24: return view.getBigInt64(0, true);
69
+ default: throw new Error(`Unknown primitive type code: 0x${typeCode.toString(16)}`);
70
+ }
71
+ }
72
+ const PRIMITIVE_TYPES = new Set([
73
+ 1,
74
+ 17,
75
+ 2,
76
+ 18,
77
+ 4,
78
+ 20,
79
+ 8,
80
+ 24
81
+ ]);
82
+ const VARLEN_TYPES = new Set([
83
+ 33,
84
+ 65,
85
+ 66,
86
+ 72
87
+ ]);
88
+ /**
89
+ * Parse all valid written entries from a single 4096-byte page.
90
+ * Returns entries in slot order, including namespace entries.
91
+ */
92
+ function parsePage(pageData) {
93
+ const bitmap = pageData.subarray(32, 64);
94
+ const entries = [];
95
+ let i = 0;
96
+ while (i < 126) {
97
+ if (entryBitmapState(bitmap, i) !== 2) {
98
+ i++;
99
+ continue;
100
+ }
101
+ const entryOff = 64 + i * 32;
102
+ const entry = pageData.subarray(entryOff, entryOff + 32);
103
+ const nsIdx = entry[0];
104
+ const typeCode = entry[1];
105
+ let span = entry[2];
106
+ const chunkIndex = entry[3];
107
+ const safeSpan = span === 0 || span === 255 ? 1 : span;
108
+ const parsed = {
109
+ nsIdx,
110
+ typeCode,
111
+ span: safeSpan,
112
+ chunkIndex,
113
+ key: readKey(entry),
114
+ raw: entry,
115
+ inlineData: entry.subarray(24, 32),
116
+ entryIndex: i
117
+ };
118
+ if (VARLEN_TYPES.has(typeCode) && typeCode !== 72) {
119
+ const childCount = safeSpan - 1;
120
+ if (childCount > 0) {
121
+ const childStart = 64 + (i + 1) * 32;
122
+ const childEnd = childStart + childCount * 32;
123
+ parsed.childData = pageData.subarray(childStart, childEnd);
124
+ }
125
+ }
126
+ entries.push(parsed);
127
+ i += safeSpan;
128
+ }
129
+ return entries;
130
+ }
131
+ /**
132
+ * Decode an NVS partition binary into structured namespaces.
133
+ *
134
+ * Handles V1 (single-page blobs) and V2 (multi-page blobs).
135
+ * Does not support encrypted partitions.
136
+ */
137
+ function decodeNVS(data) {
138
+ const buf = data instanceof Uint8Array ? data : new Uint8Array(data);
139
+ if (buf.length % 4096 !== 0) throw new Error(`Partition data length ${buf.length} is not aligned to page size ${PAGE_SIZE}`);
140
+ const allEntries = [];
141
+ for (let pageOff = 0; pageOff < buf.length; pageOff += PAGE_SIZE) {
142
+ const page = buf.subarray(pageOff, pageOff + PAGE_SIZE);
143
+ const pageStateRaw = (page[0] | page[1] << 8 | page[2] << 16 | page[3] << 24) >>> 0;
144
+ if (pageStateRaw === 4294967295 || pageStateRaw === 0) continue;
145
+ const version = page[8];
146
+ if (version !== 255 && version !== 254) continue;
147
+ allEntries.push(...parsePage(page));
148
+ }
149
+ const nsIdxToName = /* @__PURE__ */ new Map();
150
+ for (const e of allEntries) if (e.nsIdx === 0 && e.typeCode === 1) {
151
+ const assignedIdx = e.inlineData[0];
152
+ nsIdxToName.set(assignedIdx, e.key);
153
+ }
154
+ const blobDataChunks = /* @__PURE__ */ new Map();
155
+ const blobIndex = /* @__PURE__ */ new Map();
156
+ const v1Blobs = /* @__PURE__ */ new Map();
157
+ for (const e of allEntries) {
158
+ if (e.nsIdx === 0) continue;
159
+ const bk = `${e.nsIdx}:${e.key}`;
160
+ if (e.typeCode === 66) {
161
+ if (!blobDataChunks.has(bk)) blobDataChunks.set(bk, /* @__PURE__ */ new Map());
162
+ const payload = e.childData ?? new Uint8Array(0);
163
+ blobDataChunks.get(bk).set(e.chunkIndex, payload);
164
+ } else if (e.typeCode === 72) {
165
+ const view = new DataView(e.inlineData.buffer, e.inlineData.byteOffset, 8);
166
+ blobIndex.set(bk, {
167
+ totalSize: view.getUint32(0, true),
168
+ chunkCount: e.inlineData[4],
169
+ chunkStart: e.inlineData[5]
170
+ });
171
+ } else if (e.typeCode === 65) {
172
+ const size = new DataView(e.inlineData.buffer, e.inlineData.byteOffset, 8).getUint16(0, true);
173
+ const payload = e.childData ?? new Uint8Array(0);
174
+ v1Blobs.set(bk, {
175
+ size,
176
+ data: payload
177
+ });
178
+ }
179
+ }
180
+ const result = {};
181
+ for (const e of allEntries) {
182
+ if (e.nsIdx === 0) continue;
183
+ if (e.typeCode === 66 || e.typeCode === 72) continue;
184
+ const nsName = nsIdxToName.get(e.nsIdx);
185
+ if (nsName === void 0) continue;
186
+ if (!result[nsName]) result[nsName] = [];
187
+ const bk = `${e.nsIdx}:${e.key}`;
188
+ if (PRIMITIVE_TYPES.has(e.typeCode)) {
189
+ const val = parsePrimitive(e.typeCode, e.inlineData);
190
+ const enc = e.typeCode === 1 ? "u8" : e.typeCode === 17 ? "i8" : e.typeCode === 2 ? "u16" : e.typeCode === 18 ? "i16" : e.typeCode === 4 ? "u32" : e.typeCode === 20 ? "i32" : e.typeCode === 8 ? "u64" : "i64";
191
+ result[nsName].push({
192
+ name: e.key,
193
+ encoding: enc,
194
+ value: val
195
+ });
196
+ } else if (e.typeCode === 33) {
197
+ const size = new DataView(e.inlineData.buffer, e.inlineData.byteOffset, 8).getUint16(0, true);
198
+ const payload = e.childData ?? new Uint8Array(0);
199
+ payload.subarray(0, size).filter((_, idx) => {
200
+ const slice = payload.subarray(0, size);
201
+ let last = slice.length - 1;
202
+ while (last >= 0 && slice[last] === 0) last--;
203
+ return idx <= last;
204
+ });
205
+ const str = new TextDecoder().decode(payload.subarray(0, Math.max(0, size - 1)));
206
+ result[nsName].push({
207
+ name: e.key,
208
+ encoding: "string",
209
+ value: str
210
+ });
211
+ } else if (e.typeCode === 65) {
212
+ const v1 = v1Blobs.get(bk);
213
+ if (v1) {
214
+ const data = v1.data.subarray(0, v1.size);
215
+ result[nsName].push({
216
+ name: e.key,
217
+ encoding: "blob_data",
218
+ value: new Uint8Array(data)
219
+ });
220
+ }
221
+ }
222
+ }
223
+ for (const [bk, chunks] of blobDataChunks) {
224
+ const [nsIdxStr, ...keyParts] = bk.split(":");
225
+ const nsIdx = parseInt(nsIdxStr);
226
+ const key = keyParts.join(":");
227
+ const nsName = nsIdxToName.get(nsIdx);
228
+ if (nsName === void 0) continue;
229
+ const idx = blobIndex.get(bk);
230
+ const allChunkData = [...chunks.keys()].sort((a, b) => a - b).map((k) => chunks.get(k));
231
+ const totalRaw = allChunkData.reduce((acc, c) => acc + c.length, 0);
232
+ const assembled = new Uint8Array(totalRaw);
233
+ let off = 0;
234
+ for (const c of allChunkData) {
235
+ assembled.set(c, off);
236
+ off += c.length;
237
+ }
238
+ const trimmed = idx ? assembled.subarray(0, idx.totalSize) : assembled;
239
+ if (!result[nsName]) result[nsName] = [];
240
+ result[nsName].push({
241
+ name: key,
242
+ encoding: "blob_data",
243
+ value: new Uint8Array(trimmed)
244
+ });
245
+ }
246
+ return result;
247
+ }
248
+ //#endregion
249
+ export { PAGE_STATE_FULL as a, PAGE_STATE_ACTIVE as i, ENCODING_TO_TYPE_CODE as n, crc32 as o, PAGE_SIZE as r, decodeNVS as t };
@@ -0,0 +1,219 @@
1
+ //#region src/constants.ts
2
+ /** NVS page size in bytes. */
3
+ const PAGE_SIZE = 4096;
4
+ /** Page state: page is currently being written. */
5
+ const PAGE_STATE_ACTIVE = 4294967294;
6
+ /** Page state: page is full, no more entries can be written. */
7
+ const PAGE_STATE_FULL = 4294967292;
8
+ /** Map from NVS encoding string to type code. */
9
+ const ENCODING_TO_TYPE_CODE = {
10
+ u8: 1,
11
+ i8: 17,
12
+ u16: 2,
13
+ i16: 18,
14
+ u32: 4,
15
+ i32: 20,
16
+ u64: 8,
17
+ i64: 24,
18
+ string: 33,
19
+ blob_data: 66
20
+ };
21
+ //#endregion
22
+ //#region src/decoder.ts
23
+ function readKey(entry) {
24
+ let end = 8;
25
+ while (end < 24 && entry[end] !== 0) end++;
26
+ return new TextDecoder().decode(entry.subarray(8, end));
27
+ }
28
+ function entryBitmapState(bitmap, idx) {
29
+ const bitNum = idx * 2;
30
+ const byteIdx = bitNum >> 3;
31
+ const bitOffset = bitNum & 7;
32
+ return bitmap[byteIdx] >> bitOffset & 3;
33
+ }
34
+ function parsePrimitive(typeCode, inlineData) {
35
+ const view = new DataView(inlineData.buffer, inlineData.byteOffset, inlineData.byteLength);
36
+ switch (typeCode) {
37
+ case 1: return view.getUint8(0);
38
+ case 17: return view.getInt8(0);
39
+ case 2: return view.getUint16(0, true);
40
+ case 18: return view.getInt16(0, true);
41
+ case 4: return view.getUint32(0, true);
42
+ case 20: return view.getInt32(0, true);
43
+ case 8: return view.getBigUint64(0, true);
44
+ case 24: return view.getBigInt64(0, true);
45
+ default: throw new Error(`Unknown primitive type code: 0x${typeCode.toString(16)}`);
46
+ }
47
+ }
48
+ const PRIMITIVE_TYPES = new Set([
49
+ 1,
50
+ 17,
51
+ 2,
52
+ 18,
53
+ 4,
54
+ 20,
55
+ 8,
56
+ 24
57
+ ]);
58
+ const VARLEN_TYPES = new Set([
59
+ 33,
60
+ 65,
61
+ 66,
62
+ 72
63
+ ]);
64
+ /**
65
+ * Parse all valid written entries from a single 4096-byte page.
66
+ * Returns entries in slot order, including namespace entries.
67
+ */
68
+ function parsePage(pageData) {
69
+ const bitmap = pageData.subarray(32, 64);
70
+ const entries = [];
71
+ let i = 0;
72
+ while (i < 126) {
73
+ if (entryBitmapState(bitmap, i) !== 2) {
74
+ i++;
75
+ continue;
76
+ }
77
+ const entryOff = 64 + i * 32;
78
+ const entry = pageData.subarray(entryOff, entryOff + 32);
79
+ const nsIdx = entry[0];
80
+ const typeCode = entry[1];
81
+ let span = entry[2];
82
+ const chunkIndex = entry[3];
83
+ const safeSpan = span === 0 || span === 255 ? 1 : span;
84
+ const parsed = {
85
+ nsIdx,
86
+ typeCode,
87
+ span: safeSpan,
88
+ chunkIndex,
89
+ key: readKey(entry),
90
+ raw: entry,
91
+ inlineData: entry.subarray(24, 32),
92
+ entryIndex: i
93
+ };
94
+ if (VARLEN_TYPES.has(typeCode) && typeCode !== 72) {
95
+ const childCount = safeSpan - 1;
96
+ if (childCount > 0) {
97
+ const childStart = 64 + (i + 1) * 32;
98
+ const childEnd = childStart + childCount * 32;
99
+ parsed.childData = pageData.subarray(childStart, childEnd);
100
+ }
101
+ }
102
+ entries.push(parsed);
103
+ i += safeSpan;
104
+ }
105
+ return entries;
106
+ }
107
+ /**
108
+ * Decode an NVS partition binary into structured namespaces.
109
+ *
110
+ * Handles V1 (single-page blobs) and V2 (multi-page blobs).
111
+ * Does not support encrypted partitions.
112
+ */
113
+ function decodeNVS(data) {
114
+ const buf = data instanceof Uint8Array ? data : new Uint8Array(data);
115
+ if (buf.length % 4096 !== 0) throw new Error(`Partition data length ${buf.length} is not aligned to page size ${PAGE_SIZE}`);
116
+ const allEntries = [];
117
+ for (let pageOff = 0; pageOff < buf.length; pageOff += PAGE_SIZE) {
118
+ const page = buf.subarray(pageOff, pageOff + PAGE_SIZE);
119
+ const pageStateRaw = (page[0] | page[1] << 8 | page[2] << 16 | page[3] << 24) >>> 0;
120
+ if (pageStateRaw === 4294967295 || pageStateRaw === 0) continue;
121
+ const version = page[8];
122
+ if (version !== 255 && version !== 254) continue;
123
+ allEntries.push(...parsePage(page));
124
+ }
125
+ const nsIdxToName = /* @__PURE__ */ new Map();
126
+ for (const e of allEntries) if (e.nsIdx === 0 && e.typeCode === 1) {
127
+ const assignedIdx = e.inlineData[0];
128
+ nsIdxToName.set(assignedIdx, e.key);
129
+ }
130
+ const blobDataChunks = /* @__PURE__ */ new Map();
131
+ const blobIndex = /* @__PURE__ */ new Map();
132
+ const v1Blobs = /* @__PURE__ */ new Map();
133
+ for (const e of allEntries) {
134
+ if (e.nsIdx === 0) continue;
135
+ const bk = `${e.nsIdx}:${e.key}`;
136
+ if (e.typeCode === 66) {
137
+ if (!blobDataChunks.has(bk)) blobDataChunks.set(bk, /* @__PURE__ */ new Map());
138
+ const payload = e.childData ?? new Uint8Array(0);
139
+ blobDataChunks.get(bk).set(e.chunkIndex, payload);
140
+ } else if (e.typeCode === 72) {
141
+ const view = new DataView(e.inlineData.buffer, e.inlineData.byteOffset, 8);
142
+ blobIndex.set(bk, {
143
+ totalSize: view.getUint32(0, true),
144
+ chunkCount: e.inlineData[4],
145
+ chunkStart: e.inlineData[5]
146
+ });
147
+ } else if (e.typeCode === 65) {
148
+ const size = new DataView(e.inlineData.buffer, e.inlineData.byteOffset, 8).getUint16(0, true);
149
+ const payload = e.childData ?? new Uint8Array(0);
150
+ v1Blobs.set(bk, {
151
+ size,
152
+ data: payload
153
+ });
154
+ }
155
+ }
156
+ const result = {};
157
+ for (const e of allEntries) {
158
+ if (e.nsIdx === 0) continue;
159
+ if (e.typeCode === 66 || e.typeCode === 72) continue;
160
+ const nsName = nsIdxToName.get(e.nsIdx);
161
+ if (nsName === void 0) continue;
162
+ if (!result[nsName]) result[nsName] = [];
163
+ const bk = `${e.nsIdx}:${e.key}`;
164
+ if (PRIMITIVE_TYPES.has(e.typeCode)) {
165
+ const val = parsePrimitive(e.typeCode, e.inlineData);
166
+ const enc = e.typeCode === 1 ? "u8" : e.typeCode === 17 ? "i8" : e.typeCode === 2 ? "u16" : e.typeCode === 18 ? "i16" : e.typeCode === 4 ? "u32" : e.typeCode === 20 ? "i32" : e.typeCode === 8 ? "u64" : "i64";
167
+ result[nsName].push({
168
+ name: e.key,
169
+ encoding: enc,
170
+ value: val
171
+ });
172
+ } else if (e.typeCode === 33) {
173
+ const size = new DataView(e.inlineData.buffer, e.inlineData.byteOffset, 8).getUint16(0, true);
174
+ const payload = e.childData ?? new Uint8Array(0);
175
+ const str = new TextDecoder().decode(payload.subarray(0, Math.max(0, size - 1)));
176
+ result[nsName].push({
177
+ name: e.key,
178
+ encoding: "string",
179
+ value: str
180
+ });
181
+ } else if (e.typeCode === 65) {
182
+ const v1 = v1Blobs.get(bk);
183
+ if (v1) {
184
+ const data = v1.data.subarray(0, v1.size);
185
+ result[nsName].push({
186
+ name: e.key,
187
+ encoding: "blob_data",
188
+ value: new Uint8Array(data)
189
+ });
190
+ }
191
+ }
192
+ }
193
+ for (const [bk, chunks] of blobDataChunks) {
194
+ const [nsIdxStr, ...keyParts] = bk.split(":");
195
+ const nsIdx = parseInt(nsIdxStr);
196
+ const key = keyParts.join(":");
197
+ const nsName = nsIdxToName.get(nsIdx);
198
+ if (nsName === void 0) continue;
199
+ const idx = blobIndex.get(bk);
200
+ const allChunkData = [...chunks.keys()].sort((a, b) => a - b).map((k) => chunks.get(k));
201
+ const totalRaw = allChunkData.reduce((acc, c) => acc + c.length, 0);
202
+ const assembled = new Uint8Array(totalRaw);
203
+ let off = 0;
204
+ for (const c of allChunkData) {
205
+ assembled.set(c, off);
206
+ off += c.length;
207
+ }
208
+ const trimmed = idx ? assembled.subarray(0, idx.totalSize) : assembled;
209
+ if (!result[nsName]) result[nsName] = [];
210
+ result[nsName].push({
211
+ name: key,
212
+ encoding: "blob_data",
213
+ value: new Uint8Array(trimmed)
214
+ });
215
+ }
216
+ return result;
217
+ }
218
+ //#endregion
219
+ export { PAGE_STATE_FULL as a, PAGE_STATE_ACTIVE as i, ENCODING_TO_TYPE_CODE as n, PAGE_SIZE as r, decodeNVS as t };
@@ -0,0 +1,31 @@
1
+ import { t as decodeNVS } from "./decoder.js";
2
+ import * as fs from "node:fs";
3
+ import * as path from "node:path";
4
+ //#region src/cli/esp-nvs-decode.ts
5
+ function printUsage() {
6
+ console.error("Usage: esp-nvs-decode <partition.bin>");
7
+ console.error(" npm run decode -- <partition.bin>");
8
+ }
9
+ function toJsonFriendly(value) {
10
+ if (value instanceof Uint8Array) return Buffer.from(value).toString("base64");
11
+ if (typeof value === "bigint") return value.toString();
12
+ if (Array.isArray(value)) return value.map((item) => toJsonFriendly(item));
13
+ if (value && typeof value === "object") return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, toJsonFriendly(item)]));
14
+ return value ?? null;
15
+ }
16
+ function main(args) {
17
+ const inputPath = args[0];
18
+ if (!inputPath || inputPath === "-h" || inputPath === "--help") {
19
+ printUsage();
20
+ process.exit(inputPath ? 0 : 1);
21
+ }
22
+ const absoluteInputPath = path.resolve(process.cwd(), inputPath);
23
+ if (!fs.existsSync(absoluteInputPath)) {
24
+ console.error(`Input file not found: ${absoluteInputPath}`);
25
+ process.exit(1);
26
+ }
27
+ const decoded = decodeNVS(fs.readFileSync(absoluteInputPath));
28
+ process.stdout.write(`${JSON.stringify(toJsonFriendly(decoded), null, 2)}\n`);
29
+ }
30
+ //#endregion
31
+ export { main };