@@ -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