Integrate mjgodat's keyboard/display Companion, USB Companion, Repeater and Room Server support for Cardputer-Adv with Cap LoRa-1262. Fix UI retry dispatch and ACK ownership, packet/storage bounds, numeric input validation, sparse channel selection, optional-display shutdown, keyboard layout limits, reserved GPIOs and Windows cross-drive includes. Retain all 350 contacts and queue capacity using the existing contact cache; account for the custom UI's runtime allocations in the RAM admission gate. Carry the MultiMote and DejaVu notices with firmware packages and add CI coverage for all four board roles plus the shared T-Deck Companion. Validation: all four Cardputer roles and the T-Deck BLE Companion build; Cardputer sanitizer, UI, layout, ownership, licensing and storage tests; 65 RAM-policy tests and 14 contact-cache tests. PlatformIO ran serially. PR: https://github.com/mikecarper/MeshCore/pull/26
375 lines
19 KiB
Python
375 lines
19 KiB
Python
#!/usr/bin/env python3
|
|
"""Verify promised firmware capabilities and emit a machine-readable manifest."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import json
|
|
import os
|
|
from pathlib import Path
|
|
import sys
|
|
import struct
|
|
import zipfile
|
|
import zlib
|
|
from rp2040_application import read_application as read_rp2040_application
|
|
from host_kiss_capabilities import (CAPABILITY as HOST_KISS_CAPABILITY,
|
|
USB_CAPABILITY as HOST_KISS_USB_CAPABILITY, TCP_CAPABILITY as HOST_KISS_TCP_CAPABILITY,
|
|
COMMAND_MARKERS as HOST_KISS_COMMAND_MARKERS, TCP_MARKER as HOST_KISS_TCP_MARKER,
|
|
evidence as host_kiss_evidence)
|
|
from package_meshtext_license import (CAPABILITY as MESHTEXT_CAPABILITY,
|
|
meshtext_evidence, notice_bytes as meshtext_notice_bytes,
|
|
notice_record as meshtext_notice_record)
|
|
from package_pocket_gui_license import (pocket_gui_evidence,
|
|
notice_bytes as pocket_gui_notice_bytes, notice_record as pocket_gui_notice_record)
|
|
from package_cardputer_license import (cardputer_evidence,
|
|
notice_bytes as cardputer_notice_bytes, notice_record as cardputer_notice_record)
|
|
|
|
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "tools" / "mota"))
|
|
from motalib import parse_nrf52_layout
|
|
|
|
OTAFIX_RELEASE = "https://github.com/mikecarper/Adafruit_nRF52_Bootloader_OTAFIX/releases/tag/v0.11.0-OTAFIX2.4.11"
|
|
|
|
|
|
def nrf52_lora_details(application):
|
|
"""Inspect application bytes, not just an ELF symbol or an OTA CLI stub."""
|
|
if b"invalid in-place patch geometry" not in application or b"OTA: status" not in application:
|
|
raise ValueError("nRF52 LoRa OTA receiver/apply implementation is missing")
|
|
layout = parse_nrf52_layout(application)
|
|
if layout is None:
|
|
raise ValueError("nRF52 LoRa OTA requires a valid EndF and storage-layout record")
|
|
if len(application) > layout.linked_app_end - layout.app_base:
|
|
raise ValueError("nRF52 LoRa OTA application exceeds its linked flash region")
|
|
storage = ("adaptive_internal_or_external_qspi" if layout.auto_store
|
|
else "external_qspi" if layout.qspi_backed else "external_sd" if layout.sd_backed
|
|
else "internal_flash_and_retained_ram" if layout.hybrid_ram else "internal_flash")
|
|
notes = ["Use a destination-specific .mota package matching the board, target, and storage layout.",
|
|
"An in-place delta requires the exact firmware currently running as its base.",
|
|
"The package must fit the receiver's staging and apply workspace.",
|
|
"Check the release's exact-board bootloader manifest; hardware-pending boards are not covered by the normal release.",
|
|
"Artifact inspection verifies compiled support; it does not verify the bootloader installed on a physical device."]
|
|
if layout.auto_store:
|
|
notes.append("One application detects RAK external NOR and chooses QSPI only with the exact matching OTAFIX bootloader; otherwise it uses internal flash where safe.")
|
|
if layout.hybrid_ram:
|
|
notes.append("External QSPI accepts full images and in-place deltas; internal flash accepts in-place deltas only and needs the retained-RAM OTAFIX profile.")
|
|
else:
|
|
notes.append("External QSPI accepts full images and in-place deltas; this legacy application's internal staging is flash-only and accepts in-place deltas only.")
|
|
notes.append("Retained-RAM staging and bootloader self-update are not compiled into this legacy application.")
|
|
notes.append("OTAFIX 2.4.11 provides normal RAK3401/RAK4631 *_auto bootloaders with standard board identities and internal signed bootloader-update staging. Historical *_AUTO_DFU or dedicated external identities need the documented local identity-matching migration, not a cross-profile LoRa update.")
|
|
elif layout.hybrid_ram:
|
|
notes.append("Requires the exact-board retained-RAM handoff bootloader profile; a transfer cannot resume after the receiver restarts.")
|
|
elif layout.external_backed:
|
|
notes.append("Requires the matching external-storage hardware, wiring, and storage-aware OTAFIX bootloader.")
|
|
else:
|
|
notes.append("Internal storage accepts in-place application deltas, not full application packages.")
|
|
return {
|
|
"storage": storage,
|
|
"package_types": ["full", "in_place_delta"] if layout.auto_store or layout.external_backed else ["in_place_delta"],
|
|
"bootloader": ("Matching normal RAK3401 or RAK4631 adaptive OTAFIX bootloader, or the exact legacy storage profile"
|
|
if layout.auto_store else "Matching board/storage OTAFIX bootloader"),
|
|
"bootloader_release": OTAFIX_RELEASE,
|
|
"notes": notes,
|
|
}
|
|
|
|
|
|
def parse_args() -> argparse.Namespace:
|
|
parser = argparse.ArgumentParser()
|
|
parser.add_argument("--image", required=True, type=Path)
|
|
parser.add_argument("--output", required=True, type=Path)
|
|
parser.add_argument("--target", required=True)
|
|
parser.add_argument("--artifact-target")
|
|
parser.add_argument("--platformio-env")
|
|
parser.add_argument("--platform", required=True)
|
|
parser.add_argument("--build-profile", required=True)
|
|
parser.add_argument("--capability", action="append", default=[])
|
|
parser.add_argument("--reduction", action="append", default=[])
|
|
parser.add_argument("--require-ota", action="store_true")
|
|
parser.add_argument(
|
|
"--require-meshtext", action="store_true",
|
|
help="Require linked Meshtext encode/decode and Companion raw-frame transport implementations.",
|
|
)
|
|
parser.add_argument(
|
|
"--require-host-kiss", action="store_true",
|
|
help="Require linked KISS framing/Dispatcher transport and packaged USB/TCP service commands.",
|
|
)
|
|
parser.add_argument("--firmware-bin", type=Path)
|
|
parser.add_argument("--partitions", type=Path)
|
|
parser.add_argument("--dfu-package", type=Path)
|
|
parser.add_argument("--uf2-package", type=Path)
|
|
parser.add_argument(
|
|
"--expect",
|
|
action="append",
|
|
default=[],
|
|
metavar="CAPABILITY=TEXT",
|
|
help="Require TEXT to be present in the linked image.",
|
|
)
|
|
parser.add_argument(
|
|
"--expect-gzip", action="append", default=[],
|
|
metavar="CAPABILITY=TEXT",
|
|
help="Require TEXT inside a complete gzip asset in the linked image.",
|
|
)
|
|
parser.add_argument(
|
|
"--expect-application", action="append", default=[],
|
|
metavar="CAPABILITY=TEXT",
|
|
help="Require TEXT in the packaged BIN, DFU application, or verified RP2040 UF2 application, not ELF metadata.",
|
|
)
|
|
return parser.parse_args()
|
|
|
|
|
|
def stable_unique(values: list[str]) -> list[str]:
|
|
return list(dict.fromkeys(value for value in values if value))
|
|
|
|
|
|
def linked_gzip_assets(image):
|
|
"""Inspect bounded, CRC-validated assets; ELF strings alone are not proof."""
|
|
assets = []
|
|
offset = 0
|
|
while True:
|
|
offset = image.find(b"\x1f\x8b\x08", offset)
|
|
if offset < 0:
|
|
return assets
|
|
decoder = zlib.decompressobj(31)
|
|
try:
|
|
decoded = decoder.decompress(image[offset:], 1024 * 1024 + 1)
|
|
if decoder.eof and len(decoded) <= 1024 * 1024:
|
|
assets.append(decoded)
|
|
except zlib.error:
|
|
pass
|
|
offset += 3
|
|
|
|
|
|
def read_application(args):
|
|
if args.platform == "RP2040_PLATFORM" or args.uf2_package is not None:
|
|
if args.platform != "RP2040_PLATFORM" or args.uf2_package is None or args.firmware_bin is None:
|
|
raise ValueError("RP2040 application checks require both --uf2-package and --firmware-bin")
|
|
return read_rp2040_application(args.uf2_package, args.firmware_bin)
|
|
if args.firmware_bin is not None:
|
|
return args.firmware_bin.read_bytes()
|
|
if args.dfu_package is not None:
|
|
with zipfile.ZipFile(args.dfu_package) as package:
|
|
app = json.loads(package.read("manifest.json"))["manifest"]["application"]
|
|
return package.read(app["bin_file"])
|
|
raise ValueError("application checks require --firmware-bin or --dfu-package")
|
|
|
|
|
|
def verify_ota_artifacts(args, methods):
|
|
"""Check the actual update medium; source/seeder support cannot qualify."""
|
|
if not methods:
|
|
return False, "no linked wireless self-update implementation"
|
|
try:
|
|
if args.platform == "ESP32_PLATFORM":
|
|
if args.partitions is None or args.firmware_bin is None:
|
|
return False, "ESP32 OTA requires firmware.bin and partitions.bin"
|
|
table = args.partitions.read_bytes()
|
|
slots = {}
|
|
otadata = False
|
|
for offset in range(0, len(table) - 31, 32):
|
|
magic, kind, subtype, address, size = struct.unpack_from("<HBBII", table, offset)
|
|
if magic != 0x50AA:
|
|
continue
|
|
if kind == 0 and subtype in (0x10, 0x11):
|
|
slots[subtype] = (address, size)
|
|
if kind == 1 and subtype == 0 and size >= 0x2000:
|
|
otadata = True
|
|
if len(slots) != 2 or not otadata:
|
|
return False, "ESP32 OTA requires ota_0, ota_1, and otadata"
|
|
image_size = args.firmware_bin.stat().st_size
|
|
ordered = sorted(slots.values())
|
|
if ordered[0][0] + ordered[0][1] > ordered[1][0]:
|
|
return False, "ESP32 OTA application slots overlap"
|
|
if image_size <= 0 or any(image_size > size for _, size in ordered):
|
|
return False, "ESP32 firmware does not fit both OTA application slots"
|
|
return True, "firmware fits both OTA application slots; otadata present"
|
|
if args.platform == "NRF52_PLATFORM" and set(methods) <= {"bluetooth", "lora"}:
|
|
if args.dfu_package is None:
|
|
return False, "nRF52 OTA verification requires its application DFU ZIP"
|
|
with zipfile.ZipFile(args.dfu_package) as package:
|
|
manifest = json.loads(package.read("manifest.json"))["manifest"]
|
|
application = manifest["application"]
|
|
image = package.read(application["bin_file"])
|
|
if not image or not package.read(application["dat_file"]):
|
|
return False, "Bluetooth DFU application ZIP is empty"
|
|
if package.testzip() is not None:
|
|
return False, "Bluetooth DFU application ZIP is corrupt"
|
|
evidence = []
|
|
if "bluetooth" in methods:
|
|
evidence.append("application DFU ZIP verified; matching BLE DFU bootloader required")
|
|
if "lora" in methods:
|
|
if "companion" in args.target.lower() or "comp_radio" in args.target.lower():
|
|
return False, "Companion MOTA sending does not qualify as LoRa self-update"
|
|
details = nrf52_lora_details(image)
|
|
evidence.append("LoRa mOTA receiver/apply code and EndF storage layout verified: " + details["storage"] +
|
|
"; matching board/storage OTAFIX bootloader and compatible destination package required")
|
|
return True, "; ".join(evidence)
|
|
except (OSError, ValueError, KeyError, zipfile.BadZipFile) as exc:
|
|
return False, str(exc)
|
|
return False, "no qualified wireless update artifact for this platform"
|
|
|
|
|
|
def main() -> int:
|
|
args = parse_args()
|
|
try:
|
|
image = args.image.read_bytes()
|
|
except OSError as exc:
|
|
print(f"capability check: cannot read {args.image}: {exc}", file=sys.stderr)
|
|
return 2
|
|
|
|
checks = []
|
|
malformed = False
|
|
third_party_notices = []
|
|
codec_symbols, chat_symbols = [], []
|
|
try:
|
|
codec_symbols, chat_symbols = meshtext_evidence(args.image)
|
|
if codec_symbols:
|
|
stem = args.output.name.removesuffix(".capabilities.json")
|
|
third_party_notices.append(meshtext_notice_record(stem, meshtext_notice_bytes()))
|
|
if chat_symbols:
|
|
checks.append({"capability": MESHTEXT_CAPABILITY,
|
|
"evidence": "; ".join(chat_symbols), "present": True,
|
|
"source": "linked ELF symbols"})
|
|
except (OSError, ValueError) as error:
|
|
print(f"capability check: cannot qualify linked Meshtext code: {error}", file=sys.stderr)
|
|
malformed = True
|
|
try:
|
|
if pocket_gui_evidence(args.image):
|
|
stem = args.output.name.removesuffix(".capabilities.json")
|
|
third_party_notices.append(pocket_gui_notice_record(stem, pocket_gui_notice_bytes()))
|
|
except (OSError, ValueError) as error:
|
|
print(f"capability check: cannot qualify linked MeshPocket GUI/fonts: {error}", file=sys.stderr)
|
|
malformed = True
|
|
try:
|
|
if cardputer_evidence(args.image):
|
|
stem = args.output.name.removesuffix(".capabilities.json")
|
|
third_party_notices.append(cardputer_notice_record(stem, cardputer_notice_bytes()))
|
|
except (OSError, ValueError) as error:
|
|
print(f"capability check: cannot qualify linked Cardputer UI/fonts: {error}", file=sys.stderr)
|
|
malformed = True
|
|
if args.require_meshtext and not chat_symbols:
|
|
checks.append({"capability": MESHTEXT_CAPABILITY,
|
|
"evidence": "Meshtext encode/decode, Companion send/receive, and Dispatcher raw-frame queue implementations",
|
|
"present": False, "source": "linked ELF symbols"})
|
|
application = b""
|
|
if args.expect_application or args.require_host_kiss:
|
|
try:
|
|
application = read_application(args)
|
|
except (OSError, ValueError, KeyError, zipfile.BadZipFile) as exc:
|
|
print(f"capability check: cannot read packaged application: {exc}", file=sys.stderr)
|
|
malformed = True
|
|
host_kiss_symbols = []
|
|
host_kiss_verified = False
|
|
if args.require_host_kiss:
|
|
try:
|
|
host_kiss_symbols = host_kiss_evidence(args.image)
|
|
except (OSError, ValueError) as exc:
|
|
print(f"capability check: cannot qualify linked host KISS code: {exc}", file=sys.stderr)
|
|
malformed = True
|
|
checks.append({"capability": HOST_KISS_CAPABILITY,
|
|
"evidence": "; ".join(host_kiss_symbols) if host_kiss_symbols else
|
|
"executable KISS frame decoder/encoder and Dispatcher raw-frame queue",
|
|
"present": bool(host_kiss_symbols), "source": "linked ELF symbols"})
|
|
usb_present = all(marker in application for marker in HOST_KISS_COMMAND_MARKERS)
|
|
checks.append({"capability": HOST_KISS_USB_CAPABILITY,
|
|
"evidence": "; ".join(marker.decode("ascii") for marker in HOST_KISS_COMMAND_MARKERS),
|
|
"present": usb_present, "source": "packaged application"})
|
|
tcp_present = args.platform != "ESP32_PLATFORM" or HOST_KISS_TCP_MARKER in application
|
|
if args.platform == "ESP32_PLATFORM":
|
|
checks.append({"capability": HOST_KISS_TCP_CAPABILITY,
|
|
"evidence": HOST_KISS_TCP_MARKER.decode("ascii"),
|
|
"present": tcp_present, "source": "packaged application"})
|
|
host_kiss_verified = bool(host_kiss_symbols) and usb_present and tcp_present
|
|
expectations = [(value, image, "linked image") for value in args.expect]
|
|
expectations += [(value, application, "packaged application") for value in args.expect_application]
|
|
gzip_assets = linked_gzip_assets(image) if args.expect_gzip else []
|
|
expectations += [(value, gzip_assets, "linked gzip asset") for value in args.expect_gzip]
|
|
for expectation, content, source in expectations:
|
|
if "=" not in expectation:
|
|
print(
|
|
f"capability check: malformed expectation {expectation!r}",
|
|
file=sys.stderr,
|
|
)
|
|
malformed = True
|
|
continue
|
|
capability, needle = expectation.split("=", 1)
|
|
encoded = needle.encode("utf-8")
|
|
present = bool(needle) and (any(encoded in asset for asset in content)
|
|
if source == "linked gzip asset" else encoded in content)
|
|
checks.append(
|
|
{
|
|
"capability": capability,
|
|
"evidence": needle,
|
|
"present": present,
|
|
"source": source,
|
|
}
|
|
)
|
|
|
|
capabilities = stable_unique(
|
|
args.capability + [check["capability"] for check in checks]
|
|
)
|
|
if not chat_symbols:
|
|
# Neither a requested capability nor CLI strings prove the optional
|
|
# codec and its complete Companion transport survived linking.
|
|
capabilities = [value for value in capabilities if value != MESHTEXT_CAPABILITY]
|
|
if not host_kiss_verified:
|
|
capabilities = [value for value in capabilities if value not in
|
|
(HOST_KISS_CAPABILITY, HOST_KISS_USB_CAPABILITY, HOST_KISS_TCP_CAPABILITY)]
|
|
if args.platform != "ESP32_PLATFORM":
|
|
capabilities = [value for value in capabilities if value != HOST_KISS_TCP_CAPABILITY]
|
|
missing = [check for check in checks if not check["present"]]
|
|
ota_methods = stable_unique([
|
|
check["capability"].removeprefix("ota.update.")
|
|
for check in checks
|
|
if check["present"] and check["capability"].startswith("ota.update.")
|
|
])
|
|
ota_verified, ota_evidence = verify_ota_artifacts(args, ota_methods)
|
|
if not ota_verified:
|
|
capabilities = [value for value in capabilities if not value.startswith("ota.update.")]
|
|
manifest = {
|
|
"schema_version": 2,
|
|
"target": args.target,
|
|
"artifact_target": args.artifact_target or args.target,
|
|
"platformio_env": args.platformio_env or args.target,
|
|
"platform": args.platform,
|
|
"build_profile": args.build_profile,
|
|
"capabilities": capabilities,
|
|
"reductions": stable_unique(args.reduction),
|
|
"verification": checks,
|
|
"ota_update_methods": ota_methods if ota_verified else [],
|
|
"ota_update_verified": ota_verified,
|
|
"ota_update_evidence": ota_evidence,
|
|
"verified": not malformed and not missing and (not args.require_ota or ota_verified),
|
|
}
|
|
if third_party_notices:
|
|
manifest["third_party_notices"] = third_party_notices
|
|
if ota_verified and args.platform == "NRF52_PLATFORM" and "lora" in ota_methods:
|
|
with zipfile.ZipFile(args.dfu_package) as package:
|
|
app = json.loads(package.read("manifest.json"))["manifest"]["application"]
|
|
manifest["ota_update_requirements"] = {"lora": nrf52_lora_details(package.read(app["bin_file"]))}
|
|
|
|
args.output.parent.mkdir(parents=True, exist_ok=True)
|
|
temporary = args.output.with_name(f".{args.output.name}.{os.getpid()}.tmp")
|
|
temporary.write_text(json.dumps(manifest, indent=2, sort_keys=True) + "\n")
|
|
temporary.replace(args.output)
|
|
|
|
if malformed:
|
|
return 2
|
|
if args.require_ota and not ota_verified:
|
|
print(f"capability check failed: wireless self-update: {ota_evidence}", file=sys.stderr)
|
|
return 1
|
|
if missing:
|
|
for check in missing:
|
|
print(
|
|
"capability check failed: "
|
|
f"{check['capability']} promised but {check['evidence']!r} "
|
|
f"is absent from the {check['source']}",
|
|
file=sys.stderr,
|
|
)
|
|
return 1
|
|
|
|
print(
|
|
f"Verified {len(checks)} capability marker(s); manifest: {args.output}"
|
|
)
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|