Files
ESP32_Serial_Swiss_Army_Knife/hardware/PCB/generate_pcb_draft.py
T
Commander1024 c0f7a1ad51 Integrate A1 carrier PCB CAD updates
- Add pinned STEP-derived DE-9 mechanical measurements and provenance
- Refresh placement, routing, silkscreen, and nominal CAD envelopes
- Document provisional dimensions, assumptions, and validation status
2026-09-21 12:31:24 +02:00

207 lines
8.5 KiB
Python

#!/usr/bin/env python3
"""Create the compact placement draft from a fresh KiCad XML netlist.
Requires system Python + pcbnew. Refuses to overwrite an existing PCB unless
--overwrite is explicit. This is a draft generator, not a manufacturing tool.
Does not modify the schematic, footprint libraries or project settings.
SaveBoard(..., True) deliberately preserves the user's existing project rules.
"""
import argparse
from pathlib import Path
import re
import subprocess
import xml.etree.ElementTree as ET
import pcbnew as p
ROOT = Path(__file__).resolve().parent
PCB = ROOT / "serial-carrier.kicad_pcb"
LIBS = Path("/usr/share/kicad/footprints")
ORIGIN = (100, 100)
OUTLINE = [(36,0), (82,0), (82,80), (0,80), (0,19), (36,19)]
PLACEMENT = {
"U1": ("Carrier:HW678_2x22_Provisional", 8, 23),
"U2": ("Carrier:MAX3243_Reference_Provisional", 43, 26.670),
"DS1": ("Carrier:OLED_26mm_I2C_Provisional", 43, 36),
"R1": ("Resistor_THT:R_Axial_DIN0207_L6.3mm_D2.5mm_P7.62mm_Horizontal", 43, 34),
"R2": ("Resistor_THT:R_Axial_DIN0207_L6.3mm_D2.5mm_P7.62mm_Horizontal", 54, 34),
"R3": ("Resistor_THT:R_Axial_DIN0207_L6.3mm_D2.5mm_P7.62mm_Horizontal", 65, 34),
"SW1": ("Button_Switch_THT:SW_TH_Tactile_Omron_B3F-100x", 43, 68),
"SW2": ("Button_Switch_THT:SW_TH_Tactile_Omron_B3F-100x", 55, 68),
"SW3": ("Button_Switch_THT:SW_TH_Tactile_Omron_B3F-100x", 67, 68),
}
MOUNTS = [(3.5,22.5), (3.5,75), (78,4), (78,76)]
# Deliberate allocation, NOT a measured antenna envelope or an RF guarantee.
RF_RESERVE = [(10,19), (31,19), (31,32), (10,32)]
def mm(v):
return p.FromMM(v)
def xy(x,y):
return p.VECTOR2I(mm(x+ORIGIN[0]), mm(y+ORIGIN[1]))
def text(board, value, x, y, layer=p.F_SilkS, size=1):
item = p.PCB_TEXT(board)
item.SetText(value)
item.SetPosition(xy(x,y))
item.SetLayer(layer)
item.SetTextSize(p.VECTOR2I(mm(size),mm(size)))
item.SetTextThickness(mm(0.15))
board.Add(item)
return item
def polygon(zone, points):
poly=zone.Outline()
poly.NewOutline()
for x,y in points:
pt=xy(x,y)
poly.Append(pt.x,pt.y)
def load(lib_id):
lib,name=lib_id.split(":",1)
folder=ROOT / "Carrier.pretty" if lib=="Carrier" else LIBS / (lib+".pretty")
fp=p.FootprintLoad(str(folder),name)
if fp is None:
raise RuntimeError("Cannot load "+lib_id)
fp.SetFPIDAsString(lib_id)
return fp
def quiet_footprint(fp):
# Retain all mechanical geometry, but replace sprawling research annotation
# text with concise board annotations. Libraries are never modified.
for graphic in list(fp.GraphicalItems()):
if isinstance(graphic,p.PCB_TEXT):
fp.Remove(graphic)
fp.Value().SetVisible(False)
fp.Reference().SetTextSize(p.VECTOR2I(mm(1),mm(1)))
fp.Reference().SetTextThickness(mm(0.15))
fp.Reference().SetLayer(p.F_SilkS)
def copy_schematic_fields(fp, component):
for field in component.findall("fields/field"):
name=field.get("name")
if name in ("Reference", "Value", "Footprint"):
continue
fp.SetField(name, field.text or "")
fp.GetField(name).SetVisible(False)
def main():
parser=argparse.ArgumentParser(description=__doc__)
parser.add_argument("--overwrite",action="store_true")
args=parser.parse_args()
if PCB.exists() and not args.overwrite:
parser.error("PCB exists; preserve manual edits or explicitly use --overwrite")
if ROOT.joinpath("~serial-carrier.kicad_pcb.lck").exists():
parser.error("PCB is open in KiCad; close it before regenerating")
netfile=ROOT / "validation/pcb-source-netlist.xml"
subprocess.run(["kicad-cli","sch","export","netlist","--format","kicadxml","-o",str(netfile),str(ROOT/"serial-carrier.kicad_sch")],check=True)
source=ET.parse(netfile).getroot()
components={c.get("ref"):c for c in source.findall("components/comp")}
assert set(components)==set(PLACEMENT), "Schematic component set changed; review layout"
schematic=ROOT.joinpath("serial-carrier.kicad_sch").read_text()
root_uuid=re.search(r'\(uuid "?([a-f0-9-]{36})"?\)',schematic).group(1)
board=p.BOARD()
board.SetCopperLayerCount(2)
settings=board.GetDesignSettings()
settings.SetBoardThickness(mm(1.6))
settings.m_MinClearance=mm(0.25)
settings.m_CopperEdgeClearance=mm(0.5)
settings.m_HoleClearance=mm(0.25)
settings.m_TrackMinWidth=mm(0.25)
settings.m_ViasMinSize=mm(0.7)
# Proposed in-memory draft rules only; never overwrite the user's project settings.
title=board.GetTitleBlock()
title.SetTitle("Serial Swiss Army Knife - 82x80 mm carrier draft")
title.SetRevision("A1 DRAFT")
title.SetComment(0,"NOT FOR FABRICATION - USB / antenna geometry pending; verify module pinout / assembly")
pad_nets={}
for source_net in source.findall("nets/net"):
net=p.NETINFO_ITEM(board,source_net.get("name"))
board.Add(net)
for node in source_net.findall("node"):
pad_nets[node.get("ref"),node.get("pin")]=(net,node)
for ref,(lib_id,x,y) in PLACEMENT.items():
comp=components[ref]
if ref != "U2":
assert comp.findtext("footprint")==lib_id,(ref,"Schematic footprint changed")
fp=load(lib_id)
fp.SetReference(ref)
fp.SetValue(comp.findtext("value"))
path=p.KIID_PATH()
path.push_back(p.KIID(root_uuid))
path.push_back(p.KIID(comp.findtext("tstamps")))
fp.SetPath(path)
fp.SetSheetfile("serial-carrier.kicad_sch")
fp.SetSheetname("serial-carrier")
fp.SetPosition(xy(x,y))
board.Add(fp)
quiet_footprint(fp)
copy_schematic_fields(fp,comp)
if ref=="U1":
fp.Reference().SetPosition(xy(20.7,44))
elif ref=="U2":
fp.Reference().SetPosition(xy(56.97,14.670))
elif ref=="DS1":
fp.Reference().SetPosition(xy(56,49))
elif ref.startswith("SW"):
fp.Reference().SetPosition(xy(x+3.25,y-2))
else:
fp.Reference().SetLayer(p.F_Fab)
fp.Reference().SetPosition(xy(x+3.81,y-1.6))
for pad in fp.Pads():
key=ref,pad.GetNumber()
if pad.GetNumber():
net,node=pad_nets[key]
pad.SetNet(net)
pad.SetPinFunction(node.get("pinfunction", ""))
pad.SetPinType(node.get("pintype", "passive"))
for i,(x,y) in enumerate(MOUNTS,1):
fp=load("MountingHole:MountingHole_3.2mm_M3")
fp.SetReference("H"+str(i))
fp.SetValue("M3 mounting proposal / 3.2mm")
fp.SetBoardOnly(True)
fp.SetExcludedFromBOM(True)
fp.SetExcludedFromPosFiles(True)
fp.SetPosition(xy(x,y))
board.Add(fp)
quiet_footprint(fp)
fp.Reference().SetVisible(False)
for a,b in zip(OUTLINE,OUTLINE[1:]+OUTLINE[:1]):
edge=p.PCB_SHAPE(board)
edge.SetShape(p.SHAPE_T_SEGMENT)
edge.SetStart(xy(*a)); edge.SetEnd(xy(*b))
edge.SetLayer(p.Edge_Cuts); edge.SetWidth(mm(0.05))
board.Add(edge)
reserve=p.ZONE(board)
reserve.SetIsRuleArea(True)
layers=p.LSET(); layers.AddLayer(p.F_Cu); layers.AddLayer(p.B_Cu)
reserve.SetLayerSet(layers)
reserve.SetZoneName("PROVISIONAL RF RESERVE - verify actual antenna clearance")
reserve.SetDoNotAllowTracks(True); reserve.SetDoNotAllowVias(True)
reserve.SetDoNotAllowZoneFills(True); reserve.SetDoNotAllowPads(True)
reserve.SetDoNotAllowFootprints(False) # the module itself spans this region
polygon(reserve,RF_RESERVE)
board.Add(reserve)
text(board,"RF RESERVE",20.5,26,p.Dwgs_User,1.2)
text(board,"VERIFY ANTENNA",20.5,29,p.Dwgs_User,1)
text(board,"ESP32 HW678",20.7,48)
text(board,"USB ACCESS - VERIFY",18,83,p.Dwgs_User,1)
text(board,"DE-9 OUT / NOMINAL MALE",56.97,-1.830,p.Dwgs_User,1)
text(board,"MAX3243 REF",56.97,17.670)
text(board,"26mm OLED",56,52)
for label,x in zip(("BACK","SELECT","NEXT"),(46.25,58.25,70.25)):
text(board,label,x,75, size=0.9)
text(board,"DRAFT - NOT FOR FAB",56,78,size=0.9)
text(board,"82 x 80 mm / 2 copper layers",41,-5,p.Dwgs_User,1.5)
text(board,"Nominal RS-232 metal verified; ESP32 USB / antenna / assembly clearance pending",41,-2.5,p.Dwgs_User,0.9)
board.BuildConnectivity()
assert p.SaveBoard(str(PCB),board,True)
print("Created",PCB,"with 9 circuit footprints, 4 proposed M3 mounts, 2 copper layers.")
print("Placement draft only. U2 nominal male external metal dimensions user-confirmed; pin map / assembly still require qualification.")
if __name__=="__main__":
main()