mirror of
https://github.com/alexhopeoconnor/firmware.git
synced 2026-10-17 09:01:30 +10:00
393 lines
12 KiB
Python
393 lines
12 KiB
Python
#!/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()
|