Files
sandTable/pcb/tools/sexpr.py
T
ckochandClaude Opus 5.5 1400cc8afd Controller board: ESP32 + 2x StepStick carrier (v0.4), FluidNC config
- KiCad project generated by pcb/tools (design, route, schematic, fab outputs).
- v0.4 fixes the v0.3 review findings: low-dropout 3.3 V regulator, OR'd 5 V
  supply, polyfuse, USB ESD protection, input clamps, ceramic decoupling at the
  drivers, A4988/DRV8825 jumper, ground stitching. DRC/ERC clean.
- v0.3 (as ordered) kept in pcb/archive/v0.3.
- FluidNC config.yaml for the board; rho hard limit on.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 13:40:23 -05:00

101 lines
2.7 KiB
Python

"""Tiny S-expression reader/writer for KiCad files.
Lists are Python lists; atoms are str. Quoted strings are kept as QStr so they
round-trip with quotes. Numbers stay as plain str atoms (KiCad cares about text).
"""
class QStr(str):
"""A string that was quoted in the source (and must be written quoted)."""
def parse(text):
pos = 0
n = len(text)
stack = [[]]
while pos < n:
c = text[pos]
if c in ' \t\r\n':
pos += 1
elif c == '(':
stack.append([])
pos += 1
elif c == ')':
done = stack.pop()
stack[-1].append(done)
pos += 1
elif c == '"':
pos += 1
buf = []
while True:
ch = text[pos]
if ch == '\\':
buf.append(text[pos:pos + 2])
pos += 2
continue
if ch == '"':
pos += 1
break
buf.append(ch)
pos += 1
raw = ''.join(buf)
# keep escapes as-is (we re-emit them unchanged)
stack[-1].append(QStr(raw))
else:
start = pos
while pos < n and text[pos] not in ' \t\r\n()"':
pos += 1
stack[-1].append(text[start:pos])
assert len(stack) == 1
return stack[0][0] if len(stack[0]) == 1 else stack[0]
def dumps(node, indent=0):
"""Serialize back to KiCad-style text (one sub-list per line when nested)."""
if isinstance(node, QStr):
return '"' + str(node) + '"'
if isinstance(node, str):
return node
if isinstance(node, (int, float)):
return fmt(node)
# list
simple = all(not isinstance(x, list) for x in node)
if simple:
return '(' + ' '.join(dumps(x) for x in node) + ')'
pad = ' ' * (indent + 1)
parts = []
head = []
i = 0
while i < len(node) and not isinstance(node[i], list):
head.append(dumps(node[i]))
i += 1
out = '(' + ' '.join(head)
for x in node[i:]:
if isinstance(x, list):
out += '\n' + pad + dumps(x, indent + 1)
else:
out += ' ' + dumps(x)
out += '\n' + ' ' * indent + ')'
return out
def fmt(v):
if isinstance(v, str):
return v
s = f'{v:.4f}'.rstrip('0').rstrip('.')
if s in ('-0', ''):
s = '0'
return s
def find(node, key):
"""First child list whose head is key."""
for x in node:
if isinstance(x, list) and x and x[0] == key:
return x
return None
def findall(node, key):
return [x for x in node if isinstance(x, list) and x and x[0] == key]