mirror of
https://github.com/mikecarper/MeshCore.git
synced 2026-10-22 21:11:16 +00:00
238 lines
14 KiB
Python
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()
|