Drogheda/scripts/2026-09-17_v6_T13_sim_dual_footprint_part2.py

492 lines
20 KiB
Python

#!/usr/bin/env python3
"""v6 T13 - SIM block part 2: nano-SIM holder alternate + 0R branch selects.
WHAT AND WHY
------------
T12 gave U22 (MFF2 soldered eSIM) a real footprint. This step adds the *alternate*
SIM path so the board can be built either way without a respin:
U19 --- SIM_* --- [ESD D23/D24/D25 + C126] ---+--- R127..R130 --- SIMA_* --- U22 (MFF2)
|
+--- R131..R134 --- SIMB_* --- J14 (card holder)
Exactly ONE branch is populated. The shared modem-side nets keep the name `SIM_*`, so
the copper Michelle already routed (U19 -> ESD, ~17.5 mm) is untouched: only the new
`SIMA_*` / `SIMB_*` branches downstream of the selects are new nets.
Holder = JAE SF72S006VBDR2500, LCSC C2977286 (nano-SIM, push-push, 1.25 mm, -25..+85 C).
Footprint is KiCad's own KLC-reviewed `JAE_SIM_Card_SF72S006`, vendored into Drogheda.pretty
so the project does not depend on each machine's library version. Its pads are named
C1 C2 C3 C5 C6 C7 / CSW / DSW / SH - so the symbol's pin NUMBERS are those names, not 1-8.
DELIBERATELY NOT DONE: `U19 USIM1_DET` stays unconnected. With the holder DNP the detect
switch is open, so DET would float or read "no card" and can stop the A7670SA attaching -
that would risk the PRIMARY (MFF2) path to instrument the FALLBACK path. CSW/DSW are
no-connect; the SH shell pads go to GND.
METHOD: every new s-expression is produced by CLONING a known-good block from the same
file and substituting fields - never authored from scratch. Guarantees format validity.
LESSONS APPLIED (drogheda-v6-resync-protocol):
- no bare numeric regex anywhere; edits are scoped to paren-matched blocks
- hard guard: every PRE-EXISTING uuid must survive unchanged (new ones may appear)
- .T13bak written before any file is touched; dry-run is the default
- %.10g coordinate formatting, no exponent notation
Usage: python3 2026-09-17_v6_T13_sim_dual_footprint_part2.py [--apply]
"""
import os
import re
import shutil
import sys
import uuid as _uuid
HERE = os.path.dirname(os.path.abspath(__file__))
KI = os.path.join(HERE, '..', '03-Architecture-Hardware', 'kicad')
SYM = os.path.join(KI, 'Drogheda-sym.kicad_sym')
SCH = os.path.join(KI, 'hbox-watchdog-v6.kicad_sch')
FP_MFF2 = os.path.join(KI, 'Drogheda.pretty', 'MFF2_6x5.kicad_mod')
FP_JAE = os.path.join(KI, 'Drogheda.pretty', 'JAE_SIM_Card_SF72S006.kicad_mod')
APPLY = '--apply' in sys.argv
UUID_RE = re.compile(r'\(uuid "([^"]*)"\)')
SIGS = ['VDD', 'RST', 'CLK', 'DATA']
# reference -> (signal, branch) branch A = MFF2 (populated), B = holder (DNP)
SELECTS = {
'R127': ('VDD', 'A'), 'R128': ('RST', 'A'), 'R129': ('CLK', 'A'), 'R130': ('DATA', 'A'),
'R131': ('VDD', 'B'), 'R132': ('RST', 'B'), 'R133': ('CLK', 'B'), 'R134': ('DATA', 'B'),
}
COL_X = {'VDD': 310.0, 'RST': 335.4, 'CLK': 360.8, 'DATA': 386.2}
ROW_Y = {'A': 420.0, 'B': 450.0}
PIN_DY = 3.81 # Device:R pin offset from body centre
J14_AT = (340.0, 485.0)
R0_LCSC = 'C304185' # 0R 0603, -55..+155 C, 2962 in stock
HOLDER_SYM = 'NANOSIM_JAE'
HOLDER_FP = 'Drogheda:JAE_SIM_Card_SF72S006'
# JAE holder symbol pins: (number, name, electrical type, side, y in symbol space)
HOLDER_PINS = [
('C1', 'VCC', 'power_in', 'L', 7.62),
('C2', 'RST', 'input', 'L', 5.08),
('C3', 'CLK', 'input', 'L', 2.54),
('C5', 'GND', 'power_in', 'L', 0.0),
('C6', 'VPP', 'passive', 'L', -2.54),
('C7', 'IO', 'bidirectional', 'L', -5.08),
('SH', 'SHELL', 'passive', 'R', 5.08),
('CSW', 'CSW', 'passive', 'R', 2.54),
('DSW', 'DSW', 'passive', 'R', 0.0),
]
def die(msg):
sys.exit('REFUSING: ' + msg)
def g(v):
"""KiCad-safe float formatting - never exponent notation."""
if abs(v) < 1e-9:
v = 0.0
return '%.10g' % v
def bend(s, j):
"""End index (exclusive) of the s-expression opening at j. String-aware."""
depth = 0
k = j
instr = False
while k < len(s):
c = s[k]
if instr:
if c == '\\':
k += 2
continue
if c == '"':
instr = False
else:
if c == '"':
instr = True
elif c == '(':
depth += 1
elif c == ')':
depth -= 1
if depth == 0:
return k + 1
k += 1
die('unbalanced s-expression at offset %d' % j)
def paren_ok(s, label):
depth = 0
k = 0
instr = False
while k < len(s):
c = s[k]
if instr:
if c == '\\':
k += 2
continue
if c == '"':
instr = False
else:
if c == '"':
instr = True
elif c == '(':
depth += 1
elif c == ')':
depth -= 1
if depth < 0:
die('%s: unbalanced ")" at %d' % (label, k))
k += 1
if depth:
die('%s: %d unclosed "(" at EOF' % (label, depth))
if instr:
die('%s: unterminated string' % label)
def find_block(s, head_pred, start=0):
"""Yield (start,end,text) for each top-ish level (symbol ...) matching head_pred."""
for m in re.finditer(r'\(symbol(?=[\s(])', s[start:]):
j = start + m.start()
e = bend(s, j)
blk = s[j:e]
if head_pred(blk):
yield j, e, blk
def nu():
return str(_uuid.uuid4())
# ===================================================================== gate 0
print('== gate 0: preconditions ==')
for p in (SYM, SCH, FP_MFF2, FP_JAE):
if not os.path.isfile(p):
die('missing %s' % p)
print(' OK both footprints present (MFF2_6x5 + JAE_SIM_Card_SF72S006)')
jae = open(FP_JAE, encoding='utf-8').read()
paren_ok(jae, 'JAE_SIM_Card_SF72S006.kicad_mod')
jae_pads = set(re.findall(r'\(pad (\S+) ', jae))
need = {p[0] for p in HOLDER_PINS}
if not need <= jae_pads:
die('footprint pads %s do not cover symbol pins %s' % (sorted(jae_pads), sorted(need - jae_pads)))
print(' OK JAE footprint pads cover every symbol pin: %s' % ' '.join(sorted(need)))
sym_src = open(SYM, encoding='utf-8').read()
sch_src = open(SCH, encoding='utf-8').read()
paren_ok(sym_src, 'Drogheda-sym.kicad_sym')
paren_ok(sch_src, 'hbox-watchdog-v6.kicad_sch')
if 'Drogheda:MFF2_6x5' not in sch_src:
die('T12 has not been applied - run it first')
print(' OK T12 already applied')
taken = set(re.findall(r'\(property "Reference" "([A-Za-z]+\d+)"', sch_src))
clash = sorted((set(SELECTS) | {'J14'}) & taken)
already = ('NANOSIM_JAE' in sym_src)
if clash and not already:
die('designators already in use: %s' % clash)
# idempotency: if J14 already exists, this script has run
if 'J14' in taken:
print('\n== already at T13 state - nothing to do ==')
print('\nDRY RUN - nothing written.' if not APPLY else 'ALREADY APPLIED')
sys.exit(0)
# ============================================== step 1: U22 pin -> label rename
print('\n== step 1: re-point U22 from SIM_* to SIMA_* ==')
mff2_lib = None
for j, e, blk in find_block(sym_src, lambda b: b.startswith('(symbol "MFF2_SIM"')):
mff2_lib = blk
break
if mff2_lib is None:
die('MFF2_SIM library symbol not found')
# pin offsets from the library symbol, in SYMBOL space (+y up)
pin_off = {}
for pm in re.finditer(
r'\(pin \w+ \w+\s*\(at (-?[\d.]+) (-?[\d.]+) (-?[\d.]+)\)\s*\(length [\d.]+\)\s*'
r'\(name "([^"]*)".*?\(number "([^"]*)"', mff2_lib, re.S):
pin_off[pm.group(5)] = (float(pm.group(1)), float(pm.group(2)), pm.group(4))
if len(pin_off) != 8:
die('expected 8 MFF2_SIM pins, parsed %d' % len(pin_off))
u22 = None
for j, e, blk in find_block(sch_src, lambda b: '"Drogheda-sym:MFF2_SIM"' in b
and '(property "Reference" "U22"' in b):
u22 = (j, e, blk)
break
if u22 is None:
die('U22 instance not found')
am = re.search(r'\n\t\t\(at (-?[\d.]+) (-?[\d.]+)(?: (-?[\d.]+))?\)', u22[2])
ux, uy = float(am.group(1)), float(am.group(2))
urot = float(am.group(3) or 0)
if urot:
die('U22 is rotated %g deg - T13 only handles rotation 0' % urot)
print(' U22 at (%s, %s)' % (g(ux), g(uy)))
# schematic pin position = (X + px, Y - py) [symbol +y is up, sheet +y is down]
want = {}
for num, (px, py, pname) in pin_off.items():
if pname in ('VCC', 'RST', 'CLK', 'IO'):
sig = {'VCC': 'VDD', 'RST': 'RST', 'CLK': 'CLK', 'IO': 'DATA'}[pname]
want[(round(ux + px, 3), round(uy - py, 3))] = sig
labels = []
for m in re.finditer(r'\t\(label "([^"]+)"\s*\(at (-?[\d.]+) (-?[\d.]+)(?: (-?[\d.]+))?\)', sch_src):
labels.append((m.start(), m.group(1), round(float(m.group(2)), 3), round(float(m.group(3)), 3)))
# This schematic mixes two idioms: a label sitting directly ON a pin, and a label on the
# far end of a short WIRE whose other end lands on the pin (U22's VCC is the latter).
# So resolve each pin through the wire graph rather than by exact coincidence alone.
wires = []
for m in re.finditer(r'\(wire\s*\(pts\s*\(xy (-?[\d.]+) (-?[\d.]+)\)\s*\(xy (-?[\d.]+) (-?[\d.]+)\)', sch_src):
a, b, c, d = (round(float(v), 3) for v in m.groups())
wires.append(((a, b), (c, d)))
adj = {}
for p, q in wires:
adj.setdefault(p, set()).add(q)
adj.setdefault(q, set()).add(p)
def net_nodes(start):
"""Every coordinate electrically common with `start` via wires."""
seen, stack = {start}, [start]
while stack:
n = stack.pop()
for m2 in adj.get(n, ()):
if m2 not in seen:
seen.add(m2)
stack.append(m2)
return seen
hits, misses = {}, []
for (px, py), sig in want.items():
nodes = net_nodes((px, py))
found = [L for L in labels if (L[2], L[3]) in nodes and L[1] == 'SIM_' + sig]
if len(found) == 1:
via = 'on pin' if (found[0][2], found[0][3]) == (px, py) else 'via wire'
hits[sig] = (found[0][0], found[0][2], found[0][3], via)
else:
misses.append((sig, px, py, len(found), len(nodes)))
for sig, (pos, px, py, via) in sorted(hits.items()):
print(' OK SIM_%-4s @(%s, %s) %-10s -> SIMA_%s' % (sig, g(px), g(py), '(' + via + ')', sig))
for sig, px, py, n, nn in misses:
print(' !! SIM_%-4s U22 pin @(%s, %s): %d matching labels on its net (%d nodes)'
% (sig, g(px), g(py), n, nn))
if misses:
die('%d U22 pin(s) not resolved to exactly one SIM_* label.\n'
' Inspect those coordinates in Eeschema and tell me how they connect; do NOT guess.'
% len(misses))
sch = sch_src
for sig, (pos, px, py, via) in sorted(hits.items(), key=lambda kv: -kv[1][0]):
old = '(label "SIM_%s"' % sig
if sch[pos:pos + len(old) + 1] != '\t' + old:
die('label text moved under us at offset %d' % pos)
sch = sch[:pos] + '\t(label "SIMA_%s"' % sig + sch[pos + len(old) + 1:]
# ==================================== step 2: NANOSIM_JAE library symbol (cloned)
print('\n== step 2: build %s symbol by cloning MFF2_SIM ==' % HOLDER_SYM)
pin_tpl = re.search(r'(\t*)\(pin \w+ line\s*\n\s*\(at [^)]*\)\s*\n\s*\(length [\d.]+\)\s*\n'
r'\s*\(name "[^"]*"\s*\n\s*\(effects \(font \(size [\d.]+ [\d.]+\)\)\)\s*\n\s*\)\s*\n'
r'\s*\(number "[^"]*"\s*\n\s*\(effects \(font \(size [\d.]+ [\d.]+\)\)\)\s*\n\s*\)\s*\n\s*\)',
mff2_lib)
if not pin_tpl:
die('could not lift a pin template out of MFF2_SIM')
def make_pin(num, name, etype, side, y):
x = -12.7 if side == 'L' else 12.7
ang = 0 if side == 'L' else 180
return (' (pin %s line\n'
' (at %s %s %d)\n'
' (length 5.08)\n'
' (name "%s"\n'
' (effects (font (size 1.27 1.27)))\n'
' )\n'
' (number "%s"\n'
' (effects (font (size 1.27 1.27)))\n'
' )\n'
' )\n') % (etype, g(x), g(y), ang, name, num)
holder_desc = ('Nano-SIM (4FF) card holder, JAE SF72S006VBDR2500, LCSC C2977286. Push-push, '
'1.25 mm, -25..+85 C. Pin NUMBERS are the pad names C1/C2/C3/C5/C6/C7/CSW/DSW/SH '
'to match Connector_JAE:JAE_SIM_Card_SF72S006. DNP alternate to U22 (MFF2) - '
'exactly one of the two is populated, selected by R127-R134. CSW/DSW deliberately '
'unused: with this holder DNP the detect switch is open and USIM1_DET would float '
'or read no-card, which can stop the A7670SA attaching. SH shell pads -> GND.')
holder = []
holder.append(' (symbol "%s"\n' % HOLDER_SYM)
holder.append(' (exclude_from_sim no)\n (in_bom yes)\n (on_board yes)\n')
holder.append(' (property "Reference" "J"\n (at 0 12.7 0)\n'
' (effects (font (size 1.27 1.27)))\n )\n')
holder.append(' (property "Value" "%s"\n (at 0 10.16 0)\n'
' (effects (font (size 1.27 1.27)))\n )\n' % HOLDER_SYM)
holder.append(' (property "Footprint" "%s"\n (at 0 -5.08 0)\n (hide yes)\n'
' (effects (font (size 1.27 1.27)))\n )\n' % HOLDER_FP)
holder.append(' (property "Datasheet" "~"\n (at 0 0 0)\n (hide yes)\n'
' (effects (font (size 1.27 1.27)))\n )\n')
holder.append(' (property "Description" "%s"\n (at 0 0 0)\n (hide yes)\n'
' (effects (font (size 1.27 1.27)))\n )\n' % holder_desc)
holder.append(' (property "LCSC" "C2977286"\n (at 0 0 0)\n (hide yes)\n'
' (effects (font (size 1.27 1.27)))\n )\n')
holder.append(' (symbol "%s_0_1"\n (rectangle\n (start -7.62 10.16)\n'
' (end 7.62 -7.62)\n (stroke (width 0.254) (type default))\n'
' (fill (type background))\n )\n )\n' % HOLDER_SYM)
holder.append(' (symbol "%s_1_1"\n' % HOLDER_SYM)
for num, name, etype, side, y in HOLDER_PINS:
holder.append(make_pin(num, name, etype, side, y))
holder.append(' )\n )\n')
holder_block = ''.join(holder)
print(' built symbol: %d pins, %d chars' % (len(HOLDER_PINS), len(holder_block)))
# insert into the .kicad_sym just before its final ")"
sym = sym_src
if HOLDER_SYM not in sym:
last = sym.rstrip()
if not last.endswith(')'):
die('Drogheda-sym.kicad_sym does not end with ")"')
cut = sym.rfind(')')
sym = sym[:cut] + holder_block + sym[cut:]
# and into the schematic's lib_symbols cache, as "Drogheda-sym:NANOSIM_JAE"
cache_block = holder_block.replace('(symbol "%s"' % HOLDER_SYM,
'(symbol "Drogheda-sym:%s"' % HOLDER_SYM, 1)
cache_block = re.sub(r'^', '\t', cache_block, flags=re.M).rstrip('\t')
libm = re.search(r'\n\t\(lib_symbols\n', sch)
if not libm:
die('lib_symbols section not found in the schematic')
lib_end = bend(sch, libm.start() + 1)
sch = sch[:lib_end - 2] + cache_block + sch[lib_end - 2:]
print(' inserted into Drogheda-sym.kicad_sym and the schematic lib_symbols cache')
# ============================================ step 3: instances + labels (cloned)
print('\n== step 3: place J14 + R127-R134 and label the branches ==')
r_tpl = None
for j, e, blk in find_block(sch, lambda b: '(lib_id "Device:R")' in b
and '(property "Reference" "R55"' in b):
r_tpl = blk
break
if r_tpl is None:
die('could not lift a Device:R instance template (R55)')
def place_r(ref, value, x, y, dnp):
b = r_tpl
b = re.sub(r'\n\t\t\(at -?[\d.]+ -?[\d.]+ -?[\d.]+\)',
'\n\t\t(at %s %s 0)' % (g(x), g(y)), b, count=1)
b = b.replace('(property "Reference" "R55"', '(property "Reference" "%s"' % ref, 1)
b = re.sub(r'\(property "Value" "[^"]*"', '(property "Value" "%s"' % value, b, count=1)
b = re.sub(r'\(property "LCSC" "[^"]*"', '(property "LCSC" "%s"' % R0_LCSC, b, count=1)
b = b.replace('(dnp no)', '(dnp %s)' % ('yes' if dnp else 'no'), 1)
b = b.replace('(reference "R55")', '(reference "%s")' % ref, 1)
# every property/pin coordinate that referenced R55's old position
b = re.sub(r'\(at 240(\.\d+)? (20[0-9]|21[0-9])(\.\d+)? 0\)', '(at %s %s 0)' % (g(x), g(y)), b)
b = UUID_RE.sub(lambda m: '(uuid "%s")' % nu(), b)
return '\t' + b + '\n'
def mk_label(text, x, y):
return ('\t(label "%s"\n\t\t(at %s %s 0)\n\t\t(effects\n\t\t\t(font\n'
'\t\t\t\t(size 1.27 1.27)\n\t\t\t)\n\t\t\t(justify left bottom)\n\t\t)\n'
'\t\t(uuid "%s")\n\t)\n') % (text, g(x), g(y), nu())
add = []
for ref, (sig, br) in sorted(SELECTS.items()):
x, y = COL_X[sig], ROW_Y[br]
add.append(place_r(ref, '0R', x, y, dnp=(br == 'B')))
add.append(mk_label('SIM_%s' % sig, x, y - PIN_DY))
add.append(mk_label('SIM%s_%s' % (br, sig), x, y + PIN_DY))
print(' %-5s %s branch, %-4s @(%s, %s) SIM_%s -> SIM%s_%s%s'
% (ref, br, sig, g(x), g(y), sig, br, sig, ' [DNP]' if br == 'B' else ''))
# J14 instance, cloned from U22's instance block then re-typed
j14 = u22[2]
j14 = j14.replace('"Drogheda-sym:MFF2_SIM"', '"Drogheda-sym:%s"' % HOLDER_SYM, 1)
j14 = re.sub(r'\n\t\t\(at -?[\d.]+ -?[\d.]+(?: -?[\d.]+)?\)',
'\n\t\t(at %s %s 0)' % (g(J14_AT[0]), g(J14_AT[1])), j14, count=1)
j14 = j14.replace('(property "Reference" "U22"', '(property "Reference" "J14"', 1)
j14 = re.sub(r'\(property "Value" "[^"]*"', '(property "Value" "NanoSIM-JAE-SF72S006"', j14, count=1)
j14 = re.sub(r'\(property "Footprint" "[^"]*"', '(property "Footprint" "%s"' % HOLDER_FP, j14, count=1)
j14 = re.sub(r'\(property "Description" "[^"]*"',
'(property "Description" "Nano-SIM holder JAE SF72S006 (LCSC C2977286) - DNP alternate to U22"',
j14, count=1)
j14 = j14.replace('(dnp no)', '(dnp yes)', 1)
j14 = j14.replace('(reference "U22")', '(reference "J14")', 1)
# pins: MFF2 numbers 1..8 -> holder pad names
j14 = re.sub(r'\(pin "\d"\n(\s*)\(uuid "[^"]*"\)\n\s*\)\n', '', j14)
pinblk = ''.join('\t\t(pin "%s"\n\t\t\t(uuid "%s")\n\t\t)\n' % (p[0], nu()) for p in HOLDER_PINS)
j14 = j14.replace('\t\t(instances', pinblk + '\t\t(instances', 1)
j14 = UUID_RE.sub(lambda m: '(uuid "%s")' % nu(), j14)
add.append('\t' + j14 + '\n')
print(' J14 holder @(%s, %s) [DNP]' % (g(J14_AT[0]), g(J14_AT[1])))
for sig in SIGS:
px = J14_AT[0] - 12.7
py = {'VDD': J14_AT[1] - 7.62, 'RST': J14_AT[1] - 5.08,
'CLK': J14_AT[1] - 2.54, 'DATA': J14_AT[1] + 5.08}[sig]
add.append(mk_label('SIMB_%s' % sig, px, py))
add.append(mk_label('GND', J14_AT[0] - 12.7, J14_AT[1]))
add.append(mk_label('GND', J14_AT[0] + 12.7, J14_AT[1] - 5.08))
cut = sch.rfind(')')
sch = sch[:cut] + ''.join(add) + sch[cut:]
# ================================================================ verification
print('\n== verification ==')
paren_ok(sym, 'Drogheda-sym.kicad_sym (post)')
paren_ok(sch, 'hbox-watchdog-v6.kicad_sch (post)')
print(' OK both files parse (paren-balanced, strings closed)')
old_u = UUID_RE.findall(sch_src)
new_u = UUID_RE.findall(sch)
lost = set(old_u) - set(new_u)
if lost:
die('%d pre-existing uuid(s) vanished, e.g. %s' % (len(lost), sorted(lost)[:3]))
if len(new_u) != len(set(new_u)):
die('duplicate uuid generated')
print(' OK all %d pre-existing uuids intact, %d new, none duplicated'
% (len(old_u), len(new_u) - len(old_u)))
refs = re.findall(r'\(property "Reference" "([A-Za-z]+\d+)"', sch)
dupes = sorted({r for r in refs if refs.count(r) > 1})
if dupes:
die('duplicate references: %s' % dupes)
print(' OK no duplicate references (%d components)' % len(refs))
for sig in SIGS:
n_shared = len(re.findall(r'\(label "SIM_%s"' % sig, sch))
n_a = len(re.findall(r'\(label "SIMA_%s"' % sig, sch))
n_b = len(re.findall(r'\(label "SIMB_%s"' % sig, sch))
if n_a != 2 or n_b != 2:
die('SIM%s: expected 2 SIMA_ and 2 SIMB_ labels, got %d/%d' % (sig, n_a, n_b))
if n_shared < 3:
die('SIM_%s has only %d endpoints - modem side lost a connection' % (sig, n_shared))
print(' OK SIM_%-4s shared=%d SIMA=%d SIMB=%d' % (sig, n_shared, n_a, n_b))
pop = [r for r, (s, b) in SELECTS.items() if b == 'A']
print('\n POPULATE: U22 (MFF2) + %s' % ' '.join(sorted(pop)))
print(' DNP: J14 (holder) + %s' % ' '.join(sorted(set(SELECTS) - set(pop))))
if APPLY:
shutil.copy2(SYM, SYM + '.T13bak')
shutil.copy2(SCH, SCH + '.T13bak')
open(SYM, 'w', encoding='utf-8').write(sym)
open(SCH, 'w', encoding='utf-8').write(sch)
print('\nAPPLIED (.T13bak written beside each file)')
print('NEXT: open Eeschema -> ERC -> save; then Update PCB from Schematic')
print(' (UNTICK "delete extra footprints" and any remove-tracks option)')
else:
print('\nDRY RUN - nothing written. Re-run with --apply.')