#!/usr/bin/env python3 """S-expression based PCB editor. Safely replaces nets, board outline, title.""" import re, os class SExpr: """Simple S-expression node.""" def __init__(self, type_name, children=None, value=None): self.type = type_name self.children = children or [] self.value = value # For leaf nodes: their string content def __repr__(self): if self.children: return f'({self.type} {" ".join(repr(c) for c in self.children)})' return f'({self.type} {self.value})' if self.value else f'({self.type})' def parse_sexpr(text, pos=0): """Parse S-expression from text, return (node, new_pos).""" text = text.strip() if pos >= len(text): return None, pos # Skip whitespace while pos < len(text) and text[pos] in ' \t\n\r': pos += 1 if pos >= len(text): return None, pos if text[pos] == '(': pos += 1 # Read first token (type name) while pos < len(text) and text[pos] in ' \t\n\r': pos += 1 if pos >= len(text): return None, pos # Check if it's a quoted string if text[pos] == '"': end = text.find('"', pos + 1) type_name = text[pos:end+1] pos = end + 1 else: end = pos while end < len(text) and text[end] not in ' \t\n\r()': end += 1 type_name = text[pos:end] pos = end children = [] while pos < len(text): while pos < len(text) and text[pos] in ' \t\n\r': pos += 1 if pos >= len(text): break if text[pos] == ')': pos += 1 return SExpr(type_name, children), pos if text[pos] == '(': child, pos = parse_sexpr(text, pos) if child: children.append(child) elif text[pos] == '"': end = text.find('"', pos + 1) children.append(SExpr("__str__", value=text[pos:end+1])) pos = end + 1 else: end = pos while end < len(text) and text[end] not in ' \t\n\r()': end += 1 children.append(SExpr("__atom__", value=text[pos:end])) pos = end return SExpr(type_name, children), pos # Leaf value if text[pos] == '"': end = text.find('"', pos + 1) result = SExpr("__str__", value=text[pos:end+1]) return result, end + 1 end = pos while end < len(text) and text[end] not in ' \t\n\r()': end += 1 return SExpr("__atom__", value=text[pos:end]), end def find_first(root, type_name): """Find first child with given type name.""" for c in root.children: if c.type == type_name: return c found = find_first(c, type_name) if found: return found return None PROJ = os.path.expanduser("~/Documents/KiCad/10.0/esp32-s3-sensor-node") TEMPLATE = os.path.expanduser( "~/Documents/KiCad/10.0/USB_PD_ESP32S3/USB_PD_ESP32S3.kicad_pcb") PCB = os.path.join(PROJ, "esp32-s3-sensor-node.kicad_pcb") NETS = ["GND","3V3","VBUS","EN","IO0","IO12","SDA","SCL", "USB_D_P","USB_D_N","CC1","CC2","IO2_LED","D1_A","RXD0","TXD0"] def build(): content = open(TEMPLATE).read() # Simple text-based replacement that's safe # 1. Replace title content = re.sub(r'\(title "[^"]*"', '(title "ESP32-S3 Sensor Node"', content) # 2. Replace date content = re.sub(r'\(date "[^"]*"', '(date "2026-06-22"', content) # 3. Find the nets section and replace it net_start = content.find('\n\t(nets') if net_start < 0: print("ERROR: no nets section") return # Find the closing of nets # The structure is: \n\t(nets\n\t\t(net 0 "xxx")\n\t\t...\n\t) net_end = content.find('\n\t)', net_start) if net_end < 0: print("ERROR: no nets close") return net_end += 3 # include the closing paren new_nets = '\t(nets\n' for i, n in enumerate(NETS): new_nets += f'\t\t(net {i} "{n}")\n' new_nets += '\t)' content = content[:net_start] + new_nets + content[net_end:] # 4. Replace net classes # Find first net_class line nc_start = content.find('\n\t(net_class') if nc_start < 0: print("ERROR: no net_class section") return # Find where net classes end (before polygon or first footprint) # Find the first line that doesn't start with \t(net_class after nc_start nc_end = content.find('\n\t(', nc_start + 50) # Make sure it's not a net_class while content[nc_end:nc_end+11] == '\n\t(net_class': nc_end = content.find('\n\t(', nc_end + 1) net_class_def = '''\t(net_class "Default" (clearance 0.15) (trace_width 0.2) \t\t(via_dia 0.6) (via_drill 0.3) \t\t(add_net "*")) \t(net_class "POWER" (clearance 0.25) (trace_width 0.5) \t\t(via_dia 0.8) (via_drill 0.4) \t\t(add_net "3V3") (add_net "VBUS")) \t(net_class "USB" (clearance 0.15) (trace_width 0.2) \t\t(via_dia 0.6) (via_drill 0.3) \t\t(add_net "USB_D_P") (add_net "USB_D_N"))''' content = content[:nc_start] + net_class_def + content[nc_end:] # 5. Replace board outline # Find (polygon (layer "Edge.Cuts") edge_start = content.find('(layer "Edge.Cuts")') if edge_start < 0: print("ERROR: no Edge.Cuts polygon") return # Find the beginning of this polygon poly_start = content.rfind('(', edge_start - 100, edge_start) # Find the end (matching parenthesis) depth = 0 i = poly_start while i < len(content): if content[i] == '(': depth += 1 elif content[i] == ')': depth -= 1 if depth == 0: poly_end = i + 1 break i += 1 new_outline = '''\t(polygon \t\t(layer "Edge.Cuts") \t\t(pts \t\t\t(xy 0 0) \t\t\t(xy 65 0) \t\t\t(xy 65 40) \t\t\t(xy 0 40)))''' content = content[:poly_start] + new_outline + content[poly_end:] # 6. Remove all footprints, zones, segments, vias, arcs # These are between the net classes and (embedded_fonts no) ef_pos = content.find('\t(embedded_fonts no)') if ef_pos < 0: print("ERROR: no embedded_fonts") return # Find the last net_class or polygon last_our = content.find('(xy 0 40)))', poly_start) last_our_end = content.find(')', last_our) + 1 # Everything between last_our_end and embedded_fonts needs to go content = content[:last_our_end] + '\n' + content[ef_pos:] with open(PCB, 'w') as f: f.write(content) print(f"Written: {len(content)} bytes") if __name__ == "__main__": build()