Files
IoTManager/measure_size/measure.py

314 lines
11 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""
Measure firmware (flash) size of each module by building with baseline vs baseline+module,
then update about.size in each module's modinfo.json.
Usage:
From IoTManager root: python measure_size/measure.py [--env esp32_4mb] [--dry-run]
From measure_size: python measure.py [--env esp32_4mb] [--dry-run]
Size is parsed from `pio run -e ENV -t size` (text + data = program/flash). about.size in KB per env.
"""
import argparse
import fnmatch
import json
import os
import re
import shutil
import subprocess
import sys
import tempfile
from pathlib import Path
# IoTManager project root (parent of this script's folder)
PROJECT_ROOT = Path(__file__).resolve().parent.parent
os.chdir(PROJECT_ROOT)
# Log styling (ANSI; disabled if not TTY or --no-color)
USE_COLOR = sys.stdout.isatty()
def style(s, color=None, bold=False):
if not USE_COLOR or not color:
return s
codes = []
if bold:
codes.append("1")
if color == "green":
codes.append("32")
elif color == "red":
codes.append("31")
elif color == "yellow":
codes.append("33")
elif color == "cyan":
codes.append("36")
elif color == "dim":
codes.append("2")
return f"\033[{';'.join(codes)}m{s}\033[0m" if codes else s
def log_section(title):
width = 60
line = "" * width
print()
print(style(f" {title}", "cyan", bold=True))
print(style(line, "dim"))
def log_ok(msg):
print(style("", "green") + msg)
def log_fail(msg):
print(style("", "red") + msg)
def log_step(msg):
print(style("", "yellow") + msg)
def log_info(msg):
print(style(" ", "dim") + msg)
# Minimal module set so API.cpp and build succeed (Variable provides getAPI_Variable)
BASELINE_MODULE_PATHS = ["src/modules/virtual/Variable"]
# Default envs to measure (can override via --env)
DEFAULT_ENVS = ["esp32_4mb", "esp8266_4mb"]
def load_json(path):
with open(path, "r", encoding="utf-8") as f:
return json.load(f)
def save_json(path, data):
with open(path, "w", encoding="utf-8") as f:
json.dump(data, f, ensure_ascii=False, indent=4, sort_keys=False)
def collect_modules():
"""Return list of {path, moduleName, usedLibs, modinfo_path} for each module."""
modules = []
for root, _, names in os.walk(PROJECT_ROOT / "src" / "modules"):
if "modinfo.json" not in names:
continue
path = Path(root).relative_to(PROJECT_ROOT)
mod_path = path.as_posix()
info_path = PROJECT_ROOT / path / "modinfo.json"
info = load_json(info_path)
about = info.get("about", {})
modules.append({
"path": mod_path,
"moduleName": about.get("moduleName", path.name),
"usedLibs": about.get("usedLibs", info.get("usedLibs", {})),
"modinfo_path": info_path,
"modinfo": info,
})
return modules
def module_supports_env(used_libs, env):
"""True if env is supported by usedLibs (supports esp32*, esp82*, etc.)."""
if not used_libs:
return False
for pattern in used_libs:
if fnmatch.fnmatch(env, pattern):
return True
return False
def env_to_base_from_module(used_libs, env):
"""Return the usedLibs pattern that matches env (base key for about.size). From module info, no hardcode."""
if not used_libs:
return env
for pattern in used_libs:
if fnmatch.fnmatch(env, pattern):
return pattern
return env
def build_profile_with_modules(prof_template, active_paths, env):
"""Return a new profile dict with only given module paths active for the given env."""
prof = json.loads(json.dumps(prof_template))
for section, mods in prof.get("modules", {}).items():
for m in mods:
m["active"] = m["path"] in active_paths
prof["projectProp"]["platformio"]["default_envs"] = env
return prof
def run_prepare_project(profile_path, env):
"""Run PrepareProject.py -p profile -b env. Returns success."""
cmd = [sys.executable, "PrepareProject.py", "-p", str(profile_path), "-b", env]
r = subprocess.run(cmd, cwd=PROJECT_ROOT, capture_output=True, text=True, timeout=120)
if r.returncode != 0:
print(r.stderr or r.stdout, file=sys.stderr)
return r.returncode == 0
def get_size_from_output(env):
"""
Run `pio run -e ENV -t size` and parse program size = text + data (flash, bytes).
Returns int or None on failure.
"""
cmd = ["pio", "run", "-e", env, "-t", "size"]
r = subprocess.run(cmd, cwd=PROJECT_ROOT, capture_output=True, text=True, timeout=600)
out = (r.stdout or "") + (r.stderr or "")
if r.returncode != 0:
return None
# Standard: " text data bss dec hex filename" then line with firmware.elf
for line in out.split("\n"):
if "firmware.elf" in line:
parts = line.split()
if len(parts) >= 3:
try:
text = int(parts[0])
data = int(parts[1])
return text + data # program (flash) size
except (ValueError, IndexError):
pass
# Fallback: "Flash: ... (used XXXXX bytes ...)"
match = re.search(r"used\s+(\d+)\s+bytes", out)
if match:
return int(match.group(1))
return None
def main():
global USE_COLOR
ap = argparse.ArgumentParser(description="Measure module firmware size and update about.size in modinfo.json")
ap.add_argument("--env", action="append", default=[], help="Platform env (e.g. esp32_4mb). Can repeat.")
ap.add_argument("--profile", default="myProfile.json", help="Profile to use as template")
ap.add_argument("--dry-run", action="store_true", help="Only list modules and envs, do not build")
ap.add_argument("--no-color", action="store_true", help="Disable colored output")
ap.add_argument("--limit", type=int, default=None, metavar="N", help="Measure only first N modules per env (e.g. --limit 5)")
args = ap.parse_args()
if args.no_color:
USE_COLOR = False
envs = args.env if args.env else DEFAULT_ENVS
profile_path = PROJECT_ROOT / args.profile
if not profile_path.is_file():
profile_path = PROJECT_ROOT / "compilerProfile.json"
if not profile_path.is_file():
log_fail("Profile not found: myProfile.json or compilerProfile.json")
sys.exit(1)
prof_template = load_json(profile_path)
modules = collect_modules()
baseline_paths = set(BASELINE_MODULE_PATHS)
modules_by_env = {e: [m for m in modules if module_supports_env(m["usedLibs"], e)] for e in envs}
if args.limit is not None:
modules_by_env = {e: mods[: args.limit] for e, mods in modules_by_env.items()}
if args.dry_run:
log_section("Dry run — modules per env")
for e in envs:
log_ok(f"{e}: {len(modules_by_env[e])} modules")
log_info("Sample modules:")
for m in modules[:5]:
log_info(f"{m['path']}{m['moduleName']}")
print()
return
log_section("Measure module firmware size")
log_step(f"Profile: {profile_path.name}")
log_step(f"Envs: {', '.join(envs)}")
total_modules = sum(len(modules_by_env[e]) for e in envs)
limit_note = f" (first {args.limit} per env)" if args.limit is not None else ""
log_step(f"Total builds: baseline×{len(envs)} + {total_modules} module builds{limit_note}")
platformio_ini = PROJECT_ROOT / "platformio.ini"
platformio_backup = PROJECT_ROOT / "platformio.ini.ram_measure.bak"
original_env = prof_template["projectProp"]["platformio"].get("default_envs", envs[0] if envs else "esp32_4mb")
shutil.copy(platformio_ini, platformio_backup)
try:
baseline_size = {}
log_section("Baseline build (Variable only)")
for env in envs:
prof = build_profile_with_modules(prof_template, baseline_paths, env)
with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as f:
save_json(f.name, prof)
ok = run_prepare_project(f.name, env)
os.unlink(f.name)
if not ok:
log_fail(f"PrepareProject failed for baseline env={env}")
continue
size = get_size_from_output(env)
if size is None:
log_fail(f"Could not get size for baseline env={env}")
continue
baseline_size[env] = size
log_ok(f"{env}: {size:,} bytes ({size // 1024} KB)")
results = {}
for mod in modules:
results[mod["modinfo_path"]] = {}
idx = 0
for env in envs:
log_section(f"Measuring modules — {env}")
mods = modules_by_env[env]
for i, mod in enumerate(mods):
idx += 1
active_paths = baseline_paths | {mod["path"]}
prof = build_profile_with_modules(prof_template, active_paths, env)
with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as f:
save_json(f.name, prof)
ok = run_prepare_project(f.name, env)
tf = f.name
if not ok:
log_fail(f"[{idx}/{total_modules}] {mod['moduleName']} — PrepareProject failed")
continue
size = get_size_from_output(env)
os.unlink(tf)
if size is None:
log_fail(f"[{idx}/{total_modules}] {mod['moduleName']} — size parse failed")
continue
delta = size - baseline_size.get(env, 0)
size_kb = max(0, round(delta / 1024))
base = env_to_base_from_module(mod["usedLibs"], env)
results[mod["modinfo_path"]][base] = size_kb
log_ok(f"[{idx}/{total_modules}] {mod['moduleName']} ({base}): +{delta:,} B → {size_kb} KB")
updated = 0
log_section("Updating modinfo.json")
for modinfo_path, env_size in results.items():
if not env_size:
continue
info = load_json(modinfo_path)
about = info.setdefault("about", {})
if "size" not in about:
about["size"] = {}
current = about["size"]
if isinstance(current, dict):
current.update(env_size)
else:
about["size"] = env_size
save_json(modinfo_path, info)
rel = modinfo_path.relative_to(PROJECT_ROOT)
log_ok(f"{rel}: {env_size}")
updated += 1
log_section("Summary")
log_ok(f"Modinfo files updated: {updated}")
log_ok("platformio.ini restored.")
finally:
shutil.copy(platformio_backup, platformio_ini)
platformio_backup.unlink(missing_ok=True)
# Restore project state: run PrepareProject with original profile so all modules are as before
log_section("Restoring project state")
if run_prepare_project(profile_path, original_env):
log_ok(f"PrepareProject applied: {profile_path.name} (env={original_env})")
else:
log_fail(f"PrepareProject failed — restore manually: python PrepareProject.py -p {profile_path.name} -b {original_env}")
print()
if __name__ == "__main__":
main()