Ryan Malloy 945939bdad Implement security manager, diagnostics, and partition manager
Security manager: eFuse read/burn via espefuse, security audit
combining chip-id + get-security-info + eFuse summary, flash
encryption status checking.

Diagnostics: memory dump with hex formatting via dump-mem,
performance profiling by timing esptool operations, diagnostic
report combining chip-id + read-mac + flash-id.

Partition manager: OTA partition table generation with auto-layout,
custom partition table from config dict with validation, binary
partition table parsing from flash reads at 0x8000.
2026-01-30 20:32:48 -07:00

447 lines
16 KiB
Python

"""
Partition Manager Component
Handles ESP partition table operations: generating OTA-capable tables,
custom partition layouts, and reading/analyzing partition tables from
connected devices.
"""
import asyncio
import logging
from pathlib import Path
from typing import Any
from fastmcp import Context, FastMCP
from ..config import ESPToolServerConfig
logger = logging.getLogger(__name__)
# ESP partition types and subtypes
PARTITION_TYPES = {
"app": 0x00,
"data": 0x01,
}
APP_SUBTYPES = {
"factory": 0x00,
"ota_0": 0x10,
"ota_1": 0x11,
"ota_2": 0x12,
"ota_3": 0x13,
"test": 0x20,
}
DATA_SUBTYPES = {
"ota": 0x00,
"phy": 0x01,
"nvs": 0x02,
"coredump": 0x03,
"nvs_keys": 0x04,
"efuse": 0x05,
"spiffs": 0x82,
"littlefs": 0x83,
"fat": 0x81,
}
# Size multipliers
_SIZE_MULT = {"K": 1024, "M": 1024 * 1024}
def _parse_size_spec(spec: str) -> int:
"""Parse a partition size like '1MB', '64K', '0x10000' into bytes."""
spec = spec.strip().upper()
for suffix, mult in _SIZE_MULT.items():
if spec.endswith(suffix + "B"):
return int(spec[: -len(suffix) - 1]) * mult
if spec.endswith(suffix):
return int(spec[: -len(suffix)]) * mult
return int(spec, 0)
def _format_size(size_bytes: int) -> str:
"""Format byte count as human-readable size."""
if size_bytes >= 1024 * 1024 and size_bytes % (1024 * 1024) == 0:
return f"{size_bytes // (1024 * 1024)}MB"
if size_bytes >= 1024 and size_bytes % 1024 == 0:
return f"{size_bytes // 1024}KB"
return f"{size_bytes}B"
class PartitionManager:
"""ESP partition table management"""
def __init__(self, app: FastMCP, config: ESPToolServerConfig) -> None:
self.app = app
self.config = config
self._register_tools()
async def _run_esptool(
self,
port: str,
args: list[str],
timeout: float = 30.0,
) -> dict[str, Any]:
"""Run esptool as an async subprocess."""
cmd = [self.config.esptool_path, "--port", port, *args]
proc = None
try:
proc = await asyncio.create_subprocess_exec(
*cmd,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=timeout)
output = (stdout or b"").decode() + (stderr or b"").decode()
if proc.returncode != 0:
return {"success": False, "error": output.strip()[:500]}
return {"success": True, "output": output}
except asyncio.TimeoutError:
if proc and proc.returncode is None:
proc.kill()
await proc.wait()
return {"success": False, "error": f"Timeout after {timeout}s"}
except FileNotFoundError:
return {"success": False, "error": f"esptool not found at {self.config.esptool_path}"}
except Exception as e:
if proc and proc.returncode is None:
proc.kill()
await proc.wait()
return {"success": False, "error": str(e)}
def _register_tools(self) -> None:
"""Register partition management tools"""
@self.app.tool("esp_partition_create_ota")
async def create_ota_partition(
context: Context,
flash_size: str = "4MB",
app_size: str = "1MB",
) -> dict[str, Any]:
"""Create OTA-enabled partition table.
Generates a partition table CSV with two OTA app slots, NVS storage,
OTA data partition, and PHY calibration data. The layout follows
Espressif's recommended OTA structure.
The generated CSV can be converted to binary with gen_esp32part.py
(from ESP-IDF) and flashed to the partition table offset (typically 0x8000).
Args:
flash_size: Total flash size (e.g. "4MB", "8MB", "16MB", default: "4MB")
app_size: Size for each OTA app slot (e.g. "1MB", "1536K", default: "1MB")
"""
return await self._create_ota_impl(context, flash_size, app_size)
@self.app.tool("esp_partition_custom")
async def create_custom_partition(
context: Context,
partition_config: dict[str, Any],
) -> dict[str, Any]:
"""Create custom partition table from a configuration dict.
Accepts a list of partition entries and generates a valid ESP
partition table CSV. Each entry needs: name, type, subtype, size.
Offset is auto-calculated if omitted.
Example partition_config:
{
"partitions": [
{"name": "nvs", "type": "data", "subtype": "nvs", "size": "24K"},
{"name": "factory", "type": "app", "subtype": "factory", "size": "1MB"},
{"name": "storage", "type": "data", "subtype": "spiffs", "size": "512K"}
]
}
Args:
partition_config: Dict with "partitions" key containing list of entries
"""
return await self._create_custom_impl(context, partition_config)
@self.app.tool("esp_partition_analyze")
async def analyze_partitions(
context: Context,
port: str | None = None,
) -> dict[str, Any]:
"""Analyze current partition table on a connected ESP device.
Reads the partition table from flash (at offset 0x8000, 0xC00 bytes)
and parses the binary format into a human-readable table. Shows
partition names, types, offsets, sizes, and flags.
Works with physical devices and QEMU virtual devices.
Args:
port: Serial port or socket:// URI (required)
"""
return await self._analyze_impl(context, port)
async def _create_ota_impl(
self,
context: Context,
flash_size: str,
app_size: str,
) -> dict[str, Any]:
"""Generate an OTA-capable partition table."""
try:
total_bytes = _parse_size_spec(flash_size)
app_bytes = _parse_size_spec(app_size)
except (ValueError, TypeError) as e:
return {"success": False, "error": f"Invalid size: {e}"}
# Standard layout:
# 0x9000 - nvs (24KB)
# 0xf000 - otadata (8KB)
# 0x11000 - phy_init (4KB)
# 0x12000 - ota_0 (app_size)
# ota_0 + app_size - ota_1 (app_size)
nvs_size = 24 * 1024
otadata_size = 8 * 1024
phy_size = 4 * 1024
# Check it all fits (partition table at 0x8000 + 0x1000)
overhead = 0x9000 + nvs_size + otadata_size + phy_size # Before first app
needed = overhead + (2 * app_bytes)
if needed > total_bytes:
return {
"success": False,
"error": (
f"Layout requires {_format_size(needed)} but flash is {flash_size}. "
f"Reduce app_size or increase flash_size."
),
}
partitions = [
("nvs", "data", "nvs", "0x9000", _format_size(nvs_size)),
("otadata", "data", "ota", f"0x{0x9000 + nvs_size:x}", _format_size(otadata_size)),
("phy_init", "data", "phy", f"0x{0x9000 + nvs_size + otadata_size:x}", _format_size(phy_size)),
("ota_0", "app", "ota_0", f"0x{overhead:x}", _format_size(app_bytes)),
("ota_1", "app", "ota_1", f"0x{overhead + app_bytes:x}", _format_size(app_bytes)),
]
# Remaining space for storage
used = overhead + (2 * app_bytes)
remaining = total_bytes - used
if remaining >= 4096:
partitions.append(
("storage", "data", "spiffs", f"0x{used:x}", _format_size(remaining))
)
# Generate CSV
csv_lines = ["# ESP-IDF Partition Table (OTA layout)", "# Name, Type, SubType, Offset, Size, Flags"]
for name, ptype, subtype, offset, size in partitions:
csv_lines.append(f"{name}, {ptype}, {subtype}, {offset}, {size},")
csv_text = "\n".join(csv_lines) + "\n"
return {
"success": True,
"flash_size": flash_size,
"app_size": app_size,
"partition_csv": csv_text,
"partitions": [
{"name": p[0], "type": p[1], "subtype": p[2], "offset": p[3], "size": p[4]}
for p in partitions
],
"space_remaining": _format_size(remaining) if remaining >= 4096 else "0",
"note": "Flash this CSV with gen_esp32part.py to binary, then write to 0x8000",
}
async def _create_custom_impl(
self,
context: Context,
partition_config: dict[str, Any],
) -> dict[str, Any]:
"""Generate a custom partition table from config."""
partitions_input = partition_config.get("partitions", [])
if not partitions_input:
return {"success": False, "error": "partition_config must have a 'partitions' list"}
# Auto-calculate offsets starting after partition table (0x9000)
current_offset = 0x9000
partitions = []
errors = []
for i, entry in enumerate(partitions_input):
name = entry.get("name")
ptype = entry.get("type")
subtype = entry.get("subtype")
size_str = entry.get("size")
if not all([name, ptype, subtype, size_str]):
errors.append(f"Partition {i}: requires name, type, subtype, size")
continue
# Validate type
if ptype not in PARTITION_TYPES:
errors.append(f"Partition '{name}': invalid type '{ptype}' (use: {list(PARTITION_TYPES.keys())})")
continue
# Validate subtype
valid_subtypes = APP_SUBTYPES if ptype == "app" else DATA_SUBTYPES
if subtype not in valid_subtypes:
errors.append(f"Partition '{name}': invalid subtype '{subtype}' (use: {list(valid_subtypes.keys())})")
continue
try:
size_bytes = _parse_size_spec(size_str)
except (ValueError, TypeError):
errors.append(f"Partition '{name}': invalid size '{size_str}'")
continue
# Use explicit offset if provided, otherwise auto-calculate
offset = entry.get("offset")
if offset:
current_offset = int(offset, 0) if isinstance(offset, str) else offset
# App partitions must be 64KB aligned
if ptype == "app" and current_offset % 0x10000 != 0:
current_offset = (current_offset + 0xFFFF) & ~0xFFFF
partitions.append({
"name": name,
"type": ptype,
"subtype": subtype,
"offset": f"0x{current_offset:x}",
"size": _format_size(size_bytes),
"size_bytes": size_bytes,
})
current_offset += size_bytes
if errors:
return {"success": False, "errors": errors}
# Generate CSV
csv_lines = ["# ESP-IDF Partition Table (custom layout)", "# Name, Type, SubType, Offset, Size, Flags"]
for p in partitions:
csv_lines.append(f"{p['name']}, {p['type']}, {p['subtype']}, {p['offset']}, {p['size']},")
csv_text = "\n".join(csv_lines) + "\n"
return {
"success": True,
"partition_csv": csv_text,
"partitions": [{k: v for k, v in p.items() if k != "size_bytes"} for p in partitions],
"total_size": _format_size(sum(p["size_bytes"] for p in partitions)),
"note": "Flash this CSV with gen_esp32part.py to binary, then write to 0x8000",
}
async def _analyze_impl(
self,
context: Context,
port: str | None,
) -> dict[str, Any]:
"""Read and parse partition table from a connected device."""
if not port:
return {"success": False, "error": "Port is required for partition analysis"}
import tempfile
# Partition table is at 0x8000, max size 0xC00 (3KB)
with tempfile.NamedTemporaryFile(suffix=".bin", delete=False) as tmp:
tmp_path = tmp.name
try:
result = await self._run_esptool(
port,
["read-flash", "0x8000", "0xC00", tmp_path],
timeout=60.0,
)
if not result["success"]:
return {"success": False, "error": result["error"], "port": port}
raw = Path(tmp_path).read_bytes()
partitions = self._parse_partition_table_binary(raw)
if not partitions:
return {
"success": True,
"port": port,
"partitions": [],
"note": "No valid partition entries found (flash may be blank or erased)",
}
return {
"success": True,
"port": port,
"partition_count": len(partitions),
"partitions": partitions,
}
finally:
import os
try:
os.unlink(tmp_path)
except OSError:
pass
def _parse_partition_table_binary(self, raw: bytes) -> list[dict[str, Any]]:
"""Parse ESP32 binary partition table format.
Each entry is 32 bytes:
- 2 bytes: magic (0xAA50)
- 1 byte: type
- 1 byte: subtype
- 4 bytes: offset (LE)
- 4 bytes: size (LE)
- 16 bytes: name (null-terminated)
- 4 bytes: flags
"""
import struct
entry_size = 32
magic_expected = 0x50AA # Little-endian
# Reverse lookup tables
type_names = {v: k for k, v in PARTITION_TYPES.items()}
app_subtype_names = {v: k for k, v in APP_SUBTYPES.items()}
data_subtype_names = {v: k for k, v in DATA_SUBTYPES.items()}
partitions = []
for i in range(0, len(raw) - entry_size + 1, entry_size):
entry = raw[i : i + entry_size]
magic = struct.unpack_from("<H", entry, 0)[0]
if magic == 0xFFFF:
# End of table (erased flash)
break
if magic != magic_expected:
continue
ptype = entry[2]
subtype = entry[3]
offset = struct.unpack_from("<I", entry, 4)[0]
size = struct.unpack_from("<I", entry, 8)[0]
name = entry[12:28].split(b"\x00")[0].decode("ascii", errors="replace")
flags = struct.unpack_from("<I", entry, 28)[0]
type_name = type_names.get(ptype, f"0x{ptype:02x}")
if ptype == 0x00:
subtype_name = app_subtype_names.get(subtype, f"0x{subtype:02x}")
elif ptype == 0x01:
subtype_name = data_subtype_names.get(subtype, f"0x{subtype:02x}")
else:
subtype_name = f"0x{subtype:02x}"
partitions.append({
"name": name,
"type": type_name,
"subtype": subtype_name,
"offset": f"0x{offset:x}",
"size": _format_size(size),
"size_bytes": size,
"encrypted": bool(flags & 1),
})
return partitions
async def health_check(self) -> dict[str, Any]:
"""Component health check"""
return {"status": "healthy", "note": "Partition manager ready"}