v6 T15/T15b scripts + bump kicad (JLC BOM, board attrs, stubs)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YJ4eAB6zjQT49RVwpgfKdt
This commit is contained in:
parent
3014b92f08
commit
9367527d85
@ -1 +1 @@
|
||||
Subproject commit b338b71fb769f4be02488852b621d93034d7e3e6
|
||||
Subproject commit 91d2049c99f413777a69932a98f806d5517cc743
|
||||
229
scripts/2026-09-25_v6_T15_bom_lcsc_and_pcb_attrs.py
Normal file
229
scripts/2026-09-25_v6_T15_bom_lcsc_and_pcb_attrs.py
Normal file
@ -0,0 +1,229 @@
|
||||
#!/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()
|
||||
65
scripts/2026-09-25_v6_T15b_dangling_stubs.py
Normal file
65
scripts/2026-09-25_v6_T15b_dangling_stubs.py
Normal file
@ -0,0 +1,65 @@
|
||||
#!/usr/bin/env python3
|
||||
"""v6 T15b — delete the 4 dangling track stubs DRC reported on 25-Sep (warnings track_dangling).
|
||||
|
||||
Each target is identified by net + layer + the dangling end DRC printed + the reported length;
|
||||
the script refuses to delete anything that doesn't match exactly one segment.
|
||||
|
||||
python3 T15b.py hbox-watchdog-v6.kicad_pcb # dry run
|
||||
python3 T15b.py hbox-watchdog-v6.kicad_pcb --apply # writes .T15bbak
|
||||
"""
|
||||
import re, sys, math, shutil
|
||||
|
||||
STUBS = [ # (net, layer, dangling end x, y, length mm) — from the 25-Sep DRC report
|
||||
('/3V3_ETH', 'F.Cu', 91.7375, 56.0800, 0.3889),
|
||||
('/VEH5V', 'F.Cu', 187.5000, 116.1100, 0.7071),
|
||||
('/RS485_B', 'B.Cu', 145.3500, 131.1682, 0.2107),
|
||||
('/DOUT_4G', 'In2.Cu', 158.4000, 80.1000, 1.6000),
|
||||
# second DRC pass: the RS485_B stub was a 3-segment chain off a THT pad going nowhere
|
||||
('/RS485_B', 'B.Cu', 146.2000, 132.0182, 1.2021),
|
||||
('/RS485_B', 'B.Cu', 146.2000, 132.3075, 0.2894),
|
||||
]
|
||||
|
||||
def span(s, i):
|
||||
d = 0; instr = False; j = i
|
||||
while True:
|
||||
c = s[j]
|
||||
if c == '"' and s[j-1] != '\\': instr = not instr
|
||||
elif not instr:
|
||||
if c == '(': d += 1
|
||||
elif c == ')':
|
||||
d -= 1
|
||||
if d == 0: return j + 1
|
||||
j += 1
|
||||
|
||||
def main():
|
||||
path = sys.argv[1]; apply = '--apply' in sys.argv
|
||||
s0 = open(path).read(); cuts = []; log = []
|
||||
segs = []
|
||||
for m in re.finditer(r'\n\t\(segment\b', s0):
|
||||
a = m.start() + 1; e = span(s0, a); b = s0[a:e]
|
||||
st = tuple(map(float, re.search(r'\(start (-?[\d.]+) (-?[\d.]+)\)', b).groups()))
|
||||
en = tuple(map(float, re.search(r'\(end (-?[\d.]+) (-?[\d.]+)\)', b).groups()))
|
||||
segs.append(dict(a=a, e=e, st=st, en=en, l=re.search(r'\(layer "([^"]+)"\)', b).group(1),
|
||||
n=re.search(r'\(net "([^"]*)"\)', b).group(1)))
|
||||
for net, lay, x, y, L in STUBS:
|
||||
hit = [g for g in segs if g['n'] == net and g['l'] == lay
|
||||
and any(math.hypot(px - x, py - y) < 0.005 for px, py in (g['st'], g['en']))
|
||||
and abs(math.dist(g['st'], g['en']) - L) < 0.002]
|
||||
if not hit:
|
||||
log.append(f'{net} {lay} @({x},{y}): not found (already removed?)'); continue
|
||||
if len(hit) > 1: raise SystemExit(f'{net}: {len(hit)} candidates, refusing')
|
||||
cuts.append(hit[0]); log.append(f'delete {net} {lay} {hit[0]["st"]}->{hit[0]["en"]} ({L} mm)')
|
||||
s = s0
|
||||
for g in sorted(cuts, key=lambda g: -g['a']):
|
||||
s = s[:g['a'] - 1] + s[g['e']:] # also drops the preceding newline
|
||||
t = re.sub(r'"(?:[^"\\]|\\.)*"', '', s); assert t.count('(') == t.count(')'), 'unbalanced'
|
||||
assert len(re.findall(r'\n\t\(footprint "', s)) == len(re.findall(r'\n\t\(footprint "', s0))
|
||||
print('\n'.join(log))
|
||||
if not cuts: print('nothing to do'); return
|
||||
if apply:
|
||||
shutil.copy2(path, path + '.T15bbak'); open(path, 'w').write(s); print('APPLIED (backup .T15bbak)')
|
||||
else:
|
||||
print('DRY RUN — re-run with --apply')
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
Loading…
Reference in New Issue
Block a user