Integrate Measured ESP32 PCB Geometry

- Add measured F.Fab bounds and USB mouth markers
- Add guarded preservation and regression validation
- Refresh review exports and remove draft presentation labels
This commit is contained in:
2026-09-21 22:54:37 +02:00
parent ab6a8010ba
commit 901afbe754
25 changed files with 20490 additions and 850 deletions
@@ -2,13 +2,19 @@
(version 20260206)
(generator "pcbnew")
(layer "F.Cu")
(descr "HW-678, component/top view antenna up USB down; origin pad 1; left top-bottom 1..22, right top-bottom 23..44; only header centres verified; no GPIO mapping, outline, courtyard or antenna keepout; PROVISIONAL connector fit: 1.70 mm pads / 1.00 mm drills from installed KiCad PinHeader_1x04_P2.54mm_Vertical, awaiting selected actual pin/socket datasheet")
(descr "HW-678, component/top view antenna up USB down; origin pad 1; left top-bottom 1..22, right top-bottom 23..44; nominal header centres with user-measured main PCB/antenna bounds and USB shell mouth spans on F.Fab; approximate 1.365 mm top/left registration; no GPIO mapping, complete USB envelope, courtyard or RF keepout; PROVISIONAL connector fit: 1.70 mm pads / 1.00 mm drills from installed KiCad PinHeader_1x04_P2.54mm_Vertical, awaiting selected actual pin/socket datasheet")
(tags "module provisional THT")
(attr through_hole)
(property "Reference" "REF**" (at 12.7 -3) (layer "F.SilkS") (effects (font (size 1 1) (thickness 0.15))))
(property "Value" "HW678_2x22_Provisional" (at 12.7 57) (layer "F.Fab") (effects (font (size 1 1) (thickness 0.15))))
(fp_text user "PROVISIONAL CONNECTOR FIT" (at 12.7 25) (layer "F.Fab") (effects (font (size 1 1) (thickness 0.15))))
(fp_text user "ANTENNA UP / USB DOWN" (at 12.7 28) (layer "F.Fab") (effects (font (size 1 1) (thickness 0.15))))
(fp_rect (start -1.365 -1.365) (end 26.725 54.705) (stroke (width 0.1) (type solid)) (fill none) (layer "F.Fab"))
(fp_rect (start 4.045 -8.255) (end 21.675 -1.365) (stroke (width 0.1) (type solid)) (fill none) (layer "F.Fab"))
(fp_line (start 2.795 54.705) (end 11.665 54.705) (stroke (width 0.3) (type solid)) (layer "F.Fab"))
(fp_line (start 14.195 54.705) (end 23.065 54.705) (stroke (width 0.3) (type solid)) (layer "F.Fab"))
(fp_text user "ANTENNA BOUNDS - NOT RF KEEPOUT" (at 12.7 -9.5) (layer "F.Fab") (effects (font (size 0.8 0.8) (thickness 0.12))))
(fp_text user "USB SHELL MOUTHS ONLY" (at 12.7 59) (layer "F.Fab") (effects (font (size 0.8 0.8) (thickness 0.12))))
(pad "1" thru_hole rect (at 0.00 0.00) (size 1.7 1.7) (drill 1) (layers "*.Cu" "*.Mask") (remove_unused_layers no))
(pad "2" thru_hole circle (at 0.00 2.54) (size 1.7 1.7) (drill 1) (layers "*.Cu" "*.Mask") (remove_unused_layers no))
(pad "3" thru_hole circle (at 0.00 5.08) (size 1.7 1.7) (drill 1) (layers "*.Cu" "*.Mask") (remove_unused_layers no))
+25 -6
View File
@@ -26,8 +26,11 @@ the carrier. All 3.3 V and ground connections are explicit named nets.
| `validation/pcb-draft.png`, `validation/pcb-draft-3d.png` | Copper/fab preview and carrier-only 3D render |
| `validate_pcb_draft.py` | Fresh schematic-to-PCB net checks, bounds/layers and DRC/parity report |
| `generate_pcb_draft.py`, `route_pcb_draft.py` | Optional destructive-regeneration tools with explicit overwrite guards |
| `add_device_silkscreen.py` | Refresh board-local OLED/RS-232 outlines and ESP32 header guides after placement/routing; preserves copper and project settings |
| `add_device_silkscreen.py` | Refresh board-local OLED/RS-232 and measured ESP32 outlines/USB mouth markers after placement/routing; preserves copper and project settings |
| [db9-mechanical-notes.md](../../docs/devel/hardware/PCB/db9-mechanical-notes.md), `reference/db9-cad/` | STEP-derived metal dimensions, originals, source licenses and repeatable extraction/validation |
| `update_u1_graphics.py`, `validate_u1_update.py` | Guarded measured-U1 graphics integration and exact pre-update preservation / geometry / silkscreen regressions |
| `export_placement_pdf.py` | 1:1 PDF from saved board geometry; refuses stale embedded U1 graphics |
| `remove_draft_markers.py` | Guarded presentation-label cleanup with exact-content preservation audit; does not qualify fabrication |
| `add_db9_envelope.py` | Refresh nominal connector metal projections on Dwgs.User; not a case cutout or assembly clearance |
| `Carrier.kicad_sym`, `sym-lib-table` | Project-local module, switch and resistor symbols |
| `Carrier.pretty/`, `fp-lib-table` | OLED/ESP32/RS-232-reference provisional footprints; standard KiCad 10 switch/resistor/mounting-hole library references |
@@ -217,9 +220,24 @@ The wiring guide warns that compatible ESP32 boards may have different layouts.
rows fit a standard breadboard simultaneously without forcing. Use nominal
2.54 mm pitch (53.34 mm over 21 intervals) and 25.40 mm row spacing based on
that fit check, not the raw caliper measurements. Verify with a 1:1 print before fabrication.
- ESP32 seller drawing states 28 mm width and annotates 63 mm length; its 20 mm
arrow is not header row spacing. Antenna extent and header-to-outline offsets
are not fully dimensioned.
- ESP32 actual user measurements supersede the seller's 28 × 63 mm drawing:
main PCB **56.07 × 28.09 mm**, excluding the antenna projection. Top/component
view, antenna up and USB down: antenna projects **6.89 mm** above the main PCB,
inset **5.41 mm left / 5.05 mm right**. Derived antenna width **17.63 mm**,
overall length **62.96 mm**, antenna centre **0.18 mm right** of PCB centre.
- Header end offsets were initially approximately 1.5 mm; user accepted **1.365 mm**
top/bottom to preserve the breadboard-confirmed 53.34 mm span. Left-edge-to-left-row
centre distance is also approximately **1.365 mm**. Preserve 25.40 mm row spacing:
the resulting right margin is **1.325 mm**, not an independently measured offset.
These modelling offsets are not a claim of micron-level measurement accuracy.
- Both USB-C metal shells are **8.87 mm wide**, with opening faces flush with the
main PCB bottom edge. Left shell's outside left edge is **4.16 mm** from the PCB
left edge; right shell's outside right edge is **3.66 mm** from the PCB right edge.
Shell depth/height and mating-plug clearance remain unmeasured.
- The library and routed board's embedded U1 now record these bounds on F.Fab;
USB mouths are width spans only, not complete connector outlines. Measured
board-local silkscreen, 1:1 placement PDF and SVG/PNG/3D previews are refreshed. See
`../../docs/devel/hardware/PCB/footprint-notes.md` for coordinates and limits.
- OLED actual PCB outline measured by user: 26 × 26 mm, superseding the seller's
28.7 × 27.8 mm dimensions. Seller's 4.1 mm height remains unverified.
- OLED front view, connector at top: GND, VCC, SCL, SDA from left to right,
@@ -258,8 +276,9 @@ The wiring guide warns that compatible ESP32 boards may have different layouts.
1. OLED outline, mounting-hole placement, revised header position and display
outline are user fit-verified. Select carrier header/socket pads and drills
from the actual connector drawings.
2. Before final module outlines/courtyards, establish their registration to header
centres, connector overhang and display position from drawings or measurements.
2. ESP32 planar body/antenna bounds and USB mouth positions are now recorded in
the library and embedded board footprint. Verify a 1:1 fit; complete module heights, USB depth/height, mating-plug clearances and
assembly courtyards. Measured antenna bounds do not establish an RF keepout.
3. Verify power-path schematic/regulator capacity and OLED pull-up implementation.
Photos alone do not establish those electrical properties.
+21 -19
View File
@@ -10,6 +10,7 @@ import uuid
import pcbnew as p
from shapely.geometry import LineString, Point, box
from shapely.ops import unary_union, polygonize
from update_u1_graphics import verify
ROOT = Path(__file__).resolve().parent
PCB = ROOT / "serial-carrier.kicad_pcb"
@@ -38,6 +39,7 @@ def outline_lines(shape):
def add_outlines(board):
verify(board)
owned = {str(uuid.uuid5(NS, str(i))) for i in range(4096)}
for drawing in list(board.GetDrawings()):
if drawing.m_Uuid.AsString() in owned:
@@ -82,8 +84,11 @@ def add_outlines(board):
board.Add(shape)
count+=1
def line(a,b):
clipped=LineString([a,b]).intersection(silk_area).difference(blocked)
def line(a,b,width=WIDTH):
extra = max(0, (width-WIDTH)/2)
area = silk_area.buffer(-extra) if extra else silk_area
obstacles = blocked.buffer(extra) if extra else blocked
clipped=LineString([a,b]).intersection(area).difference(obstacles)
pieces=list(clipped.geoms) if hasattr(clipped,"geoms") else [clipped]
for piece in pieces:
if piece.geom_type!="LineString" or piece.length < 0.25:
@@ -91,7 +96,7 @@ def add_outlines(board):
shape=p.PCB_SHAPE(board)
shape.SetShape(p.SHAPE_T_SEGMENT)
shape.SetStart(vec(piece.coords[0])); shape.SetEnd(vec(piece.coords[-1]))
shape.SetWidth(p.FromMM(WIDTH))
shape.SetWidth(p.FromMM(width))
add(shape)
for ref in ("DS1","U2"):
@@ -137,22 +142,19 @@ def add_outlines(board):
distance+=1.3
else:
line(edge.coords[0],edge.coords[-1])
# These are pad-row guides, not inferred socket/body or ESP32 PCB dimensions.
fp=footprints["U1"]
assert fp.GetOrientationDegrees()==0, "Review guide construction for rotated U1"
pads={pad.GetNumber():xy(pad.GetPosition()) for pad in fp.Pads()}
for first,last in (("1","22"),("23","44")):
x,ytop=pads[first]; _,ybottom=pads[last]
corners=[(x-1.2,ytop-1.2),(x+1.2,ytop-1.2),(x+1.2,ybottom+1.2),(x-1.2,ybottom+1.2)]
for a,b in zip(corners,corners[1:]+corners[:1]):
line(a,b)
label=p.PCB_TEXT(board)
label.SetText("HEADER GUIDES")
a,b=pads["1"],pads["23"]
label.SetPosition(vec(((a[0]+b[0])/2,a[1]+28)))
label.SetTextSize(p.VECTOR2I(p.FromMM(0.85),p.FromMM(0.85)))
label.SetTextThickness(p.FromMM(0.12))
add(label)
# Union removes the shared body/antenna edge duplication. Mouth spans replace
# the coincident bottom-edge strokes, with their measured library line weight;
# this marks width only, without inventing connector depth or an RF keepout.
sources=[g for g in footprints["U1"].GraphicalItems()
if isinstance(g,p.PCB_SHAPE) and g.GetLayer()==p.F_Fab]
mouths=[g for g in sources if g.GetShape()==p.SHAPE_T_SEGMENT]
mouth_lines=unary_union([edge for g in mouths for edge in outline_lines(g)])
perimeter=unary_union([edge for g in sources if g.GetShape()==p.SHAPE_T_RECT
for edge in outline_lines(g)]).difference(mouth_lines)
for edge in perimeter.geoms:
line(edge.coords[0],edge.coords[-1])
for mouth in mouths:
line(xy(mouth.GetStart()),xy(mouth.GetEnd()),p.ToMM(mouth.GetWidth()))
return count
+48
View File
@@ -0,0 +1,48 @@
#!/usr/bin/env python3
"""Export a 1:1 placement PDF with measured HW678 geometry, without editing CAD.
Export the saved board's verified measured U1 graphics. Only review captions
change on a temporary plotting board; no library overlay hides stale CAD.
"""
from pathlib import Path
import hashlib
import subprocess
import tempfile
import pcbnew as p
from update_u1_graphics import verify
ROOT = Path(__file__).resolve().parent
PCB = ROOT / "serial-carrier.kicad_pcb"
PDF = ROOT / "pcb-placement-draft.pdf"
def export():
original = PCB.read_bytes()
board = p.LoadBoard(str(PCB))
verify(board)
for drawing in board.GetDrawings():
if isinstance(drawing, p.PCB_TEXT):
if drawing.GetText() == "Nominal RS-232 metal verified; ESP32 USB / antenna / assembly clearance pending":
drawing.SetText("Measured ESP32 outline / USB mouths; RF / plug / assembly clearance pending")
board.GetTitleBlock().SetComment(0, "Measured planar outlines; verify RF / plug / assembly clearance")
with tempfile.TemporaryDirectory(prefix="carrier-placement-") as temporary:
draft = Path(temporary) / PCB.name
result = Path(temporary) / PDF.name
if not p.SaveBoard(str(draft), board, True):
raise RuntimeError("Cannot save temporary plotting board")
subprocess.run([
"kicad-cli", "pcb", "export", "pdf", "--mode-single",
"--layers", "F.Fab,F.SilkS,Dwgs.User,Edge.Cuts",
"--sketch-pads-on-fab-layers", "--black-and-white", "--scale", "1",
"--output", str(result), str(draft),
], check=True)
if PCB.read_bytes() != original:
raise RuntimeError("Source PCB changed during export; PDF not replaced")
PDF.write_bytes(result.read_bytes())
print(f"Exported {PDF}; scale 1:1; source PCB unchanged")
print(f"Source PCB SHA256: {hashlib.sha256(original).hexdigest()}")
if __name__ == "__main__":
export()
+4 -4
View File
@@ -114,9 +114,9 @@ def main():
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")
title.SetTitle("Serial Swiss Army Knife - 82x80 mm carrier")
title.SetRevision("A1")
title.SetComment(0,"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"))
@@ -194,7 +194,7 @@ def main():
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()
Binary file not shown.
+74
View File
@@ -0,0 +1,74 @@
#!/usr/bin/env python3
"""Remove presentation-only PCB draft labels; retain all technical cautions."""
from pathlib import Path
import tempfile
import pcbnew as p
ROOT = Path(__file__).resolve().parent
PCB = ROOT / "serial-carrier.kicad_pcb"
TITLE = "Serial Swiss Army Knife - 82x80 mm carrier"
COMMENT = "USB / antenna geometry pending; verify module pinout / assembly"
SILK = "DRAFT - NOT FOR FAB"
def remove_labels(board):
title = board.GetTitleBlock()
assert title.GetTitle() in (TITLE, TITLE + " draft")
assert title.GetRevision() in ("A1", "A1 DRAFT")
assert title.GetComment(0) in (COMMENT, "NOT FOR FABRICATION - " + COMMENT)
title.SetTitle(TITLE)
title.SetRevision("A1")
title.SetComment(0, COMMENT)
for drawing in list(board.GetDrawings()):
if isinstance(drawing, p.PCB_TEXT) and drawing.GetText() == SILK:
assert drawing.GetLayer() == p.F_SilkS
board.Remove(drawing)
def expected_tree(tree):
"""Exact permitted delta for preservation audits, independent of pcbnew edits."""
import copy
result = copy.deepcopy(tree)
for item in list(result):
if not isinstance(item, list):
continue
if item[0] == "title_block":
for field in item[1:]:
replacements = {
('title', '"' + TITLE + ' draft"'): '"' + TITLE + '"',
('rev', '"A1 DRAFT"'): '"A1"',
('comment', '"NOT FOR FABRICATION - ' + COMMENT + '"'): '"' + COMMENT + '"',
}
replacement = replacements.get((field[0], field[-1]))
if replacement is not None:
field[-1] = replacement
elif item[0] == "gr_text" and item[1] == '"' + SILK + '"':
assert ['layer', '"F.SilkS"'] in item
result.remove(item)
return result
def main():
from validate_u1_update import parse, canonical
lock = ROOT / "~serial-carrier.kicad_pcb.lck"
if lock.exists():
raise RuntimeError("Close PCB Editor before changing labels")
original = PCB.read_bytes()
settings = {path: path.read_bytes() for path in ROOT.glob("serial-carrier.kicad_pr*")}
expected = expected_tree(parse(PCB))
board = p.LoadBoard(str(PCB))
remove_labels(board)
with tempfile.TemporaryDirectory(prefix="carrier-labels-", dir=ROOT) as temporary:
result = Path(temporary) / PCB.name
assert p.SaveBoard(str(result), board, True)
assert canonical(parse(result)) == canonical(expected), "Change beyond exact label edits"
assert canonical(expected_tree(parse(result))) == canonical(expected), "Label cleanup not idempotent"
if lock.exists() or PCB.read_bytes() != original:
raise RuntimeError("PCB changed or reopened; refusing overwrite")
assert all(path.read_bytes() == data for path, data in settings.items()), "Project settings changed"
result.replace(PCB)
print("Removed draft presentation labels; exact preservation and idempotence passed.")
if __name__ == "__main__":
main()
+175 -123
View File
@@ -8,9 +8,9 @@
)
(paper "A4")
(title_block
(title "Serial Swiss Army Knife - 82x80 mm carrier draft")
(rev "A1 DRAFT")
(comment 1 "NOT FOR FABRICATION - USB / antenna geometry pending; verify module pinout / assembly")
(title "Serial Swiss Army Knife - 82x80 mm carrier")
(rev "A1")
(comment 1 "USB / antenna geometry pending; verify module pinout / assembly")
)
(layers
(0 "F.Cu" signal)
@@ -562,6 +562,70 @@
(sheetfile "serial-carrier.kicad_sch")
(attr through_hole)
(duplicate_pad_numbers_are_jumpers no)
(fp_line
(start 2.795 54.705)
(end 11.665 54.705)
(stroke
(width 0.3)
(type solid)
)
(layer "F.Fab")
(uuid "6434f4c9-cb7e-5b60-902d-bd13b5c57f13")
)
(fp_line
(start 14.195 54.705)
(end 23.065 54.705)
(stroke
(width 0.3)
(type solid)
)
(layer "F.Fab")
(uuid "a4082d84-0a5f-5edd-b97f-afbb68b73336")
)
(fp_rect
(start -1.365 -1.365)
(end 26.725 54.705)
(stroke
(width 0.1)
(type solid)
)
(fill no)
(layer "F.Fab")
(uuid "53b4b62c-705f-584c-9987-8e20cbcb0eae")
)
(fp_rect
(start 4.045 -8.255)
(end 21.675 -1.365)
(stroke
(width 0.1)
(type solid)
)
(fill no)
(layer "F.Fab")
(uuid "1a629168-d420-520b-820e-a4de5828228b")
)
(fp_text user "ANTENNA BOUNDS - NOT RF KEEPOUT"
(at 12.7 -9.5 0)
(layer "F.Fab")
(uuid "acabe363-725d-5486-8c68-8e06b5758ceb")
(effects
(font
(size 0.8 0.8)
(thickness 0.12)
)
)
)
(fp_text user "USB SHELL MOUTHS ONLY"
(at 12.7 59 0)
(layer "F.Fab")
(uuid "4af1d9b9-5ec1-5ef3-a72d-b72b93ecc391")
(effects
(font
(size 0.8 0.8)
(thickness 0.12)
)
)
)
(pad "1" thru_hole rect
(at 0 0)
(size 1.7 1.7)
@@ -3025,8 +3089,8 @@
(embedded_fonts no)
)
(gr_line
(start 106.8 121.8)
(end 109.2 121.8)
(start 106.635 121.635)
(end 112.045 121.635)
(stroke
(width 0.12)
(type default)
@@ -3035,78 +3099,8 @@
(uuid "cdd870c2-6daf-560d-a619-6903566202a1")
)
(gr_line
(start 106.8 177.54)
(end 106.8 121.8)
(stroke
(width 0.12)
(type default)
)
(layer "F.SilkS")
(uuid "1576658b-4eb9-5105-9817-e374c6ef271e")
)
(gr_line
(start 109.2 121.8)
(end 109.2 177.54)
(stroke
(width 0.12)
(type default)
)
(layer "F.SilkS")
(uuid "8f746121-d8e6-5a89-9328-da46f2da8ac0")
)
(gr_line
(start 109.2 177.54)
(end 106.8 177.54)
(stroke
(width 0.12)
(type default)
)
(layer "F.SilkS")
(uuid "bfbd0b33-54e8-590f-87da-4935db08a121")
)
(gr_line
(start 132.2 121.8)
(end 134.6 121.8)
(stroke
(width 0.12)
(type default)
)
(layer "F.SilkS")
(uuid "fef8b02b-8a5c-5e27-8e75-fc116ddbd526")
)
(gr_line
(start 132.2 136.509273)
(end 132.2 121.8)
(stroke
(width 0.12)
(type default)
)
(layer "F.SilkS")
(uuid "abb27fb8-ae0c-56d1-9e35-c66b92681547")
)
(gr_line
(start 132.2 162.671905)
(end 132.2 137.390726)
(stroke
(width 0.12)
(type default)
)
(layer "F.SilkS")
(uuid "03740f02-7c63-574b-ad1d-eeaa2c77d2ea")
)
(gr_line
(start 132.2 170.202783)
(end 132.2 163.828094)
(stroke
(width 0.12)
(type default)
)
(layer "F.SilkS")
(uuid "2cecac83-165e-5e49-a695-c0b09f3b631c")
)
(gr_line
(start 132.2 177.54)
(end 132.2 171.397216)
(start 106.635 177.705)
(end 106.635 121.635)
(stroke
(width 0.12)
(type default)
@@ -3115,48 +3109,48 @@
(uuid "5473bcda-98ee-5a28-9fda-f27d137d534c")
)
(gr_line
(start 134.6 121.8)
(end 134.6 130.421905)
(start 110.795 177.705)
(end 106.635 177.705)
(stroke
(width 0.12)
(type default)
)
(layer "F.SilkS")
(uuid "0445e79c-43e4-5609-90ad-67bffdf64341")
(uuid "2441022c-c903-5a57-a84c-86f0a5143cb5")
)
(gr_line
(start 134.6 131.578094)
(end 134.6 140.571905)
(start 110.795 177.705)
(end 119.665 177.705)
(stroke
(width 0.3)
(type default)
)
(layer "F.SilkS")
(uuid "abb27fb8-ae0c-56d1-9e35-c66b92681547")
)
(gr_line
(start 112.045 121.635)
(end 112.045 119.28)
(stroke
(width 0.12)
(type default)
)
(layer "F.SilkS")
(uuid "6405d812-d377-52d9-8013-8c19bd4cdc2b")
(uuid "03740f02-7c63-574b-ad1d-eeaa2c77d2ea")
)
(gr_line
(start 134.6 141.728094)
(end 134.6 160.952783)
(start 112.045 121.635)
(end 129.675 121.635)
(stroke
(width 0.12)
(type default)
)
(layer "F.SilkS")
(uuid "f938fe4f-5ff2-577d-9bff-2827fb32ce22")
(uuid "8f746121-d8e6-5a89-9328-da46f2da8ac0")
)
(gr_line
(start 134.6 162.147216)
(end 134.6 169.559273)
(stroke
(width 0.12)
(type default)
)
(layer "F.SilkS")
(uuid "d472d222-21c6-5a78-ab7f-18799bceab8a")
)
(gr_line
(start 134.6 170.440726)
(end 134.6 177.54)
(start 122.195 177.705)
(end 119.665 177.705)
(stroke
(width 0.12)
(type default)
@@ -3165,14 +3159,94 @@
(uuid "bfa82c3d-e8e2-58ea-8b46-f0db41e84458")
)
(gr_line
(start 134.6 177.54)
(end 132.2 177.54)
(start 122.195 177.705)
(end 131.065 177.705)
(stroke
(width 0.3)
(type default)
)
(layer "F.SilkS")
(uuid "2b9b8e99-a473-51bc-8241-448388f63fcb")
)
(gr_line
(start 129.675 119.28)
(end 129.675 121.635)
(stroke
(width 0.12)
(type default)
)
(layer "F.SilkS")
(uuid "2441022c-c903-5a57-a84c-86f0a5143cb5")
(uuid "2cecac83-165e-5e49-a695-c0b09f3b631c")
)
(gr_line
(start 129.675 121.635)
(end 134.725 121.635)
(stroke
(width 0.12)
(type default)
)
(layer "F.SilkS")
(uuid "bfbd0b33-54e8-590f-87da-4935db08a121")
)
(gr_line
(start 134.725 121.635)
(end 134.725 130.382575)
(stroke
(width 0.12)
(type default)
)
(layer "F.SilkS")
(uuid "1576658b-4eb9-5105-9817-e374c6ef271e")
)
(gr_line
(start 134.725 131.617424)
(end 134.725 140.532575)
(stroke
(width 0.12)
(type default)
)
(layer "F.SilkS")
(uuid "fef8b02b-8a5c-5e27-8e75-fc116ddbd526")
)
(gr_line
(start 134.725 141.767424)
(end 134.725 160.924667)
(stroke
(width 0.12)
(type default)
)
(layer "F.SilkS")
(uuid "0445e79c-43e4-5609-90ad-67bffdf64341")
)
(gr_line
(start 134.725 162.175332)
(end 134.725 169.460337)
(stroke
(width 0.12)
(type default)
)
(layer "F.SilkS")
(uuid "6405d812-d377-52d9-8013-8c19bd4cdc2b")
)
(gr_line
(start 134.725 170.539662)
(end 134.725 177.705)
(stroke
(width 0.12)
(type default)
)
(layer "F.SilkS")
(uuid "f938fe4f-5ff2-577d-9bff-2827fb32ce22")
)
(gr_line
(start 134.725 177.705)
(end 131.065 177.705)
(stroke
(width 0.12)
(type default)
)
(layer "F.SilkS")
(uuid "d472d222-21c6-5a78-ab7f-18799bceab8a")
)
(gr_line
(start 141.095 100.28)
@@ -4050,17 +4124,6 @@
(layer "Edge.Cuts")
(uuid "17ad1955-d7db-47eb-a736-baff736aac39")
)
(gr_text "HEADER GUIDES"
(at 120.7 151 0)
(layer "F.SilkS")
(uuid "2b9b8e99-a473-51bc-8241-448388f63fcb")
(effects
(font
(size 0.85 0.85)
(thickness 0.12)
)
)
)
(gr_text "BACK"
(at 146.25 175 0)
(layer "F.SilkS")
@@ -4072,17 +4135,6 @@
)
)
)
(gr_text "DRAFT - NOT FOR FAB"
(at 156 178 0)
(layer "F.SilkS")
(uuid "875ba70f-7139-4924-a69c-d2e171f3f526")
(effects
(font
(size 0.9 0.9)
(thickness 0.15)
)
)
)
(gr_text "SELECT"
(at 158.25 175 0)
(layer "F.SilkS")
+91
View File
@@ -0,0 +1,91 @@
#!/usr/bin/env python3
"""Synchronize measured U1 F.Fab graphics only; never replace the footprint."""
from pathlib import Path
import uuid
import pcbnew as p
ROOT = Path(__file__).resolve().parent
PCB = ROOT / "serial-carrier.kicad_pcb"
NS = uuid.UUID("36693251-e1f8-44e9-a8a9-e201bd658ceb")
def library_graphics():
fp = p.FootprintLoad(str(ROOT / "Carrier.pretty"), "HW678_2x22_Provisional")
if fp is None:
raise RuntimeError("Cannot load measured HW678 footprint")
return fp
def signature(g, origin):
def point(v):
return v.x-origin.x, v.y-origin.y
if isinstance(g, p.PCB_SHAPE):
return ("shape", g.GetShape(), point(g.GetStart()), point(g.GetEnd()), g.GetWidth())
return ("text", g.GetText(), point(g.GetPosition()),
(g.GetTextSize().x, g.GetTextSize().y), g.GetTextThickness())
def measured_items(fp):
# Generic library captions would overlap existing board labels.
return sorted((g for g in fp.GraphicalItems() if g.GetLayer() == p.F_Fab
and not (isinstance(g, p.PCB_TEXT) and g.GetText() in (
"PROVISIONAL CONNECTOR FIT", "ANTENNA UP / USB DOWN"))),
key=lambda g: signature(g, fp.GetPosition()))
def verify(board):
u1 = next(f for f in board.GetFootprints() if f.GetReference() == "U1")
assert u1.GetOrientationDegrees() == 0 and u1.GetLayer() == p.F_Cu
assert len([g for g in u1.GraphicalItems() if g.GetLayer() == p.F_Fab]) == 6
library = library_graphics()
expected = [signature(g, library.GetPosition()) for g in measured_items(library)]
actual = [signature(g, u1.GetPosition()) for g in measured_items(u1)]
assert actual == expected, "Embedded U1 F.Fab differs from measured library; run update_u1_graphics.py"
def main():
lock = ROOT / "~serial-carrier.kicad_pcb.lck"
if lock.exists():
raise SystemExit("Close PCB Editor before updating U1 graphics")
before = PCB.read_bytes()
project = ROOT / "serial-carrier.kicad_pro"
project_before = project.read_bytes()
board = p.LoadBoard(str(PCB))
u1 = next(f for f in board.GetFootprints() if f.GetReference() == "U1")
assert u1.GetOrientationDegrees() == 0 and u1.GetLayer() == p.F_Cu
library = library_graphics()
sources = measured_items(library)
assert len(sources) == 6, "Review changed measured library before integration"
owned = {str(uuid.uuid5(NS, str(i))) for i in range(len(sources))}
existing = list(u1.GraphicalItems())
assert all(g.m_Uuid.AsString() in owned for g in existing if g.GetLayer() == p.F_Fab), "Unowned U1 fabrication graphics; review manually"
if [signature(g, u1.GetPosition()) for g in measured_items(u1)] == [
signature(g, library.GetPosition()) for g in sources]:
verify(board)
print("Embedded measured U1 graphics already current; no board write")
return
for g in existing:
if g.m_Uuid.AsString() in owned:
u1.Remove(g)
for i, source in enumerate(sources):
graphic = source.Duplicate()
graphic.SetParent(u1)
graphic.SetUuid(p.KIID(str(uuid.uuid5(NS, str(i)))))
graphic.Move(u1.GetPosition() - library.GetPosition())
u1.Add(graphic)
verify(board)
temp = ROOT / "validation/pcb-u1-tmp.kicad_pcb"
try:
assert p.SaveBoard(str(temp), board, True)
verify(p.LoadBoard(str(temp)))
if lock.exists() or PCB.read_bytes() != before:
raise RuntimeError("PCB changed or reopened; refusing to overwrite")
assert project.read_bytes() == project_before, "Project settings changed"
temp.replace(PCB)
finally:
temp.unlink(missing_ok=True)
print("Embedded measured U1 graphics; existing footprint fields, pads and routing retained")
if __name__ == "__main__":
main()
+17 -3
View File
@@ -117,9 +117,23 @@ class FootprintRegression(unittest.TestCase):
for row, x in enumerate((0, 25.4)) for index in range(22)
}
self.check_header(footprint, positions)
self.assertFalse(any(isinstance(g, k.PCB_SHAPE)
for g in footprint.GraphicalItems()),
"HW678 must not acquire guessed outlines or envelopes")
shapes = [g for g in footprint.GraphicalItems() if isinstance(g, k.PCB_SHAPE)]
expected = [
(k.SHAPE_T_RECT, mm(-1.365, -1.365), mm(26.725, 54.705), k.FromMM(0.1)),
(k.SHAPE_T_RECT, mm(4.045, -8.255), mm(21.675, -1.365), k.FromMM(0.1)),
(k.SHAPE_T_SEGMENT, mm(2.795, 54.705), mm(11.665, 54.705), k.FromMM(0.3)),
(k.SHAPE_T_SEGMENT, mm(14.195, 54.705), mm(23.065, 54.705), k.FromMM(0.3)),
]
self.assertEqual(Counter((g.GetShape(), xy(g.GetStart()), xy(g.GetEnd()),
g.GetWidth()) for g in shapes), Counter(expected))
for shape in shapes:
self.assertEqual(shape.GetLayer(), k.F_Fab)
self.assertFalse(shape.IsAnyFill())
# Only measured planar bounds/mouth spans: no invented shell depth or RF area.
self.assertAlmostEqual(26.725 - (-1.365), 28.09)
self.assertAlmostEqual(54.705 - (-1.365), 56.07)
self.assertAlmostEqual(54.705 - (-8.255), 62.96)
self.assertAlmostEqual(26.725 - 25.4, 1.325)
if __name__ == "__main__":
+3 -1
View File
@@ -10,6 +10,7 @@ from pathlib import Path
import subprocess
import xml.etree.ElementTree as ET
import pcbnew as p
from update_u1_graphics import verify
ROOT=Path(__file__).resolve().parent
PCB=ROOT/"serial-carrier.kicad_pcb"
@@ -21,6 +22,7 @@ def run(*args):
run("kicad-cli","sch","export","netlist","--format","kicadxml","-o",str(OUT/"pcb-source-netlist.xml"),str(ROOT/"serial-carrier.kicad_sch"))
run("kicad-cli","pcb","drc","--format","json","--all-track-errors","--severity-all","--schematic-parity","-o",str(OUT/"pcb-parity-drc.json"),str(PCB))
board=p.LoadBoard(str(PCB))
verify(board)
source=ET.parse(OUT/"pcb-source-netlist.xml").getroot()
comps={c.get("ref"):c for c in source.findall("components/comp")}
footprints={f.GetReference():f for f in board.GetFootprints()}
@@ -121,7 +123,7 @@ summary={
"known_parity_warning":"U2 reference footprint assigned on PCB only; schematic footprint intentionally blank; nominal male external metal dimensions user-confirmed",
"project_settings_sha256":hashlib.sha256((ROOT/'serial-carrier.kicad_pro').read_bytes()).hexdigest(),
"ignored_DRC_checks":report["ignored_checks"],
"not_validated":["male module pin map/header fit", "ESP32 geometry and complete assembly envelope/heights", "header/socket fit", "RF performance and antenna clearance", "power/return-path integrity", "USB power path", "OLED pull-ups", "fabrication process", "hardware operation"],
"not_validated":["male module pin map/header fit", "ESP32 measured-planar 1:1 fit and complete assembly envelope/heights", "header/socket fit", "RF performance and antenna clearance", "power/return-path integrity", "USB power path", "OLED pull-ups", "fabrication process", "hardware operation"],
}
(OUT/"pcb-validation-summary.json").write_text(json.dumps(summary,indent=2)+"\n")
print("PCB draft verified: 82 x 80 mm; 2 copper layers; 0 physical DRC violations; 0 unrouted.")
+108
View File
@@ -0,0 +1,108 @@
#!/usr/bin/env python3
"""Measured U1 integration regression and exact pre-update preservation audit."""
import hashlib
import json
from pathlib import Path
import re
import tempfile
import uuid
import pcbnew as p
from shapely.geometry import Polygon
from add_device_silkscreen import add_outlines, NS as SILK_NS, xy, outline_lines
from update_u1_graphics import verify, NS as U1_NS
from remove_draft_markers import expected_tree
ROOT = Path(__file__).resolve().parent
PCB = ROOT / "serial-carrier.kicad_pcb"
BASE = ROOT / "validation/pcb-before-u1-measured.kicad_pcb"
def parse(path):
tokens = iter(re.findall(r'"(?:\\.|[^"\\])*"|[()]|[^\s()]+', path.read_text()))
def group():
result = []
for token in tokens:
if token == ')':
return result
result.append(group() if token == '(' else token)
raise ValueError("Unterminated S-expression")
assert next(tokens) == '('
return group()
def ident(item):
return next((v[1].strip('"') for v in item if isinstance(v,list) and v[0]=='uuid'), None)
def canonical(item, excluded=frozenset()):
# KiCad can reorder independent board items when saving; compare their full
# parsed contents, not byte order. Pad coordinates/netnames/UUIDs remain exact.
if not isinstance(item, list):
return item
values = [canonical(v, excluded) for v in item
if not isinstance(v,list) or ident(v) not in excluded]
if item and item[0] in ("kicad_pcb", "footprint"):
return ([v for v in values if not isinstance(v,list)] +
sorted((v for v in values if isinstance(v,list)), key=repr))
# Coordinates, polygon vertex order, text and other ordered values must
# remain ordered: sorting these could conceal an X/Y swap or zone edit.
return values
def main():
assert hashlib.sha256(BASE.read_bytes()).hexdigest() == "53e36538c0669604457b9e1a9ff1b1e20422970e8f9f356088c4581868ad5045", "Pre-update snapshot changed"
assert hashlib.sha256((ROOT/"serial-carrier.kicad_pro").read_bytes()).hexdigest() == "9ad5b992785ebfe01fa7ea4e1aa391a2c7d7f1f2c3b1c13136173c301ecb6516", "Project settings changed"
before = PCB.read_bytes()
board = p.LoadBoard(str(PCB))
verify(board)
silk_ids = {str(uuid.uuid5(SILK_NS,str(i))) for i in range(4096)}
u1_ids = {str(uuid.uuid5(U1_NS,str(i))) for i in range(6)}
# The original baseline retains historical draft labels. Permit only the
# exact requested title/revision/comment edits and removal of the silk label.
assert canonical(expected_tree(parse(BASE)), silk_ids) == canonical(parse(PCB), silk_ids | u1_ids), "Change outside owned U1 additions / device silk / exact presentation labels"
generated = [g for g in board.GetDrawings() if g.m_Uuid.AsString() in silk_ids]
assert not any(isinstance(g,p.PCB_TEXT) and g.GetText()=="HEADER GUIDES" for g in board.GetDrawings())
carrier = Polygon([(136,100),(182,100),(182,180),(100,180),(100,119),(136,119)])
mouths = []
for g in generated:
assert g.GetLayer() == p.F_SilkS
for edge in outline_lines(g):
assert carrier.covers(edge.buffer(p.ToMM(g.GetWidth())/2)), "Silk outside carrier/notch"
if g.GetWidth() == p.FromMM(0.3):
mouths.append(g)
assert len(mouths) == 2
assert sorted((xy(g.GetStart()),xy(g.GetEnd())) for g in mouths) == [
((110.795,177.705),(119.665,177.705)),
((122.195,177.705),(131.065,177.705))]
# A second refresh must reproduce all drawing geometry and UUIDs.
add_outlines(board)
with tempfile.TemporaryDirectory(prefix="u1-regression-") as temporary:
result = Path(temporary)/PCB.name
assert p.SaveBoard(str(result),board,True)
assert canonical(parse(result)) == canonical(parse(PCB)), "Silkscreen refresh not idempotent"
u1 = next(f for f in board.GetFootprints() if f.GetReference()=="U1")
shape = next(g for g in u1.GraphicalItems() if isinstance(g,p.PCB_SHAPE))
shape.SetWidth(shape.GetWidth()+1)
try:
verify(board)
except AssertionError:
pass
else:
raise AssertionError("Stale embedded geometry was not rejected")
assert PCB.read_bytes() == before
assert not (ROOT/"~serial-carrier.kicad_pcb.lck").exists()
summary = dict(measured_library_matches_embedded_u1=True,
existing_uuids_pads_nets_placement_routing_settings_and_unrelated_graphics_preserved=True,
presentation_label_cleanup=True,
silk_refresh_idempotent=True, stale_geometry_negative_control=True,
silk_clipped_to_carrier=True, usb_mouth_markers=2,
generated_silk_items=len(generated),
baseline_sha256=hashlib.sha256(BASE.read_bytes()).hexdigest(),
board_sha256=hashlib.sha256(before).hexdigest(),
project_sha256=hashlib.sha256((ROOT/"serial-carrier.kicad_pro").read_bytes()).hexdigest())
(ROOT/"validation/pcb-u1-update.json").write_text(json.dumps(summary,indent=2)+"\n")
print(json.dumps(summary,indent=2))
if __name__ == "__main__":
main()
File diff suppressed because it is too large Load Diff
Binary file not shown.

Before

Width:  |  Height:  |  Size: 100 KiB

After

Width:  |  Height:  |  Size: 231 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 283 KiB

After

Width:  |  Height:  |  Size: 345 KiB

File diff suppressed because it is too large Load Diff

Before

Width:  |  Height:  |  Size: 175 KiB

After

Width:  |  Height:  |  Size: 368 KiB

+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://schemas.kicad.org/drc.v1.json",
"coordinate_units": "mm",
"date": "2026-09-21T11:39:10",
"date": "2026-09-21T22:52:26",
"ignored_checks": [
{
"description": "Footprint has no courtyard defined",
@@ -1,6 +1,6 @@
{
"immutable_geometry_nets_pads_linkage_preserved": true,
"board_sha256": "53e36538c0669604457b9e1a9ff1b1e20422970e8f9f356088c4581868ad5045",
"board_sha256": "94e941625b3b9fc9a08b9f2d532eb35003ea8da41d456a1c67269b9651200e05",
"independent_min_different_net_or_npth_clearance_mm": 0.2705441169852849,
"closest_items": [
"U1.38:4b8eabef-8493-437d-bd02-cb9ff4afd7cd",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://schemas.kicad.org/drc.v1.json",
"coordinate_units": "mm",
"date": "2026-09-21T11:39:13",
"date": "2026-09-21T22:33:53",
"ignored_checks": [
{
"description": "Footprint has no courtyard defined",
@@ -2,7 +2,7 @@
<export version="E">
<design>
<source>/home/mscholz/Repos/ESP32_serial_swiss_army_knife/hardware/PCB/serial-carrier.kicad_sch</source>
<date>2026-09-21T11:39:08</date>
<date>2026-09-21T22:52:19</date>
<tool>Eeschema 10.0.6-1.fc44</tool>
<sheet number="1" name="/" tstamps="/">
<title_block>
@@ -0,0 +1,13 @@
{
"measured_library_matches_embedded_u1": true,
"existing_uuids_pads_nets_placement_routing_settings_and_unrelated_graphics_preserved": true,
"presentation_label_cleanup": true,
"silk_refresh_idempotent": true,
"stale_geometry_negative_control": true,
"silk_clipped_to_carrier": true,
"usb_mouth_markers": 2,
"generated_silk_items": 91,
"baseline_sha256": "53e36538c0669604457b9e1a9ff1b1e20422970e8f9f356088c4581868ad5045",
"board_sha256": "b9e020939fff42b5c7a3161ed89af9dc3c1b9c016268311b3db7ab407ed67192",
"project_sha256": "9ad5b992785ebfe01fa7ea4e1aa391a2c7d7f1f2c3b1c13136173c301ecb6516"
}
@@ -130,7 +130,7 @@
],
"not_validated": [
"male module pin map/header fit",
"ESP32 geometry and complete assembly envelope/heights",
"ESP32 measured-planar 1:1 fit and complete assembly envelope/heights",
"header/socket fit",
"RF performance and antenna clearance",
"power/return-path integrity",