rushabhcodes/ESP32-E-Reader

A compact ESP32-C3 e-reader PCB integrating LiPo charging/protection, USB-C, 3.3 V regulation, e-paper display bias generation, microSD storage, and tactile controls.

Version
1.0.85
License
unset
Stars
3

scripts/check-assembly-export.py

"""Fail a Gerber export if its BOM or critical pick-and-place rows regress.

SW7 uses the exact JLCPCB C431541 footprint. Check its asymmetric contact
spacing, four grounded case feet, locator holes, and side-facing placement
against the SHOU HAN MSK12C02-HB supplier drawing.
"""

import csv
import io
import json
import re
import sys
from pathlib import Path
from zipfile import ZipFile


EXPECTED = {
    "J1": ("C165948", "top", "90"),
    "U1": ("C15220", "top", "0"),
    "U2": ("C7519", "top", "90"),
    "U3": ("C2927799", "top", "90"),
    "U4": ("C2926676", "top", "0"),
    "U5": ("C202140", "top", "0"),
    "J2": ("C324726", "top", "0"),
    "L1": ("C133195", "top", "270"),
    "L2": ("C133190", "top", "0"),
    "SW2": ("C139797", "bottom", "180"),
    "SW3": ("C139797", "bottom", "180"),
    "SW4": ("C139797", "bottom", "180"),
    "SW6": ("C139797", "bottom", "180"),
    "SW7": ("C431541", "top", "90"),
}

SUPPLIER_FOOTPRINTS = {
    "USBLC6_2SC6.tsx": "C7519",
    "DW01A.tsx": "C2927799",
    "SKRPACE010.tsx": "C139797",
    "MSK12C02_HB.tsx": "C431541",
}


def rows_by_designator(archive: ZipFile, filename: str):
    rows = csv.DictReader(io.StringIO(archive.read(filename).decode("utf-8-sig")))
    result = {}
    for row in rows:
        ref = row["Designator"]
        if ref in result:
            raise ValueError(f"Duplicate {ref} in {filename}")
        result[ref] = row
    return result


