206 lines
7.2 KiB
Python
206 lines
7.2 KiB
Python
"""
|
||
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)
|
||
<data> [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:
|
||
return binascii.crc32(data) & 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("<IB", seq, version) + b"\xFF" * 19
|
||
return _crc32(buf)
|
||
|
||
|
||
def _entry_crc(ns_index: int, entry_type: int, span: int, chunk_index: int,
|
||
key: bytes, data: bytes) -> 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("<B", ns_index) + b"\xFF" * 7
|
||
entry = _pack_entry(
|
||
ns_index=0,
|
||
entry_type=ENTRY_TYPE_NAMESPACE,
|
||
span=1,
|
||
chunk_index=0xFF,
|
||
key=ns_name,
|
||
data=data,
|
||
)
|
||
return entry, 1 # consumes 1 slot
|
||
|
||
|
||
def _build_string_entry(ns_index: int, key: str, value: str) -> 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("<HHI", value_len, 0xFFFF, data_crc)
|
||
|
||
entry = _pack_entry(
|
||
ns_index=ns_index,
|
||
entry_type=ENTRY_TYPE_STRING,
|
||
span=span,
|
||
chunk_index=0xFF,
|
||
key=key,
|
||
data=header_data,
|
||
)
|
||
|
||
# Append data chunks (each 32 bytes)
|
||
full_entry = entry + value_padded
|
||
return full_entry, span
|
||
|
||
|
||
def _build_page(entries: List[bytes], slot_counts: List[int], seq: int = 0) -> 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(
|
||
"<IIBI19sI",
|
||
NVS_PAGE_STATE_ACTIVE,
|
||
seq,
|
||
NVS_PAGE_VERSION,
|
||
0, # padding
|
||
b"\xFF" * 19,
|
||
header_crc,
|
||
)
|
||
# Trim header to exactly 32 bytes
|
||
header = struct.pack("<I", NVS_PAGE_STATE_ACTIVE)
|
||
header += struct.pack("<I", seq)
|
||
header += struct.pack("<B", NVS_PAGE_VERSION)
|
||
header += b"\xFF" * 19
|
||
header += struct.pack("<I", header_crc)
|
||
assert len(header) == 32, f"Header size mismatch: {len(header)}"
|
||
|
||
page = header + bytes(bitmap) + bytes(storage)
|
||
assert len(page) == NVS_PAGE_SIZE, f"Page size mismatch: {len(page)}"
|
||
return page
|
||
|
||
|
||
def generate(serial_number: str, hw_type: str, hw_version: str) -> bytes:
|
||
"""Generate a 0x5000-byte NVS partition binary for a Vesper device.
|
||
|
||
serial_number: full SN string e.g. 'PV-26B27-VS01R-X7KQA'
|
||
hw_type: lowercase board type e.g. 'vs', 'vp', 'vx'
|
||
hw_version: zero-padded version e.g. '01'
|
||
|
||
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)
|
||
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_type.lower())
|
||
hwv_entry, hwv_span = _build_string_entry(ns_index, "hw_version", hw_version)
|
||
|
||
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
|