forge: step 2 — add representative build tooling

This commit is contained in:
cj-vana
2026-05-14 13:07:46 -06:00
parent 346ca7cb93
commit 2f3cb1049a
7 changed files with 473 additions and 0 deletions

View File

@@ -0,0 +1,141 @@
#!/usr/bin/env bash
set -euo pipefail
source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/meshcore-env.sh"
usage() {
cat <<'EOF'
Usage: scripts/build-representative.sh [--baseline|--compare <baseline-json>]
Applies the MeshCore patch queue, builds the representative companion firmware
environments, copies firmware artifacts to out/firmware, and writes a size
summary to out/size/summary.json.
EOF
}
mode="summary"
compare_file=""
mode_selected=0
while [ "$#" -gt 0 ]; do
case "$1" in
--help|-h)
usage
exit 0
;;
--baseline)
if [ "$mode_selected" -eq 1 ]; then
echo "Choose only one of --baseline or --compare." >&2
exit 2
fi
mode="baseline"
mode_selected=1
shift
;;
--compare)
if [ "$mode_selected" -eq 1 ]; then
echo "Choose only one of --baseline or --compare." >&2
exit 2
fi
if [ "$#" -lt 2 ]; then
echo "--compare requires a baseline JSON path" >&2
exit 2
fi
mode="compare"
mode_selected=1
compare_file="$2"
shift 2
;;
*)
echo "Unknown argument: $1" >&2
usage >&2
exit 2
;;
esac
done
if [ "$mode" = "compare" ] && [ ! -f "$compare_file" ]; then
echo "Baseline JSON not found: $compare_file" >&2
exit 1
fi
compare_tmp=""
if [ "$mode" = "compare" ]; then
compare_tmp="$(mktemp)"
cp "$compare_file" "$compare_tmp"
fi
cleanup() {
if [ -n "$compare_tmp" ]; then
rm -f "$compare_tmp"
fi
}
trap cleanup EXIT
if ! command -v pio >/dev/null 2>&1; then
if [ -x "${MESHCORE_FW_ROOT}/.venv/bin/pio" ]; then
export PATH="${MESHCORE_FW_ROOT}/.venv/bin:${PATH}"
else
echo "PlatformIO is required to build representative firmware." >&2
echo "Install it with pipx or a local venv, for example: python3 -m venv .venv && .venv/bin/python -m pip install --upgrade platformio" >&2
exit 1
fi
fi
if [ ! -f "${MESHCORE_DIR}/build.sh" ]; then
echo "MeshCore build script is missing at ${MESHCORE_DIR}/build.sh" >&2
exit 1
fi
"${MESHCORE_FW_ROOT}/scripts/apply-patches.sh"
OUT_DIR="${MESHCORE_FW_ROOT}/out"
LOG_DIR="${OUT_DIR}/size"
ARTIFACT_DIR="${OUT_DIR}/firmware"
SUMMARY_PATH="${LOG_DIR}/summary.json"
rm -rf "$OUT_DIR"
mkdir -p "$LOG_DIR" "$ARTIFACT_DIR"
export FIRMWARE_VERSION="${FIRMWARE_VERSION:-local}"
failed_envs=()
for env in "${REPRESENTATIVE_ENVS[@]}"; do
log_path="${LOG_DIR}/${env}.log"
echo "Building ${env}"
if (cd "$MESHCORE_DIR" && /usr/bin/env bash build.sh build-firmware "$env") >"$log_path" 2>&1; then
if compgen -G "${MESHCORE_DIR}/out/${env}-*" >/dev/null; then
cp "${MESHCORE_DIR}/out/${env}-"* "$ARTIFACT_DIR"/
fi
else
failed_envs+=("$env")
echo "Build failed for ${env}; see ${log_path}" >&2
fi
done
parser_args=(
--logs "$LOG_DIR"
--artifacts "$ARTIFACT_DIR"
--output "$SUMMARY_PATH"
)
for env in "${REPRESENTATIVE_ENVS[@]}"; do
parser_args+=(--env "$env")
done
case "$mode" in
baseline)
parser_args+=(--baseline)
;;
compare)
parser_args+=(--compare "$compare_tmp")
;;
esac
python3 "${MESHCORE_FW_ROOT}/scripts/parse-size-report.py" "${parser_args[@]}"
if [ "${#failed_envs[@]}" -gt 0 ]; then
echo "Representative build failed for: ${failed_envs[*]}" >&2
exit 1
fi
echo "Representative build complete. Summary: ${SUMMARY_PATH#${MESHCORE_FW_ROOT}/}"

View File

