projet crepp_git/crepp-projects/station-meteo/hardware/transmetteur/base/v1.1 · branche main
export_board.py Voir sur GitLab
#!/usr/bin/env python3
"""
Génère, à la racine de CE repo (1 repo = 1 carte), le dossier _doc/ attendu
par generate_page.py :

  _doc/
    view-top.png               ← rendu du PCB, vue de dessus, avec composants (kicad-cli pcb render)
    view-bottom.png            ← rendu du PCB, vue de dessous, avec composants
    bom.csv                    ← liste du matériel complète (kicad-cli sch export bom), commune à toutes les variantes
    doc.html                  ← placeholder créé s'il est absent (document ICD à écrire à la main)
    gerbers/                  ← fichiers de fabrication (gerbers + perçage), communs à toutes les variantes
    gerbers.zip                ← archive de gerbers/ pour téléchargement en un clic
    variants/
      default/                ← toujours générée
        schematic.pdf
        assembly.html         ← vue recto/verso (SVG des couches F.Fab/B.Fab)
        ibom.html              ← BOM interactive (InteractiveHtmlBom)
        pcba.step              ← modèle 3D, avec composants (téléchargement)
        pcba.glb                ← modèle 3D, avec composants (vue interactive dans le navigateur)
      <autre-variante>/        ← si variants.json en déclare (voir variants.example.json)

Cherche automatiquement le *.kicad_pro / *.kicad_sch / *.kicad_pcb à la racine
du repo. Aucun paramètre requis dans le cas standard.

Usage :
  python3 scripts/export_board.py [--source .]
"""
import argparse
import json
import os
import re
import shutil
import subprocess
import sys
from pathlib import Path

sys.path.insert(0, str(Path(__file__).parent))
from kicad_cli_utils import resolve_kicad_cli, check_kicad_cli

ASSEMBLY_LAYERS = {
    "top": "F.Fab,F.SilkS,Edge.Cuts",
    "bottom": "B.Fab,B.SilkS,Edge.Cuts",
}
GERBER_LAYERS = "F.Cu,B.Cu,F.Mask,B.Mask,F.SilkS,B.SilkS,Edge.Cuts"

# Couches exportées individuellement en SVG pour le visualiseur de couches de
# la vitrine (_doc/svg/layers/<slug>.svg) : contrairement aux gerbers bruts,
# un SVG s'affiche directement dans le navigateur sans logiciel dédié.
# Edge.Cuts est ajouté à chaque couche pour garder le contour de la carte
# comme repère visuel.
SHOWCASE_LAYERS = [
    ("cuivre-dessus",       "Cuivre — dessus",           "F.Cu,Edge.Cuts"),
    ("cuivre-dessous",      "Cuivre — dessous",          "B.Cu,Edge.Cuts"),
    ("serigraphie-dessus",  "Sérigraphie — dessus",      "F.SilkS,Edge.Cuts"),
    ("serigraphie-dessous", "Sérigraphie — dessous",     "B.SilkS,Edge.Cuts"),
    ("masque-dessus",       "Masque de soudure — dessus", "F.Mask,Edge.Cuts"),
    ("masque-dessous",      "Masque de soudure — dessous","B.Mask,Edge.Cuts"),
]

PLACEHOLDER_DOC = """<!DOCTYPE html><html lang="fr"><head><meta charset="UTF-8">
<title>Documentation — {name}</title></head>
<body style="font-family:sans-serif;background:#0a1f17;color:#edeae0;padding:40px;">
<h1>{name}</h1>
<p>Documentation à rédiger. Remplace ce fichier par le vrai document ICD : _doc/doc.html</p>
</body></html>"""


def detect_model_vars(pcb: Path) -> list:
    """Repère les variables de chemin utilisées par les modèles 3D du PCB.

    Les empreintes référencent souvent leurs modèles via une variable de chemin
    KiCad personnalisée, ex. ${CREPP_3DMODEL_DIR}/CREPP_LEDs/WS2812C.STEP.
    Cette variable est définie dans les préférences KiCad de chaque poste
    (Préférences → Configurer les chemins), donc elle N'EXISTE PAS dans un
    conteneur Docker ni sur un runner CI : sans elle, kicad-cli ne trouve aucun
    modèle et exporte une carte nue (sans composants).
    """
    text = pcb.read_text(encoding="utf-8", errors="ignore")
    names = set()
    for m in re.finditer(r'\(model\s+"\$\{([^}]+)\}', text):
        names.add(m.group(1))
    return sorted(names)


