#!/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.')