Merge PR #26: add M5Stack Cardputer-Adv support

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
This commit is contained in:
mikecarper committed 2026-10-27 22:25:35 -07:00
commit 64a068506b
76 files changed
+27802 -4

No files matched your search

+9
View File
@@ -21,6 +21,8 @@ from package_meshtext_license import (CAPABILITY as MESHTEXT_CAPABILITY,
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
@@ -234,6 +236,13 @@ def main() -> int:
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",
+16
View File
@@ -207,6 +207,22 @@ def requirements(platform, defines, target):
else:
raise ValueError(f"add a runtime allocation budget for display {display!r}")
parts = {}
if integer(defines, "CARDPUTER_ADV_UI", 0):
if platform != "ESP32_PLATFORM" or not companion:
raise ValueError("Cardputer UI budget requires an ESP32 Companion")
# LovyanGFX renders directly to the panel, without a full RGB canvas.
# One loaded VLW font retains five metadata arrays (9 bytes/glyph),
# at most 716 glyphs. Old font metadata is freed on a text-size change.
parts["cardputer_font_metadata"] = 8192
# The source bounds all twelve screen/page objects to 8 KiB. Retain
# another 4 KiB for their bounded strings and allocation metadata.
parts["cardputer_ui_objects_and_strings"] = 12288
# Two optional keyboard layouts, each with at most 32 unique masks,
# plus one replacement candidate and allocation metadata (< 22 KiB).
parts["cardputer_keyboard_layouts"] = 22528
# Private I2C, optional SD, direct SPI DMA/driver state and I2S audio
# coexist with the UI. Keep this additive to existing core reserves.
parts["cardputer_io_drivers"] = 16384
if display == "WioTrackerL2Display":
# The pinned LGFX QSPI bus has two DMA flip buffers. RGB565 conversion
# requests at most 2048 bytes each (2304 for RGB888); shrinking can
+1 -1
View File
@@ -25,7 +25,7 @@ RECIPE_ENVIRONMENT = (
)
PACKAGE_SUFFIXES = (".bin", "-merged.bin", ".uf2", ".zip", ".hex",
".capabilities.json", ".memory.json", ".font-license.txt",
".meshtext-license.txt", ".pocket-gui-license.txt")
".meshtext-license.txt", ".pocket-gui-license.txt", ".cardputer-license.txt")
def canonical_digest(value):
+4
View File
@@ -12,6 +12,7 @@ sys.path.insert(0, str(Path(__file__).resolve().parent))
from package_nrf52_font_license import validate_artifact_notice
from package_meshtext_license import validate_artifact_notice as validate_meshtext_notice
from package_pocket_gui_license import validate_artifact_notice as validate_pocket_gui_notice
from package_cardputer_license import validate_artifact_notice as validate_cardputer_notice
def digest(path):
@@ -47,6 +48,7 @@ def artifact_files(stem):
".font-license.txt")]
candidates.append(stem.parent / (stem.name + ".meshtext-license.txt"))
candidates.append(stem.parent / (stem.name + ".pocket-gui-license.txt"))
candidates.append(stem.parent / (stem.name + ".cardputer-license.txt"))
return [p for p in candidates if p.is_file()]
@@ -56,6 +58,7 @@ def package_report(directory, stem):
validate_artifact_notice(stem)
validate_meshtext_notice(stem)
validate_pocket_gui_notice(stem)
validate_cardputer_notice(stem)
files = artifact_files(stem)
if len(files) < 2 or not any(p.suffix in (".bin", ".uf2", ".zip", ".hex") for p in files):
raise ValueError("cannot qualify an empty firmware package")
@@ -72,6 +75,7 @@ def validate_package(stem):
validate_artifact_notice(stem)
validate_meshtext_notice(stem)
validate_pocket_gui_notice(stem)
validate_cardputer_notice(stem)
report = passing_report(stem.parent / (stem.name + ".memory.json"))
actual = {p.name: digest(p) for p in artifact_files(stem)}
if len(actual) < 2 or actual != report.get("files"):
+163
View File
@@ -0,0 +1,163 @@
#!/usr/bin/env python3
"""Carry Cardputer UI and font notices beside firmware containing that code."""
from __future__ import annotations
import argparse
import hashlib
import json
import os
from pathlib import Path
import re
from firmware_elf import FirmwareElf
ROOT = Path(__file__).resolve().parents[1]
UI_NOTICE_PATH = ROOT / "variants/m5stack_cardputer_adv/LICENSE.MultiMote"
FONT_NOTICE_PATH = ROOT / "variants/m5stack_cardputer_adv/LICENSE.DejaVu"
SIDECAR_SUFFIX = ".cardputer-license.txt"
COMPONENT = "cardputer-ui-and-fonts"
LICENSE = "MIT AND LicenseRef-DejaVu"
UI_LABEL = b"--- LICENSE.MultiMote ---\n\n"
FONT_LABEL = b"--- LICENSE.DejaVu ---\n\n"
def valid_ui_notice(data):
return (0 < len(data) <= 262144 and all(text in data for text in (
b"Copyright (c) 2026 MultiMote", b"Permission is hereby granted, free of charge",
b"copies or substantial portions of the Software", b'THE SOFTWARE IS PROVIDED "AS IS"',
b"LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE",
b"SOFTWARE.")))
def valid_font_notice(data):
return (0 < len(data) <= 262144 and all(text in data for text in (
b"DejaVu changes are in public domain.",
b"Copyright (c) 2003 by Bitstream, Inc. All Rights Reserved.",
b"The above copyright and trademark notices and this permission notice shall",
b'are renamed to names not containing either the words "Bitstream" or the word',
b"THE FONT SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND",
b"Copyright (c) 2006 by Tavmjong Bah. All Rights Reserved.",
b"name of Tavmjong Bah shall not",
b"AMSFonts (v. 2.2) copyright")))
def valid_notice(data):
if not (0 < len(data) <= 524288 and data.count(UI_LABEL) == 1
and data.count(FONT_LABEL) == 1):
return False
ui_start = data.index(UI_LABEL) + len(UI_LABEL)
font_start = data.index(FONT_LABEL)
return (ui_start < font_start and valid_ui_notice(data[ui_start:font_start])
and valid_font_notice(data[font_start + len(FONT_LABEL):]))
def notice_bytes():
ui, fonts = UI_NOTICE_PATH.read_bytes(), FONT_NOTICE_PATH.read_bytes()
if not valid_ui_notice(ui) or not valid_font_notice(fonts):
raise ValueError("Cardputer UI/font source notice is incomplete")
return (b"Cardputer Adv UI and font notices\n\n"
b"Source: MultiMote/meshcore-cardputer-adv\n\n"
+ UI_LABEL + ui.rstrip() + b"\n\n" + FONT_LABEL + fonts.rstrip() + b"\n")
def cardputer_evidence(path):
"""Only allocated definitions qualify, never target names or debug strings."""
if Path(path).read_bytes()[:4] != b"\x7fELF":
return []
elf = FirmwareElf(path)
evidence = []
for name, (_address, size, index) in elf.symbols.items():
if size <= 0 or index >= len(elf.sections):
continue
section = elf.sections[index]
if section[1] == 8 or not section[2] & 2:
continue
ui = (section[2] & 4 and name.startswith((
"_ZN15CardputerUITask", "_ZNK15CardputerUITask")))
font = re.fullmatch(r"(?:_ZL[0-9]+)?DejaVuSans_[A-Za-z0-9_]+(?:\..+)?", name)
if ui or font:
evidence.append(name)
return sorted(evidence)
def sidecar(stem):
stem = Path(stem)
return stem.parent / (stem.name + SIDECAR_SUFFIX)
def notice_record(stem, data):
return {"component": COMPONENT, "license": LICENSE,
"file": sidecar(stem).name, "sha256": hashlib.sha256(data).hexdigest()}
def manifest_record(stem):
stem = Path(stem)
manifest = json.loads((stem.parent / (stem.name + ".capabilities.json")).read_text())
records = [record for record in manifest.get("third_party_notices", [])
if record.get("component") == COMPONENT]
if len(records) > 1:
raise ValueError(f"{stem}: duplicate Cardputer UI/font notice declarations")
if not records:
return None
record = records[0]
if (record.get("file") != sidecar(stem).name or record.get("license") != LICENSE
or not re.fullmatch(r"[0-9a-f]{64}", str(record.get("sha256", "")))):
raise ValueError(f"{stem}: invalid Cardputer UI/font notice declaration")
return record
def validate_artifact_notice(stem):
record = manifest_record(stem)
output = sidecar(stem)
if record is None:
if output.exists():
raise ValueError(f"{output}: stale notice for unaffected firmware")
return None
if not output.is_file():
raise ValueError(f"{output}: missing Cardputer UI/font notice")
data = output.read_bytes()
if not valid_notice(data) or hashlib.sha256(data).hexdigest() != record["sha256"]:
raise ValueError(f"{output}: incomplete or mismatched Cardputer UI/font notice")
return output
def collect_build_notice(directory, stem):
evidence = cardputer_evidence(Path(directory) / "firmware.elf")
record = manifest_record(stem)
if bool(evidence) != bool(record):
raise ValueError("Cardputer UI/font notice declaration does not match the linked ELF")
if not evidence:
return validate_artifact_notice(stem)
data = notice_bytes()
if record != notice_record(stem, data):
raise ValueError("Cardputer UI/font notice declaration does not match the source notice")
output = sidecar(stem)
if not output.exists():
temporary = output.with_name(f".{output.name}.{os.getpid()}.tmp")
try:
temporary.write_bytes(data)
temporary.replace(output)
finally:
temporary.unlink(missing_ok=True)
return validate_artifact_notice(stem)
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("action", choices=("collect", "validate"))
parser.add_argument("path", type=Path)
parser.add_argument("--stem", type=Path)
args = parser.parse_args()
try:
if args.action == "collect":
if args.stem is None:
parser.error("collect requires --stem")
collect_build_notice(args.path, args.stem)
else:
validate_artifact_notice(args.path)
except (OSError, ValueError, KeyError, TypeError) as error:
parser.exit(1, f"Cardputer UI/font notice qualification failed: {error}\n")
if __name__ == "__main__":
main()
+4
View File
@@ -20,6 +20,7 @@ from check_nrf52_uf2 import check_uf2
from package_nrf52_font_license import validate_artifact_notice
from package_meshtext_license import validate_artifact_notice as validate_meshtext_notice
from package_pocket_gui_license import validate_artifact_notice as validate_pocket_gui_notice
from package_cardputer_license import validate_artifact_notice as validate_cardputer_notice
ROOT = Path(__file__).resolve().parents[1]
@@ -315,6 +316,9 @@ def collect_artifacts(directory, version):
pocket_gui_notice = validate_pocket_gui_notice(directory / stem)
if pocket_gui_notice is not None:
files.append(pocket_gui_notice)
cardputer_notice = validate_cardputer_notice(directory / stem)
if cardputer_notice is not None:
files.append(cardputer_notice)
manifest["runtime_ram"] = {key: memory[key] for key in (
"passed", "available_internal_bytes", "required_heap_bytes", "elf_sha256")}
accounted.update(files)