feat: Phase 3 manufacturing + firmware management

This commit is contained in:
2026-02-27 02:47:08 +02:00
parent 2f610633c4
commit 32a2634739
25 changed files with 2266 additions and 52 deletions

View File

@@ -0,0 +1,205 @@
"""
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