def build_define_vars(pcb: Path) -> list:
    """Construit les arguments --define-var à partir de l'environnement.

    Pour chaque variable détectée dans le PCB (ex. CREPP_3DMODEL_DIR), on
    cherche une variable d'environnement du même nom. Si elle existe, on la
    passe à kicad-cli ; sinon on avertit clairement, car c'est la cause n°1 des
    composants absents en 3D.
    """
    args = []
    for name in detect_model_vars(pcb):
        value = os.environ.get(name)
        if value:
            args += ["--define-var", f"{name}={value}"]
            print(f"  [3D] {name} = {value}")
        else:
            print(f"  [3D] ⚠ variable {name} NON définie : les modèles 3D qui l'utilisent "
                  f"seront introuvables (composants absents des exports 3D).")
            print(f"       Définis-la avant de lancer, ex. : export {name}=/chemin/vers/les/modeles")
    return args


def check_ibom() -> tuple:
    """Vérifie que generate_interactive_bom (paquet pip InteractiveHtmlBom)
    est utilisable : présent dans le PATH ET capable d'importer pcbnew (le
    module Python interne de KiCad, indispensable pour lire le .kicad_pcb).

    Ces deux conditions sont INDÉPENDANTES : le paquet peut être installé
    (pip install InteractiveHtmlBom) sans que pcbnew soit importable depuis
    ce même interpréteur — cause la plus fréquente d'échec silencieux, et
    différente pour chaque type d'installation KiCad :
      - Install native (paquet système) : pcbnew est dans le python3 système
        SI InteractiveHtmlBom a été installé avec ce même python3 (pas un
        venv isolé, sauf à y exposer aussi pcbnew).
      - Flatpak : pcbnew tourne DANS le sandbox Flatpak, invisible à un pip
        installé sur l'hôte — il faudrait exécuter generate_interactive_bom
        à l'intérieur du sandbox, ce qui sort du cadre de ce script.
      - Image Docker CI (kicad/kicad:X, voir .gitlab-ci.yml) : ça marche
        car cette image fournit déjà le bon python3 avec pcbnew inclus.
    """
    exe = shutil.which("generate_interactive_bom")
    if not exe:
        return False, (
            "generate_interactive_bom introuvable dans le PATH — installe "
            "le paquet : pip install InteractiveHtmlBom "
            "(pip install --break-system-packages ... en CI)."
        )
    try:
        result = subprocess.run([exe, "--help"], capture_output=True, text=True, timeout=15)
    except Exception as e:
        return False, f"Erreur lors du test de generate_interactive_bom : {e}"

    combined = (result.stdout or "") + (result.stderr or "")
    if result.returncode != 0 or "No module named 'pcbnew'" in combined or "ModuleNotFoundError" in combined:
        return False, (
            "generate_interactive_bom est installé mais ne peut pas importer 'pcbnew'.\n"
            "Cause la plus fréquente : InteractiveHtmlBom a été installé (pip) dans un "
            "interpréteur Python différent de celui utilisé par KiCad — pcbnew n'est "
            "visible que depuis le Python interne de l'installation KiCad (voir "
            "check_ibom() ci-dessus pour le détail par type d'install).\n"
            f"Détail : {combined.strip()[:300]}"
        )
    return True, f"generate_interactive_bom OK ({exe})"


def run(cmd) -> bool:
    print("→", " ".join(str(c) for c in cmd))
    try:
        result = subprocess.run(cmd, capture_output=True, text=True)
    except (FileNotFoundError, OSError) as e:
        print(f"[avertissement] commande introuvable ({e}), on continue", file=sys.stderr)
        return False
    if result.returncode != 0:
        print(result.stdout)
        print(result.stderr, file=sys.stderr)
        print("[avertissement] échec de la commande ci-dessus, on continue")
        return False
    return True


