Files
bellsystems-cp/backend/utils/nvs_generator.py

216 lines
7.9 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
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:
# 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("<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_family: str, hw_revision: str, legacy: bool = False) -> 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