Drogheda/scripts/2026-09-25_v6_T15_bom_lcsc_and_pcb_attrs.py
2026-09-25 18:59:26 +00:00

230 lines
12 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env python3
"""v6 T15 — make the board orderable at JLCPCB: LCSC numbers + fabrication attributes.
Why a direct PCB edit: on 25-Sep "Update PCB from Schematic" did not carry the T14 SIM flip
to the board, so T15 writes the attributes into the .kicad_pcb itself (only `attr` tokens and
hidden LCSC properties; no geometry, no copper, no placement is touched).
Schematic (.kicad_sch):
1. LCSC field on the 66 populated parts that had none (+ RS1, whose value was a TBD placeholder).
Every number verified in the local JLC DB on 25-Sep (see LCSC below).
2. JP1–JP5 (solder jumpers = bare copper) -> in_bom no, in_pos_files no.
Board (.kicad_pcb):
3. Same LCSC values on the matching footprints (hidden F.Fab property).
4. SIM flip that Update PCB didn't carry (T14): J14 + R131–R134 lose `dnp`; U22 + R127–R130 get `dnp`.
5. U19 (A7670SA LGA) and L22 (SMD inductor) through_hole -> smd.
6. JP1–JP5 -> exclude_from_pos_files + exclude_from_bom.
NOT touched on purpose: F2 (vehicle-input fuse, still TBD — needs Leo's part choice),
J10/P11/P12 (DNP intent pending Leo), anything geometric.
Method: clone-and-transform (LCSC blocks cloned from R131's own), fresh UUIDs, invariant guards,
dry-run by default, .T15bak backups, idempotent.
python3 T15.py hbox-watchdog-v6.kicad_sch hbox-watchdog-v6.kicad_pcb # dry run
python3 T15.py hbox-watchdog-v6.kicad_sch hbox-watchdog-v6.kicad_pcb --apply # writes .T15bak
"""
import re, sys, uuid, shutil
LCSC = {
# 100 nF 0402 X7R 16 V (Basic)
**{r: 'C1525' for r in 'C121 C122 C123 C126 C129 C134 C137 C138 C139 C140 C144 C158 C159 C143'.split()},
# 10 uF 0805 X5R 50 V Murata (Basic) — already used 5x on the board; 50 V covers C127 on /VIN_MODEM (24 V)
**{r: 'C440198' for r in 'C119 C120 C124 C127 C160'.split()},
'C125': 'C23630', # 2.2 uF 0603 16 V (Basic) on /1V8_MODEM
'C128': 'C15849', # 1 uF 0603 X5R 50 V (Basic) on /VIN_MODEM (24 V)
'C133': 'C15849', # 1 uF 0603 50 V on /VBAT_MODEM
'C135': 'C1562', # 33 pF 0402 C0G 50 V (Basic)
'C136': 'C32949', # 10 pF 0402 C0G 50 V (Basic)
'C141': 'C1536', # 3.3 nF 0402 X7R 50 V (Ext) — modem buck compensation
'C142': 'C1567', # 47 pF 0402 C0G 50 V (Basic) — modem buck compensation
'C145': 'C1546', # 100 pF 0402 C0G 50 V (Basic)
'C149': 'C153036', # 2.2 uF 1210 X7R 100 V FH (Ext, 471k stock) on /VIN_PROT
'C150': 'C153036',
'C151': 'C116241', # 100 uF 50 V Nichicon UUD 8x10, 105 C / 5000 h (Ext)
'C152': 'C14663', # 100 nF 0603 50 V (Basic) — U25 bootstrap
'C157': 'C14663', # 100 nF 0603 on /NTC2
'C153': 'C5440143', # 47 uF 1210 X7R 16 V (Ext) — same part already on the board
'C154': 'C5440143',
'C155': 'C1542', # 6.8 nF 0402 X7R 50 V (Preferred) — U25 compensation
'C156': 'C1563', # 39 pF 0402 C0G 50 V (Ext) — U25 compensation
'D20': 'C8678', # SS34 SMA (Basic) — already used 5x
'D33': 'C80857', # BZX84C15LT1G (Ext) — already used on the board
'L20': 'C17700167', # Sunlord MWSA0804S-4R7MT 4.7 uH — same family as the other SRU8028-footprint inductors
'L21': 'C162555', # Murata LQG18HN68NJ00D 68 nH 0603 — GNSS active-antenna bias choke
'Q20': 'C8545', 'Q21': 'C8545', # 2N7002 CJ (Basic)
'Q22': 'C20526', # MMBT3904 (Basic) — already used
'Q23': 'C2624', # IRFR5305TRPBF (Ext) — already used
'R105': 'C25804', # 10k 1% (Basic)
'R126': 'C25804',
'R106': 'C22775', # 100R (Basic)
'R109': 'C23162', # 4.7k (Basic)
'R110': 'C23242', # 75k 1% (Basic) — modem buck FB top (0.8 V x (1+75/20) = 3.80 V)
'R111': 'C4184', # 20k 1% (Basic) — modem buck FB bottom
'R112': 'C14676', # 1k — same as the other 1k on the board
**{r: 'C25803' for r in 'R113 R114 R115 R119'.split()}, # 100k 1% (Basic)
'R116': 'C22803', # 14k 1% (Ext)
'R117': 'C25811', # 200k 1% (Basic)
'R118': 'C17168', # 0R 0402 (Basic)
'R120': 'C165760', # 162k 1% Yageo (Ext) — U25 RT (~620 kHz)
'R121': 'C23074', # 53.6k 1% (Ext) — U25 FB top (0.8 V x (1+53.6/10.2) = 5.00 V)
'R122': 'C22772', # 10.2k 1% (Ext) — U25 FB bottom
'R123': 'C22797', # 13k 1% (Basic) — U25 compensation
'RS1': 'C105362', # 10 mOhm 1% 1 W 1206 current-sense (Ext) — replaces "TBD-10mR-1206"
'P8': 'C158012', # JST B2B-XH-A — same as the other 2-pin XH on the board
'J14': 'C2977286', # JAE SF72S006VBDR2500 — schematic got it in T14b; the board needs it too
}
JUMPERS = ['JP1', 'JP2', 'JP3', 'JP4', 'JP5']
POPULATE = ['J14', 'R131', 'R132', 'R133', 'R134']
MAKE_DNP = ['U22', 'R127', 'R128', 'R129', 'R130']
TO_SMD = ['U19', 'L22']
ATTR_ORDER = ['board_only', 'exclude_from_pos_files', 'exclude_from_bom',
'allow_missing_courtyard', 'dnp', 'allow_soldermask_bridges']
def span(s, i):
d = 0; instr = False; esc = False; j = i
while True:
c = s[j]
if esc: esc = False
elif c == '\\': esc = True
elif c == '"': instr = not instr
elif not instr:
if c == '(': d += 1
elif c == ')':
d -= 1
if d == 0: return j + 1
j += 1
def balanced(s):
d = 0; instr = False; esc = False
for c in s:
if esc: esc = False; continue
if c == '\\': esc = True; continue
if c == '"': instr = not instr; continue
if instr: continue
if c == '(': d += 1
elif c == ')': d -= 1
if d < 0: return False
return d == 0
UUID = re.compile(r'\(uuid "([0-9a-f-]{36})"\)')
def block(s, ref, head):
m = re.search(r'\n\t\t\(property "Reference" "%s"\n' % re.escape(ref), s)
if not m: raise SystemExit(f'{head} {ref} not found')
a = s.rfind('\n\t(' + head, 0, m.start()) + 1
return a, span(s, a)
def prop_block(blk, name):
m = re.search(r'\n\t\t\(property "%s" "' % re.escape(name), blk)
if not m: return None
a = m.start() + 3
return a, span(blk, a)
def set_lcsc(s, ref, head, value, template_ref, changes, tag):
"""Set/insert the LCSC property of symbol/footprint `ref`. Returns new s."""
a, e = block(s, ref, head); blk = s[a:e]
pb = prop_block(blk, 'LCSC')
if pb:
pa, pe = pb; cur = re.match(r'\(property "LCSC" "([^"]*)"', blk[pa:pe]).group(1)
if cur == value: return s
if cur and not cur.startswith('TBD'):
raise SystemExit(f'{tag} {ref}: has real LCSC {cur}, refusing to overwrite with {value}')
nb = blk[:pa] + blk[pa:pe].replace(f'"LCSC" "{cur}"', f'"LCSC" "{value}"', 1) + blk[pe:]
changes.append(f'{tag} {ref}: LCSC {cur} -> {value}')
else:
if head == 'symbol':
ta, te = block(s, template_ref, head); tb = s[ta:te]
pa, pe = prop_block(tb, 'LCSC'); lc = tb[pa:pe]
x, y = re.search(r'\n\t\t\(at (-?[\d.]+) (-?[\d.]+) ', blk).groups()
lc = re.sub(r'\(at [^)]*\)', f'(at {x} {y} 0)', lc, count=1)
else: # footprint: clone this footprint's own hidden Datasheet field (right layer/rotation)
pa, pe = prop_block(blk, 'Datasheet'); lc = blk[pa:pe]
if '(hide yes)' not in lc: raise SystemExit(f'{ref}: Datasheet field not hidden, unsafe template')
lc = re.sub(r'^\(property "[^"]*" "[^"]*"', f'(property "LCSC" "{value}"', lc, count=1)
lc = UUID.sub(lambda m: f'(uuid "{uuid.uuid4()}")', lc)
da, de = prop_block(blk, 'Description')
nb = blk[:de] + '\n\t\t' + lc + blk[de:]
changes.append(f'{tag} {ref}: + LCSC {value}')
return s[:a] + nb + s[e:]
def sch_flags(s, ref, want, changes):
a, e = block(s, ref, 'symbol'); blk = s[a:e]
for k, v in want.items():
m = re.search(r'\n\t\t\(%s (yes|no)\)' % k, blk)
if not m: raise SystemExit(f'sch {ref}: no ({k} …)')
if m.group(1) != v:
blk = blk[:m.start()] + f'\n\t\t({k} {v})' + blk[m.end():]
changes.append(f'sch {ref}: {k} {m.group(1)} -> {v}')
return s[:a] + blk + s[e:]
def pcb_attr(s, ref, add=(), remove=(), set_type=None, changes=None):
a, e = block(s, ref, 'footprint'); blk = s[a:e]
m = re.search(r'\n\t\t\(attr ([^)]*)\)', blk)
if not m: # KiCad omits (attr) when the type is "unspecified" and no flags are set
i = blk.find('\n\t\t(duplicate_pad_numbers_are_jumpers')
if i < 0: i = re.search(r'\n\t\t\((fp_|pad )', blk).start()
blk = blk[:i] + '\n\t\t(attr )' + blk[i:]
m = re.search(r'\n\t\t\(attr ([^)]*)\)', blk)
toks = m.group(1).split()
typ = toks[0] if toks and toks[0] in ('smd', 'through_hole') else None
flags = [t for t in toks if t not in ('smd', 'through_hole')]
unknown = [t for t in flags if t not in ATTR_ORDER]
if unknown: raise SystemExit(f'pcb {ref}: unknown attr tokens {unknown}')
ntyp = set_type or typ
nflags = (set(flags) | set(add)) - set(remove)
new = ' '.join(([ntyp] if ntyp else []) + [t for t in ATTR_ORDER if t in nflags])
if new != m.group(1):
blk = blk[:m.start()] + f'\n\t\t(attr {new})' + blk[m.end():]
changes.append(f'pcb {ref}: attr "{m.group(1)}" -> "{new}"')
return s[:a] + blk + s[e:]
def guard(s0, s, what):
assert balanced(s), f'{what}: output not paren-balanced'
u0 = set(UUID.findall(s0)); u1 = UUID.findall(s)
assert u0 <= set(u1), f'{what}: a pre-existing UUID disappeared'
assert len(u1) == len(set(u1)), f'{what}: duplicate UUID'
return len(u0), len(u1) - len(u0)
def main():
sch, pcb = sys.argv[1], sys.argv[2]; apply = '--apply' in sys.argv
S0 = open(sch).read(); P0 = open(pcb).read(); S = S0; P = P0
assert balanced(S0) and balanced(P0), 'input not paren-balanced'
ch = []
n_fp0 = len(re.findall(r'\n\t\(footprint "', P0)); n_sym0 = len(re.findall(r'\n\t\(symbol\s*\(lib_', S0))
for ref, val in LCSC.items():
S = set_lcsc(S, ref, 'symbol', val, 'R131', ch, 'sch')
P = set_lcsc(P, ref, 'footprint', val, None, ch, 'pcb')
for ref in JUMPERS:
S = sch_flags(S, ref, {'in_bom': 'no', 'in_pos_files': 'no'}, ch)
P = pcb_attr(P, ref, add=('exclude_from_pos_files', 'exclude_from_bom'), changes=ch)
for ref in POPULATE: P = pcb_attr(P, ref, remove=('dnp',), changes=ch)
for ref in MAKE_DNP: P = pcb_attr(P, ref, add=('dnp',), changes=ch)
for ref in TO_SMD: P = pcb_attr(P, ref, set_type='smd', changes=ch)
ks, ns = guard(S0, S, 'sch'); kp, np_ = guard(P0, P, 'pcb')
assert len(re.findall(r'\n\t\(footprint "', P)) == n_fp0, 'footprint count changed'
assert len(re.findall(r'\n\t\(symbol\s*\(lib_', S)) == n_sym0, 'symbol count changed'
# geometry invariant: every original board line survives, except attr lines and LCSC values
from collections import Counter
keep = lambda t: Counter(l for l in t.split('\n')
if not re.match(r'\t*\((attr |property "LCSC" )', l))
lost = keep(P0) - keep(P)
assert not lost, f'pcb: unexpected changed lines: {list(lost)[:3]}'
for ref in LCSC: # schematic and board agree
for t, h in ((S, 'symbol'), (P, 'footprint')):
a, e = block(t, ref, h); assert f'(property "LCSC" "{LCSC[ref]}"' in t[a:e], (ref, h)
print(f'{len(ch)} change(s)'); [print(' ', c) for c in ch]
print(f'sch UUIDs: {ks} kept, {ns} new | pcb UUIDs: {kp} kept, {np_} new | 0 duplicated; paren-balanced; geometry untouched')
if not ch: print('nothing to do (already applied)'); return
if apply:
shutil.copy2(sch, sch + '.T15bak'); shutil.copy2(pcb, pcb + '.T15bak')
open(sch, 'w').write(S); open(pcb, 'w').write(P); print('APPLIED (backups .T15bak)')
else:
print('DRY RUN — re-run with --apply')
if __name__ == '__main__':
main()