shibosoftwaredev/f1c100s-linux-nema17-stepper-controller

A four-layer NEMA 17 motor-controller PCB combining an Allwinner F1C100S Linux computer, USB‑PD/buck power system, W5500 Ethernet, STM32 real-time motion control, DRV8825 stepper driver, protection/temperature/current telemetry, USB interfaces, and local LED/buzzer diagnostics.

Version
0.4.56
License
unset
Stars
0

scripts/export-assembly.py

#!/usr/bin/env python3
"""Create a self-consistent Gerber, drill, BOM and CPL fabrication package."""

import csv
import hashlib
import io
import json
import os
from pathlib import Path
import subprocess
import tempfile
import zipfile
from fabrication_handoff import promote_handoff


ROOT = Path(__file__).resolve().parents[1]
DIST = ROOT / "dist" / "index"
FINAL_FAB = ROOT / "fabrication"
REPORTS = ROOT / "reports"
CIRCUIT_PATH = DIST / "circuit.json"

# Never overwrite a fabrication handoff using an incomplete routing build.
preflight_bytes = CIRCUIT_PATH.read_bytes()
preflight = json.loads(preflight_bytes)
assert not [e for e in preflight if e["type"].endswith("_error")]
assert next(e for e in preflight if e["type"]=="pcb_board")["num_layers"] == 4
for via in [e for e in preflight if e["type"]=="pcb_via"]:
    assert via["hole_diameter"]==.3 and via["outer_diameter"]==.45
    assert set(via["layers"])=={"top","inner1","inner2","bottom"}
preflight_stock=json.loads((REPORTS/"jlcpcb-stock-audit.json").read_text())
assert preflight_stock["circuitSha256"] == hashlib.sha256(preflight_bytes).hexdigest()
assert all(p["status"]=="in-stock" for p in preflight_stock["parts"]), "JLCPCB orderable assembly stock is insufficient; do not release an assembly handoff"
continuity=json.loads((REPORTS/"connectivity-audit.json").read_text())
assert continuity['circuitSha256']==hashlib.sha256(preflight_bytes).hexdigest()
assert continuity['sourceFailures']==0 and continuity['disconnectedTerminalIslands']==0
assert continuity['emittedCopperOnly'] is True
preflight_sha = hashlib.sha256(preflight_bytes).hexdigest()
bom_validation = json.loads((REPORTS / "bom-validation.json").read_text())
assert bom_validation["circuitSha256"] == preflight_sha
assert not bom_validation["findings"], "Catalog value/voltage/package checks must pass before export"
drill_validation = json.loads((REPORTS / "drill-clearance-regression.json").read_text())
assert drill_validation["circuitSha256"] == preflight_sha
assert drill_validation["currentFindings"] == 0, "Inherited drill findings are blockers, not waivers"
additional_dfm = json.loads((REPORTS / "additional-dfm-review.json").read_text())
assert additional_dfm["circuitSha256"] == preflight_sha
assert not additional_dfm["findings"], "Independent pad/PTH DFM checks must pass before export"
assert additional_dfm["silkscreenBelowPublished1mmHeight"] == 0, "Manufacturing legend cleanup must pass before export"
solder_process = json.loads((REPORTS / "solder-process-audit.json").read_text())
assert solder_process["circuitSha256"] == preflight_sha, "Fresh solder-process review required"
assert solder_process["approvedForAssembly"] is True, "Ordinary via-in-pad and thermal/opposite-face solder process remain unresolved; do not release an assembly handoff"


def normalize_csv(data: bytes):
    text = data.decode().replace("\\r\\n", "\n").replace("\r\n", "\n")
    rows = list(csv.DictReader(io.StringIO(text)))
    assert rows, "empty assembly CSV"
    out = io.StringIO(newline="")
    writer = csv.DictWriter(out, fieldnames=list(rows[0]))
    writer.writeheader()
    writer.writerows(rows)
    return out.getvalue().encode(), rows


# Build in an isolated directory. Nothing in the previous handoff is removed
# while export, archive validation or assembly validation can still fail.
staging = tempfile.TemporaryDirectory(prefix="nema17-fabrication-", dir=ROOT)
FAB = Path(staging.name) / "fabrication"
FAB.mkdir()
ARCHIVE = FAB / "board-gerbers.zip"
REPORTS.mkdir(exist_ok=True)

# tsci resolves output paths relative to the input circuit JSON directory.
result = subprocess.run(
    [str(ROOT/"node_modules"/".bin"/"tsci"), "export", "dist/index/circuit.json", "--format", "gerbers",
     "--output", os.path.relpath(ARCHIVE, DIST)],
    cwd=ROOT, text=True, capture_output=True, env={**os.environ,"TMPDIR":"/tmp"},
)
if result.returncode:
    raise SystemExit(result.stdout + result.stderr)

