Files
HaloKeymind/scripts/generate_mesh_america_release.py
T

238 lines
14 KiB
Python

#!/usr/bin/env python3
"""Generate Mesh America catalogs from exact, staged release assets."""
from __future__ import annotations
import argparse
import copy
import hashlib
import json
from pathlib import Path
import re
import subprocess
from urllib.parse import quote
ROOT = Path(__file__).resolve().parents[1]
SUFFIXES = {".bin", ".zip", ".uf2", ".hex"}
PLATFORMS = {"ESP32_PLATFORM": "esp32", "NRF52_PLATFORM": "nrf52",
"RP2040_PLATFORM": "noflash", "STM32_PLATFORM": "noflash"}
def identity(name):
stem = Path(name).stem.removesuffix("-merged")
match = re.fullmatch(r"(.+?)-v\d+(?:\.\d+){2,3}-.+", stem)
if not match:
raise ValueError(f"firmware filename has no release identity: {name}")
return match[1]
def target_profiles(targets):
# Use the same exact hardware/role parser as the online firmware picker.
program = """const fs = require('fs');
const picker = require(process.argv[1]);
const targets = JSON.parse(fs.readFileSync(0, 'utf8'));
process.stdout.write(JSON.stringify(Object.fromEntries(
targets.map(t => [t, picker.parseTargetProfile(t)]))));
"""
return json.loads(subprocess.check_output(
["node", "-e", program, str(ROOT / "docs/_javascript/firmware_picker.js")],
input=json.dumps(sorted(targets)), text=True))
def file_entry(path, platform, tag, repo):
if platform == "noflash":
kind, title = "download", path.suffix[1:].upper() + " download"
elif path.name.endswith("-merged.bin"):
kind, title = "flash-wipe", "USB full install (bootloader + partitions + app)"
elif path.suffix == ".bin" and platform == "esp32":
kind, title = "flash-update", "App update (matching partition layout only)"
elif path.suffix == ".zip" and platform == "nrf52":
kind, title = "flash", "Serial DFU package"
else:
kind, title = "download", path.suffix[1:].upper() + " download"
return {"type": kind, "name": path.name, "title": title,
"url": f"https://github.com/{repo}/releases/download/{quote(tag, safe='')}/{quote(path.name, safe='')}"}
def read_stage(stage):
plan = json.loads((stage / "release-plan.json").read_text())
source = plan["source"]
if not re.fullmatch(r"[a-f0-9]{40}", source):
raise ValueError("release source must be a full commit")
family = f"v{plan['version']}-halo-keymind-cascade-dev-{source[:8]}"
records = []
seen = set()
for group in plan["groups"]:
expected_tag = family if group["key"] == "companion" else group["key"] + "-" + family
if group["tag"] != expected_tag:
raise ValueError("release group belongs to a different family")
directory = stage / group["key"]
checksums = {}
for line in (directory / "SHA256SUMS.txt").read_text(encoding="ascii").splitlines():
digest, name = line.split(" ", 1)
if name in checksums or Path(name).name != name:
raise ValueError("duplicate or unsafe release asset")
checksums[name] = digest
manifest_path = directory / "TARGET-MANIFEST.json"
if hashlib.sha256(manifest_path.read_bytes()).hexdigest() != checksums.get(manifest_path.name):
raise ValueError("release target manifest checksum failed")
manifests = json.loads(manifest_path.read_text())
if len(manifests) != group["target_count"]:
raise ValueError("release target count differs from manifest")
for row in manifests:
logical_target = row["artifact_target"]
if row.get("verified") is not True or row.get("source_commit", source) != source:
raise ValueError("unqualified or wrong-source release profile")
files = []
for name in row["files"]:
if Path(name).name != name or name not in checksums:
raise ValueError("manifest asset is outside the release checksum inventory")
path = directory / name
if hashlib.sha256(path.read_bytes()).hexdigest() != checksums[name]:
raise ValueError(f"release asset checksum failed: {name}")
if path.suffix in SUFFIXES:
if family not in name:
raise ValueError("firmware filename belongs to a different family")
files.append(path)
if not files:
raise ValueError("qualified profile contains no firmware")
identities = {identity(path.name) for path in files}
if len(identities) != 1:
raise ValueError("firmware filenames disagree on publication profile")
published_target = identities.pop()
# Full images keep their OTA identity while the filename adds its
# qualified build profile. Do not relabel the logical target.
infix = published_target.removeprefix(logical_target)
valid_infix = (published_target == logical_target
or (published_target.startswith(logical_target + "-") and (
(infix == "-ota" and "lora" in row.get("ota_update_methods", []))
or (row["build_profile"] == "full" and
re.fullmatch(r"-full(?:-usb-wifi|-logging)?(?:-ota)?", infix))
or (row["build_profile"] == "logging" and
re.fullmatch(r"-logging(?:-ota)?", infix)))))
qualified_stem = published_target + "-" + family
if any(path.stem.removesuffix("-merged") != qualified_stem for path in files):
raise ValueError("firmware filename differs from exact release source/version")
if (not valid_infix or any(qualified_stem + suffix not in row["files"]
for suffix in (".capabilities.json", ".memory.json"))):
raise ValueError("firmware filename differs from qualified profile")
if published_target in seen:
raise ValueError("duplicate release publication profile")
seen.add(published_target)
records.append(({**row, "publication_target": published_target}, group["tag"], files))
return plan, family, records
def generate(catalog, plan, family, records, profiles, repo):
result = copy.deepcopy(catalog)
by_identity, by_hardware, existing_roles = {}, {}, {}
for index, device in enumerate(catalog["device"]):
for firmware in device["firmware"]:
for version in firmware["version"].values():
for file in version["files"]:
key = identity(file["name"])
by_identity.setdefault(key, set()).add(index)
existing_roles.setdefault(key, set()).add((firmware["role"], firmware.get("title", firmware["role"])))
hardware = profiles[key]["hardware"]
by_hardware.setdefault((device["type"], hardware), set()).add(index)
result["device"][index]["firmware"] = []
for row, tag, files in records:
target = row.get("publication_target", row["artifact_target"])
parsed = profiles[target]
platform = PLATFORMS[row["platform"]]
indexes = by_identity.get(target) or by_identity.get(row["target"])
if not indexes:
indexes = set(by_hardware.get((platform, parsed["hardware"]), set()))
# A download-only card is an installer restriction, not a chip.
# Retain that restriction for new profiles of the same hardware.
indexes.update(by_hardware.get(("noflash", parsed["hardware"]), set()))
if len(indexes) != 1:
raise ValueError(f"new profile needs an exact hardware card mapping: {target}")
if any(catalog["device"][i]["type"] not in (platform, "noflash") for i in indexes):
raise ValueError("firmware platform differs from its hardware card")
role, title = {"repeater": ("repeater", "Repeater"),
"room": ("roomServer", "Room Server"),
"sensor": ("sensor", "Sensor"),
"terminal": ("terminalChat", "Terminal Chat")}.get(parsed["role"], (None, None))
if parsed["role"] == "companion":
role, title = {"full": ("companionFull", "Full Companion"),
"ble": ("companionBle", "Companion Bluetooth"),
"usb": ("companionUsb", "Companion USB"),
"wifi": ("companionWifi", "Companion WiFi"),
"ethernet": ("companionEthernet", "Companion Ethernet"),
"usb-ble": ("companionBle", "Companion USB + Bluetooth")}.get(parsed["mode"], (None, None))
if role is None and len(existing_roles.get(target, set())) == 1:
# Some older exact recipes (PicoW_companion_radio) omit a
# transport suffix. Preserve their curated role without guessing.
role, title = next(iter(existing_roles[target]))
if role is None:
raise ValueError(f"unsupported firmware role or transport: {target}")
sensor = row.get("sensor_profile", "")
profile = ("Full supported sensors + LoRa OTA" if sensor == "full" else
"Reduced optional sensors + LoRa OTA" if sensor == "reduced" else
"Full profile" if row["build_profile"] == "full" or parsed["mode"] == "full" else
"Standard profile")
url = f"https://github.com/{repo}/releases/download/{tag}/"
cap = next(name for name in row["files"] if name.endswith(".capabilities.json"))
memory = next(name for name in row["files"] if name.endswith(".memory.json"))
methods = ", ".join(row.get("ota_update_methods", [])) or "USB or board-specific wired programming"
notes = [f"Keymind Cascade MeshCore {plan['version']}. USA Cascade: 910.525 MHz / BW62.5 / SF7 / CR5. Saved radio settings take precedence.",
f"EXACT TARGET - {row['target']}. Match the exact board, radio, screen, and storage hardware.",
f"PROFILE - {profile}. See the qualified capabilities and memory reports for this image.",
f"SELF-UPDATE - Verified methods: {methods}."]
if platform == "esp32":
notes.append("INSTALL - The merged .bin installs the bootloader, partitions and application over USB. An app-only .bin requires a compatible existing partition layout. Use the exact board and role migration ZIP from the utility release when expanding partitions over Wi-Fi or LoRa; follow its README and install its supplied Full application.")
elif platform == "nrf52":
notes.append("INSTALL - UF2 and Serial DFU ZIP files contain application firmware. LoRa updates require the matching board and storage OTAFIX bootloader. Use the firmware picker's exact bootloader mapping.")
if row.get("reductions"):
notes.append("CAPACITY - " + "; ".join(row["reductions"]))
if "-full-usb-wifi" in target:
notes.append("OUTPUT - Select saved output with set logging.output off|usb|wifi|both. USB-connected MQTT uses the serial packet log. Direct Wi-Fi MQTT uses the saved broker configuration.")
elif parsed["mode"] == "full" and parsed["role"] == "companion":
notes.append("TRANSPORT - Full Companion starts its primary USB interface in the ASCII terminal; a complete framed app command selects Binary Companion. USB logging is off by default. Use the picker directions for this image's supported transports and runtime logging controls.")
notes += [f"SETUP - https://mikecarper.github.io/MeshCore/firmware_picker/?hardware={quote(parsed['hardware'])}",
"CAPABILITIES - " + url + quote(cap), "MEMORY - " + url + quote(memory),
f"SOURCE - https://github.com/{repo}/tree/{row.get('source_commit', plan['source'])}",
f"RELEASE - https://github.com/{repo}/releases/tag/{tag}"]
for index in sorted(indexes):
install_platform = catalog["device"][index]["type"]
card_notes = list(notes)
if install_platform == "noflash":
card_notes.append("MESH AMERICA - Downloads only. In-app flashing is not qualified for this hardware card.")
entry = {"role": role, "title": title, "subTitle": target + " - " + profile,
"version": {family: {"notes": "\n\n".join(card_notes),
"files": [file_entry(path, install_platform, tag, repo) for path in sorted(files,
key=lambda p: (not p.name.endswith('-merged.bin'), p.suffix != '.bin', p.name))]}}}
result["device"][index]["firmware"].append(entry)
result["device"] = [device for device in result["device"] if device["firmware"]]
for device in result["device"]:
device["firmware"].sort(key=lambda entry: (entry["role"], entry["subTitle"]))
result["description"] = (f"Keymind Cascade MeshCore {plan['version']} USA Cascade. "
f"910.525 MHz / BW62.5 / SF7 / CR5. {len(records)} qualified firmware profiles "
f"across {len(plan['groups'])} GitHub release pages. See each exact image's capability and memory reports.")
return result
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--stage", type=Path, required=True)
parser.add_argument("--catalog", type=Path, required=True)
parser.add_argument("--output", type=Path, required=True)
parser.add_argument("--repo", default="mikecarper/MeshCore")
args = parser.parse_args()
catalog = json.loads(args.catalog.read_text())
plan, family, records = read_stage(args.stage)
targets = {row.get("publication_target", row["artifact_target"]) for row, _, _ in records}
targets.update(identity(file["name"]) for device in catalog["device"]
for firmware in device["firmware"] for version in firmware["version"].values()
for file in version["files"])
result = generate(catalog, plan, family, records, target_profiles(targets), args.repo)
args.output.write_text(json.dumps(result, ensure_ascii=True, indent=2) + "\n", encoding="ascii")
print(json.dumps({"profiles": len(records), "hardware_cards": len(result["device"]),
"firmware_entries": sum(len(d["firmware"]) for d in result["device"])}))
if __name__ == "__main__":
main()