Skip to content

Commit c37ff64

Browse files
authored
feature nvs: blob upload (#110)
1 parent 85b22de commit c37ff64

1 file changed

Lines changed: 177 additions & 0 deletions

File tree

js/nvs-editor.js

Lines changed: 177 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1641,6 +1641,7 @@ export class NVSEditor {
16411641
<div><strong>Status:</strong> <span class="${status === "OK" ? "nvs-ok" : "nvs-warn"}">${status}</span></div>
16421642
<button class="nvs-blob-dump" data-key="${this._esc(id)}" title="Show hex dump">📄 Hex Dump</button>
16431643
<button class="nvs-blob-download" data-key="${this._esc(id)}" title="Download blob">⬇️ Download</button>
1644+
<button class="nvs-blob-upload" data-key="${this._esc(id)}" title="Upload / replace blob from file">⬆️ Upload</button>
16441645
</div>`;
16451646
}
16461647
} else {
@@ -1735,5 +1736,181 @@ export class NVSEditor {
17351736
}, 0);
17361737
});
17371738
});
1739+
1740+
// Blob upload buttons — replace blob payload from a local file
1741+
dialogContainer.querySelectorAll(".nvs-blob-upload").forEach((btn) => {
1742+
btn.addEventListener("click", () => {
1743+
const id = btn.dataset.key;
1744+
const blob = blobs.get(id);
1745+
if (!blob) return;
1746+
1747+
const input = document.createElement("input");
1748+
input.type = "file";
1749+
input.style.display = "none";
1750+
document.body.appendChild(input);
1751+
1752+
input.addEventListener("change", async () => {
1753+
const file = input.files && input.files[0];
1754+
input.remove();
1755+
if (!file) return;
1756+
1757+
try {
1758+
const buf = await file.arrayBuffer();
1759+
const bytes = new Uint8Array(buf);
1760+
1761+
if (
1762+
!confirm(
1763+
`Replace blob "${blob.namespace}::${blob.key}" ` +
1764+
`(current ${blob.totalSize} bytes) with ${bytes.length} bytes ` +
1765+
`from "${file.name}"?`,
1766+
)
1767+
)
1768+
return;
1769+
1770+
this._uploadBlobToNVS(blob, bytes);
1771+
1772+
// Re-render the dialog to reflect the new blob state
1773+
dialogContainer.remove();
1774+
this._showBlobs();
1775+
} catch (e) {
1776+
alert("Upload failed: " + (e && e.message ? e.message : e));
1777+
}
1778+
});
1779+
1780+
input.click();
1781+
});
1782+
});
1783+
}
1784+
1785+
/**
1786+
* Replace the data of an existing blob in the NVS partition with `fileBytes`.
1787+
* Distributes bytes across the existing data-chunk slots (0x42 entries),
1788+
* updates each entry's size + data-CRC + header-CRC, and refreshes the
1789+
* blob_index totalSize (0x48) when present. Will not allocate new chunks
1790+
* or extend the partition; the new payload must fit in the existing slots.
1791+
*/
1792+
_uploadBlobToNVS(blob, fileBytes) {
1793+
// Refuse 0-byte uploads outright — replacing all chunks with an empty
1794+
// payload would leave the blob_index as an orphan (totalSize=0,
1795+
// chunkCount=0) which ESP-IDF NVS does not surface as a valid blob.
1796+
// Users who want to remove a blob should use the per-entry delete UI.
1797+
if (fileBytes.length === 0) {
1798+
throw new Error("Cannot upload an empty file as blob replacement.");
1799+
}
1800+
1801+
// Resolve the parsed data-chunk items (in chunk-index order) so we know
1802+
// each slot's offset/span and can size-check before mutating anything.
1803+
const sortedChunks = [...blob.chunks].sort((a, b) => a.index - b.index);
1804+
if (sortedChunks.length === 0) {
1805+
throw new Error("No data chunks found for this blob.");
1806+
}
1807+
1808+
const dataEntries = [];
1809+
for (const chunk of sortedChunks) {
1810+
// chunk.offset points to the payload; the entry header is the 32 bytes before.
1811+
const entryOff = chunk.offset - 32;
1812+
const item = this._findItem(entryOff);
1813+
if (!item || item.datatype !== 0x42) {
1814+
throw new Error(
1815+
`Could not locate data entry at 0x${entryOff.toString(16)}.`,
1816+
);
1817+
}
1818+
// span 1 = inline (no payload extension); capacity is (span-1)*32 bytes.
1819+
const maxSize = (item.span - 1) * 32;
1820+
dataEntries.push({ item, maxSize });
1821+
}
1822+
1823+
const totalCapacity = dataEntries.reduce((s, e) => s + e.maxSize, 0);
1824+
if (fileBytes.length > totalCapacity) {
1825+
throw new Error(
1826+
`File too large: ${fileBytes.length} bytes, but only ${totalCapacity} bytes ` +
1827+
`available across ${dataEntries.length} chunk slot(s). ` +
1828+
`Adding new chunks is not supported.`,
1829+
);
1830+
}
1831+
1832+
// Distribute payload across chunks sequentially. Track how many slots
1833+
// we actually populate so we can erase trailing unused chunks and keep
1834+
// blob_index.chunkCount in sync with reality.
1835+
let writeOffset = 0;
1836+
let populatedChunks = 0;
1837+
for (const { item, maxSize } of dataEntries) {
1838+
const off = item.offset;
1839+
const dataOff = off + 32;
1840+
const remaining = fileBytes.length - writeOffset;
1841+
const writeLen = Math.min(remaining, maxSize);
1842+
1843+
// Trailing unused chunk: fully erase the slot (payload + header) and
1844+
// clear its bitmap flag, exactly like _deleteEntry does for a manual
1845+
// delete. This avoids leaving zero-sized orphan chunks behind.
1846+
if (writeLen === 0) {
1847+
this._deleteEntry(item);
1848+
continue;
1849+
}
1850+
1851+
populatedChunks++;
1852+
1853+
// Wipe the old payload area, then write the new segment.
1854+
this.data.fill(0xff, dataOff, dataOff + maxSize);
1855+
this.data.set(
1856+
fileBytes.subarray(writeOffset, writeOffset + writeLen),
1857+
dataOff,
1858+
);
1859+
1860+
// Update size field at +24 (u16) — the high two bytes are unused (0xFF).
1861+
this.data[off + 24] = writeLen & 0xff;
1862+
this.data[off + 25] = (writeLen >> 8) & 0xff;
1863+
this.data[off + 26] = 0xff;
1864+
this.data[off + 27] = 0xff;
1865+
1866+
// Update data CRC at +28 (u32 little-endian).
1867+
const segData = this.data.subarray(dataOff, dataOff + writeLen);
1868+
const dataCrc = NVSEditor.crc32(segData, 0, writeLen);
1869+
const dv = new DataView(
1870+
this.data.buffer,
1871+
this.data.byteOffset + off + 28,
1872+
4,
1873+
);
1874+
dv.setUint32(0, dataCrc >>> 0, true);
1875+
1876+
// Recalculate header CRC at +4.
1877+
const hcrc = NVSEditor.crc32Header(this.data, off);
1878+
const hdv = new DataView(
1879+
this.data.buffer,
1880+
this.data.byteOffset + off + 4,
1881+
4,
1882+
);
1883+
hdv.setUint32(0, hcrc >>> 0, true);
1884+
1885+
writeOffset += writeLen;
1886+
}
1887+
1888+
// If a blob_index (0x48) entry exists, sync its totalSize at +24 (u32)
1889+
// AND chunkCount at +28 (u8) with the number of slots actually populated,
1890+
// then recalc its header CRC so the index entry and bitmap stay
1891+
// consistent with the surviving data chunks.
1892+
if (blob.indexEntry && blob.indexEntry.datatype === 0x48) {
1893+
const idxOff = blob.indexEntry.offset;
1894+
const dv = new DataView(
1895+
this.data.buffer,
1896+
this.data.byteOffset + idxOff + 24,
1897+
4,
1898+
);
1899+
dv.setUint32(0, fileBytes.length >>> 0, true);
1900+
// chunkCount is a single byte; chunkStart at +29 and the rest stay intact.
1901+
this.data[idxOff + 28] = populatedChunks & 0xff;
1902+
const hcrc = NVSEditor.crc32Header(this.data, idxOff);
1903+
const hdv = new DataView(
1904+
this.data.buffer,
1905+
this.data.byteOffset + idxOff + 4,
1906+
4,
1907+
);
1908+
hdv.setUint32(0, hcrc >>> 0, true);
1909+
}
1910+
1911+
this.modified = true;
1912+
this.pages = this._parse();
1913+
this._renderContent();
1914+
this._updateWriteButton();
17381915
}
17391916
}

0 commit comments

Comments
 (0)