@@ -0,0 +1,151 @@
#!/usr/bin/env python3
import argparse
import json
import re
from datetime import datetime, timezone
from pathlib import Path
ANSI_RE = re.compile(r"\x1b\[[0-9;?]*[ -/]*[@-~]")
RAM_RE = re.compile(
r"RAM:\s+\[[^\]]*\]\s+\s*[0-9.]+%\s+\(used\s+([0-9]+)\s+bytes\s+from\s+([0-9]+)\s+bytes\)",
re.IGNORECASE,
)
FLASH_RE = re.compile(
r"Flash:\s+\[[^\]]*\]\s+\s*[0-9.]+%\s+\(used\s+([0-9]+)\s+bytes\s+from\s+([0-9]+)\s+bytes\)",
re.IGNORECASE,
)
def strip_ansi(text):
return ANSI_RE.sub("", text)
def metric_from_match(match):
if not match:
return None, None
return int(match.group(1)), int(match.group(2))
def parse_log(path):
if not path.exists():
return None, None, None, None, False
text = strip_ansi(path.read_text(errors="replace"))
ram_match = None
flash_match = None
for match in RAM_RE.finditer(text):
ram_match = match
for match in FLASH_RE.finditer(text):
flash_match = match
ram_used, ram_total = metric_from_match(ram_match)
flash_used, flash_total = metric_from_match(flash_match)
return ram_used, ram_total, flash_used, flash_total, bool(ram_match and flash_match)
def artifact_sizes(artifact_dir, env):
sizes = {}
if not artifact_dir.exists():
return sizes
for path in sorted(artifact_dir.glob(f"{env}-*")):
if path.is_file():
sizes[path.name] = path.stat().st_size
return sizes
def load_baseline(path):
data = json.loads(path.read_text())
return {entry["env"]: entry for entry in data.get("environments", [])}
def artifact_kind(name):
path = Path(name)
suffix = path.suffix.lstrip(".") or "no_extension"
if path.stem.endswith("-merged") and suffix == "bin":
return "merged.bin"
return suffix
def artifact_kind_sizes(artifacts):
sizes = {}
for name, size in artifacts.items():
sizes[artifact_kind(name)] = size
return sizes
def add_delta(entry, baseline_by_env):
baseline = baseline_by_env.get(entry["env"])
if not baseline:
return
delta = {}
for key in ("ram_used", "flash_used"):
current_value = entry.get(key)
baseline_value = baseline.get(key)
if current_value is not None and baseline_value is not None:
delta[key] = current_value - baseline_value
else:
delta[key] = None
baseline_artifacts = artifact_kind_sizes(baseline.get("artifact_bytes", {}))
current_artifacts = artifact_kind_sizes(entry.get("artifact_bytes", {}))
artifact_delta = {}
for suffix, current_size in current_artifacts.items():
baseline_size = baseline_artifacts.get(suffix)
artifact_delta[suffix] = current_size - baseline_size if baseline_size is not None else None
delta["artifact_bytes_by_kind"] = artifact_delta
entry["delta"] = delta
def build_summary(args):
log_dir = args.logs
artifact_dir = args.artifacts
envs = args.env or sorted(path.stem for path in log_dir.glob("*.log"))
baseline_by_env = load_baseline(args.compare) if args.compare else {}
entries = []
for env in envs:
log_path = log_dir / f"{env}.log"
ram_used, ram_total, flash_used, flash_total, metrics_found = parse_log(log_path)
entry = {
"env": env,
"ram_used": ram_used,
"ram_total": ram_total,
"flash_used": flash_used,
"flash_total": flash_total,
"artifact_bytes": artifact_sizes(artifact_dir, env),
"log": str(log_path),
"metrics_found": metrics_found,
}
add_delta(entry, baseline_by_env)
entries.append(entry)
mode = "compare" if args.compare else "baseline" if args.baseline else "summary"
return {
"mode": mode,
"generated_at": datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z"),
"environments": entries,
}
def main():
parser = argparse.ArgumentParser(description="Parse PlatformIO size output and firmware artifact sizes.")
parser.add_argument("--logs", type=Path, required=True, help="Directory containing <env>.log files")
parser.add_argument("--artifacts", type=Path, required=True, help="Directory containing copied firmware artifacts")
parser.add_argument("--env", action="append", help="Environment name to include; may be passed more than once")
parser.add_argument("--baseline", action="store_true", help="Mark output as a baseline summary")
parser.add_argument("--compare", type=Path, help="Baseline JSON to compare against")
parser.add_argument("--output", type=Path, help="Write JSON summary to this path")
args = parser.parse_args()
summary = build_summary(args)
text = json.dumps(summary, indent=2, sort_keys=True)
if args.output:
args.output.parent.mkdir(parents=True, exist_ok=True)
args.output.write_text(text + "\n")
print(text)
if __name__ == "__main__":
main()