Drogheda/scripts/2026-09-15_v6_T12_sim_dual_footprint.py
Leopoldo Rodríguez 33b62d7052 Drogheda: T12 (MFF2 repoint as a replayable script) + kicad submodule bump
scripts/2026-09-15_v6_T12_sim_dual_footprint.py moves the U22 edits into the
T-script chain. Idempotent, dry-run by default, .T12bak backups, UUID invariant
guard, and a gate-0 assertion on the MFF2 land pattern. Verified to reproduce the
hand edits byte-for-byte. Part 2 (nano-SIM holder + 0R selects) pending the
holder datasheet.
2026-09-15 10:58:36 -06:00

228 lines
8.4 KiB
Python

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