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