Files
firmware/bin/platformio-custom.py

521 lines
20 KiB
Python

#!/usr/bin/env python3
# trunk-ignore-all(ruff/F821)
# trunk-ignore-all(flake8/F821): For SConstruct imports
import sys
from os.path import basename, isabs, isfile, join, realpath
import shutil
import subprocess
import json
import re
from datetime import datetime
from typing import Dict
from readprops import readProps
Import("env")
platform = env.PioPlatform()
progname = env.get("PROGNAME")
lfsbin = f"{progname.replace('firmware-', 'littlefs-')}.bin"
manifest_ran = False
def infer_architecture(board_cfg):
try:
mcu = board_cfg.get("build.mcu") if board_cfg else None
except KeyError:
mcu = None
except Exception:
mcu = None
if not mcu:
return None
mcu_l = str(mcu).lower()
if "esp32s3" in mcu_l:
return "esp32-s3"
if "esp32c6" in mcu_l:
return "esp32-c6"
if "esp32c3" in mcu_l:
return "esp32-c3"
if "esp32" in mcu_l:
return "esp32"
if "rp2040" in mcu_l:
return "rp2040"
if "rp2350" in mcu_l:
return "rp2350"
if "nrf52" in mcu_l or "nrf52840" in mcu_l:
return "nrf52840"
if "nrf54l15" in mcu_l:
return "nrf54l15"
if "stm32" in mcu_l:
return "stm32"
print(f"mtjson: could not infer architecture from MCU '{mcu_l}'")
return None
def run_size_tool(env, flag, purpose):
"""Run the toolchain size tool against the built ELF and return its output.
Shared plumbing for compute_ram_bytes / compute_flash_bytes. Returns None
when the ELF is missing or the tool fails; the manifest then simply omits
the metric and downstream size reports show "n/a".
"""
elf = env.File(env.subst("$BUILD_DIR/${PROGNAME}.elf"))
if not elf.exists():
return None
size_tool = env.subst("$SIZETOOL") or "size"
try:
return subprocess.check_output(
[size_tool, flag, elf.get_abspath()],
env=env["ENV"],
universal_newlines=True,
)
except Exception as exc:
print(f"mtjson: skipping {purpose} ({size_tool} failed: {exc})")
return None
def compute_ram_bytes(env):
"""Static RAM usage (.data + .bss) of the ELF, via the toolchain size tool.
Deliberately excludes heap/stack placeholder sections (e.g. the nRF52 .heap
section): on nRF52840 the heap arena is the linker gap after .bss, so static
RAM growth shrinks the usable heap 1:1 - which is exactly why we track it.
Returns None when the value cannot be determined; the manifest then simply
omits ram_bytes and downstream size reports show "n/a".
"""
output = run_size_tool(env, "-A", "ram_bytes")
if output is None:
return None
ram = 0
found = False
for line in output.splitlines():
parts = line.split()
if len(parts) < 2:
continue
name = parts[0]
# Main-SRAM static sections: .data/.bss, platform-prefixed variants (e.g.
# ESP32 .dram0.data/.dram0.bss), and RISC-V small-data .sdata/.sbss.
# ESP-IDF .rtc.* sections live outside the heap-competing SRAM; .heap and
# .tdata never match.
if name.startswith(".rtc"):
continue
if name in (".data", ".bss", ".sdata", ".sbss") or name.endswith(".data") or name.endswith(".bss"):
try:
ram += int(parts[1])
found = True
except ValueError:
continue
return ram if found else None
def compute_flash_bytes(env):
"""Flash footprint (text + data) of the ELF via the toolchain size tool.
Approximates the flashed app image for targets whose packaged artifacts do
not include a raw .bin (e.g. nRF52: hex/uf2/DFU zip only); consumed by
bin/collect_sizes.py as a fallback flash measurement for the size report
and the bin/ram_budgets.json budget gate. Returns None when the value
cannot be determined; the manifest then simply omits flash_bytes.
"""
output = run_size_tool(env, "-B", "flash_bytes")
if output is None:
return None
for line in output.splitlines():
parts = line.split()
if len(parts) >= 3 and parts[0].isdigit() and parts[1].isdigit():
return int(parts[0]) + int(parts[1])
return None
def export_link_files(source, target, env):
"""Copy the linker scripts that define MEMORY regions, and the linker map, next to the ELF.
CI uploads them with the ELF so MemBrowse can report usage per memory region and
attribute symbols to archives. Scripts come from LDSCRIPT_PATH and -T link flags,
resolved like the linker does: relative to the project dir, then each LIBPATH entry.
--defsym values a script uses (e.g. STM32's LD_MAX_SIZE) are prepended to its copy so
its MEMORY block resolves standalone. The map is the last -Map in LINKFLAGS, the one
the linker writes: STM32's framework adds its own after the global output.map, and ESP-IDF
builds use --Map instead.
"""
names = []
if env.get("LDSCRIPT_PATH"):
names.append(env.subst("$LDSCRIPT_PATH"))
flags = [env.subst(str(f)) for f in env.Flatten(env.get("LINKFLAGS", []))]
for i, flag in enumerate(flags):
if flag == "-T" and i + 1 < len(flags):
names.append(flags[i + 1])
elif flag.startswith("-Wl,-T"):
names.append(flag[len("-Wl,-T"):].lstrip(","))
elif flag.startswith("-T"):
names.append(flag[2:])
defsyms = [m.groups() for m in (re.match(r"-Wl,--defsym[,=]([A-Za-z_]\w*)=(\S+)", f) for f in flags) if m]
search_dirs = [env.subst("$PROJECT_DIR")] + [
env.Dir(env.subst(str(p))).get_abspath() for p in env.Flatten(env.get("LIBPATH", []))
]
exported = set()
for name in names:
name = name.strip('"')
candidates = [name] if isabs(name) else [join(d, name) for d in search_dirs]
path = next((c for c in candidates if isfile(c)), None)
if path is None or realpath(path) in exported:
continue
with open(path, errors="replace") as f:
script = f.read()
if not re.search(r"\bMEMORY\s*\{", script):
continue
exported.add(realpath(path))
dest = env.subst(f"$BUILD_DIR/${{PROGNAME}}.{basename(path)}")
with open(dest, "w") as f:
used = [(n, v) for n, v in defsyms if re.search(rf"\b{n}\b", script)]
if used:
f.write("/* --defsym values from the link command */\n")
f.writelines(f"{n} = {v};\n" for n, v in used)
f.write(script)
print(f"Exported linker script {path} -> {dest}")
map_path = None
for flag in flags:
m = re.match(r"-Wl,--?Map[,=](.+)", flag)
if m:
map_path = m.group(1).replace('"', "")
if map_path:
map_path = join(env.subst("$PROJECT_DIR"), map_path)
dest = env.subst("$BUILD_DIR/${PROGNAME}.map")
if isfile(map_path) and realpath(map_path) != realpath(dest):
shutil.copyfile(map_path, dest)
print(f"Exported linker map {map_path} -> {dest}")
def manifest_gather(source, target, env):
global manifest_ran
if manifest_ran:
return
# Skip manifest generation if we cannot determine architecture (host/native builds)
board_arch = infer_architecture(env.BoardConfig())
if not board_arch:
print(f"Skipping mtjson generation for unknown architecture (env={env.get('PIOENV')})")
manifest_ran = True
return
manifest_ran = True
out = []
board_platform = env.BoardConfig().get("platform")
board_mcu = env.BoardConfig().get("build.mcu").lower()
needs_ota_suffix = board_platform == "nordicnrf52"
# Mapping of bin files to their target partition names
# Maps the filename pattern to the partition name where it should be flashed
partition_map = {
f"{progname}.bin": "app0", # primary application slot (app0 / OTA_0)
lfsbin: "spiffs", # filesystem image flashed to spiffs
}
check_paths = [
progname,
f"{progname}.elf",
f"{progname}.bin",
f"{progname}.factory.bin",
f"{progname}.hex",
f"{progname}.merged.hex",
f"{progname}.uf2",
f"{progname}.factory.uf2",
f"{progname}.zip",
lfsbin,
f"mt-{board_mcu}-ota.bin",
"bleota-c3.bin"
]
for p in check_paths:
f = env.File(env.subst(f"$BUILD_DIR/{p}"))
if f.exists():
manifest_name = p
if needs_ota_suffix and p == f"{progname}.zip":
manifest_name = f"{progname}-ota.zip"
d = {
"name": manifest_name,
"md5": f.get_content_hash(), # Returns MD5 hash
"bytes": f.get_size() # Returns file size in bytes
}
# Add part_name if this file represents a partition that should be flashed
if p in partition_map:
d["part_name"] = partition_map[p]
out.append(d)
print(d)
manifest_write(out, env, compute_ram_bytes(env), compute_flash_bytes(env))
def manifest_write(files, env, ram_bytes=None, flash_bytes=None):
# Defensive: also skip manifest writing if we cannot determine architecture
def get_project_option(name):
try:
return env.GetProjectOption(name)
except Exception:
return None
def get_project_option_any(names):
for name in names:
val = get_project_option(name)
if val is not None:
return val
return None
def as_bool(val):
return str(val).strip().lower() in ("1", "true", "yes", "on")
def as_int(val):
try:
return int(str(val), 10)
except (TypeError, ValueError):
return None
def as_list(val):
return [item.strip() for item in str(val).split(",") if item.strip()]
manifest = {
"version": verObj["long"],
"build_epoch": build_epoch,
"platformioTarget": env.get("PIOENV"),
"mcu": env.get("BOARD_MCU"),
"repo": repo_owner,
"files": files,
"has_mui": False,
"has_inkhud": False,
}
# Static RAM footprint (.data + .bss); consumed by bin/collect_sizes.py for
# the CI size report and the bin/ram_budgets.json budget gate.
if ram_bytes is not None:
manifest["ram_bytes"] = ram_bytes
# Flash footprint (text + data); fallback flash measurement for targets
# whose packaged artifacts include no raw .bin (e.g. nRF52).
if flash_bytes is not None:
manifest["flash_bytes"] = flash_bytes
# Get partition table (generated in esp32_pre.py) if it exists
if env.get("custom_mtjson_part"):
# custom_mtjson_part is a JSON string, convert it back to a dict
pj = json.loads(env.get("custom_mtjson_part"))
manifest["part"] = pj
# Enable has_mui for TFT builds
if ("HAS_TFT", 1) in env.get("CPPDEFINES", []):
manifest["has_mui"] = True
if "MESHTASTIC_INCLUDE_INKHUD" in env.get("CPPDEFINES", []):
manifest["has_inkhud"] = True
pioenv = env.get("PIOENV")
device_meta = {}
device_meta_fields = [
("hwModel", ["custom_meshtastic_hw_model"], as_int),
("hwModelSlug", ["custom_meshtastic_hw_model_slug"], str),
("architecture", ["custom_meshtastic_architecture"], str),
("activelySupported", ["custom_meshtastic_actively_supported"], as_bool),
("displayName", ["custom_meshtastic_display_name"], str),
("supportLevel", ["custom_meshtastic_support_level"], as_int),
("images", ["custom_meshtastic_images"], as_list),
("tags", ["custom_meshtastic_tags"], as_list),
("requiresDfu", ["custom_meshtastic_requires_dfu"], as_bool),
("partitionScheme", ["custom_meshtastic_partition_scheme"], str),
("url", ["custom_meshtastic_url"], str),
("key", ["custom_meshtastic_key"], str),
("variant", ["custom_meshtastic_variant"], str),
]
for manifest_key, option_keys, caster in device_meta_fields:
raw_val = get_project_option_any(option_keys)
if raw_val is None:
continue
parsed = caster(raw_val) if callable(caster) else raw_val
if parsed is not None and parsed != "":
device_meta[manifest_key] = parsed
# Board MCU wins over a hand-typed custom_meshtastic_architecture: only the
# spellings infer_architecture emits are recognized downstream.
declared = device_meta.get("architecture")
board_arch = infer_architecture(env.BoardConfig()) or declared
if declared and declared != board_arch:
print(f"{pioenv}: architecture '{declared}' overridden with '{board_arch}'")
if not board_arch:
print(f"Skipping mtjson write for unknown architecture (env={env.get('PIOENV')})")
return
device_meta["architecture"] = board_arch
# Always set requiresDfu: true for nrf52840 targets
if board_arch == "nrf52840":
device_meta["requiresDfu"] = True
device_meta.setdefault("displayName", pioenv)
device_meta.setdefault("activelySupported", False)
if device_meta:
manifest.update(device_meta)
# Write the manifest to the build directory
with open(env.subst("$BUILD_DIR/${PROGNAME}.mt.json"), "w") as f:
json.dump(manifest, f, indent=2)
Import("projenv")
prefsLoc = projenv["PROJECT_DIR"] + "/version.properties"
verObj = readProps(prefsLoc)
print(f"Using meshtastic platformio-custom.py, firmware version {verObj['long']} on {env.get('PIOENV')}")
# get repository owner if git is installed
try:
r_owner = (
subprocess.check_output(["git", "config", "--get", "remote.origin.url"])
.decode("utf-8")
.strip().split("/")
)
repo_owner = r_owner[-2] + "/" + r_owner[-1].replace(".git", "")
except subprocess.CalledProcessError:
repo_owner = "unknown"
jsonLoc = env["PROJECT_DIR"] + "/userPrefs.jsonc"
with open(jsonLoc) as f:
jsonStr = re.sub("//.*","", f.read(), flags=re.MULTILINE)
userPrefs = json.loads(jsonStr)
# Channels::initDefaultChannel() applies a configured index as a whole, so resolve per-field
# optionality here: any field the vendor left out gets the value that function would have kept.
MAX_NUM_CHANNELS = 8
CHANNEL_FIELD_DEFAULTS = {
"PSK": "{ 0x01 }", # short-form index into the well-known default PSK
"NAME": "",
"PRECISION": "0",
"IS_MUTED": "false",
"UPLINK_ENABLED": "false",
"DOWNLINK_ENABLED": "false",
}
channelsToWriteRaw = userPrefs.get("USERPREFS_CHANNELS_TO_WRITE", "1")
channelsToWrite = int(channelsToWriteRaw, 16 if channelsToWriteRaw.lower().startswith("0x") else 10)
if channelsToWrite > MAX_NUM_CHANNELS:
sys.exit(
f"userPrefs.jsonc: USERPREFS_CHANNELS_TO_WRITE is {channelsToWrite}, "
f"the channel table holds {MAX_NUM_CHANNELS}"
)
for i in range(MAX_NUM_CHANNELS):
prefix = f"USERPREFS_CHANNEL_{i}_"
if any(k.startswith(prefix) for k in list(userPrefs)):
for field, default in CHANNEL_FIELD_DEFAULTS.items():
userPrefs.setdefault(prefix + field, default)
pref_flags = []
# Pre-process the userPrefs
for pref in userPrefs:
if userPrefs[pref].startswith("{"):
pref_flags.append("-D" + pref + "=" + userPrefs[pref])
elif userPrefs[pref].lstrip("-").replace(".", "").isdigit():
pref_flags.append("-D" + pref + "=" + userPrefs[pref])
elif re.fullmatch(r"0[xX][0-9a-fA-F]+", userPrefs[pref]):
pref_flags.append("-D" + pref + "=" + userPrefs[pref])
elif userPrefs[pref] == "true" or userPrefs[pref] == "false":
pref_flags.append("-D" + pref + "=" + userPrefs[pref])
elif userPrefs[pref].startswith("meshtastic_"):
pref_flags.append("-D" + pref + "=" + userPrefs[pref])
# If the value is a string, we need to wrap it in quotes
else:
pref_flags.append("-D" + pref + "=" + env.StringifyMacro(userPrefs[pref]) + "")
# General options that are passed to the C and C++ compilers
# Calculate unix epoch for current day (midnight)
current_date = datetime.now().replace(hour=0, minute=0, second=0, microsecond=0)
build_epoch = int(current_date.timestamp())
flags = [
"-DAPP_VERSION=" + verObj["long"],
"-DAPP_VERSION_SHORT=" + verObj["short"],
"-DAPP_ENV=" + env.get("PIOENV"),
"-DAPP_REPO=" + repo_owner,
"-DBUILD_EPOCH=" + str(build_epoch),
] + pref_flags
print("Using flags:")
for flag in flags:
print(flag)
projenv.Append(
CCFLAGS=flags,
)
for lb in env.GetLibBuilders():
if lb.name == "meshtastic-device-ui":
lb.env.Append(CPPDEFINES=[("APP_VERSION", verObj["long"])])
break
# DEBUG_MUTE already compiles every LOG_* call out entirely; nanopb's own error-message
# strings are a separate mechanism it doesn't touch, so mirror the same intent into nanopb.
debug_mute_defined = any(
d == "DEBUG_MUTE" or (isinstance(d, tuple) and d[0] == "DEBUG_MUTE") for d in env.get("CPPDEFINES", [])
)
if debug_mute_defined:
projenv.Append(CPPDEFINES=[("PB_NO_ERRMSG", 1)])
for lb in env.GetLibBuilders():
if lb.name == "Nanopb":
lb.env.Append(CPPDEFINES=[("PB_NO_ERRMSG", 1)])
break
# Get the display resolution from macros
def get_display_resolution(build_flags):
# Check "DISPLAY_SIZE" to determine the screen resolution
for flag in build_flags:
if isinstance(flag, tuple) and flag[0] == "DISPLAY_SIZE":
screen_width, screen_height = map(int, flag[1].split("x"))
return screen_width, screen_height
print("No screen resolution defined in build_flags. Please define DISPLAY_SIZE.")
exit(1)
def load_boot_logo(source, target, env):
build_flags = env.get("CPPDEFINES", [])
logo_w, logo_h = get_display_resolution(build_flags)
print(f"TFT build with {logo_w}x{logo_h} resolution detected")
# Load the boot logo from `branding/logo_<width>x<height>.png` if it exists
source_path = join(env["PROJECT_DIR"], "branding", f"logo_{logo_w}x{logo_h}.png")
dest_dir = join(env["PROJECT_DIR"], "data", "boot")
dest_path = join(dest_dir, "logo.png")
if env.File(source_path).exists():
print(f"Loading boot logo from {source_path}")
# Prepare the destination
env.Execute(f"mkdir -p {dest_dir} && rm -f {dest_path}")
# Copy the logo to the `data/boot` directory
env.Execute(f"cp {source_path} {dest_path}")
# Load the boot logo on TFT builds
if ("HAS_TFT", 1) in env.get("CPPDEFINES", []):
env.AddPreAction(f"$BUILD_DIR/{lfsbin}", load_boot_logo)
board_arch = infer_architecture(env.BoardConfig())
should_skip_manifest = board_arch is None
# Most platforms can generate the manifest as part of the default 'buildprog' target.
# Typically this passes success/failure properly.
mtjson_deps = ["buildprog"]
if platform.name == "espressif32":
# On ESP32, we need to explicitly depend upon the binary to prevent fake-success upon failure.
mtjson_deps = ["$BUILD_DIR/${PROGNAME}.bin"]
# Build littlefs image as part of mtjson target
# Equivalent to `pio run -t buildfs`
target_lfs = env.DataToBin(
join("$BUILD_DIR", "${ESP32_FS_IMAGE_NAME}"), "$PROJECT_DATA_DIR"
)
mtjson_deps.append(target_lfs)
if should_skip_manifest:
def skip_manifest(source, target, env):
print(f"mtjson: skipped for native environment: {env.get('PIOENV')}")
env.AddCustomTarget(
name="mtjson",
# For host/native envs, avoid depending on 'buildprog' (some targets don't define it)
dependencies=[],
actions=[skip_manifest],
title="Meshtastic Manifest (skipped)",
description="mtjson generation is skipped for native environments",
always_build=True,
)
else:
env.AddCustomTarget(
name="mtjson",
dependencies=mtjson_deps,
actions=[manifest_gather],
title="Meshtastic Manifest",
description="Generating Meshtastic manifest JSON + Checksums",
always_build=True,
)
# Run manifest generation as part of the default build pipeline for non-native builds.
env.Default("mtjson")
env.AddPostAction("$BUILD_DIR/${PROGNAME}.elf", export_link_files)