def find_one(folder: Path, pattern: str):
    matches = sorted(folder.glob(pattern))
    return matches[0] if matches else None


def export_layer_svgs(pcb: Path, out_dir: Path, kicad_cli: list) -> list[dict]:
    """Exporte chaque couche de SHOWCASE_LAYERS en SVG individuel (contour
    Edge.Cuts inclus), pour le visualiseur de couches de la vitrine — pas
    besoin d'un logiciel de gerbers pour se faire une idée du PCB en ligne."""
    out_dir.mkdir(parents=True, exist_ok=True)
    exported = []
    for slug, label, layer_arg in SHOWCASE_LAYERS:
        svg_path = out_dir / f"{slug}.svg"
        ok = run(kicad_cli + ["pcb", "export", "svg", "--output", str(svg_path),
                  "--layers", layer_arg, str(pcb)])
        if ok and svg_path.exists():
            exported.append({"slug": slug, "label": label})
        else:
            print(f"[avertissement] export SVG de la couche '{label}' échoué, ignorée")
    return exported


def load_variants(repo: Path):
    cfg_file = repo / "variants.json"
    variants = ["default"]
    field = "Variant"
    if cfg_file.exists():
        try:
            cfg = json.loads(cfg_file.read_text(encoding="utf-8"))
            for v in cfg.get("variants", []):
                if v not in variants:
                    variants.append(v)
            field = cfg.get("field", field)
        except json.JSONDecodeError:
            print(f"[avertissement] {cfg_file} invalide (JSON), ignoré")
    return variants, field


def make_assembly_html(svg_top: str, svg_bottom: str, out_file: Path, board_name: str):
    out_file.write_text(f"""<!DOCTYPE html><html lang="fr"><head><meta charset="UTF-8">
<title>Plan d'assemblage — {board_name}</title>
<style>
body{{margin:0;background:#0a1f17;color:#edeae0;font-family:sans-serif}}
.tabs{{display:flex;gap:8px;padding:12px;background:#123328}}
button{{background:none;border:1px solid #c87f4a;color:#edeae0;padding:6px 14px;cursor:pointer;font-size:12px}}
button.active{{background:#c87f4a;color:#0a1f17}}
.view{{padding:20px;text-align:center}}
.view svg{{max-width:100%;height:auto;background:#fff;border-radius:4px}}
</style></head><body>
<div class="tabs">
  <button class="active" onclick="show(this,'top')">Recto</button>
  <button onclick="show(this,'bottom')">Verso</button>
</div>
<div class="view" id="viewTop">{svg_top}</div>
<div class="view" id="viewBottom" style="display:none">{svg_bottom}</div>
<script>
function show(btn, side){{
  document.getElementById('viewTop').style.display = side==='top' ? 'block' : 'none';
  document.getElementById('viewBottom').style.display = side==='bottom' ? 'block' : 'none';
  document.querySelectorAll('.tabs button').forEach(b=>b.classList.remove('active'));
  btn.classList.add('active');
}}
</script></body></html>""", encoding="utf-8")


