# -*- coding: utf-8 -*- u"""R2 刀片 **B2-CTRL** 页:PCA9555 + 侧键 MOS ×3 + 本槽 I²C 上拉。 /c/Python314/python .claude/r2/b2_ctrl.py # 只算 + 本地自检 /c/Python314/python .claude/r2/b2_ctrl.py --push # 清页 + 重画(幂等) ## 这一页画什么 | 位号 | 器件 | 作用 | |---|---|---| | `U4` | PCA9555PW(0x20)| 3 个侧键 + `EDL_EN` + `VBUS_EN` 输出;4 路状态输入 | | `Q3/Q4/Q5` | 2N7002 | 侧键低边 MOS(R1 §13.3 原样:1k 串阻 + 100k 栅极下拉)| | `R40/R41` | 4.7k | 本槽 mux 分支的 I²C 上拉(每片刀片各自一套)| | `R42~R47` | 1k ×3 + 100k ×3 | 三个栅极的串阻与下拉 | | `C40` | 100nF | PCA9555 去耦 | ## ⚠️ 2026-09-08 改:**DAC 挪到底板**(推翻 D11 的「一片刀片一颗」) 原来这一页还有 `U4 DAC63204`(只用 CH0)。用户拍板改成**底板 2 颗 DAC63204 喂 5 路** —— 就是 R1 的 U6/U7 那套原样搬,固件 A4 驱动、NVM 预置 0.548V、 §4.5 的三条外围硬要求、通道映射表全部现成,不引入任何新料。 **为什么敢让 `VDAC` 跨连接器**(设计文档 §4.4 的失效表已经算过): | 故障 | Vd | Vout | |---|---|---| | 金手指断了 / 空槽 | 悬空 | **3.890 V** ✅ 正好是目标值 | | 对地短路 | 0 V | 4.010 V ✅ | | 短到 +3V3 | 3.3 V | 3.35 V ✅ 安全侧(手机关机)| 注入增益只有 **0.2007**,线上噪声和两板之间的地差到输出都**衰减 5 倍**; 而 §4.5 要求的 `R8 1k + C12 10nF` 低通留在刀片上(Rinj 的 DAC 侧), 正好把长线拾到的东西全滤掉 —— **B1-POWER 一个字节不用改**。 ⇒ 位号顺移:PCA9555 从 `U5` 变 **`U4`**,B3-PHONE 的 TPS22918 / TS5A3157 相应变成 `U5` / `U6`(方案 §7.2b 已同步)。 ## 本页不放的四样(别在这里找) - **DAC** -> 底板 `A3-CTRL`(2 × DAC63204,0x4A / 0x4B) - `EDL_EN` / `VBUS_EN` 的 100k 下拉 -> **B3-PHONE**(贴各自的负载,R1 R741/R701 就在那边) - `INT#` 的上拉 -> **底板 A3-CTRL**(五槽 wire-OR 共用一颗 10k,刀片上放会并成 2k) - `EFUSE_EN` -> **不经 PCA9555**,走金手指专用针直连底板 ESP32(方案 §7.3 的框) ## 几何约定 skill §6b/§6c:`create()` 的 rotation 语义同一个料号在不同页都可能相反, 所以放完逐个 `getAllPins()` 读回、和 `EXPECT` 比对,对不上就翻 180 重试(最多 3 轮)。 两脚件按**坐标集合**比(非极性件翻转只换脚号,无害)。 """ import io import json import os import sys from collections import defaultdict HERE = os.path.dirname(os.path.abspath(__file__)) # ⚠ `.claude` 下有和 r2 同名的 R1 脚本(pcb_pour / pcb_rules / pcb_place)。 # 只插父目录的话,**后面任何一个 `import pcb_rules` 都会拿到 R1 那一份**, # 而且不报错,只在调到 R2 专有的函数时才炸(2026-09-08 踩过)。 # 规矩:两行都要写,HERE 后插(在 sys.path 最前)。 sys.path.insert(0, os.path.dirname(HERE)) sys.path.insert(0, HERE) from eda import run # noqa: E402 PAGE = '4f458d678c50f7a4' # BLADE / B2-CTRL PAGE_NAME = 'B2-CTRL' PARTS = json.load(io.open(os.path.join(os.path.dirname(HERE), 'eda-parts.json'), encoding='utf-8'))['parts'] PCA, MOS = 'C2688893', 'C8545' R4K7, R1K, R100K, C100N = 'C25900', 'C11702', 'C25741', 'C1525' U4 = (420, 640) # PCA9555,rot 0:输出在左、输入与 I²C 在右 PU = [(700, 'SDA'), (770, 'SCL')] # 两颗 4.7k 上拉的 x BX = [250, 550, 850] # 三路侧键 MOS 的中心 x KEYS = ['PWR', 'VUP', 'VDN'] # ── 引脚期望(相对中心的偏移,来自 R1 P3B-EXPAND / C4-SITE1 实测)────────── PCA_REL = dict([(str(i + 1), (-50, 55 - 10 * i)) for i in range(12)] + [(str(13 + i), (50, -55 + 10 * i)) for i in range(12)]) MOS_REL = {'1': (-20, 0), '2': (10, -20), '3': (10, 20)} # G / S / D NC = {} # 本页没有有意不接的脚 ALLOW_SPLIT = set(['GND', '+3V3', 'SDA', 'SCL'] + ['KEY_' + k for k in KEYS]) # 靠端口连起来的 def call(js, timeout=180): r = run(js, timeout=timeout) if not r.get('success'): raise RuntimeError(json.dumps(r, ensure_ascii=False)[:900]) return r['result'] def plan(): W, F, PT = [], [], [] def w(net, *pts): for a, b in zip(pts, pts[1:]): if a != b: W.append((a, b, net)) def gnd(x, y, rot): F.append(['Ground', 'GND', x, y, rot]) def pwr(x, y, rot): F.append(['Power', '+3V3', x, y, rot]) def port(d, net, x, y, rot): PT.append([d, net, x, y, rot]) # ── I²C 上拉(本槽 mux 分支各自一套;INA226 在 B1-POWER,也吃这两颗)── for x, net in PU: w(net, (x, 700), (x, 675)) port('BI', net, x, 675, 0) w('+3V3', (x, 740), (x, 765)) pwr(x, 765, 0) # ── U4 PCA9555 ─────────────────────────────────────────────────────── px, py = U4 PL, PR = px - 50, px + 50 w('INT#', (PL, py + 55), (330, py + 55), (330, 745)) # 1 INT# port('OUT', 'INT#', 330, 745, 0) w('GND', (PL, py + 45), (300, py + 45)) # 2 A1 w('GND', (PL, py + 35), (300, py + 35)) # 3 A2 w('GND', (300, py + 35), (300, 725)) gnd(300, 725, 180) OUTS = [('KEY_PWR', 25), ('KEY_VUP', 15), ('KEY_VDN', 5), ('EDL_EN', -5), ('VBUS_EN', -15)] # IO0_0 ~ IO0_4 for net, dy in OUTS: w(net, (PL, py + dy), (300, py + dy)) port('OUT', net, 300, py + dy, 180) for dy in (-25, -35, -45, -55): # IO0_5~7 + VSS w('GND', (PL, py + dy), (320, py + dy)) w('GND', (320, py - 25), (320, 555)) gnd(320, 555, 0) INS = [('EFUSE_FLT', -55), ('BUCK_PG', -45), ('INA_ALERT', -35), ('EFUSE_PG', -25)] # IO1_0 ~ IO1_3 for net, dy in INS: w(net, (PR, py + dy), (510, py + dy)) port('IN', net, 510, py + dy, 180) for dy in (-15, -5, 5, 15, 25): # IO1_4~7 + A0 w('GND', (PR, py + dy), (570, py + dy)) w('GND', (570, py - 15), (570, 720)) gnd(570, 720, 180) w('SCL', (PR, py + 35), (490, py + 35)) port('BI', 'SCL', 490, py + 35, 0) w('SDA', (PR, py + 45), (490, py + 45)) port('BI', 'SDA', 490, py + 45, 0) w('+3V3', (PR, py + 55), (520, py + 55)) # 24 VDD -> C40 下脚 w('+3V3', (490, py + 55), (490, 730)) pwr(490, 730, 0) w('GND', (520, 735), (520, 760)) gnd(520, 760, 180) # ── 侧键 MOS ×3(R1 §13.3:栅极 1k 串阻 + 100k 下拉,源极接地)──────── for k, bx in enumerate(BX): g, kg = 'KEY_' + KEYS[k], 'KG_' + KEYS[k] w(kg, (bx - 20, 400), (bx - 60, 400)) # 栅极 w(kg, (bx - 60, 420), (bx - 60, 400)) # 1k 下脚 w(kg, (bx - 60, 380), (bx - 60, 400)) # 100k 上脚 w(g, (bx - 60, 460), (bx - 60, 500)) # 1k 上脚 -> 端口 port('IN', g, bx - 60, 500, 0) w('GND', (bx - 60, 340), (bx - 60, 315)) gnd(bx - 60, 315, 0) w('GND', (bx + 10, 380), (bx + 10, 340)) # 源极 gnd(bx + 10, 340, 0) w('KPAD_' + KEYS[k], (bx + 10, 420), (bx + 10, 465)) # 漏极 -> 中框焊盘 port('OUT', 'KPAD_' + KEYS[k], bx + 10, 465, 0) TX = [ (45, 790, u'【B2-CTRL】刀片控制页:PCA9555 + 侧键 MOS ×3(DAC 已挪到底板)', 14), (45, 275, u'U4 PCA9555(A0/A1/A2 全接地 ⇒ 0x20):P0.0~P0.2 三个侧键 / P0.3 EDL_EN / ' u'P0.4 VBUS_EN 为输出;P1.0~P1.3 收 EFUSE_FLT / BUCK_PG / INA_ALERT / EFUSE_PG。', 10), (45, 257, u'⚠ 备用脚 P0.5~P0.7 / P1.4~P1.7 直接接地:固件严禁把它们配成输出,' u'否则输出高电平直接对地短路(R1 §17.2 同款约束)。', 10), (45, 239, u'⚠ 复位安全(D9):PCA9555 上电为高阻输入 + 栅极 100k 下拉 ⇒ ' u'三个侧键必然松开、VBUS 必然断开,不依赖任何软件动作。', 10), (45, 221, u'2026-09-08 改:DAC63204 挪到底板(2 颗喂 5 路,沿用 R1 的 U6/U7 与通道映射表)。' u'VDAC 从金手指进来,直接去 B1-POWER 的 R8/C12 低通 + Rinj。', 10), (45, 203, u'⚠ VDAC 跨连接器是安全的(设计文档 §4.4):断线 = Rinj 悬空 = 3.890V;对地短路 = 4.010V。' u'注入增益 0.2007,线上噪声与两板地差到输出衰减 5 倍。', 10), (45, 185, u'⚠ EDL_EN / VBUS_EN 的 100k 下拉在 B3-PHONE;INT# 的上拉在底板(五槽 wire-OR)。', 10), (45, 167, u'⚠ EFUSE_EN 不经 PCA9555,直连底板 ESP32(方案 §7.3)。', 10), (45, 149, u'4.7k I²C 上拉每片刀片自带;INA226 仍在 B1-POWER。', 10), ] # 文字越界自检(skill §12 的宽度经验值 + §8c 的标题栏范围)—— # 这一条几何验证抓不到:文字压在标题栏上,六项检查全过,只有截图看得见。 for x, y, s, size in TX: cjk = sum(1 for ch in s if ord(ch) > 0x2E7F) right = x + size * (cjk + 0.5 * (len(s) - cjk)) limit = 455 if y < 192 else 1145 assert right <= limit, u'文字右边界 %.0f 越过 %d(y=%d): %s' % (right, limit, y, s[:24]) # ── 下发前的几何断言(skill §3 / §6d / §8a-2b)──────────────────────── for a, b, net in W: assert (a[0] == b[0]) != (a[1] == b[1]), u'%s 段不是横平竖直: %s->%s' % (net, a, b) for v in (a[0], a[1], b[0], b[1]): assert v % 5 == 0, u'%s 坐标 %d 不是 5 的倍数' % (net, v) for it in F + PT: assert it[2] % 5 == 0 and it[3] % 5 == 0, u'旗/端口 %s 坐标不是 5 的倍数' % (it[1],) assert 20 <= it[2] <= 1145 and 10 <= it[3] <= 812, u'旗/端口 %s 出框' % (it[1],) return W, F, PT, TX # ── 本地几何自检(skill §10b)───────────────────────────────────────────── def _on(p, a, b): if a[0] == b[0]: return p[0] == a[0] and min(a[1], b[1]) <= p[1] <= max(a[1], b[1]) return p[1] == a[1] and min(a[0], b[0]) <= p[0] <= max(a[0], b[0]) def netcheck(W, F, PT, pins): errs = [] n = len(W) par = list(range(n)) def find(i): while par[i] != i: par[i] = par[par[i]] i = par[i] return i for i in range(n): for j in range(i + 1, n): a1, a2, _ = W[i] b1, b2, _ = W[j] if _on(a1, b1, b2) or _on(a2, b1, b2) or _on(b1, a1, a2) or _on(b2, a1, a2): ri, rj = find(i), find(j) if ri != rj: par[ri] = rj grp = defaultdict(list) for i in range(n): grp[find(i)].append(i) pieces = defaultdict(int) for root, idx in grp.items(): names = sorted(set(W[i][2] for i in idx)) if len(names) > 1: errs.append(u'短路: ' + ' + '.join(names) + u' :: ' + ' | '.join('%s%s-%s' % (W[i][2], W[i][0], W[i][1]) for i in idx[:6])) pieces[names[0]] += 1 for name, cnt in sorted(pieces.items()): if cnt > 1 and name not in ALLOW_SPLIT: errs.append(u'网络 %s 被拆成 %d 块(漏连)' % (name, cnt)) for d, pd in pins.items(): for num, xy in pd.items(): if num in NC.get(d, []): continue if not any(_on(tuple(xy), s[0], s[1]) for s in W): errs.append(u'悬空引脚: %s.%s @ %s' % (d, num, tuple(xy))) # 旗/端口:必须落在导线**端点**,且不能是合并后母线的中段(skill §8a-2 / §8a-3) for kind, lst in ((u'旗', F), (u'端口', PT)): for it in lst: p = (it[2], it[3]) through = any((s[0][0] == s[1][0] and p[0] == s[0][0] and min(s[0][1], s[1][1]) < p[1] < max(s[0][1], s[1][1])) or (s[0][1] == s[1][1] and p[1] == s[0][1] and min(s[0][0], s[1][0]) < p[0] < max(s[0][0], s[1][0])) for s in W) ends = [s for s in W if s[0] == p or s[1] == p] if through: errs.append(u'%s %s @ %s 压在导线中段' % (kind, it[1], p)) elif not ends: errs.append(u'%s %s @ %s 不在任何导线端点上' % (kind, it[1], p)) else: ax = set(('V' if s[0][0] == s[1][0] else 'H') for s in ends) if len(ends) > 1 and len(ax) == 1: errs.append(u'%s %s @ %s 两段共线,合并后会变成中段' % (kind, it[1], p)) return errs def crossings(W): out = [] for i in range(len(W)): for j in range(i + 1, len(W)): a1, a2, na = W[i] b1, b2, nb = W[j] if na == nb: continue ha, hb = a1[1] == a2[1], b1[1] == b2[1] if ha == hb: continue h, v = ((a1, a2), (b1, b2)) if ha else ((b1, b2), (a1, a2)) y, x = h[0][1], v[0][0] if (min(h[0][0], h[1][0]) < x < max(h[0][0], h[1][0]) and min(v[0][1], v[1][1]) < y < max(v[0][1], v[1][1])): out.append((na, nb, x, y)) return out def placement(): px, py = U4 out = [['U4', PCA, px, py, 0], ['C40', C100N, 520, 715, 270], ['R40', R4K7, PU[0][0], 720, 270], ['R41', R4K7, PU[1][0], 720, 270]] for k, bx in enumerate(BX): out += [['Q%d' % (k + 3), MOS, bx, 400, 0], ['R%d' % (42 + 2 * k), R1K, bx - 60, 440, 270], ['R%d' % (43 + 2 * k), R100K, bx - 60, 360, 270]] return out def expect(place): u"""期望引脚坐标。IC 逐脚;两脚件只比坐标集合(非极性件翻转只换脚号)。""" exp = {} for d, lcsc, x, y, rot in place: if lcsc == PCA: exp[d] = dict((k, (x + dx, y + dy)) for k, (dx, dy) in PCA_REL.items()) elif lcsc == MOS: exp[d] = dict((k, (x + dx, y + dy)) for k, (dx, dy) in MOS_REL.items()) else: exp[d] = {'?': set([(x, y + 20), (x, y - 20)])} return exp def main(): push = '--push' in sys.argv place = placement() for it in place: assert it[1] in PARTS, u'eda-parts.json 里没有 %s' % it[1] exp = expect(place) pins = {} for d, want in exp.items(): if '?' in want: a, b = sorted(want['?']) pins[d] = {'1': a, '2': b} else: pins[d] = dict(want) W, F, PT, TX = plan() errs = netcheck(W, F, PT, pins) print(u'元件 %d 导线 %d 旗 %d 端口 %d 文字 %d' % (len(place), len(W), len(F), len(PT), len(TX))) xs = crossings(W) print(u'交叉点(不相连,供人工确认): %d %s' % (len(xs), xs if xs else '')) if errs: print(u'本地自检 %d 条问题:' % len(errs)) for e in errs: print(u' ! ' + e) raise SystemExit(1) print(u'本地自检通过(短路 0 / 漏连 0 / 悬空 0 / 旗端口全在端点)') if not push: return n = call(""" await eda.dmt_EditorControl.openDocument('%s'); await new Promise(r=>setTimeout(r,1200)); const nm=(await eda.dmt_Schematic.getCurrentSchematicPageInfo()).name; if (nm!=='%s') throw new Error('page='+nm); let k=0; for (const c of await eda.sch_PrimitiveComponent.getAll()){ if (c.getState_ComponentType()==='sheet') continue; await eda.sch_PrimitiveComponent.delete(c.getState_PrimitiveId()); k++; } for (const w of await eda.sch_PrimitiveWire.getAll()) await eda.sch_PrimitiveWire.delete(w.getState_PrimitiveId()); for (const t of await eda.sch_PrimitiveText.getAll()) await eda.sch_PrimitiveText.delete(t.getState_PrimitiveId()); return k; """ % (PAGE, PAGE_NAME)) print(u'%s 已清空(删掉 %d 个图元)' % (PAGE_NAME, n)) put = [[d, PARTS[l]['uuid'], x, y, rot, PARTS[l]['lib']] for d, l, x, y, rot in place] for it in put: assert len(it) == 6 and it[5], u'placement 少了 libraryUuid: %s' % (it,) # skill §9c-2 for i in range(0, len(put), 4): call(""" for (const [d,uuid,x,y,rot,lib] of %s){ await eda.sch_PrimitiveComponent.create({libraryUuid:lib, uuid}, x, y, undefined, rot, false, true, true); for (const c of await eda.sch_PrimitiveComponent.getAll()){ if (c.getState_ComponentType()!=='part') continue; if (Math.round(c.getState_X())!==x || Math.round(c.getState_Y())!==y) continue; await c.toAsync().setState_Designator(d).done(); } } return 1; """ % json.dumps(put[i:i + 4])) print(u'已放置 %d 个元件' % len(put)) READ = """ const out={}; for (const c of await eda.sch_PrimitiveComponent.getAll()){ if (c.getState_ComponentType()!=='part') continue; out[c.getState_Designator()] = (await c.getAllPins()).map( p=>[p.getState_PinNumber(), Math.round(p.getState_X()), Math.round(p.getState_Y())]); } return out; """ rots = dict((d, rot) for d, _l, _x, _y, rot in place) for rnd in range(4): got = call(READ) bad = [] for d, want in exp.items(): if d not in got: raise SystemExit(u'读不到元件 %s' % d) have = dict((p, (x, y)) for p, x, y in got[d]) if '?' in want: if set(have.values()) != want['?']: bad.append((d, sorted(want['?']), sorted(have.values()))) else: for p, xy in sorted(want.items()): if have.get(p) != xy: bad.append((d, p, xy, have.get(p))) break if not bad: print(u'引脚坐标逐个核对通过(%d 个元件)' % len(exp)) break if rnd == 3: raise SystemExit(u'引脚坐标对不上:\n ' + '\n '.join(repr(b) for b in bad)) names = sorted(set(b[0] for b in bad)) print(u'第 %d 轮:翻转 %d 件重试 %s' % (rnd + 1, len(names), names)) call(""" for (const [d,rot] of %s){ for (const c of await eda.sch_PrimitiveComponent.getAll()){ if (c.getState_ComponentType()!=='part') continue; if (c.getState_Designator()!==d) continue; await c.toAsync().setState_Rotation(rot).done(); } } return 1; """ % json.dumps([[d, (rots[d] + 180 * (rnd + 1)) % 360] for d in names])) def batch(items, size, js): for i in range(0, len(items), size): call(js % json.dumps(items[i:i + size], ensure_ascii=False)) batch([[a[0], a[1], b[0], b[1]] for a, b, _ in W], 40, "for (const s of %s) await eda.sch_PrimitiveWire.create(s); return 1;") batch(PT, 25, "for (const p of %s) await eda.sch_PrimitiveComponent.createNetPort(p[0],p[1],p[2],p[3],p[4],false); return 1;") batch(F, 25, "for (const f of %s) await eda.sch_PrimitiveComponent.createNetFlag(f[0],f[1],f[2],f[3],f[4],false); return 1;") batch([list(t) for t in TX], 12, "for (const t of %s) await eda.sch_PrimitiveText.create(t[0],t[1],t[2],0,null,null,t[3],false,false,false,0); return 1;") json.dump(NC, io.open(os.path.join(HERE, 'b2-nc.json'), 'w', encoding='utf-8'), ensure_ascii=False, indent=1) print(u'推送完成;接着跑 verify_sch.py --nets') if __name__ == '__main__': main()