#!/usr/bin/env python3 """v6 T12 - SIM block: MFF2 footprint repoint (part 1 of 2). WHY THIS SCRIPT EXISTS ---------------------- U22 (MFF2 soldered eSIM, Block Q) shipped from T4 with a placeholder footprint field: the literal string "Drogheda:MFF2_5x6_TBD", which resolved to nothing - Drogheda.pretty held only the E-J1 socket. U22 was also absent from the board. On 2026-09-15 the real land pattern was built as Drogheda.pretty/MFF2_6x5.kicad_mod (geometry derived from ETSI TS 102 671 V19.0.0 section 6, cross-checked against the ST ST4SIM-200M VFDFPN8 outline) and the three property fields were edited BY HAND. Hand edits live outside the T-script replay chain, so a future v5 schematic change would silently discard them. This script puts them back in the chain. It is IDEMPOTENT: on an already-patched tree it reports 0 changes and exits 0. NAMING: the old field said "5x6"; ETSI dimensions the package as E=6.00 x D=5.00, so the footprint is MFF2_6x5. PART 2 (pending, will be appended to this same script) ------------------------------------------------------ The nano-SIM holder alternate + eight 0R selects downstream of the existing ESD (D23 SIM_DATA / D24 SIM_CLK / D25 SIM_RST) + DNP flags, so MFF2 and a card holder can share the bus with exactly one populated. Blocked on the holder's land pattern. LESSONS APPLIED (see drogheda-v6-resync-protocol) ------------------------------------------------- - no bare numeric regex; every substitution is a complete quoted token or is scoped to one paren-matched s-expression block - hard invariant guard: aborts if ANY pre-existing UUID would change - .T12bak written before any file is touched; dry-run is the default Usage: python3 2026-09-15_v6_T12_sim_dual_footprint.py [--apply] """ import os import re import shutil import sys 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') FPF = os.path.join(KI, 'Drogheda.pretty', 'MFF2_6x5.kicad_mod') APPLY = '--apply' in sys.argv UUID_RE = re.compile(r'\(uuid "([^"]*)"\)') OLD_FP = '"Drogheda:MFF2_5x6_TBD"' NEW_FP = '"Drogheda:MFF2_6x5"' OLD_DESC = ( 'MFF2 / DFN-8 5x6 mm soldered M2M SIM (ETSI TS 102 671). Pads: 1=C1 VCC, 2=C2 RST, ' '3=C3 CLK, 4=C4 NC, 5=C5 GND, 6=C6 VPP (NC), 7=C7 I/O, 8=C8 NC. FOOTPRINT TBD - build ' 'from the chosen carrier chip datasheet before layout. Carrier-supplied part, not on LCSC.' ) NEW_DESC = ( 'MFF2 / VFDFPN-8 6.0x5.0 mm soldered M2M eSIM (ETSI TS 102 671). Pads: 1=C1 VCC, 2=C2 RST, ' '3=C3 CLK, 4=C4 NC, 5=C5 GND, 6=C6 VPP (NC), 7=C7 I/O, 8=C8 NC - JEDEC DFN counterclockwise, ' 'pin 1 top-left, matches ST ST4SIM-200M. Footprint Drogheda:MFF2_6x5 (2026-09-15). ' 'Carrier-supplied part: NO LCSC number, exclude from the JLCPCB PCBA BOM, hand-solder or ' 'consign. Verify pinout + die-pad exposure against the sourced carrier drawing before fab.' ) U22_DESC = 'MFF2 eSIM (carrier-supplied, NO LCSC - do not order from JLCPCB)' # pad number -> (x, y); size is 0.9 x 0.5 for all eight EXPECTED_PADS = { '1': (-2.85, -1.905), '2': (-2.85, -0.635), '3': (-2.85, 0.635), '4': (-2.85, 1.905), '5': (2.85, 1.905), '6': (2.85, 0.635), '7': (2.85, -0.635), '8': (2.85, -1.905), } def die(msg): sys.exit('REFUSING: ' + msg) def balanced_end(s, j): """End index (exclusive) of the s-expression opening at j. String-aware.""" depth = 0 k = j in_str = False while k < len(s): c = s[k] if in_str: if c == '\\': k += 2 continue if c == '"': in_str = False else: if c == '"': in_str = True elif c == '(': depth += 1 elif c == ')': depth -= 1 if depth == 0: return k + 1 k += 1 die('unbalanced s-expression starting at offset %d' % j) def paren_ok(s, label): depth = 0 k = 0 in_str = False while k < len(s): c = s[k] if in_str: if c == '\\': k += 2 continue if c == '"': in_str = False else: if c == '"': in_str = True elif c == '(': depth += 1 elif c == ')': depth -= 1 if depth < 0: die('%s: unbalanced ")" at offset %d' % (label, k)) k += 1 if depth != 0: die('%s: %d unclosed "(" at EOF' % (label, depth)) if in_str: die('%s: unterminated string at EOF' % label) # ---------------------------------------------------------------- gate 0: footprint print('== gate 0: the footprint file must exist and match the documented geometry ==') if not os.path.isfile(FPF): die('%s is missing - build the land pattern before running T12' % FPF) fp_src = open(FPF, encoding='utf-8').read() paren_ok(fp_src, 'MFF2_6x5.kicad_mod') pads = dict( (m.group(1), (float(m.group(2)), float(m.group(3)))) for m in re.finditer(r'\(pad "(\d)" smd rect \(at (-?[\d.]+) (-?[\d.]+)\) \(size 0\.9 0\.5\)', fp_src) ) if pads != EXPECTED_PADS: die('MFF2_6x5.kicad_mod pad geometry does not match the T12 reference.\n' ' found: %s\n expected: %s' % (sorted(pads.items()), sorted(EXPECTED_PADS.items()))) print(' OK 8 pads, 0.9 x 0.5, columns at X +/-2.85, rows at Y +/-1.905 / +/-0.635') # ---------------------------------------------------------------- the edits edits = 0 reports = [] for path in (SYM, SCH): name = os.path.basename(path) if not os.path.isfile(path): die('%s is missing' % path) src = open(path, encoding='utf-8').read() paren_ok(src, name) new = src local = [] # (a) footprint field - complete quoted token only n = new.count(OLD_FP) if n: new = new.replace(OLD_FP, NEW_FP) local.append('%d x footprint field -> Drogheda:MFF2_6x5' % n) # (b) library description (SYM: the symbol; SCH: the lib_symbols cache copy) n = new.count(OLD_DESC) if n: new = new.replace(OLD_DESC, NEW_DESC) local.append('%d x symbol description de-TBD-ed' % n) # (c) U22 instance Description - scoped to U22's own paren-matched block if name.endswith('.kicad_sch'): targets = [] for m in re.finditer(r'\(symbol(?=[\s(])', new): j = m.start() e = balanced_end(new, j) blk = new[j:e] if '"Drogheda-sym:MFF2_SIM"' in blk and '(property "Reference" "U22"' in blk: targets.append((j, e)) if len(targets) != 1: die('expected exactly 1 U22 MFF2_SIM instance in %s, found %d' % (name, len(targets))) j, e = targets[0] blk = new[j:e] if '(property "Description" ""' in blk: blk2 = blk.replace('(property "Description" ""', '(property "Description" "%s"' % U22_DESC, 1) new = new[:j] + blk2 + new[e:] local.append('1 x U22 instance Description set') elif U22_DESC not in blk: die('U22 Description is neither empty nor the T12 value - inspect by hand') # sanity: U22 must carry no LCSC number (carrier-supplied part) blk_now = new[j:balanced_end(new, j)] mm = re.search(r'\(property "LCSC" "([^"]*)"', blk_now) if mm and mm.group(1).strip(): die('U22 carries LCSC "%s" - it is a carrier-supplied part and must have none ' '(C730237 belongs to U23/U24, the 74LVC2T45s)' % mm.group(1)) if new == src: reports.append('%-28s already at T12 state, nothing to do' % name) continue # invariants if UUID_RE.findall(new) != UUID_RE.findall(src): die('%s: a UUID would change' % name) paren_ok(new, name + ' (post-edit)') if OLD_FP in new or 'MFF2_5x6_TBD' in new: die('%s: a TBD footprint reference survived the edit' % name) reports.append('%-28s %s' % (name, '; '.join(local))) edits += len(local) if APPLY: shutil.copy2(path, path + '.T12bak') open(path, 'w', encoding='utf-8').write(new) print() print('== edits ==') for r in reports: print(' ' + r) print() print('total change groups: %d' % edits) if APPLY: print('APPLIED (.T12bak written beside each modified file)') else: print('DRY RUN - nothing written. Re-run with --apply.')