""" 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)