diff --git a/03-Architecture-Hardware/kicad b/03-Architecture-Hardware/kicad index b338b71..91d2049 160000 --- a/03-Architecture-Hardware/kicad +++ b/03-Architecture-Hardware/kicad @@ -1 +1 @@ -Subproject commit b338b71fb769f4be02488852b621d93034d7e3e6 +Subproject commit 91d2049c99f413777a69932a98f806d5517cc743 diff --git a/scripts/2026-09-25_v6_T15_bom_lcsc_and_pcb_attrs.py b/scripts/2026-09-25_v6_T15_bom_lcsc_and_pcb_attrs.py new file mode 100644 index 0000000..a0f7a53 --- /dev/null +++ b/scripts/2026-09-25_v6_T15_bom_lcsc_and_pcb_attrs.py @@ -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() diff --git a/scripts/2026-09-25_v6_T15b_dangling_stubs.py b/scripts/2026-09-25_v6_T15b_dangling_stubs.py new file mode 100644 index 0000000..6d3e252 --- /dev/null +++ b/scripts/2026-09-25_v6_T15b_dangling_stubs.py @@ -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()