""" Pure-Python ESP32 NVS partition binary generator. Generates a binary-compatible NVS partition for a Vesper device identity. No ESP-IDF toolchain required on the server. NVS partition layout (ESP32 NVS format v2): - Partition size: 0x5000 (20480 bytes) = 5 pages - Page size: 4096 bytes - Page structure: Offset 0x000 - 0x01F : Page header (32 bytes) Offset 0x020 - 0x03F : Entry state bitmap (32 bytes, 2 bits per slot) Offset 0x040 - 0xFFF : Entry storage (120 slots × 32 bytes each) Entry state bitmap: 2 bits per entry 11 = empty 10 = written (active) 00 = erased Page header (32 bytes): uint32 page_state (0xFFFFFFFE = active) uint32 sequence_number uint8 version (0xFE = v2) uint8 reserved[19] uint32 crc32 (of bytes 4..27) Entry (32 bytes): uint8 ns_index (namespace index, 0 = namespace entry itself) uint8 type (0x01=uint8, 0x02=uint16, 0x04=uint32, 0x08=uint64, 0x21=string, 0x41=blob) uint8 span (number of 32-byte slots this entry occupies) uint8 chunk_index (0xFF for non-blob) uint32 crc32 (of the entry header bytes 0..3 and data, excluding the crc field itself) char key[16] (null-terminated, max 15 chars + null) [8 bytes for primitives, or inline for short strings] For strings: - If len <= 8 bytes (incl. null): fits in the data field of the same entry (span=1) - Longer strings: data follows in subsequent 32-byte "data entries" (span = 1 + ceil(strlen+1, 32)) - The entry header data field contains: uint16 data_size, uint16 reserved=0xFFFF, uint32 crc32_of_data """ import struct import binascii from typing import List, Tuple NVS_PAGE_SIZE = 4096 NVS_PARTITION_SIZE = 0x5000 # 20480 bytes = 5 pages NVS_ENTRY_SIZE = 32 NVS_ENTRY_COUNT = 126 # entries per page (first 3 slots are header + bitmap) NVS_PAGE_STATE_ACTIVE = 0xFFFFFFFE NVS_PAGE_VERSION = 0xFE ENTRY_STATE_WRITTEN = 0b10 # 2 bits ENTRY_STATE_EMPTY = 0b11 # 2 bits (erased flash) ENTRY_TYPE_NAMESPACE = 0x01 # used for namespace entries (uint8) ENTRY_TYPE_STRING = 0x21 def _crc32(data: bytes) -> int: # ESP-IDF uses 0xFFFFFFFF as the initial CRC seed (matches esp_rom_crc32_le) return binascii.crc32(data, 0xFFFFFFFF) & 0xFFFFFFFF def _page_header_crc(seq: int, version: int) -> int: """CRC covers bytes 4..27 of the page header (seq + version + reserved).""" buf = struct.pack(" int: """CRC covers the entry minus the 4-byte crc field at offset 4..7.""" header_no_crc = struct.pack("BBBB", ns_index, entry_type, span, chunk_index) return _crc32(header_no_crc + key + data) def _pack_entry(ns_index: int, entry_type: int, span: int, chunk_index: int, key: str, data: bytes) -> bytes: key_bytes = key.encode("ascii").ljust(16, b"\x00")[:16] data_bytes = data.ljust(8, b"\xFF")[:8] crc = _entry_crc(ns_index, entry_type, span, chunk_index, key_bytes, data_bytes) return struct.pack("BBBBI", ns_index, entry_type, span, chunk_index, crc) + key_bytes + data_bytes def _bitmap_set_written(bitmap: bytearray, slot_index: int) -> None: """Mark a slot as written (10) in the entry state bitmap.""" bit_pos = slot_index * 2 byte_idx = bit_pos // 8 bit_off = bit_pos % 8 # Clear both bits for this slot (set to 00 then OR in 10) bitmap[byte_idx] &= ~(0b11 << bit_off) bitmap[byte_idx] |= (ENTRY_STATE_WRITTEN << bit_off) def _build_namespace_entry(ns_name: str, ns_index: int) -> Tuple[bytes, int]: """Build the namespace declaration entry. ns_index is the assigned namespace id (1-based).""" data = struct.pack(" Tuple[bytes, int]: """Build a string entry. May span multiple 32-byte slots for long strings.""" value_bytes = value.encode("utf-8") + b"\x00" # null-terminated value_len = len(value_bytes) # Pad to multiple of 32 padded_len = ((value_len + 31) // 32) * 32 value_padded = value_bytes.ljust(padded_len, b"\xFF") span = 1 + (padded_len // 32) # Data field in the header entry: uint16 data_size, uint16 0xFFFF, uint32 crc_of_data data_crc = _crc32(value_bytes) header_data = struct.pack(" bytes: """Assemble a full 4096-byte NVS page.""" # Build entry storage area storage = bytearray(NVS_ENTRY_COUNT * NVS_ENTRY_SIZE) # all 0xFF (erased) storage[:] = b"\xFF" * len(storage) bitmap = bytearray(b"\xFF" * 32) # all slots empty (11 bits) slot = 0 for entry_bytes, span in zip(entries, slot_counts): entry_offset = slot * NVS_ENTRY_SIZE storage[entry_offset:entry_offset + len(entry_bytes)] = entry_bytes for s in range(span): _bitmap_set_written(bitmap, slot + s) slot += span # Page header header_crc = _page_header_crc(seq, NVS_PAGE_VERSION) header = struct.pack( " bytes: """Generate a 0x5000-byte NVS partition binary for a Vesper device. serial_number: full SN string e.g. 'BSVSPR-26C13X-STD01R-X7KQA' hw_family: board family e.g. 'vesper-standard', 'vesper-plus' hw_revision: hardware revision string e.g. '1.0' legacy: if True, writes old key names expected by legacy firmware (pre-new-schema): device_uid, hw_type, hw_version if False (default), writes new key names: serial, hw_family, hw_revision Returns raw bytes ready to flash at 0x9000. """ ns_index = 1 # first (and only) namespace # Build entries for namespace "device_id" ns_entry, ns_span = _build_namespace_entry("device_id", ns_index) if legacy: uid_entry, uid_span = _build_string_entry(ns_index, "device_uid", serial_number) hwt_entry, hwt_span = _build_string_entry(ns_index, "hw_type", hw_family.lower()) hwv_entry, hwv_span = _build_string_entry(ns_index, "hw_version", hw_revision) else: uid_entry, uid_span = _build_string_entry(ns_index, "serial", serial_number) hwt_entry, hwt_span = _build_string_entry(ns_index, "hw_family", hw_family.lower()) hwv_entry, hwv_span = _build_string_entry(ns_index, "hw_revision", hw_revision) entries = [ns_entry, uid_entry, hwt_entry, hwv_entry] spans = [ns_span, uid_span, hwt_span, hwv_span] page0 = _build_page(entries, spans, seq=0) # Remaining pages are blank (erased flash = 0xFF) blank_page = b"\xFF" * NVS_PAGE_SIZE remaining_pages = (NVS_PARTITION_SIZE // NVS_PAGE_SIZE) - 1 return page0 + blank_page * remaining_pages