Implement Hardware Support Agent Specification and Workflow

This commit is contained in:
Ben Meadors
2026-03-26 08:55:11 -05:00
parent b9bdc87367
commit ad03f6f6d8
50 changed files with 7625 additions and 0 deletions
+628
View File
@@ -0,0 +1,628 @@
#!/usr/bin/env python3
"""Board intake assessment for new Meshtastic hardware support.
Validates a board intake request against the repository hardware context,
identifies evidence gaps, and produces a structured readiness report.
Usage:
python3 bin/board_intake.py <intake.json>
python3 bin/board_intake.py <intake.json> --output report.md
python3 bin/board_intake.py <intake.json> --validate # gaps check only, exit 1 if not scaffold_ready
"""
from __future__ import annotations
import argparse
import json
import re
import sys
from dataclasses import dataclass, field
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
DEFAULT_CONTEXT_PATH = ROOT / "docs" / "hardware-support-context.md"
# Metadata keys every new PlatformIO environment should declare.
REQUIRED_METADATA_KEYS = [
"custom_meshtastic_hw_model",
"custom_meshtastic_hw_model_slug",
"custom_meshtastic_architecture",
"custom_meshtastic_actively_supported",
"custom_meshtastic_support_level",
"custom_meshtastic_display_name",
]
RECOMMENDED_METADATA_KEYS = [
"custom_meshtastic_images",
"custom_meshtastic_tags",
"custom_meshtastic_requires_dfu",
"custom_meshtastic_partition_scheme",
]
# Pin groups that should be backed by evidence before scaffolding.
EVIDENCE_CATEGORIES = ["radio", "display", "input", "GPS", "power"]
# Architecture families that rely on BSP defaults for many pin defines.
BSP_DEFAULT_FAMILIES = {"nrf52840", "rp2040", "stm32", "native"}
# Known valid architectures from the repository.
KNOWN_ARCHITECTURES = {
"esp32",
"esp32-s3",
"esp32-c3",
"esp32-c6",
"esp32s2",
"nrf52840",
"rp2040",
"rp2350",
"stm32",
"native",
}
# ---------------------------------------------------------------------------
# T004 – BoardIntakeRequest dataclass
# ---------------------------------------------------------------------------
@dataclass
class BoardIntakeRequest:
"""Maintainer-supplied description of a proposed new board."""
# Required
environment_name: str
hardware_model: str
display_name: str
architecture: str
# Recommended
hardware_model_slug: str = ""
actively_supported: bool | None = None
support_level: str = ""
source_materials: list[str] = field(default_factory=list)
board_notes: str = ""
@classmethod
def from_dict(cls, data: dict) -> "BoardIntakeRequest":
return cls(
environment_name=data.get("environment_name", ""),
hardware_model=str(data.get("hardware_model", "")),
display_name=data.get("display_name", ""),
architecture=data.get("architecture", ""),
hardware_model_slug=data.get("hardware_model_slug", ""),
actively_supported=data.get("actively_supported"),
support_level=str(data.get("support_level", "")),
source_materials=list(data.get("source_materials", [])),
board_notes=data.get("board_notes", ""),
)
@classmethod
def from_json(cls, path: Path) -> "BoardIntakeRequest":
data = json.loads(path.read_text(encoding="utf-8"))
return cls.from_dict(data)
# ---------------------------------------------------------------------------
# T005 – EvidenceGap dataclass
# ---------------------------------------------------------------------------
@dataclass
class EvidenceGap:
"""A specific missing, conflicting, or ambiguous hardware fact."""
category: str # metadata | radio | display | input | GPS | power | storage | connectivity | revision-scope
description: str
affected_artifact: str
required_evidence: str
blocking: bool
# ---------------------------------------------------------------------------
# T006 – IntakeAssessment dataclass
# ---------------------------------------------------------------------------
@dataclass
class IntakeAssessment:
"""Structured result of evaluating a BoardIntakeRequest."""
request: BoardIntakeRequest
expected_artifacts: list[str]
required_metadata: list[str]
matched_patterns: list[dict]
evidence_gaps: list[EvidenceGap]
risk_flags: list[str]
next_actions: list[str]
scaffold_ready: bool
# ---------------------------------------------------------------------------
# T007 – load_hardware_context
# ---------------------------------------------------------------------------
def load_hardware_context(path: Path = DEFAULT_CONTEXT_PATH) -> dict:
"""Parse the generated hardware-support-context.md into a usable dict.
Returns:
{
"architecture_names": list[str], # families present in the inventory
"metadata_keys": list[str], # custom_meshtastic_* keys observed
"environments": dict[str, dict], # env_name -> {display, hw_model, hw_slug, variant_dir, arch}
}
"""
if not path.exists():
raise FileNotFoundError(
f"Hardware context not found at {path}. "
"Run: python3 bin/generate_hardware_support_context.py"
)
text = path.read_text(encoding="utf-8")
# Extract metadata keys from the "## Repository Metadata Inputs" section.
metadata_keys: list[str] = re.findall(r"`(custom_meshtastic_[^`]+)`", text)
metadata_keys = list(dict.fromkeys(metadata_keys)) # deduplicate, preserve order
# Extract architecture families from the "## Architecture and Environment Inventory" section.
arch_names: list[str] = re.findall(r"^### ([a-z0-9\-]+)\s*$", text, re.MULTILINE)
# Filter out sub-headings that are architecture names (exclude e.g. "nrf52840" inside examples)
# The inventory section has short arch names; filter to known set plus any that look like archs.
arch_names = [a for a in dict.fromkeys(arch_names) if not a[0].isupper()]
# Extract environments from inventory table rows.
environments: dict[str, dict] = {}
current_arch = ""
for line in text.splitlines():
arch_match = re.match(r"^### ([a-z0-9\-]+)\s*$", line)
if arch_match:
current_arch = arch_match.group(1)
continue
# Table row: | env | display | hw_model | hw_slug | variant_dir | categories |
row = re.match(
r"^\|\s*([^|]+?)\s*\|\s*([^|]*?)\s*\|\s*([^|]*?)\s*\|\s*([^|]*?)\s*\|\s*([^|]*?)\s*\|",
line,
)
if (
row
and not row.group(1).startswith("Environment")
and not row.group(1).startswith("---")
):
env_name = row.group(1).strip()
if env_name:
environments[env_name] = {
"display_name": row.group(2).strip(),
"hw_model": row.group(3).strip(),
"hw_slug": row.group(4).strip(),
"variant_dir": row.group(5).strip(),
"architecture": current_arch,
}
return {
"architecture_names": arch_names,
"metadata_keys": metadata_keys,
"environments": environments,
}
# ---------------------------------------------------------------------------
# T018 – validate_intake
# ---------------------------------------------------------------------------
def validate_intake(request: BoardIntakeRequest, context: dict) -> list[str]:
"""Check required fields and detect conflicts with existing environments/models.
Returns a list of validation error strings (empty = valid).
"""
errors: list[str] = []
if not request.environment_name:
errors.append("environment_name is required.")
if not request.hardware_model:
errors.append("hardware_model is required.")
if not request.display_name:
errors.append("display_name is required.")
if not request.architecture:
errors.append("architecture is required.")
if request.architecture and request.architecture not in KNOWN_ARCHITECTURES:
errors.append(
f"architecture '{request.architecture}' is not a known repository architecture. "
f"Known: {', '.join(sorted(KNOWN_ARCHITECTURES))}"
)
# Conflict: environment name already exists
if request.environment_name and request.environment_name in context.get(
"environments", {}
):
errors.append(
f"environment_name '{request.environment_name}' already exists in the repository. "
"Choose a unique name or confirm this is an intentional update."
)
# Conflict: hardware model already assigned to a different environment
if request.hardware_model:
for env_name, env_data in context.get("environments", {}).items():
if (
env_data.get("hw_model") == request.hardware_model
and env_name != request.environment_name
):
errors.append(
f"hardware_model '{request.hardware_model}' is already assigned to "
f"environment '{env_name}' ({env_data.get('display_name', '')!r}). "
"Verify this is a new model number or confirm the shared-model intent."
)
break # report once
return errors
# ---------------------------------------------------------------------------
# T019 – find_matched_patterns
# ---------------------------------------------------------------------------
def find_matched_patterns(request: BoardIntakeRequest, context: dict) -> list[dict]:
"""Return up to 3 existing environments closest to the request by architecture."""
envs = context.get("environments", {})
arch = request.architecture
# Prefer exact architecture match, then partial (e.g., "esp32" matches "esp32-s3").
exact: list[dict] = []
partial: list[dict] = []
for env_name, env_data in envs.items():
entry = {**env_data, "environment": env_name}
env_arch = env_data.get("architecture", "")
if env_arch == arch:
exact.append(entry)
elif arch and (env_arch.startswith(arch) or arch.startswith(env_arch)):
partial.append(entry)
candidates = exact + partial
# Prefer boards that have a display_name and hw_model (more complete entries).
candidates.sort(key=lambda e: (not e.get("display_name"), not e.get("hw_model")))
return candidates[:3]
# ---------------------------------------------------------------------------
# T020 – build_evidence_gaps
# ---------------------------------------------------------------------------
def build_evidence_gaps(request: BoardIntakeRequest) -> list[EvidenceGap]:
"""Identify missing pin-group evidence and metadata gaps."""
gaps: list[EvidenceGap] = []
has_sources = bool(request.source_materials)
if not has_sources:
# Every pin category is unresolvable without sources.
for cat in EVIDENCE_CATEGORIES:
gaps.append(
EvidenceGap(
category=cat,
description=(
f"No source materials supplied. {cat.capitalize()} pin mappings cannot be "
"verified without a schematic, pinout diagram, or vendor datasheet."
),
affected_artifact="variant.h",
required_evidence="Schematic, pinout image, or vendor board page",
blocking=True,
)
)
else:
# Sources exist but may still be incomplete; flag as non-blocking advisory.
for cat in EVIDENCE_CATEGORIES:
gaps.append(
EvidenceGap(
category=cat,
description=(
f"Source materials are present but {cat} pin assignments have not been "
"extracted and cross-checked against repository macro conventions."
),
affected_artifact="variant.h",
required_evidence=f"Explicit {cat} pin listing matched to repository #define names",
blocking=False,
)
)
# Metadata gaps
if not request.hardware_model_slug:
gaps.append(
EvidenceGap(
category="metadata",
description="hardware_model_slug is not set. The repository requires an UPPER_SNAKE_CASE slug for PlatformIO metadata.",
affected_artifact="platformio.ini (custom_meshtastic_hw_model_slug)",
required_evidence="Agreed slug from the project maintainers",
blocking=True,
)
)
if request.actively_supported is None:
gaps.append(
EvidenceGap(
category="metadata",
description="actively_supported is not specified. This controls CI matrix inclusion.",
affected_artifact="platformio.ini (custom_meshtastic_actively_supported)",
required_evidence="Maintainer decision on support status",
blocking=False,
)
)
if not request.support_level:
gaps.append(
EvidenceGap(
category="metadata",
description="support_level is not specified (expected: 1 = active, 2 = supported, 3 = extra).",
affected_artifact="platformio.ini (custom_meshtastic_support_level)",
required_evidence="Maintainer decision on support tier",
blocking=False,
)
)
# Revision-scope: multiple display/radio options without disambiguation.
# Match whole-word choice language rather than raw substrings so normal text
# like "radio" does not trigger the ambiguity gate.
note_text = request.board_notes.lower()
revision_scope_patterns = (
r"\brevision\b",
r"\bvariant\b",
r"\bvariants\b",
r"\boption\b",
r"\boptions\b",
r"\balternative\b",
r"\balternatives\b",
r"\bmulti\b",
r"\btwo\b",
r"\beither\b",
r"\bor\b",
)
if note_text and any(re.search(pattern, note_text) for pattern in revision_scope_patterns):
gaps.append(
EvidenceGap(
category="revision-scope",
description=(
"Board notes mention multiple variants, revisions, or options. "
"The intake must be scoped to a single hardware revision before scaffolding can proceed."
),
affected_artifact="variant.h, platformio.ini",
required_evidence="Explicit decision on which revision this intake covers",
blocking=True,
)
)
return gaps
# ---------------------------------------------------------------------------
# T021 – assess_intake
# ---------------------------------------------------------------------------
def assess_intake(request: BoardIntakeRequest, context: dict) -> IntakeAssessment:
"""Produce a full structured assessment from intake request and context."""
validation_errors = validate_intake(request, context)
matched = find_matched_patterns(request, context)
gaps = build_evidence_gaps(request)
expected_artifacts = [
f"variants/{request.architecture or '<architecture>'}/<variant-dir>/variant.h",
f"variants/{request.architecture or '<architecture>'}/<variant-dir>/platformio.ini (env:{request.environment_name or '<env>'})",
]
if request.architecture in {"esp32", "esp32-s3", "esp32-c3", "esp32-c6"}:
expected_artifacts.append(
"(optional) variants/.../variant.cpp — only if board requires custom init hooks"
)
expected_artifacts += [
"PlatformIO metadata: all required custom_meshtastic_* keys (see required_metadata below)",
"(optional) board image under branding/ or images/ if custom_meshtastic_images is set",
]
required_metadata = list(REQUIRED_METADATA_KEYS)
if request.architecture in BSP_DEFAULT_FAMILIES:
required_metadata.append(
f"(BSP note) {request.architecture} boards may inherit some pin defines from BSP headers — "
"check the Inherited Defaults section of docs/hardware-support-context.md before assuming a missing define is an error."
)
risk_flags: list[str] = []
for err in validation_errors:
risk_flags.append(f"Validation error: {err}")
if request.architecture in BSP_DEFAULT_FAMILIES:
risk_flags.append(
f"Architecture '{request.architecture}' uses BSP defaults for some pin defines. "
"Verify which macros are inherited before declaring them explicitly in variant.h."
)
if not matched:
risk_flags.append(
"No closely matched existing board found for this architecture. "
"Manual review of variant structure is required."
)
next_actions: list[str] = []
if validation_errors:
next_actions.append("Resolve validation errors before proceeding.")
blocking_gaps = [g for g in gaps if g.blocking]
non_blocking_gaps = [g for g in gaps if not g.blocking]
for gap in blocking_gaps:
next_actions.append(
f"Provide {gap.required_evidence} for {gap.category} ({gap.affected_artifact})."
)
if non_blocking_gaps:
next_actions.append(
f"Review {len(non_blocking_gaps)} non-blocking gap(s) before merging scaffold output."
)
if not next_actions:
next_actions.append(
"All required evidence is present. Proceed to scaffold generation."
)
scaffold_ready = len(validation_errors) == 0 and all(not g.blocking for g in gaps)
return IntakeAssessment(
request=request,
expected_artifacts=expected_artifacts,
required_metadata=required_metadata,
matched_patterns=matched,
evidence_gaps=gaps,
risk_flags=risk_flags,
next_actions=next_actions,
scaffold_ready=scaffold_ready,
)
# ---------------------------------------------------------------------------
# T022 – render_assessment_markdown
# ---------------------------------------------------------------------------
def render_assessment_markdown(assessment: IntakeAssessment) -> str:
req = assessment.request
lines: list[str] = []
lines.append(f"# Board Intake Assessment: `{req.environment_name or '(unnamed)'}`")
lines.append("")
lines.append(
f"**Scaffold ready**: {'✅ Yes' if assessment.scaffold_ready else '❌ No — see blocking gaps below'}"
)
lines.append("")
lines.append("## Request Summary")
lines.append("")
lines.append(f"- **Environment name**: `{req.environment_name}`")
lines.append(f"- **Hardware model**: `{req.hardware_model}`")
lines.append(
f"- **Hardware model slug**: `{req.hardware_model_slug or '(not set)'}`"
)
lines.append(f"- **Display name**: {req.display_name}")
lines.append(f"- **Architecture**: `{req.architecture}`")
if req.actively_supported is not None:
lines.append(f"- **Actively supported**: {req.actively_supported}")
if req.support_level:
lines.append(f"- **Support level**: {req.support_level}")
if req.source_materials:
lines.append("- **Source materials**:")
for src in req.source_materials:
lines.append(f" - {src}")
if req.board_notes:
lines.append(f"- **Board notes**: {req.board_notes}")
lines.append("")
lines.append("## Expected Artifacts")
lines.append("")
for artifact in assessment.expected_artifacts:
lines.append(f"- {artifact}")
lines.append("")
lines.append("## Required Metadata")
lines.append("")
for key in assessment.required_metadata:
lines.append(
f"- `{key}`" if key.startswith("custom_meshtastic") else f"- {key}"
)
lines.append("")
lines.append("## Matched Repository Patterns")
lines.append("")
if assessment.matched_patterns:
for pattern in assessment.matched_patterns:
name = pattern.get("display_name") or pattern.get("environment", "")
env = pattern.get("environment", "")
arch = pattern.get("architecture", "")
vdir = pattern.get("variant_dir", "")
lines.append(f"- **{name}** (`{env}`, {arch}) — `{vdir}`")
else:
lines.append("- No closely matched patterns found for this architecture.")
lines.append("")
blocking = [g for g in assessment.evidence_gaps if g.blocking]
non_blocking = [g for g in assessment.evidence_gaps if not g.blocking]
lines.append("## Evidence Gaps")
lines.append("")
if blocking:
lines.append("### Blocking")
lines.append("")
for gap in blocking:
lines.append(f"- **[{gap.category}]** {gap.description}")
lines.append(f" - Affected: `{gap.affected_artifact}`")
lines.append(f" - Required evidence: {gap.required_evidence}")
else:
lines.append("*No blocking evidence gaps.*")
lines.append("")
if non_blocking:
lines.append("### Non-blocking (review before merge)")
lines.append("")
for gap in non_blocking:
lines.append(f"- **[{gap.category}]** {gap.description}")
lines.append(f" - Affected: `{gap.affected_artifact}`")
lines.append(f" - Required evidence: {gap.required_evidence}")
lines.append("")
if assessment.risk_flags:
lines.append("## Risk Flags")
lines.append("")
for flag in assessment.risk_flags:
lines.append(f"- {flag}")
lines.append("")
lines.append("## Next Actions")
lines.append("")
for i, action in enumerate(assessment.next_actions, 1):
lines.append(f"{i}. {action}")
lines.append("")
return "\n".join(lines)
# ---------------------------------------------------------------------------
# T023 – CLI entry point
# ---------------------------------------------------------------------------
def main() -> None:
parser = argparse.ArgumentParser(
description="Evaluate a board intake request against the repository hardware context."
)
parser.add_argument("intake", help="Path to the intake JSON file")
parser.add_argument(
"--output",
default="-",
help="Output path for the assessment markdown (default: stdout)",
)
parser.add_argument(
"--validate",
action="store_true",
help="Exit with code 1 if scaffold_ready is false (useful for CI gate checks)",
)
parser.add_argument(
"--context",
default=str(DEFAULT_CONTEXT_PATH),
help="Path to docs/hardware-support-context.md (default: auto-detected)",
)
args = parser.parse_args()
intake_path = Path(args.intake)
if not intake_path.exists():
print(f"Error: intake file not found: {intake_path}", file=sys.stderr)
sys.exit(2)
request = BoardIntakeRequest.from_json(intake_path)
context = load_hardware_context(Path(args.context))
assessment = assess_intake(request, context)
report = render_assessment_markdown(assessment)
if args.output == "-":
print(report)
else:
output_path = Path(args.output)
output_path.parent.mkdir(parents=True, exist_ok=True)
output_path.write_text(report, encoding="utf-8")
print(f"Assessment written to {output_path}")
if args.validate and not assessment.scaffold_ready:
sys.exit(1)
if __name__ == "__main__":
main()
+392
View File
@@ -0,0 +1,392 @@
#!/usr/bin/env python3
"""Scaffold board-support files from a validated intake assessment."""
from __future__ import annotations
import argparse
import re
import sys
from pathlib import Path
from board_intake import (
BoardIntakeRequest,
EvidenceGap,
IntakeAssessment,
assess_intake,
load_hardware_context,
render_assessment_markdown,
)
ROOT = Path(__file__).resolve().parents[1]
DEFAULT_OUTPUT_ROOT = ROOT / "generated" / "hardware-support"
ARCH_BASE_ENV = {
"esp32": "esp32_base",
"esp32-s3": "esp32s3_base",
"esp32-c3": "esp32c3_base",
"esp32-c6": "esp32c6_base",
"esp32s2": "esp32s2_base",
"nrf52840": "nrf52_base",
"rp2040": "rp2040_base",
"rp2350": "rp2350_base",
"stm32": "stm32_base",
"native": "native_base",
}
ARCH_VARIANT_ROOT = {
"esp32": "esp32",
"esp32-s3": "esp32s3",
"esp32-c3": "esp32c3",
"esp32-c6": "esp32c6",
"esp32s2": "esp32s2",
"nrf52840": "nrf52840",
"rp2040": "rp2040",
"rp2350": "rp2350",
"stm32": "stm32",
"native": "native",
}
PLACEHOLDER_DEFINES = {
"status": [
"#define LED_PIN // TODO: verify status LED pin if present",
],
"input": [
"#define BUTTON_PIN // TODO: verify user button pin",
],
"display": [
"#define HAS_SCREEN 1",
"#define USE_SSD1306 // TODO: verify display controller",
"#define I2C_SCL // TODO: verify display I2C clock pin",
"#define I2C_SDA // TODO: verify display I2C data pin",
],
"GPS": [
"#define GPS_RX_PIN // TODO: verify GPS RX pin or remove if GPS absent",
"#define GPS_TX_PIN // TODO: verify GPS TX pin or remove if GPS absent",
],
"power": [
"#define BATTERY_PIN // TODO: verify battery sense pin",
"#define ADC_MULTIPLIER // TODO: verify voltage divider ratio",
],
"radio": [
"#define USE_SX1262 // TODO: verify radio chip selection from schematic",
"#define SX126X_CS // TODO: verify radio chip-select pin",
"#define SX126X_BUSY // TODO: verify radio busy pin",
"#define SX126X_DIO1 // TODO: verify radio IRQ pin",
"#define SX126X_RESET // TODO: verify radio reset pin",
"#define LORA_SCK // TODO: verify radio SPI clock pin",
"#define LORA_MISO // TODO: verify radio SPI MISO pin",
"#define LORA_MOSI // TODO: verify radio SPI MOSI pin",
],
}
DEFINE_PATTERN = re.compile(r"^#define\s+([A-Za-z0-9_]+)(?:\s+(.*?))?\s*(?://.*)?$")
CATEGORY_MACROS = {
"status": ["LED_POWER", "LED_PIN", "LED_STATE_ON", "VEXT_ENABLE"],
"input": ["BUTTON_PIN", "BUTTON_NEED_PULLUP", "ROTARY_A", "ROTARY_B"],
"display": [
"HAS_SCREEN",
"USE_SSD1306",
"USE_SH1106",
"USE_TFTDISPLAY",
"USE_ST7789",
"I2C_SCL",
"I2C_SDA",
"I2C_SCL1",
"I2C_SDA1",
"TFT_HEIGHT",
"TFT_WIDTH",
"TFT_OFFSET_X",
"TFT_OFFSET_Y",
"SCREEN_ROTATE",
"SCREEN_TRANSITION_FRAMERATE",
],
"GPS": [
"HAS_GPS",
"GPS_RX_PIN",
"GPS_TX_PIN",
"GPS_BAUDRATE",
"PIN_GPS_PPS",
"GPS_THREAD_INTERVAL",
],
"power": [
"ADC_CTRL",
"ADC_CTRL_ENABLED",
"BATTERY_PIN",
"ADC_CHANNEL",
"ADC_ATTENUATION",
"ADC_MULTIPLIER",
"USE_POWERSAVE",
"SLEEP_TIME",
],
"radio": [
"USE_SX1262",
"USE_SX1268",
"USE_LR1121",
"USE_SX1280",
"LORA_DIO0",
"LORA_RESET",
"LORA_DIO1",
"LORA_DIO2",
"LORA_SCK",
"LORA_MISO",
"LORA_MOSI",
"LORA_CS",
"SX126X_CS",
"SX126X_DIO1",
"SX126X_BUSY",
"SX126X_RESET",
"SX126X_DIO2_AS_RF_SWITCH",
"SX126X_DIO3_TCXO_VOLTAGE",
"LR1121_IRQ_PIN",
"LR1121_NRESET_PIN",
"LR1121_BUSY_PIN",
"LR1121_SPI_NSS_PIN",
"LR1121_SPI_SCK_PIN",
"LR1121_SPI_MOSI_PIN",
"LR1121_SPI_MISO_PIN",
"LR11X0_DIO3_TCXO_VOLTAGE",
"LR11X0_DIO_AS_RF_SWITCH",
],
}
def slugify_variant_dir(req: BoardIntakeRequest) -> str:
return req.environment_name.replace("_", "-").lower()
def target_variant_dir(req: BoardIntakeRequest) -> Path:
arch_root = ARCH_VARIANT_ROOT.get(req.architecture, req.architecture)
return Path("variants") / arch_root / slugify_variant_dir(req)
def pick_pattern(assessment: IntakeAssessment) -> dict | None:
return assessment.matched_patterns[0] if assessment.matched_patterns else None
def source_basis_lines(assessment: IntakeAssessment, context: dict) -> list[str]:
lines = [
f"// Intake environment: {assessment.request.environment_name}",
f"// Intake architecture: {assessment.request.architecture}",
]
pattern = pick_pattern(assessment)
if pattern:
lines.append(
"// Repository pattern basis: "
f"{pattern.get('environment', '')} -> {pattern.get('variant_dir', '')}"
)
lines.append(
f"// Context source: {context.get('context_path', 'docs/hardware-support-context.md')}"
)
return lines
def parse_variant_defines(variant_path: Path) -> dict[str, str]:
defines: dict[str, str] = {}
if not variant_path.exists():
return defines
for raw_line in variant_path.read_text(encoding="utf-8").splitlines():
match = DEFINE_PATTERN.match(raw_line.strip())
if not match:
continue
name = match.group(1)
value = (match.group(2) or "1").strip()
defines[name] = value
return defines
def infer_category_lines(pattern_variant_path: Path, category: str) -> list[str]:
defines = parse_variant_defines(pattern_variant_path)
lines: list[str] = []
for macro_name in CATEGORY_MACROS[category]:
value = defines.get(macro_name)
if value is None:
continue
if value == "1":
lines.append(f"#define {macro_name}")
else:
lines.append(f"#define {macro_name} {value}")
if lines:
return lines
return PLACEHOLDER_DEFINES[category]
# T028
def generate_variant_h(assessment: IntakeAssessment, context: dict) -> str:
req = assessment.request
lines: list[str] = []
lines.extend(source_basis_lines(assessment, context))
lines.append("")
lines.append("#pragma once")
lines.append("")
pattern = pick_pattern(assessment)
pattern_variant_path = None
if pattern and pattern.get("variant_dir"):
pattern_variant_path = (ROOT / str(pattern["variant_dir"]) / "variant.h").resolve()
for category in ("status", "input", "display", "GPS", "power", "radio"):
lines.append(f"// {category}")
if pattern_variant_path is not None:
lines.extend(infer_category_lines(pattern_variant_path, category))
else:
lines.extend(PLACEHOLDER_DEFINES[category])
lines.append("")
lines.append("// Board identity")
macro_name = req.hardware_model_slug or req.environment_name.upper().replace(
"-", "_"
)
lines.append(f"#define {macro_name} 1")
lines.append("")
return "\n".join(lines).rstrip() + "\n"
# T029
def generate_platformio_env(assessment: IntakeAssessment) -> str:
req = assessment.request
extends = ARCH_BASE_ENV.get(req.architecture, f"{req.architecture}_base")
variant_dir = target_variant_dir(req)
build_define = (
req.hardware_model_slug or req.environment_name.upper().replace("-", "_")
).replace(" ", "_")
lines = [
f"[env:{req.environment_name}]",
f"custom_meshtastic_hw_model = {req.hardware_model}",
f"custom_meshtastic_hw_model_slug = {req.hardware_model_slug or 'TODO_SET_HW_MODEL_SLUG'}",
f"custom_meshtastic_architecture = {req.architecture}",
f"custom_meshtastic_actively_supported = {str(req.actively_supported).lower() if req.actively_supported is not None else 'TODO_SET_ACTIVELY_SUPPORTED'}",
f"custom_meshtastic_support_level = {req.support_level or 'TODO_SET_SUPPORT_LEVEL'}",
f"custom_meshtastic_display_name = {req.display_name}",
"custom_meshtastic_images = TODO_SET_IMAGES",
"custom_meshtastic_tags = TODO_SET_TAGS",
"custom_meshtastic_requires_dfu = TODO_SET_REQUIRES_DFU",
"custom_meshtastic_partition_scheme = TODO_SET_PARTITION_SCHEME",
"",
"board = TODO_SET_PLATFORMIO_BOARD",
f"extends = {extends}",
"build_flags =",
f" ${{{extends}.build_flags}}",
f" -D {build_define}",
f" -I {variant_dir.as_posix()}",
]
return "\n".join(lines).rstrip() + "\n"
# T030
def annotate_unresolved(content: str, gaps: list[EvidenceGap], is_ini: bool = False) -> str:
annotations = [
gap
for gap in gaps
if not gap.blocking
and gap.category in {"radio", "display", "input", "GPS", "power", "metadata"}
]
if not annotations:
return content
lines = content.splitlines()
todo_lines = [f"; TODO: verify — {gap.description}" for gap in annotations]
if is_ini:
# For INI files, insert TODOs after the first section header
if lines and lines[0].startswith("["):
return "\n".join(lines[:1] + todo_lines + [""] + lines[1:]) + "\n"
else:
# For .h files, prepend TODOs with C++ comment syntax
todo_lines = [f"// TODO: verify — {gap.description}" for gap in annotations]
return "\n".join(todo_lines + [""] + lines) + "\n"
return content
# T031
def scaffold_board(
assessment: IntakeAssessment, context: dict, output_dir: Path
) -> dict[str, Path]:
req = assessment.request
variant_dir = output_dir / target_variant_dir(req)
variant_dir.mkdir(parents=True, exist_ok=True)
variant_h_path = variant_dir / "variant.h"
platformio_path = variant_dir / "platformio.ini"
variant_h_content = annotate_unresolved(
generate_variant_h(assessment, context), assessment.evidence_gaps, is_ini=False
)
platformio_content = annotate_unresolved(
generate_platformio_env(assessment), assessment.evidence_gaps, is_ini=True
)
variant_h_path.write_text(variant_h_content, encoding="utf-8")
platformio_path.write_text(platformio_content, encoding="utf-8")
generated = {
"variant_h": variant_h_path,
"platformio": platformio_path,
}
if req.architecture in {"esp32", "esp32-s3", "esp32-c3", "esp32-c6"}:
variant_cpp_path = variant_dir / "variant.cpp"
variant_cpp_content = annotate_unresolved(
"\n".join(
[
'#include "variant.h"',
"",
"// Optional board-specific initialization hooks go here.",
"// Leave this file out if the board does not need custom startup behavior.",
]
)
+ "\n",
assessment.evidence_gaps,
)
variant_cpp_path.write_text(variant_cpp_content, encoding="utf-8")
generated["variant_cpp"] = variant_cpp_path
return generated
# T032 + T033
def main() -> None:
parser = argparse.ArgumentParser(
description="Generate board-support scaffold files from an intake JSON file."
)
parser.add_argument("intake", help="Path to intake JSON")
parser.add_argument(
"--output-dir",
default=str(DEFAULT_OUTPUT_ROOT),
help="Directory where scaffold files will be written",
)
args = parser.parse_args()
intake_path = Path(args.intake)
request = BoardIntakeRequest.from_json(intake_path)
context = load_hardware_context()
context["context_path"] = "docs/hardware-support-context.md"
assessment = assess_intake(request, context)
if not assessment.scaffold_ready:
print(render_assessment_markdown(assessment))
sys.exit(1)
generated = scaffold_board(assessment, context, Path(args.output_dir))
print("Generated scaffold files:")
for name, path in generated.items():
print(f"- {name}: {path}")
if __name__ == "__main__":
main()
+14
View File
@@ -0,0 +1,14 @@
{
"environment_name": "new_esp32s3_board",
"hardware_model": "999",
"hardware_model_slug": "NEW_ESP32S3_BOARD",
"display_name": "Acme ESP32-S3 Dev Board v1",
"architecture": "esp32-s3",
"actively_supported": true,
"support_level": "1",
"source_materials": [
"https://example.com/acme-esp32s3-schematic.pdf",
"https://example.com/acme-esp32s3-pinout.png"
],
"board_notes": "Initial bring-up for v1 PCB only. SX1262 radio on SPI2, OLED on I2C bus."
}
+6
View File
@@ -0,0 +1,6 @@
{
"environment_name": "hypothetical_esp32s3_board",
"hardware_model": "9981",
"display_name": "Hypothetical ESP32-S3 Board",
"architecture": "esp32-s3"
}
+13
View File
@@ -0,0 +1,13 @@
{
"environment_name": "new_nrf52_tracker",
"hardware_model": "998",
"hardware_model_slug": "NEW_NRF52_TRACKER",
"display_name": "Acme nRF52840 Tracker v2",
"architecture": "nrf52840",
"actively_supported": null,
"support_level": "2",
"source_materials": [
"https://example.com/acme-tracker-pinout.pdf"
],
"board_notes": "Two revisions exist: v2 (SSD1306 OLED) and v2a (no display). This intake covers v2 only."
}
+592
View File
@@ -0,0 +1,592 @@
#!/usr/bin/env python3
"""Generate a markdown inventory of board-support metadata and common target-definition macros."""
from __future__ import annotations
import argparse
import re
from collections import Counter, defaultdict
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
VARIANTS_DIR = ROOT / "variants"
OUTPUT_PATH = ROOT / "docs" / "hardware-support-context.md"
PLATFORMIO_METADATA_KEYS = [
"custom_meshtastic_hw_model",
"custom_meshtastic_hw_model_slug",
"custom_meshtastic_architecture",
"custom_meshtastic_actively_supported",
"custom_meshtastic_support_level",
"custom_meshtastic_display_name",
"custom_meshtastic_images",
"custom_meshtastic_tags",
"custom_meshtastic_requires_dfu",
"custom_meshtastic_partition_scheme",
]
MACRO_PATTERNS = {
"Input": [
"BUTTON_PIN",
"BUTTON_PIN_TOUCH",
"ALT_BUTTON_PIN",
"CANCEL_BUTTON_PIN",
"ROTARY_",
"KB_",
"INPUTDRIVER_",
],
"Radio": [
"USE_RF95",
"USE_SX126",
"USE_SX128",
"USE_LLCC68",
"USE_LR11",
"LORA_",
"SX126X_",
"SX128X_",
"LR1121_",
"RF95_",
],
"GPS": [
"HAS_GPS",
"GPS_",
"PIN_GPS_",
"GPS_DEFAULT_NOT_PRESENT",
],
"Display": [
"HAS_SCREEN",
"USE_TFTDISPLAY",
"USE_TFT",
"USE_SSD",
"USE_SH",
"USE_ST",
"TFT_",
"OLED_",
"SCREEN_",
"PIN_EINK_",
"EINK_",
"DISPLAY_",
"LGFX_",
"HAS_TFT",
],
"I2C/SPI": [
"I2C_",
"SPI_",
"PIN_SPI",
"WIRE_",
],
"Power": [
"BATTERY_",
"ADC_",
"PIN_POWER",
"USE_POWERSAVE",
"SLEEP_TIME",
"XPOWERS_",
"HAS_AXP",
"HAS_PPM",
"HAS_BQ",
"EXT_NOTIFY_OUT",
"FAN_CTRL_PIN",
"RF95_FAN_EN",
],
"Connectivity/Other": [
"HAS_WIFI",
"HAS_BLUETOOTH",
"HAS_ETHERNET",
"HAS_NEOPIXEL",
"HAS_I2S",
"HAS_TOUCH",
"HAS_TOUCHSCREEN",
"HAS_NFC",
"NFC_",
"USE_XL9555",
"EXPANDS_",
"PCF",
"RTC",
],
}
def classify_macro(name: str) -> str:
for category, prefixes in MACRO_PATTERNS.items():
for prefix in prefixes:
if name.startswith(prefix):
return category
return "Other"
def parse_platformio_envs(path: Path) -> list[dict[str, object]]:
envs: list[dict[str, object]] = []
current: dict[str, object] | None = None
for raw_line in path.read_text(encoding="utf-8").splitlines():
line = raw_line.strip()
if not line or line.startswith(";"):
continue
if line.startswith("[") and line.endswith("]"):
if current:
envs.append(current)
current = {"section": line[1:-1], "metadata": {}}
continue
if current is None or "=" not in line:
continue
key, value = [part.strip() for part in line.split("=", 1)]
if key in PLATFORMIO_METADATA_KEYS:
current["metadata"][key] = value
if current:
envs.append(current)
return envs
def parse_variant_macros(path: Path) -> tuple[dict[str, str], Counter[str]]:
defines: dict[str, str] = {}
category_counts: Counter[str] = Counter()
pattern = re.compile(r"^#define\s+([A-Za-z0-9_]+)(?:\s+(.*?))?\s*(?://.*)?$")
for raw_line in path.read_text(encoding="utf-8").splitlines():
match = pattern.match(raw_line.strip())
if not match:
continue
name = match.group(1)
value = (match.group(2) or "1").strip()
defines[name] = value
category_counts[classify_macro(name)] += 1
return defines, category_counts
MACRO_DESCRIPTIONS: dict[str, str] = {
# ---------- Input ----------
"BUTTON_PIN": "Primary user button GPIO pin number.",
"ALT_BUTTON_PIN": "Secondary / alternate button pin, used on boards with two buttons.",
"CANCEL_BUTTON_PIN": "Button wired to cancel/back actions (e.g., T-Deck cancel key).",
"BUTTON_NEED_PULLUP": "Set to 1 when the button GPIO requires the internal pull-up to be enabled.",
"BUTTON_PIN_ALT": "Alternative spelling used by some older board families for a second button.",
"KB_BL_PIN": "Keyboard backlight control pin (e.g., T-LoRa Pager keyboard).",
"KB_INT": "Keyboard interrupt input pin; signals a keypress to the MCU.",
"KB_POWERON": "Pin used to power-on or enable the keyboard peripheral.",
"KB_SLAVE_ADDRESS": "I2C address of the keyboard controller IC.",
"INPUTDRIVER_ENCODER_TYPE": "Selects the rotary encoder driver variant (0 = none, 1+ = specific type).",
"INPUTDRIVER_TWO_WAY_ROCKER": "Enables the two-way rocker input driver.",
"INPUTDRIVER_TWO_WAY_ROCKER_RIGHT": "GPIO pin for the rightward rocker direction.",
"INPUTDRIVER_TWO_WAY_ROCKER_LEFT": "GPIO pin for the leftward rocker direction.",
"INPUTDRIVER_TWO_WAY_ROCKER_BTN": "GPIO pin for the rocker click/press direction.",
"ROTARY_A": "First encoder channel pin (clock / A signal).",
"ROTARY_B": "Second encoder channel pin (data / B signal).",
# ---------- Radio ----------
"USE_SX1262": "Select the SX1262 sub-GHz LoRa chip driver.",
"USE_SX1268": "Select the SX1268 sub-GHz LoRa chip driver (higher power variant of SX1262).",
"USE_SX1280": "Select the SX1280 2.4 GHz LoRa chip driver.",
"USE_RF95": "Select the RFM95/SX1276 legacy LoRa chip driver.",
"USE_LLCC68": "Select the LLCC68 low-cost LoRa chip driver.",
"USE_LR1110": "Select the LR1110 wideband radio driver.",
"USE_LR1120": "Select the LR1120 wideband radio driver.",
"USE_LR1121": "Select the LR1121 wideband radio driver.",
"LORA_CS": "LoRa radio SPI chip-select GPIO pin.",
"LORA_RESET": "LoRa radio hardware-reset GPIO pin (active low).",
"LORA_DIO0": "LoRa radio DIO0 interrupt pin (RF95/SX1276 done/RxDone).",
"LORA_DIO1": "LoRa radio DIO1 interrupt pin; on SX126x this is the primary IRQ line.",
"LORA_DIO2": "LoRa radio DIO2 pin; on RF95 used for FSK interrupt; on SX126x tx/rx control.",
"LORA_SCK": "LoRa radio SPI clock pin.",
"LORA_MISO": "LoRa radio SPI MISO pin.",
"LORA_MOSI": "LoRa radio SPI MOSI pin.",
"SX126X_CS": "SX126x chip-select pin (often same as LORA_CS).",
"SX126X_RESET": "SX126x reset pin (often same as LORA_RESET).",
"SX126X_BUSY": "SX126x BUSY pin; must be polled low before issuing SPI commands.",
"SX126X_DIO1": "SX126x DIO1 interrupt output used for all IRQ events.",
"SX126X_DIO3_TCXO_VOLTAGE": "Drives the TCXO regulator via DIO3; set to the supply voltage (e.g., 1.8).",
"SX126X_DIO2_AS_RF_SWITCH": "Set to 1 so the driver controls the TX/RX RF switch via DIO2.",
"LR1121_IRQ_PIN": "LR1121 interrupt request pin.",
"RF95_FAN_EN": "Enables a cooling fan for high-power RF95 installations.",
# ---------- GPS ----------
"HAS_GPS": "Set to 1 if the board has an on-board GPS receiver; 0 to disable GPS entirely.",
"GPS_RX_PIN": "UART RX pin connected to the GPS module TX output.",
"GPS_TX_PIN": "UART TX pin connected to the GPS module RX input.",
"PIN_GPS_EN": "GPIO to power-enable or power-gate the GPS module.",
"PIN_GPS_PPS": "GPS pulse-per-second input pin for timing synchronisation.",
"PIN_GPS_STANDBY": "Places the GPS into standby/low-power mode when driven.",
"PIN_GPS_RESET": "Hardware-reset line to the GPS module.",
"PIN_GPS_REINIT": "Pin used to trigger GPS re-initialisation sequences.",
"GPS_THREAD_INTERVAL": "Millisecond poll interval for the GPS background thread.",
"GPS_BAUDRATE": "UART baud rate for GPS serial communication.",
"GPS_L76K": "Selects the Quectel L76K GPS driver and protocol.",
"GPS_UBLOX": "Selects the u-blox GPS driver and UBX protocol.",
"GPS_EN_ACTIVE": "Logic level (HIGH or LOW) that enables the GPS power pin.",
"GPS_RESET_MODE": "Defines the reset signal polarity or protocol for the GPS chip.",
"GPS_DEFAULT_NOT_PRESENT": "Compiled-in default assuming no GPS; overridden at runtime if detected.",
# ---------- Display ----------
"HAS_SCREEN": "Set to 1 if the board has any display hardware.",
"USE_TFTDISPLAY": "Selects the TFT LCD driver path.",
"USE_SSD1306": "Selects the SSD1306 128×64 OLED driver over I2C.",
"USE_SH1106": "Selects the SH1106 128×64 OLED driver.",
"USE_ST7735": "Selects the ST7735 TFT SPI driver.",
"USE_ST7789": "Selects the ST7789 TFT SPI driver.",
"TFT_WIDTH": "Horizontal pixel resolution of the TFT display.",
"TFT_HEIGHT": "Vertical pixel resolution of the TFT display.",
"TFT_OFFSET_X": "Horizontal pixel offset for display alignment correction.",
"TFT_OFFSET_Y": "Vertical pixel offset for display alignment correction.",
"TFT_BL": "Backlight control PWM or GPIO pin for the TFT panel.",
"SCREEN_ROTATE": "Non-zero value rotates the display 180° for mounted-upside-down screens.",
"SCREEN_TRANSITION_FRAMERATE": "Target framerate for UI animations and transitions.",
"PIN_EINK_CS": "E-ink display SPI chip-select pin.",
"PIN_EINK_BUSY": "E-ink display BUSY output; high when a page update is in progress.",
"PIN_EINK_DC": "E-ink display data/command select pin.",
"PIN_EINK_RES": "E-ink display hardware-reset pin.",
"PIN_EINK_SCLK": "E-ink display SPI clock pin.",
"PIN_EINK_MOSI": "E-ink display SPI MOSI pin.",
# ---------- I2C/SPI ----------
"I2C_SDA": "Primary I2C data line GPIO pin.",
"I2C_SCL": "Primary I2C clock line GPIO pin.",
"PIN_SPI_MISO": "Primary SPI MISO (data from peripheral) GPIO pin.",
"PIN_SPI_MOSI": "Primary SPI MOSI (data to peripheral) GPIO pin.",
"PIN_SPI_SCK": "Primary SPI clock GPIO pin.",
"SPI_INTERFACES_COUNT": "Number of hardware SPI buses available on this board.",
"WIRE_INTERFACES_COUNT": "Number of hardware I2C buses available on this board.",
"PIN_SPI1_MISO": "Secondary SPI bus MISO pin.",
"PIN_SPI1_MOSI": "Secondary SPI bus MOSI pin.",
"PIN_SPI1_SCK": "Secondary SPI bus clock pin.",
"SPI_FREQUENCY": "Default SPI clock frequency in Hz for this board.",
"SPI_READ_FREQUENCY": "Reduced SPI clock rate used for read transactions.",
"SPI_SCK": "SPI clock pin alias used in older board files.",
"SPI_MOSI": "SPI MOSI pin alias used in older board files.",
"SPI_MISO": "SPI MISO pin alias used in older board files.",
# ---------- Power ----------
"BATTERY_PIN": "ADC input GPIO connected to the battery voltage divider.",
"ADC_MULTIPLIER": "Floating-point scale factor to convert raw ADC reading to battery voltage.",
"ADC_CHANNEL": "ADC channel enum or number for the battery sense input.",
"BATTERY_SENSE_RESOLUTION_BITS": "ADC resolution in bits used for battery voltage sampling.",
"ADC_RESOLUTION": "Board-level ADC resolution definition, referenced by other power macros.",
"BATTERY_SENSE_RESOLUTION": "Alias for the effective ADC resolution for battery sense.",
"ADC_CTRL": "GPIO that enables or gates the ADC voltage-divider circuit.",
"ADC_CTRL_ENABLED": "Logic level (HIGH or LOW) that turns on the ADC control switch.",
"ADC_ATTENUATION": "ESP32 ADC input attenuation setting; controls measurable voltage range.",
"BATTERY_SENSE_SAMPLES": "Number of ADC samples to average for a stable battery reading.",
"EXT_NOTIFY_OUT": "GPIO output used to signal an external LED or buzzer for notifications.",
"USE_POWERSAVE": "Enables aggressive power-save mode (deep sleep, reduced poll intervals).",
"SLEEP_TIME": "Default light-sleep duration in milliseconds between wakeups.",
"PIN_POWER_EN": "GPIO to assert to enable a board power rail or load switch.",
"HAS_PPM": "Set to 1 if the board has an IP5306 or similar PPM power path IC.",
# ---------- Connectivity/Other ----------
"HAS_TOUCHSCREEN": "Set to 1 for boards with a capacitive or resistive touch panel.",
"HAS_NEOPIXEL": "Set to 1 if the board has addressable RGB LEDs (WS2812 / NeoPixel).",
"PCF8563_RTC": "I2C address of the PCF8563 real-time clock IC.",
"PCF85063_RTC": "I2C address of the PCF85063 real-time clock IC.",
"HAS_ETHERNET": "Set to 1 for boards with a wired Ethernet interface.",
"HAS_I2S": "Set to 1 if I2S audio output is present.",
"NFC_INT": "Interrupt pin from the NFC controller IC.",
"NFC_CS": "SPI chip-select for the NFC controller.",
"USE_XL9555": "Enables the XL9555 16-bit I2C GPIO expander driver.",
"EXPANDS_DRV_EN": "GPIO expander pin used to enable the haptic driver.",
"EXPANDS_AMP_EN": "GPIO expander pin used to power-on the audio amplifier.",
"EXPANDS_KB_RST": "GPIO expander pin used to reset the keyboard controller.",
"EXPANDS_LORA_EN": "GPIO expander pin used to power-gate the LoRa radio.",
"EXPANDS_GPS_EN": "GPIO expander pin used to power-gate the GPS module.",
"EXPANDS_NFC_EN": "GPIO expander pin used to power-gate the NFC controller.",
# ---------- Other ----------
"LED_POWER": "GPIO for the status LED, defines the LED pin number.",
"LED_STATE_ON": "Logic level (HIGH or LOW) that turns the status LED on.",
"PIN_SERIAL1_RX": "Secondary UART RX pin (used for accessories, GPS on some boards).",
"PIN_SERIAL1_TX": "Secondary UART TX pin.",
"PIN_WIRE_SDA": "Arduino-framework I2C SDA pin alias (nRF52 / RP2040 style).",
"PIN_WIRE_SCL": "Arduino-framework I2C SCL pin alias.",
"PIN_LED1": "First LED GPIO pin in the nRF52 Arduino BSP pin table.",
"VARIANT_MCK": "Crystal oscillator frequency in Hz for nRF52 variant clock configuration.",
"PINS_COUNT": "Total number of GPIO pins defined in the Arduino BSP variant table.",
"NUM_DIGITAL_PINS": "Count of digital-capable pins in the BSP variant table.",
"NUM_ANALOG_INPUTS": "Count of analog-input pins in the BSP variant table.",
"NUM_ANALOG_OUTPUTS": "Count of analog-output (DAC) pins in the BSP variant table.",
"LED_BLUE": "GPIO number of the blue status LED (typical on nRF52 and RP2040 boards).",
"USE_LFXO": "Instructs the nRF52 BSP to use the low-frequency crystal oscillator.",
"BUTTON_NEED_PULLUP": "Enables internal pull-up on the button GPIO (duplicate entry for clarity).",
}
def shorten(value: str, limit: int = 60) -> str:
compact = " ".join(value.split())
return compact if len(compact) <= limit else compact[: limit - 3] + "..."
# Architecture families known to rely heavily on BSP or base-environment defaults rather
# than declaring every field locally. Used in the Inherited Defaults Note section.
_BSP_DEFAULT_FAMILIES: dict[str, list[str]] = {
"nrf52840": [
"VARIANT_MCK — nRF52 BSP clock constant (e.g., 64000000ul); always inherited from BSP unless overridden.",
"USE_LFXO — low-frequency crystal oscillator selection; declared locally only when the board uses LFXO rather than the RC oscillator.",
"PIN_SPI_* / PIN_SPI1_* — SPI bus pin numbers come from the BSP variant table; boards override only when the LoRa radio or display uses non-default SPI routing.",
"WIRE_INTERFACES_COUNT / SPI_INTERFACES_COUNT — bus count comes from BSP; explicitly set only when the board deviates.",
"LED_BLUE / PIN_LED1 / PINS_COUNT / NUM_DIGITAL_PINS — standard BSP pin-table entries inherited from the nRF52 Arduino core.",
],
"rp2040": [
"PIN_SPI_* — primary SPI pins come from the RP2040 Arduino BSP; most boards declare them explicitly, but the defaults align with the Pico pin assignments.",
"NUM_DIGITAL_PINS / NUM_ANALOG_INPUTS — Arduino BSP counts; rarely overridden locally.",
],
"stm32": [
"Radio and pin assignments for STM32WL targets are largely internal to the WL SoC and declared via STM32 HAL/BSP headers; variant.h files are minimal.",
"USE_STM32WLx is typically the only explicit define; all other radio config comes from the BSP.",
],
"native": [
"The native/Portduino target uses runtime configuration rather than compile-time pin defines; variant.h only sets display and GPS stubs.",
],
}
def collect_inventory() -> dict[str, object]:
architectures: defaultdict[str, list[dict[str, object]]] = defaultdict(list)
category_frequency: defaultdict[str, Counter[str]] = defaultdict(Counter)
total_variant_dirs = 0
total_envs = 0
no_variant_h: defaultdict[str, int] = defaultdict(int) # arch -> count of dirs with no variant.h
has_metadata: int = 0 # env count that has at least one custom_meshtastic_* key
for platformio_path in sorted(VARIANTS_DIR.glob("**/platformio.ini")):
variant_dir = platformio_path.parent
total_variant_dirs += 1
variant_path = variant_dir / "variant.h"
board_level_arch_raw = variant_dir.parts[-2] if len(variant_dir.parts) >= 2 else "unknown"
if not variant_path.exists():
no_variant_h[board_level_arch_raw] += 1
continue
envs = parse_platformio_envs(platformio_path)
defines, category_counts = parse_variant_macros(variant_path)
total_envs += len(envs)
board_level_arch = board_level_arch_raw
for env in envs:
section = str(env["section"])
if not section.startswith("env:"):
continue
metadata = dict(env["metadata"])
if metadata:
has_metadata += 1
architecture = str(metadata.get("custom_meshtastic_architecture") or board_level_arch)
if architecture == "esp32s3":
architecture = "esp32-s3"
if architecture == "esp32c3":
architecture = "esp32-c3"
if architecture == "esp32c6":
architecture = "esp32-c6"
entry = {
"environment": section.split(":", 1)[1],
"variant_dir": str(variant_dir.relative_to(ROOT)),
"metadata": metadata,
"defines": defines,
"category_counts": category_counts,
}
architectures[architecture].append(entry)
for name in defines:
category = classify_macro(name)
category_frequency[category][name] += 1
return {
"architectures": architectures,
"category_frequency": category_frequency,
"total_variant_dirs": total_variant_dirs,
"total_envs": total_envs,
"no_variant_h": dict(no_variant_h),
"has_metadata": has_metadata,
}
def render_markdown(inventory: dict[str, object]) -> str:
architectures = inventory["architectures"]
category_frequency = inventory["category_frequency"]
lines: list[str] = []
lines.append("# Hardware Support Context")
lines.append("")
lines.append("This document inventories the board-support inputs and common target-definition fields")
lines.append("currently used in the Meshtastic firmware repository. It is intended as reusable context")
lines.append("for adding new hardware support without re-discovering naming patterns, metadata keys, and")
lines.append("frequently used pin or capability macros from scratch.")
lines.append("")
lines.append("## Scope")
lines.append("")
lines.append(f"- Variant directories scanned: {inventory['total_variant_dirs']}")
lines.append(f"- PlatformIO environments summarized: {inventory['total_envs']}")
lines.append("- Sources: `variants/**/platformio.ini` and `variants/**/variant.h`")
lines.append("- Notes: This inventory reflects explicit per-variant declarations. Some boards also inherit")
lines.append(" defaults from architecture headers or shared base environments, which must still be checked")
lines.append(" before creating new hardware support.")
lines.append("")
lines.append("## Repository Metadata Inputs")
lines.append("")
lines.append("The following `custom_meshtastic_*` metadata keys are already used across board environments:")
lines.append("")
for key in PLATFORMIO_METADATA_KEYS:
lines.append(f"- `{key}`")
lines.append("")
lines.append("## Common Target-Definition Categories")
lines.append("")
for category in ["Input", "Radio", "GPS", "Display", "I2C/SPI", "Power", "Connectivity/Other", "Other"]:
counter = category_frequency.get(category, Counter())
if not counter:
continue
lines.append(f"### {category}")
lines.append("")
lines.append("| Macro | Used in | Description |")
lines.append("| --- | --- | --- |")
for name, count in counter.most_common(15):
description = MACRO_DESCRIPTIONS.get(name, "")
lines.append(f"| `{name}` | {count} variants | {description} |")
lines.append("")
lines.append("## Architecture and Environment Inventory")
lines.append("")
for architecture in sorted(architectures):
entries = sorted(architectures[architecture], key=lambda item: item["environment"])
lines.append(f"### {architecture}")
lines.append("")
lines.append("| Environment | Display Name | HW Model | HW Slug | Variant Dir | Common Categories |")
lines.append("| --- | --- | --- | --- | --- | --- |")
for entry in entries:
metadata = entry["metadata"]
category_counts = entry["category_counts"]
categories = []
for category in ["Display", "Radio", "Input", "GPS", "Power", "Connectivity/Other"]:
count = category_counts.get(category, 0)
if count:
categories.append(f"{category}:{count}")
category_summary = ", ".join(categories) if categories else "none"
lines.append(
"| {environment} | {display_name} | {hw_model} | {hw_slug} | {variant_dir} | {categories} |".format(
environment=entry["environment"],
display_name=metadata.get("custom_meshtastic_display_name", ""),
hw_model=metadata.get("custom_meshtastic_hw_model", ""),
hw_slug=metadata.get("custom_meshtastic_hw_model_slug", ""),
variant_dir=entry["variant_dir"],
categories=category_summary,
)
)
lines.append("")
lines.append("## Representative Board Examples")
lines.append("")
for architecture in sorted(architectures):
entries = sorted(architectures[architecture], key=lambda item: item["environment"])
if not entries:
continue
sample = entries[0]
lines.append(f"### {architecture}: `{sample['environment']}`")
lines.append("")
lines.append(f"- Variant directory: `{sample['variant_dir']}`")
metadata = sample["metadata"]
for key in PLATFORMIO_METADATA_KEYS:
value = metadata.get(key)
if value:
lines.append(f"- `{key}`: `{value}`")
defines = sample["defines"]
for category in ["Input", "Radio", "GPS", "Display", "I2C/SPI", "Power", "Connectivity/Other"]:
selected = [name for name in defines if classify_macro(name) == category][:8]
if not selected:
continue
lines.append(f"- {category} examples:")
for name in selected:
lines.append(f" - `{name}` = `{shorten(defines[name])}`")
lines.append("")
lines.append("## Intake Guidance For New Hardware")
lines.append("")
lines.append("When using this context to add a new board, collect these inputs before generating files:")
lines.append("")
lines.append("- PlatformIO environment name")
lines.append("- `custom_meshtastic_hw_model` and `custom_meshtastic_hw_model_slug`")
lines.append("- Display name and architecture")
lines.append("- Whether the board is actively supported and its support level")
lines.append("- Partition scheme, DFU requirement, and image/tag metadata if applicable")
lines.append("- Radio chip family and complete radio pin group")
lines.append("- Input/button/rotary/keyboard pins")
lines.append("- Display interface pins and driver-related macros")
lines.append("- GPS, power-management, I2C, SPI, storage, and auxiliary peripheral definitions")
lines.append("- Any board-specific initialization that requires `variant.cpp` or extra variant hooks")
lines.append("")
lines.append("## Inherited Defaults Note")
lines.append("")
lines.append(
"Some architecture families rely on BSP (Board Support Package) or base-environment defaults "
"rather than declaring every pin or capability macro explicitly in `variant.h`. "
"When adding a new board for one of these families, check the relevant BSP headers before "
"assuming a missing define means a feature is absent."
)
lines.append("")
no_variant_h = inventory.get("no_variant_h", {})
if no_variant_h:
lines.append(
f"Directories scanned that had no `variant.h` "
f"(relying entirely on BSP/base-environment): "
+ ", ".join(f"{arch}: {count}" for arch, count in sorted(no_variant_h.items()))
)
lines.append("")
for family, notes in _BSP_DEFAULT_FAMILIES.items():
lines.append(f"### {family}")
lines.append("")
for note in notes:
lines.append(f"- {note}")
lines.append("")
lines.append("## Cautions")
lines.append("")
lines.append("- Some boards rely on architecture defaults rather than declaring every field locally.")
lines.append("- Some board families expose multiple environments or display variants that share one hardware model.")
lines.append("- Source materials such as schematics still need human verification before new pin mappings are trusted.")
lines.append("- This document is a starting context artifact, not proof that a new board definition is safe to merge.")
lines.append("")
return "\n".join(lines)
def print_validate_summary(inventory: dict[str, object]) -> None:
"""Print a validation summary to stdout without writing the output file."""
total_dirs = inventory["total_variant_dirs"]
total_envs = inventory["total_envs"]
has_metadata = inventory["has_metadata"]
no_variant_h = inventory.get("no_variant_h", {})
no_variant_h_total = sum(no_variant_h.values())
architectures = inventory["architectures"]
arch_count = len(architectures)
print("Hardware Support Context — Validation Summary")
print("=" * 48)
print(f"Variant directories scanned : {total_dirs}")
print(f"Directories with no variant.h (BSP-only) : {no_variant_h_total}")
if no_variant_h:
for arch, count in sorted(no_variant_h.items()):
print(f" {arch}: {count}")
print(f"PlatformIO environments found : {total_envs}")
print(f"Environments with custom_meshtastic_* metadata : {has_metadata}")
print(f"Architecture families represented : {arch_count}")
print("")
print("Architecture families:")
for arch in sorted(architectures):
entries = architectures[arch]
print(f" {arch}: {len(entries)} environment(s)")
print("")
print("BSP-default families with special notes:")
for family in _BSP_DEFAULT_FAMILIES:
marker = "YES" if family in {a.split("-")[0] for a in architectures} else "not in scan"
print(f" {family}: {marker}")
def main() -> None:
parser = argparse.ArgumentParser(description="Generate hardware support context markdown")
parser.add_argument("--output", default=str(OUTPUT_PATH), help="Output markdown path")
parser.add_argument(
"--validate",
action="store_true",
help="Print a validation summary (variant counts, BSP-only dirs, metadata coverage) without writing output",
)
args = parser.parse_args()
inventory = collect_inventory()
if args.validate:
print_validate_summary(inventory)
return
markdown = render_markdown(inventory)
output_path = Path(args.output)
output_path.parent.mkdir(parents=True, exist_ok=True)
output_path.write_text(markdown + "\n", encoding="utf-8")
if __name__ == "__main__":
main()