mirror of
https://github.com/alexhopeoconnor/firmware.git
synced 2026-10-19 09:51:30 +10:00
Implement Hardware Support Agent Specification and Workflow
This commit is contained in:
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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."
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"environment_name": "hypothetical_esp32s3_board",
|
||||
"hardware_model": "9981",
|
||||
"display_name": "Hypothetical ESP32-S3 Board",
|
||||
"architecture": "esp32-s3"
|
||||
}
|
||||
@@ -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."
|
||||
}
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user