#!/usr/bin/env python3 """v6 T10 - final-version sensor set: embedded SHT40 + two cheap external NTC probes. - U3 SHT40-AD1B stays soldered on-board (already in the design) = tair + humidity. - DELETE U4 TMP102 (replaced by the 2nd NTC probe channel). - DELETE P5 TEMP_PROBE jack (external SHT40 probe; redundant vs on-board U3, and a probe SHT40 would collide with U3 at I2C address 0x44 anyway). - KEEP P12 + R42 10k pullup + C24 100nF -> PC5 ADC (/NTC_TEMP) = external probe #1 (tntc). - ADD P18 (same JST-XH 2-pin) + R126 10k/1% (3V3 pullup) + C157 100nF -> /NTC2 -> PC4 (U16 pin 24, ADC12_IN14) = external probe #2. Identical topology to P12's channel so the firmware conversion carries over (per-probe Beta constant in firmware). Usage: python3 2026-08-31_v6_T10_sensors.py [--apply] """ import os, sys, shutil, uuid, re KI = os.path.join(os.path.dirname(os.path.abspath(__file__)), '..', '03-Architecture-Hardware', 'kicad') SCH = os.path.join(KI, 'hbox-watchdog-v6.kicad_sch') APPLY = '--apply' in sys.argv ROOT = '/561403b3-895e-4153-a7be-3876ae930d43' def f(v): v = 0.0 if abs(v) < 1e-9 else v return '%.10g' % v def bal(t): d=0; instr=False; esc=False for ch in t: if esc: esc=False; continue if ch=='\\': esc=True; continue if ch=='"': instr = not instr; continue if instr: continue if ch=='(': d+=1 elif ch==')': d-=1 return d def span_from(text, i): d=0; instr=False; esc=False; j=i while j < len(text): ch=text[j] if esc: esc=False elif ch=='\\': esc=True elif ch=='"': instr = not instr elif not instr: if ch=='(': d+=1 elif ch==')': d-=1 if d==0: return j+1 j+=1 raise ValueError('unbalanced') PIN_RE2= re.compile(r'\(pin \w+ \w+\s*\n\s*\(at ([-\d.]+) ([-\d.]+) (\d+)\)(?:(?!\(pin ).)*?\(number "([^"]+)"', re.S) def libpins(libsrc, name): i = libsrc.find('(symbol "%s"' % name) if i < 0: return None j = libsrc.find('\n\t\t(symbol "', i+10) body = libsrc[i: j if j>0 else len(libsrc)] return [(num, float(x), float(y)) for x,y,rot,num in PIN_RE2.findall(body)] sch = open(SCH, encoding='utf-8').read() uuids_before = set(re.findall(r'\(uuid "([0-9a-f-]+)"\)', sch)) lib_cached = sch.split('(lib_symbols',1)[1] def cachedpins(name): return libpins(lib_cached, name) # ---------- 1. locate the doomed instances ---------- def find_inst(ref): for b in re.finditer(r'\n\t\(symbol\n', sch): a = b.start()+1 e = span_from(sch, sch.index('(', a)) blk = sch[a:e] if '(property "Reference" "%s"' % ref in blk: l = re.search(r'\(lib_id "([^"]+)"', blk).group(1) m = re.search(r'\(at ([-\d.]+) ([-\d.]+) (\d+)\)', blk) assert m.group(3) == '0' and '(mirror' not in blk[:400], (ref,'not rot0/nomirror') return (a, e, l, float(m.group(1)), float(m.group(2))) sys.exit('%s not found' % ref) doomed = {} for ref in ('U4','P5'): doomed[ref] = find_inst(ref) # sanity: expected identities assert 'TMP102' in sch[doomed['U4'][0]:doomed['U4'][1]] assert 'TEMP_PROBE' in sch[doomed['P5'][0]:doomed['P5'][1]] # refdes guards for additions for r in ('P18','R126','C157'): if '(property "Reference" "%s"' % r in sch: sys.exit('refdes %s already used' % r) assert '(property "Reference" "P12"' in sch and '(property "Reference" "R42"' in sch # ---------- 2. delete labels / no_connects at the doomed pins, then the symbols ---------- def pin_coords_of(inst): a,e,l,X,Y = inst pp = cachedpins(l) assert pp, 'no lib pins for %s' % l return {(round(X+px,3), round(Y-py,3)) for num,px,py in pp} kill_coords = set() for ref in doomed: kill_coords |= pin_coords_of(doomed[ref]) removed_lab = removed_nc = 0 out=[]; last=0 for m in re.finditer(r'\n\t\((label|global_label) "[^"]+"\s*\n\s*\(at ([-\d.]+) ([-\d.]+) (\d+)\)(?:(?!\n\t\().)*?\n\t\)', sch, re.S): c=(round(float(m.group(2)),3),round(float(m.group(3)),3)) if c in kill_coords: out.append(sch[last:m.start()]); last=m.end(); removed_lab+=1 out.append(sch[last:]); sch=''.join(out) out=[]; last=0 for m in re.finditer(r'\n\t\(no_connect\s*\n\s*\(at ([-\d.]+) ([-\d.]+)\)(?:(?!\n\t\().)*?\n\t\)', sch, re.S): c=(round(float(m.group(1)),3),round(float(m.group(2)),3)) if c in kill_coords: out.append(sch[last:m.start()]); last=m.end(); removed_nc+=1 out.append(sch[last:]); sch=''.join(out) # wires hanging off doomed pins (U4 ADD0 has a 1.27mm wire to a GND label): chain-trace # from the doomed pin coords, delete chained wires + labels at chain points. Guarded: abort # if the chain grows past 3 wires (would mean eating a shared net wire) or touches a # surviving symbol's pin. wires=[] for m in re.finditer(r'\n\t\(wire\s*\n\s*\(pts\s*\n((?:\s*\(xy [-\d.]+ [-\d.]+\)\s*\n?)+)(?:(?!\n\t\().)*?\n\t\)', sch, re.S): pts=[(round(float(a),3),round(float(b),3)) for a,b in re.findall(r'\(xy ([-\d.]+) ([-\d.]+)\)', m.group(1))] wires.append((pts, m.start()+1, m.end())) seen=set(kill_coords); chain=[]; grew=True while grew: grew=False for w in wires: if w in chain: continue if any(p in seen for p in w[0]): chain.append(w); seen|=set(w[0]); grew=True assert len(chain) <= 3, 'wire chain from doomed pins too large: %d' % len(chain) surv_pins=set() for b2 in re.finditer(r'\n\t\(symbol\n(?:(?!\n\t\(symbol\n).)*', sch, re.S): blk2=b2.group(0) rr=re.search(r'\(property "Reference" "([^"]+)"',blk2) if rr and rr.group(1) in doomed: continue ll=re.search(r'\(lib_id "([^"]+)"',blk2); aa=re.search(r'\(at ([-\d.]+) ([-\d.]+) (\d+)\)',blk2) if ll and aa: for num,px,py in (cachedpins(ll.group(1)) or []): surv_pins.add((round(float(aa.group(1))+px,3), round(float(aa.group(2))-py,3))) assert not (seen - kill_coords) & surv_pins, 'wire chain touches a surviving pin - refusing' removed_wires=0 for pts,a,e in sorted(chain, key=lambda t:-t[1]): sch = sch[:a-1] + sch[e:]; removed_wires+=1 # labels at chain points (e.g. the GND at the far end of the ADD0 stub) out=[]; last=0; removed_lab2=0 for m in re.finditer(r'\n\t\((label|global_label) "[^"]+"\s*\n\s*\(at ([-\d.]+) ([-\d.]+) (\d+)\)(?:(?!\n\t\().)*?\n\t\)', sch, re.S): c=(round(float(m.group(2)),3),round(float(m.group(3)),3)) if c in (seen - kill_coords): out.append(sch[last:m.start()]); last=m.end(); removed_lab2+=1 out.append(sch[last:]); sch=''.join(out) removed_lab += removed_lab2 # re-find the symbol spans on the label-stripped text, delete (largest offset first) spans = [] for ref in doomed: a,e,l,X,Y = find_inst(ref) spans.append((a-1, e)) # a-1 swallows the leading newline for a,e in sorted(spans, key=lambda t:-t[0]): sch = sch[:a] + sch[e:] # ---------- 3. additions: P18 + R126 + C157 + PC4 label ---------- add = '' newlabels=[] def add_lab(net,x,y,rot,just): global add add += ('\n\t(label "%s"\n\t\t(at %s %s %d)\n\t\t(effects\n\t\t\t(font\n\t\t\t\t(size 1.27 1.27)\n' '\t\t\t)\n\t\t\t(justify %s)\n\t\t)\n\t\t(uuid "%s")\n\t)') % (net,f(x),f(y),rot,just,uuid.uuid4()) newlabels.append((net,round(x,3),round(y,3))) def mksym(lib_id, ref, val, x, y, fp, npins, lcsc=''): props=[('Reference',ref,x-3,y-3,''),('Value',val,x+3,y-3,''),('Footprint',fp,x,y,'\n\t\t\t(hide yes)'), ('Datasheet','~',x,y,'\n\t\t\t(hide yes)'),('Description','',x,y,'\n\t\t\t(hide yes)')] if lcsc: props.append(('LCSC',lcsc,x,y,'\n\t\t\t(hide yes)')) p='' for nm,v,px,py,hide in props: p += ('\t\t(property "%s" "%s"\n\t\t\t(at %s %s 0)%s\n\t\t\t(show_name no)\n\t\t\t(do_not_autoplace no)\n' '\t\t\t(effects\n\t\t\t\t(font\n\t\t\t\t\t(size 1.27 1.27)\n\t\t\t\t)\n\t\t\t)\n\t\t)\n') % (nm,v,f(px),f(py),hide) pn=''.join('\t\t(pin "%s"\n\t\t\t(uuid "%s")\n\t\t)\n' % (n,uuid.uuid4()) for n in npins) return ('\n\t(symbol\n\t\t(lib_id "%s")\n\t\t(at %s %s 0)\n\t\t(unit 1)\n\t\t(body_style 1)\n' '\t\t(exclude_from_sim no)\n\t\t(in_bom yes)\n\t\t(on_board yes)\n\t\t(in_pos_files yes)\n' '\t\t(dnp no)\n\t\t(uuid "%s")\n%s%s\t\t(instances\n\t\t\t(project "hbox-watchdog-v6"\n' '\t\t\t\t(path "%s"\n\t\t\t\t\t(reference "%s")\n\t\t\t\t\t(unit 1)\n\t\t\t\t)\n\t\t\t)\n\t\t)\n\t)' ) % (lib_id,f(x),f(y),uuid.uuid4(),p,pn,ROOT,ref) def orient(px,py): if abs(px) < 0.01: return 90,'left bottom' return (0,'left bottom') if px>0 else (180,'right bottom') def place(lib, ref, val, x, y, fp, nets, lcsc=''): pp = cachedpins(lib) if not pp: sys.exit('no pins for %s' % lib) global add add += mksym(lib,ref,val,x,y,fp,[num for num,_,_ in pp],lcsc) for num,px,py in pp: rot,just = orient(px,py) add_lab(nets[num],x+px,y-py,rot,just) add += ('\n\t(text "2ND EXTERNAL NTC PROBE (T10): same channel as P12/R42/C24 -> PC5. ' 'This one -> PC4 (ADC12_IN14). TMP102 (U4) and SHT40 probe jack (P5) deleted; ' 'on-board U3 SHT40 = tair+humidity."\n' '\t\t(exclude_from_sim no)\n\t\t(at 376 254 0)\n\t\t(effects\n\t\t\t(font\n\t\t\t\t(size 1.6 1.6)\n' '\t\t\t)\n\t\t\t(justify left bottom)\n\t\t)\n\t\t(uuid "%s")\n\t)') % uuid.uuid4() place('Connector_Generic:Conn_01x02','P18','NTC-PROBE-2',384.0,266.0, 'Connector_JST:JST_XH_B2B-XH-A_1x02_P2.50mm_Vertical',{'1':'NTC2','2':'GND'}) place('Device:R','R126','10k/1%',396.0,258.0,'Resistor_SMD:R_0603_1608Metric', {'1':'3V3','2':'NTC2'}) place('Device:C','C157','100nF',404.0,258.0,'Capacitor_SMD:C_0603_1608Metric', {'1':'NTC2','2':'GND'}) # PC4 = U16 pin 24 at sheet (518.16, 411.48): label + remove stale NC if present PC4 = (518.16, 411.48) add_lab('NTC2', PC4[0], PC4[1], 0, 'left bottom') out=[]; last=0; pc4_nc=0 for m in re.finditer(r'\n\t\(no_connect\s*\n\s*\(at ([-\d.]+) ([-\d.]+)\)(?:(?!\n\t\().)*?\n\t\)', sch, re.S): c=(round(float(m.group(1)),2),round(float(m.group(2)),2)) if c == PC4: out.append(sch[last:m.start()]); last=m.end(); pc4_nc+=1 out.append(sch[last:]); sch=''.join(out) # ---------- 4. collision check on the post-delete text ---------- labs = set() for m in re.finditer(r'\n\t\((label|global_label) "[^"]+"\s*\n\s*\(at ([-\d.]+) ([-\d.]+)', sch): labs.add((round(float(m.group(2)),3), round(float(m.group(3)),3))) wire_vts = set() for m in re.finditer(r'\(wire\s*\n\s*\(pts\s*\n((?:\s*\(xy [-\d.]+ [-\d.]+\)\s*\n?)+)', sch): for a,b in re.findall(r'\(xy ([-\d.]+) ([-\d.]+)\)', m.group(1)): wire_vts.add((round(float(a),3), round(float(b),3))) inst = {} for b in re.finditer(r'\n\t\(symbol\n(?:(?!\n\t\(symbol\n).)*', sch, re.S): blk=b.group(0) r=re.search(r'\(property "Reference" "([^"]+)"',blk); l=re.search(r'\(lib_id "([^"]+)"',blk); a=re.search(r'\(at ([-\d.]+) ([-\d.]+) (\d+)\)',blk) if r and l and a: inst[r.group(1)]=(l.group(1),float(a.group(1)),float(a.group(2))) pin_coords=set() for ref,(lb_,X_,Y_) in inst.items(): for num,px,py in (cachedpins(lb_) or []): pin_coords.add((round(X_+px,3), round(Y_-py,3))) INTENTIONAL = {(round(PC4[0],3),round(PC4[1],3))} existing = (labs | wire_vts | pin_coords) - INTENTIONAL coll = [(n,x,y) for n,x,y in newlabels if (x,y) in existing] if coll: sys.exit('LABEL COLLISION: %s' % coll[:8]) end = sch.rstrip(); assert end.endswith(')') sch = end[:-1].rstrip('\n') + add + '\n)\n' lost = uuids_before - set(re.findall(r'\(uuid "([0-9a-f-]+)"\)', sch)) # the two deleted symbols and their pin/label/nc uuids are EXPECTED losses; count them print('deleted: U4 (TMP102) + P5 (TEMP_PROBE); labels removed: %d, NCs: %d, stub wires: %d' % (removed_lab, removed_nc, removed_wires)) print('pre-existing uuids gone: %d (expected: the 2 symbols + their %d labels + %d NCs + pin uuids)' % (len(lost), removed_lab, removed_nc)) print('added: P18 NTC-PROBE-2 + R126 10k + C157 100nF; labels: %d; PC4 label placed, PC4 stale NC removed: %d' % (len(newlabels)+1, pc4_nc)) print('paren: %d' % bal(sch)) if not APPLY: print('\nDRY RUN - nothing written. Re-run with --apply.'); sys.exit(0) if bal(sch)!=0: sys.exit('REFUSING: paren imbalance') shutil.copy2(SCH,SCH+'.T10bak') open(SCH,'w',encoding='utf-8').write(sch) print('\nAPPLIED. Backup: .T10bak')