Introduce an editable KiCad 10 schematic and routed two-layer 82 × 80 mm layout with local provisional footprints, validation tooling, and component research. Keep the RS-232 male-module mapping and mechanical clearances explicitly provisional pending hardware verification.
222 lines
9.3 KiB
Python
222 lines
9.3 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 or footprint libraries. KiCad SaveBoard also saves
|
|
PCB design settings into the project; close the project before regeneration.
|
|
"""
|
|
import argparse
|
|
import json
|
|
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 = [(0,0), (82,0), (82,80), (36,80), (36,61), (0,61)]
|
|
PLACEMENT = {
|
|
"U1": ("Carrier:HW678_2x22_Provisional", 8, 4),
|
|
"U2": ("Carrier:MAX3243_Reference_Provisional", 43, 30),
|
|
"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,3.5), (3.5,56), (78,4), (78,76)]
|
|
# Deliberate allocation, NOT a measured antenna envelope or an RF guarantee.
|
|
RF_RESERVE = [(10,0), (31,0), (31,13), (10,13)]
|
|
|
|
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 configure_draft_netclasses():
|
|
# SaveBoard creates the project defaults. Align future interactive routing
|
|
# with the draft's physical minimum widths and its explicit power routing.
|
|
path=ROOT/"serial-carrier.kicad_pro"
|
|
project=json.loads(path.read_text())
|
|
settings=project["net_settings"]
|
|
default=next(c for c in settings["classes"] if c["name"]=="Default")
|
|
default.update(clearance=0.25, track_width=0.25, via_diameter=0.7, via_drill=0.3)
|
|
power=dict(default, name="Power", priority=0, track_width=0.5, diff_pair_width=0.25)
|
|
settings["classes"]=[c for c in settings["classes"] if c["name"]!="Power"]+[power]
|
|
patterns=[v for v in settings.get("netclass_patterns",[]) if v["pattern"] not in ("/+3V3","/GND")]
|
|
settings["netclass_patterns"]=patterns+[{"netclass":"Power","pattern":name} for name in ("/+3V3","/GND")]
|
|
path.write_text(json.dumps(project,indent=2)+"\n")
|
|
|
|
|
|
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 any(ROOT.glob("~serial-carrier.*.lck")):
|
|
parser.error("Project is open in KiCad; close it before regenerating PCB/settings")
|
|
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)
|
|
# SaveBoard persists these proposed draft rules into the project settings.
|
|
title=board.GetTitleBlock()
|
|
title.SetTitle("Serial Swiss Army Knife - 82x80 mm carrier draft")
|
|
title.SetRevision("A0 DRAFT")
|
|
title.SetComment(0,"NOT FOR FABRICATION - male module / USB / antenna geometry unverified")
|
|
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,25))
|
|
elif ref=="U2":
|
|
fp.Reference().SetPosition(xy(56.97,18))
|
|
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,7,p.Dwgs_User,1.2)
|
|
text(board,"VERIFY ANTENNA",20.5,10,p.Dwgs_User,1)
|
|
text(board,"ESP32 HW678",20.7,29)
|
|
text(board,"USB ACCESS - VERIFY",18,64,p.Dwgs_User,1)
|
|
text(board,"DE-9 OUT / VERIFY MALE",56.97,1.5,p.Dwgs_User,1)
|
|
text(board,"MAX3243 REF",56.97,21)
|
|
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,"Carrier envelope only; module overhang and connector clearance not qualified",41,-2.5,p.Dwgs_User,0.9)
|
|
board.BuildConnectivity()
|
|
p.SaveBoard(str(PCB),board)
|
|
configure_draft_netclasses()
|
|
print("Created",PCB,"with 9 circuit footprints, 4 proposed M3 mounts, 2 copper layers.")
|
|
print("Placement draft only. Board-only U2 footprint is a female-CAD reference, not male qualification.")
|
|
|
|
if __name__=="__main__":
|
|
main()
|