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

1. Add one extra scriptPlace `build_bin.py` in the project's `scripts/` folder and add it to PlatformIO with `extra_scripts`.
2. Build the board environmentRun `pio run -e board_env`. The helper creates a merged `.bin` in `firmware/` and records the flash offset in `firmware/firmware.json`.
3. Review the outputUse the generated `.bin`, `firmware/firmware.json`, board name, and release details when preparing the mission.
4. Add it to ESPVerseCreate or update the mission from the dashboard and attach the generated `.bin` file.

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.