Mission guide
Generate a firmware bin for ESPVerse
Create a merged browser-flashable `.bin` for a PlatformIO project, then add it to an ESPVerse mission from the dashboard.
Workflow
From PlatformIO build to ESPVerse mission
PlatformIO
Only the necessary config
Add this to the existing `platformio.ini`. The board-specific fields are optional, but they make the generated metadata easier to match on ESPVerse.
[env:m5_dial]
platform = espressif32@6.9.0
framework = arduino
board = m5stack-stamps3
extra_scripts = post:scripts/build_bin.py
; Custom ESPVerse metadata (optional)
custom_espverse_version = v1.0.0
custom_espverse_board_slug = m5_dial
custom_espverse_name_m5_dial = M5Stack Dial
Build
Generate the merged bin
The helper reads PlatformIO's upload command, finds every ESP32 flash segment, and merges those segments into one `.bin` file that ESP Web Tools can flash from the browser.
mkdir -p scripts
# put build_bin.py at scripts/build_bin.py
pio run -e m5_dial
# use these files in ESPVerse:
# firmware/m5_dial_v1.0.0_0x0.bin
# firmware/firmware.json
Helper script
build_bin.py
Bin-only PlatformIO helper Creates `firmware/*.bin` and `firmware/firmware.json`. It does not upload or publish.
"""
ESPVerse bin builder for PlatformIO.
Use as a PlatformIO extra script:
extra_scripts = post:scripts/build_bin.py
It creates a merged browser-flashable .bin in firmware/ and writes
firmware/firmware.json with the flash offset, chip, and size.
"""
from __future__ import annotations
import json
import re
import sys
from pathlib import Path
from typing import Iterable
try:
Import("env") # type: ignore[name-defined]
except NameError:
env = None
FIRMWARE_DIR = Path("firmware")
FIRMWARE_INDEX = FIRMWARE_DIR / "firmware.json"
def option(name: str, default: str = "") -> str:
if env is None:
return default
return str(env.GetProjectOption(f"custom_espverse_{name.lower()}", default) or default).strip()
def env_name_from_source(source: Iterable[object]) -> str:
first = str(list(source)[0])
parts = Path(first).parts
if ".pio" in parts:
pio_index = parts.index(".pio")
if len(parts) > pio_index + 2:
return parts[pio_index + 2]
return option("board_slug") or "firmware"
def display_name_for_env(environment: str) -> str:
if env is not None:
value = env.GetProjectOption(f"custom_espverse_name_{environment}", "")
if value:
return str(value)
return environment.replace("_", " ").replace("-", " ").title()
def parse_flash_segments(upload_command: str) -> tuple[str, list[tuple[int, Path]]]:
chip_match = re.search(r"--chip\s+(\S+)", upload_command)
chip = chip_match.group(1).upper() if chip_match else option("chip_family", "ESP32").upper()
chip = chip.replace("ESP32S", "ESP32-S").replace("ESP32C", "ESP32-C")
segments: list[tuple[int, Path]] = []
for offset, path in re.findall(r"(0x[0-9a-fA-F]+)\s+([^\s]+\.bin)", upload_command):
bin_path = Path(path.strip('"'))
if bin_path.exists():
segments.append((int(offset, 16), bin_path))
return chip, segments
def merge_bins(segments: list[tuple[int, Path]], output_base: Path) -> tuple[Path, int, int]:
if not segments:
raise RuntimeError("No ESP32 binary segments were found in the PlatformIO upload command.")
loaded: list[tuple[int, bytes, Path]] = []
for offset, path in segments:
loaded.append((offset, path.read_bytes(), path))
start_offset = min(offset for offset, _, _ in loaded)
end_offset = max(offset + len(data) for offset, data, _ in loaded)
merged = bytearray([0xFF]) * (end_offset - start_offset)
for offset, data, path in loaded:
rel = offset - start_offset
merged[rel : rel + len(data)] = data
print(f"ESPVerse: segment {path} @ 0x{offset:X}, {len(data)} bytes")
output_path = output_base.with_name(f"{output_base.stem}_0x{start_offset:X}.bin")
output_path.parent.mkdir(parents=True, exist_ok=True)
output_path.write_bytes(merged)
return output_path, start_offset, len(merged)
def update_firmware_index(environment: str, firmware: dict[str, object]) -> None:
FIRMWARE_DIR.mkdir(parents=True, exist_ok=True)
data: dict[str, object] = {}
if FIRMWARE_INDEX.exists():
data = json.loads(FIRMWARE_INDEX.read_text(encoding="utf-8"))
data[environment] = firmware
FIRMWARE_INDEX.write_text(json.dumps(data, indent=4) + "\n", encoding="utf-8")
def after_build(source, target, platformio_env) -> None: # noqa: ANN001
if env is None:
raise RuntimeError("This script is intended to run as a PlatformIO extra script.")
environment = env_name_from_source(source)
board_slug = option("board_slug", environment)
version = option("version", "dev")
upload_command = env.subst("$UPLOADCMD") + f" {str(source[0])}"
chip, segments = parse_flash_segments(upload_command)
output_base = FIRMWARE_DIR / f"{environment}_{version}.bin"
firmware_path, flash_offset, size = merge_bins(segments, output_base)
update_firmware_index(environment, {
"id": environment,
"name": display_name_for_env(environment),
"file": firmware_path.name,
"address": f"0x{flash_offset:X}",
"size": size,
"chip": chip,
"board_slug": board_slug,
})
print(f"ESPVerse: wrote {firmware_path} ({size} bytes), flash offset 0x{flash_offset:X}")
if env is not None:
env.AddPostAction("buildprog", after_build)
else:
print("Run this file through PlatformIO extra_scripts.", file=sys.stderr)
Optional
Uploading stays separate
If you want automated publishing later, use the separate Developer API flow. The bin builder stays focused on producing the firmware artifact.