def main(path: Path):
    imports = Path(__file__).resolve().parents[1] / "imports"
    for filename, part in SUPPLIER_FOOTPRINTS.items():
        if f'footprint="jlcpcb:{part}"' not in (imports / filename).read_text():
            raise ValueError(f"{filename}: expected exact supplier footprint {part}")

    for filename, part in {
        "BQ24073RGTR.tsx": "C15220",
        "TYPE_C_31_M_12.tsx": "C165948",
        "ESP32_C3_WROOM_02U_N4.tsx": "C2926676",
        "TPS63021DSJR.tsx": "C202140",
        "SMMS0420_1R5M.tsx": "C133190",
    }.items():
        source = (imports / filename).read_text()
        if part not in source or "<smtpad" not in source:
            raise ValueError(f"{filename}: missing exact supplier footprint for {part}")

    with ZipFile(path) as archive:
        bom = rows_by_designator(archive, "bom.csv")
        placements = rows_by_designator(archive, "pick_and_place.csv")

        drill = archive.read("drill-L1-L4.drl").decode()
        if "METRIC" not in drill:
            raise ValueError("Expected metric drill file")
        drills = [float(size) for size in re.findall(r"^T\d+C([0-9.]+)$", drill, re.M)]
        if not drills or min(drills) < 0.3 - 1e-6:
            raise ValueError("Export contains plated drills below 0.30 mm")
        if not any(abs(size - 0.3) < 1e-6 for size in drills):
            raise ValueError("Export is missing the expected 0.30 mm via tool")

    if bom.keys() != placements.keys():
        raise ValueError("BOM and pick-and-place designators differ")
    missing = [ref for ref, row in bom.items() if not row["JLCPCB Part #"]]
    if missing:
        raise ValueError(f"Missing JLCPCB part numbers: {', '.join(missing)}")

    for ref, (part, layer, rotation) in EXPECTED.items():
        if bom[ref]["JLCPCB Part #"] != part:
            raise ValueError(f"{ref}: expected JLCPCB {part}")
        row = placements[ref]
        if (row["Layer"], row["Rotation"]) != (layer, rotation):
            raise ValueError(f"{ref}: expected {layer} rotation {rotation}°")

    circuit_path = path.parent / "index" / "circuit.json"
    circuit = json.loads(circuit_path.read_text())
    source_names = {
        item["source_component_id"]: item["name"]
        for item in circuit
        if item.get("type") == "source_component"
    }
    # Guard the fabrication settings in the actual routed artifact, not only JSX.
    errors = [item for item in circuit if item["type"].endswith("_error")]
    if errors:
        raise ValueError(f"Circuit has {len(errors)} unresolved errors; do not fabricate")
    vias = [item for item in circuit if item["type"] == "pcb_via"]
    if not vias or not any(item["type"] == "pcb_trace" for item in circuit):
        raise ValueError("Expected a fully routed board with vias")
    for via in vias:
        if via["hole_diameter"] < 0.3 - 1e-6 or via["outer_diameter"] < 0.6 - 1e-6:
            raise ValueError(f"Nonstandard via size: {via}")
        if set(via["layers"]) != {"top", "inner1", "inner2", "bottom"}:
            raise ValueError("Only ordinary through vias are allowed")
    for hole in (item for item in circuit if item["type"] == "pcb_hole"):
        if hole.get("hole_shape") == "circle" and hole["hole_diameter"] < 0.5 - 1e-6:
            raise ValueError("NPTH below JLCPCB 0.50 mm minimum")
    j2 = [item for item in circuit if item["type"] == "pcb_component"
          and source_names.get(item.get("source_component_id")) == "J2"]
    spec = json.loads((imports.parent / "assets/enclosure/display-connection.json").read_text())
    if len(j2) != 1 or any(abs(j2[0]["center"][axis] - spec["connector"]["center" + axis.upper()]) > 1e-6 for axis in ("x", "y")):
        raise ValueError("J2 must match the Waveshare L-tail connector position")
    if j2[0].get("pin1_location") != "topside_right":
        raise ValueError("J2 pin 1 must match the canonical FH34SRJ footprint")
    j2_source_id = j2[0]["source_component_id"]
    for pin in (1, 4, 6, 7, 19):
        port = next(item for item in circuit if item["type"] == "source_port"
                    and item.get("source_component_id") == j2_source_id
                    and item.get("pin_number") == pin)
        connected = any(item["type"] == "source_trace"
                        and port["source_port_id"] in item.get("connected_source_port_ids", [])
                        for item in circuit)
        if not port.get("do_not_connect") or connected:
            raise ValueError(f"Waveshare J2 pin {pin} must be left open")
    print(f"Fabrication sizes checked: {len(vias)} vias at least 0.30/0.60 mm; Waveshare connector/NC pins checked.")

    sw7_ids = {
        item["pcb_component_id"]
        for item in circuit
        if item.get("type") == "pcb_component"
        and source_names.get(item.get("source_component_id")) == "SW7"
    }
    if len(sw7_ids) != 1:
        raise ValueError("Expected exactly one SW7 PCB component")
    all_sw7_pads = [
        item for item in circuit
        if item.get("type") == "pcb_smtpad"
        and item.get("pcb_component_id") in sw7_ids
    ]
    contacts = {item["port_hints"][0]: item for item in all_sw7_pads
                if item["port_hints"][0] in {"pin1", "pin2", "pin3"}}
    case_pads = [item for item in all_sw7_pads if "pin4" in item["port_hints"]]
    if len(all_sw7_pads) != 7 or len(contacts) != 3 or len(case_pads) != 4:
        raise ValueError("SW7 needs three contact pads and four metal-case solder feet")
    pcb_ports = {item["pcb_port_id"]: item for item in circuit if item["type"] == "pcb_port"}
    source_ports = {item["source_port_id"]: item for item in circuit if item["type"] == "source_port"}
    for pad in all_sw7_pads:
        port = pcb_ports.get(pad.get("pcb_port_id"))
        if port is None:
            raise ValueError("SW7 pad is not bound to an electrical PCB port")
        physical_pin = int(pad["port_hints"][0].removeprefix("pin"))
        if f"pin{physical_pin}" not in source_ports[port["source_port_id"]]["port_hints"]:
            raise ValueError("SW7 pad maps to the wrong schematic pin")
    if not all(pad["layer"] == "top" for pad in all_sw7_pads):
        raise ValueError("SW7 pads must be on the top layer")
    if not (contacts["pin1"]["y"] < contacts["pin2"]["y"] < contacts["pin3"]["y"]):
        raise ValueError("SW7 pad 1 must be below pads 2 and 3")
    for a, b, pitch in [("pin1", "pin2", 3.0), ("pin2", "pin3", 1.5)]:
        if abs(contacts[b]["y"] - contacts[a]["y"] - pitch) > 0.01:
            raise ValueError(f"SW7 {a}/{b} contact spacing must be {pitch} mm")
        if abs(contacts[b]["x"] - contacts[a]["x"]) > 0.001:
            raise ValueError("SW7 contacts must remain collinear")
    sw7_source_id = next(item["source_component_id"] for item in circuit
                         if item["type"] == "source_component" and item["name"] == "SW7")
    expected_nets = {1: "SYS_OUT", 2: "POWER_ENABLE", 3: "POWER_OFF_PULLDOWN", 4: "GND"}
    net_ids = {item["name"]: item["source_net_id"] for item in circuit
               if item["type"] == "source_net"}
    for pin, net in expected_nets.items():
        port_id = next(item["source_port_id"] for item in circuit
                       if item["type"] == "source_port"
                       and item["source_component_id"] == sw7_source_id
                       and item["pin_number"] == pin
                       and item["name"] == {1: "ON", 2: "COM", 3: "OFF", 4: "CASE"}[pin])
        if not any(item["type"] == "source_trace"
                   and port_id in item.get("connected_source_port_ids", [])
                   and net_ids[net] in item.get("connected_source_net_ids", [])
                   for item in circuit):
            raise ValueError(f"SW7 pin {pin} must connect to {net}")
    # The supplier's four case feet get separate PCB ports with internal ties.
    parent = {}
    def connected_group(identity):
        parent.setdefault(identity, identity)
        if parent[identity] != identity:
            parent[identity] = connected_group(parent[identity])
        return parent[identity]
    groups = [item.get("connected_source_port_ids", []) + item.get("connected_source_net_ids", [])
              for item in circuit if item["type"] == "source_trace"]
    groups += [group for item in circuit if item["type"] == "source_component"
               for group in item.get("internally_connected_source_port_ids", [])]
    for members in groups:
        for member in members[1:]:
            parent[connected_group(member)] = connected_group(members[0])

    # These replacements have different pin functions and contact grouping.
    # Check the emitted connections, rather than only their BOM identifiers.
    replacement_nets = {
        "J1": {
            **dict.fromkeys((1, 2, 3, 4, 13, 14), "GND"),
            6: "USB_CC1", 12: "USB_CC2", 7: "USB_DM", 9: "USB_DM",
            8: "USB_DP", 10: "USB_DP", 15: "USB_VBUS", 16: "USB_VBUS",
        },
        "U1": {
            **dict.fromkeys((4, 5, 8, 15, 17), "GND"),
            1: "CHARGE_TS", 2: "BATT_P", 3: "BATT_P", 6: "USB_VBUS",
            9: "CHARGE_STAT", 10: "SYS_OUT", 11: "SYS_OUT",
            12: "CHARGE_ILIM", 13: "USB_VBUS", 16: "CHARGE_PROG",
        },
    }
    for ref, expected in replacement_nets.items():
        source_id = next(identity for identity, name in source_names.items() if name == ref)
        ports = {item["pin_number"]: item for item in source_ports.values()
                 if item.get("source_component_id") == source_id}
        for pin, net in expected.items():
            if connected_group(ports[pin]["source_port_id"]) != connected_group(net_ids[net]):
                raise ValueError(f"{ref} pin {pin} must connect to {net}")
        for pin in ((5, 11) if ref == "J1" else (7, 14)):
            port = ports[pin]
            if not port.get("do_not_connect") or any(
                    port["source_port_id"] in item.get("connected_source_port_ids", [])
                    for item in circuit if item["type"] == "source_trace"):
                raise ValueError(f"{ref} pin {pin} must remain open")
    j1 = next(item for item in circuit if item["type"] == "pcb_component"
              and source_names.get(item.get("source_component_id")) == "J1")
    contacts = [item for item in circuit if item["type"] == "pcb_smtpad"
                and item.get("pcb_component_id") == j1["pcb_component_id"]]
    feet = [item for item in circuit if item["type"] == "pcb_plated_hole"
            and item.get("pcb_component_id") == j1["pcb_component_id"]]
    locators = [item for item in circuit if item["type"] == "pcb_hole"
                and item.get("pcb_component_id") == j1["pcb_component_id"]]
    if len(contacts) != 12 or len(feet) != 4 or len(locators) != 2:
        raise ValueError("J1 C165948 needs 12 contact pads, four shell slots and two locators")
    if any(abs(item["hole_diameter"] - 0.6) > 0.001 for item in locators):
        raise ValueError("J1 C165948 needs 0.60 mm locator holes")
    for pad in contacts + feet:
        physical_hint = next(hint for hint in pad["port_hints"] if re.fullmatch(r"pin\d+", hint))
        pin = int(physical_hint.removeprefix("pin"))
        source_port_id = pcb_ports[pad["pcb_port_id"]]["source_port_id"]
        if f"pin{pin}" not in source_ports[source_port_id]["port_hints"]:
            raise ValueError(f"J1 physical pad {pin} maps to the wrong source pin")
        # SBU pads are deliberately unconnected.
        if pin not in (5, 11) and connected_group(source_port_id) != connected_group(net_ids[replacement_nets["J1"][pin]]):
            raise ValueError(f"J1 physical pad {pin} maps to the wrong net")
    charger_id = next(identity for identity, name in source_names.items() if name == "U1")
    td = next(port for port in source_ports.values()
              if port.get("source_component_id") == charger_id and port.get("pin_number") == 15)
    if td.get("name") != "TD":
        raise ValueError("BQ24073 pin 15 must be TD, with termination enabled by GND")
    for ref, resistance in {"R7": 5100, "R8": 5100, "R9": 2500, "R31": 10000}.items():
        resistor = next(item for item in circuit if item["type"] == "source_component" and item["name"] == ref)
        if resistor.get("resistance") != resistance:
            raise ValueError(f"{ref}: unexpected USB/charger programming resistance")
    print("J1 C165948 contact mapping and U1 C15220 grounded TD/charge configuration verified.")
    for pad in all_sw7_pads:
        pin = int(pad["port_hints"][0].removeprefix("pin"))
        source_port = pcb_ports[pad["pcb_port_id"]]["source_port_id"]
        if connected_group(source_port) != connected_group(net_ids[expected_nets[pin]]):
            raise ValueError(f"SW7 pad {pin} is not electrically tied to {expected_nets[pin]}")
    locator_holes = [item for item in circuit if item["type"] == "pcb_hole"
                     and item.get("pcb_component_id") in sw7_ids]
    if len(locator_holes) != 2 or any(abs(item["hole_diameter"] - 0.9) > 0.01
                                     for item in locator_holes):
        raise ValueError("SW7 needs the supplier's two 0.9 mm locator holes")

    print(f"Assembly export checked: {len(bom)} BOM and placement rows; no missing part numbers.")
    print("SW7: C431541 top-side 90°, asymmetric contacts, grounded case feet, and locator holes verified.")


if __name__ == "__main__":
    main(Path(sys.argv[1]))