build_firmware.py
PlatformIO extra script that merges ESP32 segments and can publish directly to ESPVerse.
"""
ESPVerse PlatformIO helper.
Use as a PlatformIO extra script:
extra_scripts = post:scripts/platformio/build_firmware.py
Optional platformio.ini values:
custom_espverse_project = smartwatch
custom_espverse_version = 6.3
custom_espverse_board_slug = m5_dial
custom_espverse_status = published
custom_espverse_publish = yes
custom_espverse_allow_unspecified_board = no
Environment variables override config values:
ESPVERSE_API_KEY
ESPVERSE_API_URL=https://espverse.com/api/v1/firmware/publish
ESPVERSE_PROJECT_SLUG
ESPVERSE_VERSION
ESPVERSE_BOARD_SLUG
ESPVERSE_STATUS=draft|published
ESPVERSE_PUBLISH=1
ESPVERSE_ALLOW_UNSPECIFIED_BOARD=1
"""
from __future__ import annotations
import json
import os
import re
import sys
import uuid
import urllib.error
import urllib.request
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"
DEFAULT_API_URL = "https://espverse.com/api/v1/firmware/publish"
def truthy(value: object) -> bool:
return str(value or "").strip().lower() in {"1", "true", "yes", "on", "publish"}
def option(name: str, default: str = "") -> str:
env_name = f"ESPVERSE_{name.upper()}"
if os.environ.get(env_name):
return os.environ[env_name].strip()
if env is not None:
project_option = env.GetProjectOption(f"custom_espverse_{name.lower()}", default)
return str(project_option or default).strip()
return default
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 multipart_body(fields: dict[str, str], file_field: str, file_path: Path) -> tuple[bytes, str]:
boundary = f"----ESPVerse{uuid.uuid4().hex}"
chunks: list[bytes] = []
for name, value in fields.items():
chunks.append(f"--{boundary}\r\n".encode())
chunks.append(f'Content-Disposition: form-data; name="{name}"\r\n\r\n'.encode())
chunks.append(str(value).encode())
chunks.append(b"\r\n")
chunks.append(f"--{boundary}\r\n".encode())
chunks.append(
(
f'Content-Disposition: form-data; name="{file_field}"; filename="{file_path.name}"\r\n'
"Content-Type: application/octet-stream\r\n\r\n"
).encode()
)
chunks.append(file_path.read_bytes())
chunks.append(b"\r\n")
chunks.append(f"--{boundary}--\r\n".encode())
return b"".join(chunks), f"multipart/form-data; boundary={boundary}"
def publish_to_espverse(firmware_path: Path, metadata: dict[str, object]) -> None:
api_key = option("api_key")
if not api_key:
raise RuntimeError("ESPVERSE_API_KEY is required to publish firmware.")
api_url = option("api_url", DEFAULT_API_URL)
fields = {key: str(value) for key, value in metadata.items() if value not in (None, "")}
body, content_type = multipart_body(fields, "firmware", firmware_path)
request = urllib.request.Request(
api_url,
data=body,
headers={
"Authorization": f"Bearer {api_key}",
"Content-Type": content_type,
"Accept": "application/json",
},
method="POST",
)
try:
with urllib.request.urlopen(request, timeout=120) as response:
payload = response.read().decode("utf-8")
except urllib.error.HTTPError as exc:
payload = exc.read().decode("utf-8", errors="replace")
raise RuntimeError(f"ESPVerse publish failed with HTTP {exc.code}: {payload}") from exc
print("ESPVerse publish response:")
print(payload)
def after_build(source, target, platformio_env) -> None: # noqa: ANN001
environment = env_name_from_source(source)
project_slug = option("project_slug")
version = option("version")
board_slug = option("board_slug", environment)
status = option("status", "draft")
publish = truthy(option("publish"))
allow_unspecified = truthy(option("allow_unspecified_board"))
if env is None:
raise RuntimeError("This script is intended to run as a PlatformIO extra script.")
upload_command = env.subst("$UPLOADCMD") + f" {str(source[0])}"
chip, segments = parse_flash_segments(upload_command)
version_suffix = version or "dev"
output_base = FIRMWARE_DIR / f"{environment}_{version_suffix}.bin"
firmware_path, flash_offset, size = merge_bins(segments, output_base)
firmware_info = {
"id": environment,
"name": display_name_for_env(environment),
"file": firmware_path.name,
"address": f"0x{flash_offset:X}",
"size": size,
"chip": chip,
"project_slug": project_slug,
"version": version,
"board_slug": board_slug,
}
update_firmware_index(environment, firmware_info)
print(f"ESPVerse: wrote {firmware_path} ({size} bytes), flash offset 0x{flash_offset:X}")
if not publish:
print("ESPVerse: publish disabled. Set ESPVERSE_PUBLISH=1 or custom_espverse_publish=yes to upload.")
return
if not project_slug or not version:
raise RuntimeError("ESPVERSE_PROJECT_SLUG and ESPVERSE_VERSION are required when publishing.")
publish_to_espverse(
firmware_path,
{
"project_slug": project_slug,
"version": version,
"board_slug": board_slug,
"chip_family": chip,
"flash_offset": f"0x{flash_offset:X}",
"status": status,
"target_label": option("target_label", board_slug),
"build_label": option("build_label", display_name_for_env(environment)),
"allow_unspecified_board": "1" if allow_unspecified else "0",
"changelog": option("changelog"),
},
)
if env is not None:
env.AddPostAction("buildprog", after_build)
env.AddPostAction("upload", after_build)
else:
print("Run this file through PlatformIO extra_scripts.", file=sys.stderr)