circuit_bytes = CIRCUIT_PATH.read_bytes()
assert circuit_bytes == preflight_bytes, "Circuit changed during export; retain previous handoff"
circuit = json.loads(circuit_bytes)
sources = {item["source_component_id"]: item for item in circuit
           if item["type"] == "source_component"}
placed = {sources[item["source_component_id"]]["name"]: item for item in circuit
          if item["type"] == "pcb_component" and item["source_component_id"] in sources}
expected = {name for name, item in placed.items()
            if sources[item["source_component_id"]].get("ftype") != "simple_test_point"
            and not item.get("do_not_place", False)}
excluded = set(placed) - expected

with zipfile.ZipFile(ARCHIVE) as archive:
    entries = {name: archive.read(name) for name in archive.namelist()}

bom_data, bom_rows = normalize_csv(entries["bom.csv"])
pnp_data, pnp_rows = normalize_csv(entries["pick_and_place.csv"])
bom_designators = {row["Designator"] for row in bom_rows}
pnp_designators = {row["Designator"] for row in pnp_rows}
assert bom_designators == expected, f"BOM mismatch: missing={expected - bom_designators}, extra={bom_designators - expected}"
assert pnp_designators == expected, f"CPL mismatch: missing={expected - pnp_designators}, extra={pnp_designators - expected}"
assert all(row["Layer"] in {"top", "bottom"} for row in pnp_rows), "unexpected assembly layer"
assert all(row["JLCPCB Part #"].startswith("C") for row in bom_rows), "missing JLCPCB part number"

entries["bom.csv"] = bom_data
entries["pick_and_place.csv"] = pnp_data
with zipfile.ZipFile(ARCHIVE, "w", zipfile.ZIP_DEFLATED) as archive:
    for name, data in entries.items():
        archive.writestr(name, data)

for name, data in entries.items():
    if name.endswith((".gbr", ".drl")):
        assert Path(name).name == name, "Unexpected nested fabrication export path"
        (FAB / name).write_bytes(data)
(FAB / "assembly-bom.csv").write_bytes(bom_data)
(FAB / "assembly-pick-and-place.csv").write_bytes(pnp_data)

vias = [item for item in circuit if item["type"] == "pcb_via"]
errors = [item for item in circuit if item["type"].endswith("_error")]
stock = json.loads((REPORTS / "jlcpcb-stock-audit.json").read_text())
circuit_sha = hashlib.sha256(circuit_bytes).hexdigest()
assert not errors
assert all(via["hole_diameter"] == 0.3 and via["outer_diameter"] == 0.45 for via in vias)
assert stock["circuitSha256"] == circuit_sha
assert all(part["status"] == "in-stock" for part in stock["parts"])

audit = {
    "circuitJsonSha256": circuit_sha,
    "gerbersSha256": hashlib.sha256(ARCHIVE.read_bytes()).hexdigest(),
    "drcErrors": len(errors),
    "assemblyComponents": len(expected),
    "assemblyTopCount": sum(row["Layer"] == "top" for row in pnp_rows),
    "assemblyBottomCount": sum(row["Layer"] == "bottom" for row in pnp_rows),
    "assemblyBottomComponents": sorted(
        row["Designator"] for row in pnp_rows if row["Layer"] == "bottom"
    ),
    "excludedBareTestFeatures": sorted(excluded),
    "pcbVias": len(vias),
    "viaHoleDiameterMm": 0.3,
    "viaPadDiameterMm": 0.45,
    "jlcpcbUniqueParts": stock["uniquePartCount"],
    "jlcpcbStockIssues": sum(part["status"] != "in-stock" for part in stock["parts"]),
    "assemblyOrderCaveats": [
        {key:part.get(key) for key in ["code","designators","orderableStock","minimumAssemblyQuantity","assemblyLossQuantity"]}
        for part in stock["parts"]
        if part["orderableStock"] < (part.get("minimumAssemblyQuantity") or part["quantityPerBoard"])
    ],
    "generatedFiles": sorted(name for name in entries if name.endswith((".gbr", ".drl"))),
    "assemblyNote": "Review polarity and rotation in the JLCPCB placement preview before payment; this is required for every PCBA order.",
}
audit_text = json.dumps(audit, indent=2) + "\n"
(FAB / "fabrication-audit.json").write_text(audit_text)
backup = promote_handoff(FAB, FINAL_FAB)
staging.cleanup()
# The authoritative audit travels inside the promoted handoff. Keep the
# conventional report copy in sync using an atomic single-file replacement.
report_tmp = REPORTS / "fabrication-audit.json.tmp"
report_tmp.write_text(audit_text)
report_tmp.replace(REPORTS / "fabrication-audit.json")
if backup:
    print(f"Previous fabrication handoff preserved at {backup}")
print(json.dumps(audit, indent=2))