def main():
    ap = argparse.ArgumentParser()
    ap.add_argument("--source", default=".", help="racine du repo (défaut : .)")
    args = ap.parse_args()

    # Résolution cross-plateforme de kicad-cli (natif, Flatpak, PATH restreint
    # d'un processus GUI-lancé...) — voir kicad_cli_utils.py. On échoue vite
    # et clairement ici plutôt que de laisser chaque appel kicad-cli échouer
    # un par un plus loin avec juste un avertissement.
    ok, msg = check_kicad_cli()
    print(msg)
    if not ok:
        print("[erreur] kicad-cli introuvable, impossible de continuer.", file=sys.stderr)
        sys.exit(1)
    KICAD_CLI = resolve_kicad_cli()

    repo = Path(args.source).resolve()
    sch = find_one(repo, "*.kicad_sch")
    pcb = find_one(repo, "*.kicad_pcb")
    if not (sch and pcb):
        print("[erreur] .kicad_sch ou .kicad_pcb introuvable à la racine du repo", file=sys.stderr)
        sys.exit(1)

    pro = find_one(repo, "*.kicad_pro")
    board_name = pro.stem if pro else repo.name

    doc_dir = repo / "_doc"
    doc_dir.mkdir(exist_ok=True)

    if not (doc_dir / "doc.html").exists():
        (doc_dir / "doc.html").write_text(PLACEHOLDER_DOC.format(name=board_name), encoding="utf-8")

    # Variables de chemin des modèles 3D (ex. CREPP_3DMODEL_DIR) : sans elles,
    # kicad-cli ne trouve pas les modèles et exporte une carte SANS composants.
    print("Modèles 3D — variables de chemin détectées dans le PCB :")
    dv = build_define_vars(pcb)

    run(KICAD_CLI + ["pcb", "render", "--output", str(doc_dir / "view-top.png"),
         "--side", "top", "--quality", "high", "--width", "900", "--height", "700"]
        + dv + [str(pcb)])
    run(KICAD_CLI + ["pcb", "render", "--output", str(doc_dir / "view-bottom.png"),
         "--side", "bottom", "--quality", "high", "--width", "900", "--height", "700"]
        + dv + [str(pcb)])

    # BOM complète (tous les composants montés, hors DNP) : générée une fois au
    # niveau de la carte, comme les gerbers — kicad-cli ne permet pas de filtrer
    # cet export par variante (contrairement à InteractiveHtmlBom pour ibom.html).
    run(KICAD_CLI + ["sch", "export", "bom", "--output", str(doc_dir / "bom.csv"),
         "--fields", "Reference,Value,Footprint,Datasheet,MPN",
         "--group-by", "Value,Footprint", "--sort-field", "Reference",
         "--exclude-dnp", str(sch)])

    tmp_top = doc_dir / "_top.svg"
    tmp_bottom = doc_dir / "_bottom.svg"
    ok_top = run(KICAD_CLI + ["pcb", "export", "svg", "--output", str(tmp_top),
                  "--layers", ASSEMBLY_LAYERS["top"], str(pcb)])
    ok_bottom = run(KICAD_CLI + ["pcb", "export", "svg", "--output", str(tmp_bottom),
                     "--layers", ASSEMBLY_LAYERS["bottom"], str(pcb)])
    svg_top = tmp_top.read_text(encoding="utf-8") if ok_top and tmp_top.exists() else "<p>Vue non générée</p>"
    svg_bottom = tmp_bottom.read_text(encoding="utf-8") if ok_bottom and tmp_bottom.exists() else "<p>Vue non générée</p>"

    (doc_dir / "svg").mkdir(exist_ok=True)
    run(KICAD_CLI + ["sch", "export", "svg", "--output", str(doc_dir) + "/svg/", str(sch)])

    # Fichiers de fabrication (gerbers + perçage) : identiques pour toutes les
    # variantes (le routage ne change pas), générés une seule fois.
    gerbers_dir = doc_dir / "gerbers"
    gerbers_dir.mkdir(exist_ok=True)
    run(KICAD_CLI + ["pcb", "export", "gerbers", "--output", str(gerbers_dir) + "/",
         "--layers", GERBER_LAYERS, str(pcb)])
    run(KICAD_CLI + ["pcb", "export", "drill", "--output", str(gerbers_dir) + "/",
         "--format", "excellon", "--drill-origin", "absolute", "--excellon-units", "mm",
         "--generate-map", "--map-format", "pdf", str(pcb)])
    # Fichier de placement (pick-and-place) : données d'assemblage lisibles
    # directement en CSV, sans ouvrir KiCad. Généré AVANT le zip ci-dessous
    # pour être inclus dedans (comme le font la plupart des fabricants).
    run(KICAD_CLI + ["pcb", "export", "pos", "--output", str(gerbers_dir / "positions.csv"),
         "--side", "both", "--format", "csv", "--units", "mm", str(pcb)])
    if any(gerbers_dir.iterdir()):
        (doc_dir / "gerbers.zip").unlink(missing_ok=True)
        shutil.make_archive(str(doc_dir / "gerbers"), "zip", root_dir=str(gerbers_dir))
    else:
        print("[avertissement] aucun gerber généré, vérifie les logs kicad-cli ci-dessus")

    # Vérifications qualité (DRC PCB + ERC schéma) : rapports JSON, seule
    # info qu'on ne peut vraiment pas obtenir sans ouvrir KiCad et relancer
    # les checks soi-même. On n'utilise PAS --exit-code-violations : on veut
    # le rapport même s'il y a des violations, pas faire échouer l'export.
    run(KICAD_CLI + ["pcb", "drc", "--output", str(doc_dir / "drc-report.json"),
         "--format", "json", "--severity-all", str(pcb)])
    run(KICAD_CLI + ["sch", "erc", "--output", str(doc_dir / "erc-report.json"),
         "--format", "json", "--severity-all", str(sch)])

    # DXF mécanique (contour + niveau fab) : pour un logiciel de CAO
    # mécanique (boîtier, découpe), sans passer par KiCad.
    run(KICAD_CLI + ["pcb", "export", "dxf", "--output", str(doc_dir / "board-mechanical.dxf"),
         "--layers", "Edge.Cuts,F.Fab", str(pcb)])

    # Couches individuelles en SVG (visualiseur de couches de la vitrine) —
    # dans un sous-dossier de _doc/svg/ pour ne pas être confondues avec les
    # SVG de schémas exportés juste au-dessus (find_svg_for_sheet, côté
    # generate_icd.py, ne regarde que les fichiers directement dans _doc/svg/).
    layer_svgs = export_layer_svgs(pcb, doc_dir / "svg" / "layers", KICAD_CLI)
    print(f"[ok] {len(layer_svgs)}/{len(SHOWCASE_LAYERS)} couche(s) exportée(s) en SVG")

    variants, variant_field = load_variants(repo)

    ibom_ok, ibom_msg = check_ibom()
    print(ibom_msg)
    if not ibom_ok:
        print("[avertissement] ibom.html ne sera généré pour aucune variante (voir message ci-dessus)")

    for variant in variants:
        vdir = doc_dir / "variants" / variant
        vdir.mkdir(parents=True, exist_ok=True)

        run(KICAD_CLI + ["sch", "export", "pdf", "--output", str(vdir / "schematic.pdf"), str(sch)])
        run(KICAD_CLI + ["pcb", "export", "step", "--output", str(vdir / "pcba.step"),
             "--subst-models"] + dv + [str(pcb)])
        # GLB : même modèle 3D, dans un format lisible directement dans le
        # navigateur (via <model-viewer>) pour une vue interactive.
        run(KICAD_CLI + ["pcb", "export", "glb", "--output", str(vdir / "pcba.glb"),
             "--subst-models"] + dv + [str(pcb)])
        # PDF 3D interactif : même modèle, mais lisible dans un simple
        # lecteur PDF (Acrobat Reader) sans AUCUN logiciel de CAO — encore
        # plus accessible que le STEP pour quelqu'un qui veut juste
        # inspecter la carte en 3D.
        run(KICAD_CLI + ["pcb", "export", "3dpdf", "--output", str(vdir / "pcba-3d.pdf"),
             "--subst-models"] + dv + [str(pcb)])

        make_assembly_html(svg_top, svg_bottom, vdir / "assembly.html", board_name)

        ibom_cmd = ["generate_interactive_bom", "--no-browser",
                    "--dest-dir", str(vdir), "--name-format", "ibom"]
        if variant != "default":
            ibom_cmd += ["--variant-field", variant_field, "--variants-whitelist", variant]
        ibom_cmd.append(str(pcb))
        # mode --no-browser sur Linux headless : on passe par xvfb-run si dispo.
        if shutil.which("xvfb-run"):
            ibom_cmd = ["xvfb-run", "--auto-servernum"] + ibom_cmd
        if ibom_ok:
            run(ibom_cmd)

    tmp_top.unlink(missing_ok=True)
    tmp_bottom.unlink(missing_ok=True)
    print(f"[ok] {board_name} : {len(variants)} variante(s) exportée(s)")


if __name__ == "__main__":
    main()