# -*- coding: utf-8 -*- u"""R2 底板 **A4-HUB** 页:USB2517I 七口 Hub + Hub 专用 3.3V LDO。 /c/Python314/python .claude/r2/a4_hub.py # 只算 + 本地自检 /c/Python314/python .claude/r2/a4_hub.py --push # 清页 + 重画(幂等,只有主代理跑) M3 的第四页。**主体是 R1 载板 `C2-HUB` 整页原样搬过来**(`OFF = (0,0)`,块内坐标一个字 不动,连 U32 / J23 / Y1 / 去耦阵列的绝对引脚坐标都照抄 `_c2hub_live.json`), 在此之上做三处改动: | # | 改动 | 依据 | |---|---|---| | 1 | **新增 `U31` AMS1117-3.3 + `C752`/`C753`/`C737`** —— R1 里这颗不在 C2-HUB 页上 | 契约 §1.1 / §2 | | 2 | **放出第 6 个下行口**:`USBDN6_DM/DP`(53/54) → `USB6_DM/DP` 双向端口去 A3 | 方案 §8.2(D13)| | 3 | `HUB_RST` 的保命上拉保留并写进页面文字;`UP_VBUS_DET` 端口 `BI`→`OUT`、`HUB_RST` 的第二个端口 `BI`→`IN` | 契约 §3 第 1 条 / §1.2 | --- ## ⭐ T6:`CFG_SEL[2:0]=110` 下七个下行口是不是默认全开?—— **是,全开** 逐条对 `kicad/datasheets/ic/USB2517.pdf`(Microchip **DS00001598C**,USB2517/USB2517I, 59 页,sha256 记在同名 `.json` 里;和 `eda-parts.json` 指名要用的 SMSC 2517 Rev 2.8 是同一份内容的 Microchip 重排版,**不是** USB251x**B** 的 00001692C): | 手册位置 | 原文 | 结论 | |---|---|---| | **Table 5-2**(p.15~16)`CFG_SEL[2:0]=110` | 「Internal Default Configuration • Strap Options are Disabled」 | strap **一个都不采样**,`PRT_DIS_*` 失效 | | **Table 7-1**(p.20)`0Ah PDS` Port Disable (Self) | 默认 **00h** | ⇒ 七个口**全部可用** | | **Table 7-1**(p.20)`0Bh PDB` Port Disable (Bus) | 默认 **00h** | 同上(本板自供电,看 0Ah)| | **7.2.1.11**(p.26)`PORT_DIS_SP` 位定义 | 「'0' = port is available, '1' = port is disabled」 | 00h = 一个都没禁 | | **Table 7-1**(p.20)`09h NRD` Non-Removable | 默认 **00h** | 全部可移除;`NON_REM[1:0]` 是 strap,同样不采样 | | **Table 7-1**(p.20)`06h CFG1` | 默认 **9Bh**,bit7 `SELF_BUS_PWR`=1 | 自供电(只有 `010` 那一档强制总线供电,`110` 不改)| ⇒ **第 6 个口不需要改任何 strap,直接把 `USBDN6_DP/DM` 引出来接线即可**。 方案 §8.2 表第 6 条写的「若需要禁用多余口,改的是 strap 而不是电路」在这里反过来了: 本来就全开,要做的是**连线**,不是改 strap。 ⭐ **2026-09-10 R2 底板改回 `CFG_SEL[2:0]=000`(strap 使能)—— 推翻 §21.6b,仅底板;R1 载板仍是 110。** 理由:底板上口 1~3 的 D± 北去,Hub 侧 D−/D+ 左右顺序与槽上相反,原来靠「U 形掉头」构造对调, 占掉 U32 北面最挤的一块。strap 使能后 `PRT_SWPx` 上拉 = 该口 D+/D− 在芯片内对调,直走即可。 R1 当年放弃 strap 是因为载板上 51/49/47 三颗电阻「走线 0 解」;底板 U32 转了 270°,这三只脚 外侧只有去耦和可改道的布线挡着(2026-09-10 实测),摆位阶段与去耦一起求解。 CFG_SEL2(13) —— 纯输入 → 由 +3V3_HUB 端口改 **GND 端口** CFG_SEL1(42) —— 复用 HS_IND 输出 → R712 10k 由上拉改 **下拉**(换那面旗) CFG_SEL0(41) —— R711 10k 下拉,不动 ⇒ 0/0/0 = 000 PRT_SWP1~3(51/49/47) —— R724~R726 100k **上拉** = 口 1~3 D+/D− 对调; ⚠ 本页 1/2、3/4、6/7 脚的端口网名**同时对调**(1 脚 → USB1_DP …), 否则片内翻了、网表没翻,极性反而错(`to_strap_mode()` / `SWAP_ROWS`) PRT_SWP4~6(33/31/17) —— R727~R729 100k **下拉** 钉住(口 4/5 手机、口 6 ESP32;I/O12 无内部下拉,§21.6a) PRT_SWP7 / BOOST / GANG_EN —— 采到 1 无害(口 7 空着),不装(§21.6a 同一筛法) NON_REM[1:0] —— R719/R720 各 10k 下拉 = 00,R1 原样(strap 使能后才真正起作用) 另:**R723 1 MΩ 跨 XTAL1–XTAL2**(USB2517 Hardware Design Checklist p9:「A 1 MΩ resistor connected across XTAL1 and XTAL2 is required. Failure to place this resistor will result in unstable crystal operation.」;数据手册 p52 Figure 9-1 也画着 1Meg)—— R1 起就漏了。 ⚠️ **口 7 仍悬空**(`USBDN7_DP/DM` 55/56 不接):strap 使能后 PRT_DIS 采的是 D± 上拉, 悬空时被 Hub 内部 15k 下拉拉低 ⇒ 口 7 开着、空闲,无害。R714/R715 不装。 ## 第 6 个口要不要串阻 / ESD?——**不要**,和 R1 的五个口一样直连 R1 的口 1~5(`_c2hub_live.json` 实测)是 `U32` 引脚 → 一段短线 → `USBn_DP/DM` 端口, **中间一颗元件都没有**;设计文档也写着「不要在 D± 上串电阻或加 ESD 之外的任何东西」, 而 ESD 在手机原厂尾插小板上。第 6 个口的对端是**同一块板上的 ESP32-S3 原生 USB** (IO19=D−/IO20=D+),不过连接器、不出板、走线 ≈25mm(方案 §6.3 第 6 对), 比五个手机口的处境好得多 ⇒ 照做,直连。 ## U31:5V → 3.3V,只喂 U32 Vin = +5V(A2 的 U30,2A 轨) Vout = +3V3_HUB(**只**给 U32) Hub 满配电流 ≈ 0.42 A(设计文档 §9.4) 耗散 P = 0.42 × (5.0 − 3.3) = **0.714 W**,SOT-223 封装 → PCB 上必须给 SOT-223 的散热片(2 脚 tab)铺一块铜: θJA 靠铜面从 ~150 °C/W 压到 ~60 °C/W ⇒ ΔT ≈ 43 K,环境 40 °C 下结温 ~83 °C, 对 125 °C 的上限有余量。**这条是 PCB 的约束,原理图上只能记着。** 压差裕量:AMS1117 手册 p.1「dropout guaranteed maximum 1.3 V」(@0.8 A), 这里只有 0.42 A、压差 1.7 V ⇒ 够。 输出电容 **必须是固态钽** `C737` T491A226K010AT 22µF/10V(`C116575`)—— AMS1117 手册 §Stability 原文:「The addition of **22µF solid tantalum** on the output will ensure stability for all operating conditions.」**陶瓷不算数**(ESR 太低会让环路失稳)。 ⚠️ 这颗**1 脚是正极**(钽的条纹侧),本脚本用显式引脚号断言 `1` 在上、`2` 在下, 放件时若方向反了 `Sheet.place()` 会自己翻 180 重试(skill §6b/§6c)。 输入侧 `C752` 10µF/50V 0805(`C440198`)+ `C753` 100nF 0402(`C1525`),都在 `+5V` 干线上。 ⚠️ **契约 §3 第 3 条:`+3V3_HUB` 与 `+3V3` 是两条轨。** 本页从头到尾没有 `+3V3`, 也没有 `+12V`;两条轨只在 GND 上相遇(`build()` 里有断言)。 ## HUB_RST 的保命处理(契约 §3 第 1 条,硬约束) `R716` 10k 把 `RESET_N`(43) 上拉到 `+3V3_HUB`(R1 已有,本页原样保留、不许删, `main()` 里逐脚断言它还在原位)。ESP32 复位 / 高阻 / 未烧固件时 `HUB_RST` 被这颗电阻 拉高 = **Hub 保持工作**。理由写进页面文字:D13 之后 ESP32 的控制台与烧录口就挂在这颗 Hub 后面,一旦「不驱动 = 复位」,误复位就等于自断控制台,而且下一次烧录也进不来 (退路是 A3 的 `J33` 5P UART 应急口,但那是退路,不是设计依据)。 ## 几何约定(skill) Y 轴向上(§1)、坐标全部 5 的倍数(§3/§8a-2b)、旗端口只落在导线端点(§8a/§8a-2)、 端口方向决定标签在左还是在右(§8b:`IN` 与 `BI`/`OUT` 相反)、 netport 符号只有 ~10 高,10 单位间距排一列不打架(§8b-2)、 标题栏 `x>455 且 y<192`(§8c)、文字宽度 ≈ 字号×(CJK + 0.5×ASCII)(§12)。 """ import io import json import os import sys from collections import defaultdict HERE = os.path.dirname(os.path.abspath(__file__)) sys.path.insert(0, HERE) from schcheck import Sheet, _on, call, two_pin, PARTS # noqa: E402 PAGE, NAME = '09d583ff95cd35d9', 'A4-HUB' R2_PROJECT = '05a65c22f14846b8b95360ef8d3c1e2e' SRC = os.path.join(HERE, '_c2hub_live.json') # R1 载板 C2-HUB 实测 dump OFF = (0, 0) # 整页原样搬,一个字不动 # ── ⚠ 唯一一处必须挪的 R1 图元:VBUS_UP 分压那一小块 ──────────────────── # R1 载板 C2-HUB 上它坐在 y 45~135 / x 780~1060,搬到本页正好**落进 A4 标题栏** # (skill §8c:标题栏是 x>455 且 y<192,比想象的大)—— 图上 R721 / R722 / # UP_VBUS_DET 端口直接压在「PhoneRail-R2」和日期格上。 # 六项几何验证不看这个(它只管连接性),`text_clash` 也只查文字, # **是靠人眼截图发现的**。整块平移 +380,落到 x 730~1070 / y 425~515 那条空带 # (上边是 U32 左列端口的 y≤465 @ x=760、下边是 +3V3_HUB 端口 y=525,都不撞)。 SHIFT_RECT = (770, 40, 1080, 140) # 源页坐标里的这一块 # ⚠ 2026-09-10 再右移 40(原 (0,380)):改动 4 在 U32 右列 47/49/51 行(y 475/495/515)加了 strap 电阻, # 原位置的 `VBUS_UP` 端口 (800,500) 标签往左伸到 x≈725,正压在 49 脚那行的 R725 上(截图发现, # 几何验证 / text_clash 都够不着)。右移后标签到 765 为止,R725 那一行的铜到 750 为止。 SHIFT_D = (40, 380) def _in_rect(x, y, r=SHIFT_RECT): return r[0] <= x <= r[2] and r[1] <= y <= r[3] def shift_block(W, F, PT, place, exp): u"""把 SHIFT_RECT 里的图元整块平移 SHIFT_D。跨界的导线一律当错误。""" dx, dy = SHIFT_D W2 = [] n = 0 for a, b, net in W: ia, ib = _in_rect(*a), _in_rect(*b) assert ia == ib, u'导线 %s %s-%s 跨过 SHIFT_RECT 边界,不能整块平移' % (net, a, b) if ia: W2.append(((a[0] + dx, a[1] + dy), (b[0] + dx, b[1] + dy), net)) n += 1 else: W2.append((a, b, net)) for lst in (F, PT): for it in lst: if _in_rect(it[2], it[3]): it[2] += dx it[3] += dy n += 1 moved = [] for it in place: if _in_rect(it[2], it[3]): it[2] += dx it[3] += dy moved.append(it[0]) n += 1 for d in moved: exp[d] = dict((k, (v[0] + dx, v[1] + dy)) for k, v in exp[d].items()) assert sorted(moved) == ['R721', 'R722'], u'平移到的元件不是 R721/R722 而是 %s' % (moved,) assert n, u'SHIFT_RECT 一个图元都没框到 —— 源页坐标变了?' return W2 # ── 改动 3:端口方向按契约 §1.2 校正(键 = (网名, x, y) → 新 dir)─────────── # R1 这两个是随手写的 BI;契约里 `UP_VBUS_DET` 是 A4 发给 A3、`HUB_RST` 是 A3 发给 A4, # 主代理要拿端口清单对账,方向必须对得上。**只改 dir,坐标一个字不动**。 # ⚠ 顺带:`IN` 与 `BI`/`OUT` 的标签方向正好相反(skill §8b),所以改 dir 会让标签换边 —— # (1130,340) 是 rot=180,`BI` 时标签在左、`IN` 时在右,右边到 1145 只剩 15, # 于是这一个连 rot 一起翻成 0(`PORT_ROT`),标签仍旧落在左边。 PORT_DIR = { ('UP_VBUS_DET', 930, 60): 'OUT', # A4 → A3(电脑在不在) ('HUB_RST', 1130, 340): 'IN', # A3 → A4;(760,435) 那个 R1 本来就是 IN } PORT_ROT = {('HUB_RST', 1130, 340): 0} # ── 改动 2:第 6 个下行口(D13)────────────────────────────────────────── # U32 右列 53=USBDN6_DM(680,535) / 54=USBDN6_DP(680,545)。 # 右侧端口列在 x=760,R1 已占 405/415/425/455/465/525/575/585/595/605/615/625/635/645/655, # **535 / 545 正好是空的**,插进去即可,间距仍是 10(skill §8b-2)。 USB6 = [('USB6_DM', 535), ('USB6_DP', 545)] # ── 改动 1:U31 AMS1117-3.3 块(新画)─────────────────────────────────── # 落在 R1 版图留下的空带 x 300~900 / y 190~330(`assert_clear()` 每次重算校验)。 # 引脚(契约 §4 实测,rot 0,相对中心):1 GND(-45,+10) 2 VOUT(-45,0) 3 VIN(-45,-10) 4 VOUT(+45,0) # ⚠ GND 夹在 VIN 与 VOUT 中间、且在 VIN **上方**,所以「+5V 从左边平进、GND 往左再往下」 # 是唯一不产生交叉的走法:GND 的下行点必须**在 +5V 端口符号左边** # (+5V 引脚 x=440,IN+rot0 的符号往左伸到 380;GND 下行点 x=350),差 30 净空。 U31_C = (700, 265) U31_PINS = {'1': (655, 275), '2': (655, 265), '3': (655, 255), '4': (745, 265)} C752_C, C753_C = (480, 235), (520, 235) # 输入 10µF/50V 0805 + 100nF 0402,跨距 ±20 # ⚠ 2026-09-08 主代理实测更正(原来写 (810,250) / rot270 / ±15,推送时 place() 四轮翻转全对不上): # T491A226K010AT(C116575)**空页四个角度逐一放件读回**的结果是 # rot 0 → pin1 (0,+20) rot 90 → pin1 (-20,0) rot180 → pin1 (0,-20) rot270 → pin1 (+20,0) # ⇒ 正极(1 脚)朝上要的是 **rot 0**,跨距是 **±20 不是 ±15**。 # `p1b-pins.json` 里那个 ±15 是别的封装的电容,skill §8d:引脚跨距因符号而异,必须读回。 C737_C = (810, 245) # 输出 22µF/10V 固态钽:rot0、±20 ⇒ 1脚(正)在 265、2脚在 225 NEW_PARTS = [ ['U31', 'C6186', U31_C[0], U31_C[1], 0], ['C752', 'C440198', C752_C[0], C752_C[1], 270], ['C753', 'C1525', C753_C[0], C753_C[1], 270], ['C737', 'C116575', C737_C[0], C737_C[1], 0], ] BLOCK = (300, 190, 900, 330) # 新块要占的净空矩形(含端口标签) # ── 改动 4(2026-09-10,R2 底板):CFG_SEL 改回 000 + PRT_SWP + 晶振 1MΩ(理由见文件头)───── # U32 引脚在本页的坐标:左列 x=320(33@340 / 31@360 / 17@500),右列 x=680(47@475 / 49@495 / 51@515)。 # 这六行原来都是悬空脚,两侧端口列(x=170 / x=760)对应的行也都空着(2026-09-10 从 `_c2hub_live.json` 查过)。 # 电阻横放 rot 0:1 脚 (x−20)、2 脚 (x+20)(同本页 R708 实测)。 STRAP_RIGHT = [('R724', 515, '51'), ('R725', 495, '49'), ('R726', 475, '47')] # 上拉 → +3V3_HUB 端口 STRAP_LEFT = [('R727', 340, '33'), ('R728', 360, '31'), ('R729', 500, '17')] # 下拉 → GND 端口 # 口 1~3 的 D± 在本页左列(x=320 脚 → x=170 端口,一脚一段直线,2026-09-10 查过): # y 660 = 1 脚 USBDN1_DM、650 = 2 脚 DN1_DP、640 = 3 脚 DN2_DM、630 = 4 脚 DN2_DP、610 = 6 脚 DN3_DM、600 = 7 脚 DN3_DP # PRT_SWP1~3 上拉之后这六只脚的 DP/DM 片内对调 ⇒ 端口网名跟着对调:{y: (原网名, 新网名)} SWAP_ROWS = [(660, ('USB1_DM', 'USB1_DP')), (650, ('USB1_DP', 'USB1_DM')), (640, ('USB2_DM', 'USB2_DP')), (630, ('USB2_DP', 'USB2_DM')), (610, ('USB3_DM', 'USB3_DP')), (600, ('USB3_DP', 'USB3_DM'))] R723_C = (1000, 800) # 1MΩ:Y1 正上方的空行。B 说明块最上一行 y=780(文字盒到 789),端口标签 794~806 不压字 STRAP_PARTS = ([[d, 'C25741', 720, y, 0] for d, y, _p in STRAP_RIGHT] + [[d, 'C25741', 250, y, 0] for d, y, _p in STRAP_LEFT] + [['R723', 'C26083', R723_C[0], R723_C[1], 0]]) def hpin(x, y, half=20): u"""横放(rot 0)两脚件的期望:只比坐标集合(`schcheck.two_pin` 只管竖放)。""" return {'?': set([(x - half, y), (x + half, y)])} def to_strap_mode(W, F, PT): u"""源页是 R1 的 110 接法 → 改成 000:13 脚端口改名 GND、R712 的上拉旗换地旗。**只改名字,坐标不动**。""" n13 = [it for it in PT if (it[1], it[2], it[3]) == ('+3V3_HUB', 170, 540)] assert len(n13) == 1, u'13 脚那个 +3V3_HUB 端口没找到(源页坐标变了?)' n13[0][1] = 'GND' f712 = [it for it in F if (it[0], it[1], it[2], it[3]) == ('Power', '+3V3_HUB', 970, 305)] assert len(f712) == 1, u'R712 上拉那面 +3V3_HUB 旗没找到' f712[0][:] = ['Ground', 'GND', 970, 305, 0] # Ground rot0:符号在下、引脚在上,导线从上方来(同 R711 那面) W2, hit = [], 0 for a, b, net in W: if set([a, b]) in (set([(170, 540), (320, 540)]), set([(970, 305), (970, 360)])): assert net == '+3V3_HUB', u'%s-%s 原来不是 +3V3_HUB 而是 %s' % (a, b, net) W2.append((a, b, 'GND')) hit += 1 else: W2.append((a, b, net)) assert hit == 2, u'要改名的两段导线只找到 %d 段(13 脚那段 + R712 那段)' % hit # ⭐ 口 1~3 的 Hub 侧 DP/DM **网名对调**:PRT_SWPx=1 之后 USBDNx_DM 脚输出的是 D+。 # 不对调的话 1 脚还连着槽上的 D− —— 芯片里翻了一次、网表又原样,极性反而错。 # 对调后网表就是「Hub 西脚 ↔ 槽西针(D+)」,底板上直走、不交叉(这正是删 U 形掉头的前提)。 swap = dict(SWAP_ROWS) got = 0 for it in PT: if it[2] == 170 and it[3] in swap: assert it[1] == swap[it[3]][0], u'y=%d 端口原来是 %s 不是 %s' % (it[3], it[1], swap[it[3]][0]) it[1] = swap[it[3]][1] got += 1 assert got == len(swap), u'要对调的 D± 端口只找到 %d / %d 个' % (got, len(swap)) W3, got = [], 0 for a, b, net in W2: y = a[1] if a[1] == b[1] and y in swap and set([a[0], b[0]]) == set([170, 320]): assert net == swap[y][0], u'y=%d 导线原来是 %s 不是 %s' % (y, net, swap[y][0]) W3.append((a, b, swap[y][1])) got += 1 else: W3.append((a, b, net)) assert got == len(swap), u'要对调的 D± 导线只找到 %d / %d 段' % (got, len(swap)) return W3 def add_straps(s): u"""六颗 strap 电阻 + R723。脚 → 电阻 → 同名端口,同本页「每个芯片脚一段短线 + 一个端口」的画法。""" for _d, y, pin in STRAP_RIGHT: s.w('#SWP' + pin, (680, y), (700, y)) # 51 / 47 两行各一个 +3V3_HUB 端口;49 那一行(y=495)**不放端口**:平移过来的 VBUS 分压块的 # `VBUS_UP` 标签就在 y=500,放了必压。R725 右端用一段竖线接到 47 行那根线上 —— # 47 行的线在 x=750 断开成两段,三根线在 (750,475) **端点**相交,不做中段 T 接。 s.w('+3V3_HUB', (740, 515), (760, 515)) s.port('BI', '+3V3_HUB', 760, 515, 0) # BI + rot0 ⇒ 符号在右,同这一列其他端口 s.w('+3V3_HUB', (740, 475), (750, 475), (760, 475)) s.port('BI', '+3V3_HUB', 760, 475, 0) s.w('+3V3_HUB', (740, 495), (750, 495), (750, 475)) for _d, y, pin in STRAP_LEFT: s.w('#SWP' + pin, (320, y), (270, y)) # 33 / 17 两行各一个 GND 端口;31 那一行(y=360)**不放端口**:R709/R710 分压的 `VBUS_DET` # 输出端口 (90,360) 标签往右伸到 x≈170,放了必压。改成往上引 25 挂一面地旗 # (Ground rot180 = 符号在上、引脚在下,导线从下方来,skill §2);上方 y 365~415 只有 U32 的悬空脚。 for y in (340, 500): s.w('GND', (230, y), (170, y)) s.port('BI', 'GND', 170, y, 180) # BI + rot180 ⇒ 符号在左,同这一列其他端口 s.w('GND', (230, 360), (230, 385)) s.gnd(230, 385, 180) x, y = R723_C s.w('XTAL1', (950, y), (x - 20, y)) s.port('BI', 'XTAL1', 950, y, 180) s.w('XTAL2', (x + 20, y), (1050, y)) s.port('BI', 'XTAL2', 1050, y, 0) # ── U32 上故意不接的脚(设计文档 §21.6 / §21.6b)+ J23 的 SBU ────────────── # 53/54 已被改动 2 接走,所以**不在**这张表里。 NC = { # 17/31/33/47/49/51 是 PRT_SWP6/5/4/3/2/1,2026-09-10 起接 strap 电阻(改动 4),不在表里 'U32': ['14', '15', '16', '18', '20', '21', '22', '23', '26', '27', '28', '29', '30', '32', '34', '35', '36', '37', '38', '39', '48', '50', '55', '56'], 'J23': ['A8', 'B8'], # SBU1/SBU2,USB2.0 用不上 } # ── 靠同名旗/端口成网、导线上天然分块的网络(契约 §7 第 10 条)────────────── # R1 这一页的画法就是「每个芯片脚一段短线 + 一个同名 netport」,所以页内网络天然多块。 # 五个下行口 / USB6 / UP_VBUS_DET / +5V **不在**表里 —— 它们只许有一块。 ALLOW_SPLIT = set([ 'GND', '+3V3_HUB', 'VDD18', 'VDD18PLL', 'XTAL1', 'XTAL2', 'RBIAS', 'CFGSEL0', 'CFGSEL1', 'NONREM0', 'NONREM1', 'UP_DP', 'UP_DM', 'VBUS_UP', 'VBUS_DET', 'HUB_RST', ]) # ⚠ 可用的文字区只有三块(R1 版图 + 标题栏 x>455 且 y<192 挤剩下的,`text_clash` # / `label_clash` 每次重算): # A 标题 (45, 800) # B 说明块 x 345~800 / y 690~780 —— R1 原来放说明的位置 # C U31 说明块 x 45~355 / y 195~260 —— x=60 的地线在 y 270~300,别越过 260 # D HUB_RST x 900~1145 / y 204~240 —— 就在 R716 底下;x=1130 的线从 y=250 起 # 左下 y<190 那一片**不能用**:J23 的本体和 12 根引脚就在 x 155~360 / y 45~180。 # y<665 而 x 345~680 那一片也不能用:U32 的符号本体在那里(引脚只到 y=660, # 本体还要往上一点),所以 B 块最低只能到 y=690(R1 原来也是到这儿为止)。 TEXTS = [ (45, 800, u'【A4-HUB】板载 USB 2.0 七口 Hub + Hub 专用 3.3V —— 对电脑只有一根线(D13)', 14), # B:Hub 配置 / T6 / 第 6 口 / HUB_RST (345, 780, u'U32 USB2517I:CFG_SEL[2:0]=000 = 内部默认 + strap 使能(2026-09-10 底板改回)', 11), (345, 762, u'CFG_SEL2(13) 接 GND;R711/R712 10k 下拉;NON_REM=00(R719/R720 下拉)', 11), (345, 744, u'PRT_SWP1~3 经 R724~R726 上拉 = 口 1~3 片内对调;1~7 脚端口 DP/DM 名随之对调', 11), (345, 726, u'PRT_SWP4~6 经 R727~R729 100k 下拉钉住;口 1~5 → A5 五槽,口 6 → ESP32', 11), (345, 708, u'R723 1MΩ 跨 XTAL1–XTAL2:Microchip 检查清单写 required,不装晶振不稳', 11), (345, 690, u'HUB_RST 固件只在启动自检脉冲一次;误复位 = 自断控制台 + 下次烧不进去', 11), # C:U31 说明(就在 U31 块左边) # ⚠ 2026-09-08 主代理改:原来这四行在 x=45,**被 R1 带来的地旗 (60,270) 压穿** # —— `Ground rot=0` 的符号带 GND 标签往锚点**下方**伸到 y≈240,正好落在第 1/2 行上 # (渲染成「U32 满配」上盖一个 GND)。同 A2-AUX 的两处叠字,六项几何验证与 # `text_clash`(只查文字之间)都够不着,靠人眼截图发现。⇒ 整块挪到 x=100 / y 246~300、字号 10→9。 # x=110 那一版会被 x=350 的地竖线(y 195~275)划穿 —— 脚本自带的 `text_clash` # 当场报了出来;x=200 那一版又压上 U32 左排引脚(y 330~660 一直伸到 x=320)。 # ⚠ 注意 `flag_clash` **不建模旗的标签占地**:`Ground rot=0` 的符号带 GND 字 # 从锚点往下伸约 38 个单位(本页实测 (60,270) 的旗压到 y≈240)。把这个盒子 # 连同 U32 的引脚包围盒 (310,330)-(690,670)、其余文字块一起当障碍全页扫一遍, # **字号 10 的四行一个位置都放不下**,字号 9 才有 —— 这就是它比别的块小一号的原因。 (100, 300, u'【U31】+5V → +3V3_HUB 专用 LDO(AMS1117-3.3)', 9), (100, 282, u'U32 满配约 0.42A,挂不进 U19 那条 0.5A 的 +3V3 轨', 9), (100, 264, u'耗散 0.42A×1.7V=0.71W @SOT-223,tab 必须铺铜', 9), (100, 246, u'C737 必须 22µF 固态钽(手册 Stability),陶瓷不算数', 9), # D:HUB_RST 的保命上拉(就在 R716 底下) (900, 240, u'⚠ R716 10k 是 HUB_RST 的保命上拉:', 10), (900, 222, u'ESP32 复位 / 高阻时 Hub 保持工作 ——', 10), (900, 204, u'控制台与烧录口就在它后面(D13)', 10), ] # ══════════════════════════════════════════════════════════════════════════ def load(path, off): u"""整页读回并平移。返回 (导线[(a,b,net)], 旗, 端口, place, expect, 悬空引脚数)。 与 `a2_aux.load()` 的两点不同: 1. 本页**整页搬**,没有 keep 过滤(U32 一颗芯片就把全页导线连成一片,切不开); 2. **允许悬空引脚** —— U32 有 30 个脚是故意不接的(见 NC), `a2_aux` 那句 `assert hit` 在这里会当场炸掉。改成计数,由 `build()` 和 NC 对账。 """ src = json.load(io.open(path, encoding='utf-8')) dx, dy = off pts = src['wires'] segs = [((int(round(pts[i][0])) + dx, int(round(pts[i][1])) + dy), (int(round(pts[i + 1][0])) + dx, int(round(pts[i + 1][1])) + dy)) for i in range(0, len(pts), 2)] for a, b in segs: assert a != b, u'%s 有零长度导线 %s(skill §8a)' % (path, (a,)) assert (a[0] == b[0]) != (a[1] == b[1]), u'%s 有斜线 %s-%s' % (path, a, b) n = len(segs) 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 = segs[i] b1, b2 = segs[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 gname, gpins = defaultdict(set), defaultdict(set) for lst in (src['flags'], src['ports']): for f in lst: xy = (int(round(f[2])) + dx, int(round(f[3])) + dy) hit = [i for i in range(n) if _on(xy, segs[i][0], segs[i][1])] assert hit, u'%s 的旗/端口 %s 不在任何导线上' % (path, f[1]) gname[find(hit[0])].add(f[1]) nfloat = 0 for p in src['parts']: for q in p[9]: xy = (int(round(q[2])) + dx, int(round(q[3])) + dy) hit = [i for i in range(n) if _on(xy, segs[i][0], segs[i][1])] if not hit: # 故意不接的脚,由 NC 兜底核对 nfloat += 1 continue gpins[find(hit[0])].add('%s.%s' % (p[0], q[0])) W, F, PT = [], [], [] for r_ in sorted(set(find(i) for i in range(n))): names = gname.get(r_, set()) assert len(names) <= 1, u'组 %s 名字不唯一:%s' % (sorted(gpins[r_])[:3], sorted(names)) # 无名的短节点(J23 的两个 CC)沿用 R1 不起名,`#` 开头只给本地自检用、不下发 net = sorted(names)[0] if names else '#' + '+'.join(sorted(gpins[r_])[:2]) for i in range(n): if find(i) == r_: W.append((segs[i][0], segs[i][1], net)) for f in src['flags']: xy = (int(round(f[2])) + dx, int(round(f[3])) + dy) F.append([f[0].split('-')[0], f[1], xy[0], xy[1], f[4]]) hit_dir = 0 for p in src['ports']: xy = (int(round(p[2])) + dx, int(round(p[3])) + dy) d, rot = p[0].replace('Netport-', ''), p[4] key = (p[1], xy[0], xy[1]) if key in PORT_DIR: d = PORT_DIR[key] rot = PORT_ROT.get(key, rot) hit_dir += 1 PT.append([d, p[1], xy[0], xy[1], rot]) assert hit_dir == len(PORT_DIR), \ u'端口方向修正表只命中 %d/%d(坐标写错了)' % (hit_dir, len(PORT_DIR)) byname = {} for k, v in PARTS.items(): byname.setdefault(v['name'], k) place, exp = [], {} for p in src['parts']: d, nm = p[0], p[1] base = nm.split('_C')[0] if '_C' in nm else nm k = byname.get(nm) or byname.get(base) assert k, u'eda-parts.json 里找不到器件 %r(%s)' % (nm, d) assert not p[6], u'%s 是镜像件,Sheet.place 处理不了' % d place.append([d, k, int(round(p[3])) + dx, int(round(p[4])) + dy, p[5]]) exp[d] = dict((str(q[0]), (int(round(q[2])) + dx, int(round(q[3])) + dy)) for q in p[9]) return W, F, PT, place, exp, nfloat def assert_clear(W, F, PT, exp, rect): u"""新块要占的矩形里不许有 R1 的任何图元 —— 坐标改错了当场终止,别等截图。""" x0, y0, x1, y1 = rect bad = [] for a, b, net in W: for p in (a, b): if x0 <= p[0] <= x1 and y0 <= p[1] <= y1: bad.append(u'导线 %s 端点 %s' % (net, (p,))) if a[0] == b[0] and x0 <= a[0] <= x1 \ and min(a[1], b[1]) < y1 and max(a[1], b[1]) > y0: bad.append(u'竖线 %s x=%d 穿过' % (net, a[0])) if a[1] == b[1] and y0 <= a[1] <= y1 \ and min(a[0], b[0]) < x1 and max(a[0], b[0]) > x0: bad.append(u'横线 %s y=%d 穿过' % (net, a[1])) for it in F + PT: if x0 <= it[2] <= x1 and y0 <= it[3] <= y1: bad.append(u'旗/端口 %s @(%d,%d)' % (it[1], it[2], it[3])) for d, ps in exp.items(): for num, xy in ps.items(): if x0 <= xy[0] <= x1 and y0 <= xy[1] <= y1: bad.append(u'引脚 %s.%s @%s' % (d, num, (xy,))) assert not bad, u'新块矩形 %s 里有 R1 的东西:\n %s' % (rect, '\n '.join(sorted(set(bad)))) def add_u31(s): u"""U31 AMS1117-3.3:+5V(IN 端口) → U31 → +3V3_HUB(Power 旗)。 走法(Y 轴向上): +5V ─●──●──────── 3 VIN ← y=255,从左平进,全程无竖线 C752 C753 2 VOUT ──< +3V3_HUB ← 635,BI+rot180,符号往左 │ │ 1 GND ──┐ ⏚─────┴────┴──────────┬────┘ ← GND 先往左到 x=350 再下行 350,195 y=215 地干线 ──● C737 下脚 2 脚和 4 脚都是 VOUT(4 脚是散热片),**两个都接**:4 脚往右接 C737 与电源旗, 2 脚被 1/3 脚夹在中间(同一个 x、上下各差 10)只能往左,走 y=255 与 y=275 之间那条 20 单位的走廊,末端放一个同名 `+3V3_HUB` netport —— netport 符号只有 ~10 高,R1 这一页两列端口就是 10 单位间距排下来的(skill §8b-2)。 """ p = U31_PINS # 输入:+5V 干线(C752 / C753 的上脚落在这条线上,EasyEDA 会在引脚处截断,skill §8b) # ⚠ 两颗电容必须待在 x<535 —— 535~635 是下面那个 `+3V3_HUB` 端口标签占的横向范围。 s.w('+5V', (440, 255), (C752_C[0], 255), (C753_C[0], 255), p['3']) s.port('IN', '+5V', 440, 255, 0) # IN + rot0 ⇒ 符号在**左**(§8b),占 380~440 # 地:U31 GND 左出 → x=350 下行 → 地旗;y=215 是全块的地干线,T 在竖线中段 s.w('GND', p['1'], (350, 275), (350, 195)) s.gnd(350, 195, 0) # Ground rot0:符号在下、引脚在上,导线从上方来 s.w('GND', (350, 215), (C752_C[0], 215), (C753_C[0], 215), (C737_C[0], 215), (C737_C[0], 225)) # 输出:4 脚(散热片)→ C737 → 电源旗;2 脚往左出一个同名端口 s.w('+3V3_HUB', (635, 265), p['2']) s.port('BI', '+3V3_HUB', 635, 265, 180) # BI + rot180 ⇒ 符号在**左**,占 535~635 s.w('+3V3_HUB', p['4'], (C737_C[0], 265), (880, 265), (880, 320)) s.pwr('+3V3_HUB', 880, 320, 0) # Power rot0:符号在上、引脚在下,导线从下方来 def add_usb6(s): u"""第 6 个下行口引到右侧端口列(D13)。串阻 / ESD 一律不加,同 R1 的口 1~5。""" for net, y in USB6: s.w(net, (680, y), (760, y)) s.port('BI', net, 760, y, 0) # BI + rot0 ⇒ 符号在右,占 760~854 def _text_boxes(s): out = [] for x, y, txt, size in s.TX: cjk = sum(1 for ch in txt if ord(ch) > 0x2E7F) w = size * (cjk + 0.5 * (len(txt) - cjk)) out.append((x, y - size * 0.8, x + w, y + size * 0.8, txt)) return out def text_clash(s, exp): u"""文字压在导线 / 引脚 / 旗端口上(同 a1_input.py / a2_aux.py)。""" errs = [] pts = [] for d, ps in exp.items(): if '?' in ps: for xy in ps['?']: pts.append((xy[0], xy[1], u'引脚 %s' % d)) else: for num, xy in ps.items(): pts.append((xy[0], xy[1], u'引脚 %s.%s' % (d, num))) for it in s.F + s.PT: pts.append((it[2], it[3], u'旗/端口 %s' % it[1])) for x0, y0, x1, y1, txt in _text_boxes(s): for px, py, what in pts: if x0 <= px <= x1 and y0 <= py <= y1: errs.append(u'文字压住 %s @(%d,%d): %s' % (what, px, py, txt[:26])) for a, b, net in s.W: if a[1] == b[1]: if y0 <= a[1] <= y1 and min(a[0], b[0]) <= x1 and max(a[0], b[0]) >= x0: errs.append(u'文字压住导线 %s y=%d: %s' % (net, a[1], txt[:26])) else: if x0 <= a[0] <= x1 and min(a[1], b[1]) <= y1 and max(a[1], b[1]) >= y0: errs.append(u'文字压住导线 %s x=%d: %s' % (net, a[0], txt[:26])) return sorted(set(errs)) def label_clash(s): u"""端口的符号+标签往锚点某一侧伸出去 ~40+6.7×字符(skill §8b),会压住页面文字。 契约 §7 第 8 条点名说这是本项目的常见事故,而 `flag_clash` 的「横向 <20」判据 根本够不着。这里把标签算成一个盒子,和**页面文字**逐个比,顺带查越框。 """ errs = [] tb = _text_boxes(s) for d, net, x, y, rot in s.PT: left = (d == 'IN') == (int(rot) % 360 == 0) # §8b:IN 与 BI/OUT 正好相反 # 净空用 6.7/字符(§8b 说是偏保守的安全值);**越框**只能用实测的 5/字符 # (`PORTTEST` 8 字符实测 80 单位 = 40 + 5×8),否则 R1 那个贴着右边框的 # `NONREM0`(1070 + 40 + 5×7 = 1145,正好压线)会被误判成越框。 for k, tag in ((6.7, None), (5.0, 'frame')): w = 40 + k * len(net) bx0, bx1 = (x - w, x) if left else (x, x + w) if tag == 'frame': if bx0 < 20 or bx1 > 1145: errs.append(u'端口 %s @(%d,%d,%s,rot%d) 的标签越框 %.0f~%.0f' % (net, x, y, d, rot, bx0, bx1)) continue for tx0, ty0, tx1, ty1, txt in tb: if bx0 < tx1 and tx0 < bx1 and ty0 < y + 6 and y - 6 < ty1: errs.append(u'端口 %s @(%d,%d) 的标签压住文字: %s' % (net, x, y, txt[:26])) return sorted(set(errs)) def port_label_clash(s, place): u"""端口标签(符号 + 字)与别的端口标签 / 两脚件本体叠在一起。 2026-09-10 改动 4 加 strap 时,`VBUS_UP` 标签压 R725、`VBUS_DET` 标签顶 GND 端口 —— `text_clash` 只查页面文字、`flag_clash` 只看锚点距离,都够不着,是截图发现的。 标签按 40 + 5×字符(§8b 实测)、高 ±5 算盒子;两脚件本体按 rot0 ±(20,5) / rot90·270 ±(5,20)。 R1 原页可能本来就有擦边,所以只报告、不拦下发。""" boxes = [] for d, net, x, y, rot in s.PT: left = (d == 'IN') == (int(rot) % 360 == 0) w = 40 + 5 * len(net) boxes.append(((x - w, y - 5, x, y + 5) if left else (x, y - 5, x + w, y + 5), u'端口 %s@(%d,%d)' % (net, x, y))) bodies = [] for d, _k, x, y, rot in place: if d[0] in 'RC' and d[1:].isdigit(): bodies.append(((x - 20, y - 5, x + 20, y + 5) if int(rot) % 180 == 0 else (x - 5, y - 20, x + 5, y + 20), d)) hit = lambda a, b: a[0] < b[2] and b[0] < a[2] and a[1] < b[3] and b[1] < a[3] # noqa: E731 errs = [] for i in range(len(boxes)): for j in range(i + 1, len(boxes)): if hit(boxes[i][0], boxes[j][0]): errs.append(u'%s 与 %s 的标签叠在一起' % (boxes[i][1], boxes[j][1])) for b, d in bodies: if hit(boxes[i][0], b): errs.append(u'%s 的标签压在 %s 上' % (boxes[i][1], d)) return sorted(set(errs)) def flag_clash(s, gx=20, gy=10): u"""两面旗/端口的符号叠在一起(同 a1_input.py / a2_aux.py)。""" errs, items = [], [(it[1], it[2], it[3]) for it in s.F + s.PT] for i in range(len(items)): for j in range(i + 1, len(items)): (n1, x1, y1), (n2, x2, y2) = items[i], items[j] if abs(x1 - x2) < gx and abs(y1 - y2) < gy: errs.append(u'旗/端口 %s @(%d,%d) 与 %s @(%d,%d) 挨太近' % (n1, x1, y1, n2, x2, y2)) return sorted(set(errs)) def build(): W, F, PT, place, exp, nfloat = load(SRC, OFF) W = shift_block(W, F, PT, place, exp) # 53/54(第 6 口)和六个 strap 脚在源页上都还是悬空的 want_nc = sum(len(v) for v in NC.values()) + len(USB6) + len(STRAP_RIGHT) + len(STRAP_LEFT) assert nfloat == want_nc, \ u'源页悬空引脚 %d 个,NC 表 + 第 6 口 + strap 六脚 = %d 个(对不上就是漏接或者表过期)' \ % (nfloat, want_nc) W = to_strap_mode(W, F, PT) assert_clear(W, F, PT, exp, BLOCK) for _d, y, _p in STRAP_RIGHT: assert_clear(W, F, PT, exp, (695, y - 8, 745, y + 8)) for _d, y, _p in STRAP_LEFT: assert_clear(W, F, PT, exp, (225, y - 8, 275, y + 8)) assert_clear(W, F, PT, exp, (940, R723_C[1] - 8, 1060, R723_C[1] + 8)) assert_clear(W, F, PT, exp, (745, 470, 755, 500)) # R725 → 47 行那段竖线 assert_clear(W, F, PT, exp, (215, 365, 245, 410)) # R728 的地旗(符号朝上) s = Sheet(PAGE, NAME) s.W += W s.F += F s.PT += PT add_u31(s) add_usb6(s) add_straps(s) for t in TEXTS: s.txt(t[0], t[1], t[2], t[3]) place = place + [list(x) for x in NEW_PARTS] + [list(x) for x in STRAP_PARTS] exp = dict(exp) for d, y, _p in STRAP_RIGHT: exp[d] = hpin(720, y) for d, y, _p in STRAP_LEFT: exp[d] = hpin(250, y) exp['R723'] = hpin(*R723_C) exp['U31'] = dict(U31_PINS) # 4 脚件,逐脚断言 exp['C752'] = two_pin(C752_C[0], C752_C[1]) # 无极性,只比坐标集合 exp['C753'] = two_pin(C753_C[0], C753_C[1]) exp['C737'] = {'1': (C737_C[0], C737_C[1] + 20), # ⚠ 钽电容:1 脚是正极,必须在上 '2': (C737_C[0], C737_C[1] - 20)} # 跨距 ±20、rot0(本页实测,见 C737_C 处注释) assert len(set(d for d, _k, _x, _y, _r in place)) == len(place), u'位号撞车' # 契约 §2:A4 只许用 U31 / U32 / Y1 / J23 + R708~R730 + C737 / C740~C760 for d, _k, _x, _y, _r in place: ok = d in ('U31', 'U32', 'Y1', 'J23') \ or (d[0] == 'R' and d[1:].isdigit() and 708 <= int(d[1:]) <= 730) \ or (d == 'C737') \ or (d[0] == 'C' and d[1:].isdigit() and 740 <= int(d[1:]) <= 760) assert ok, u'位号 %s 不在契约 §2 给 A4 的区段里' % d # 契约 §3 第 3 条 + 本页不许出现 +12V for it in s.F + s.PT: assert it[1] not in ('+3V3', '+12V'), u'本页不该出现 %s' % it[1] return s, place, exp def main(): push = '--push' in sys.argv s, place, exp = build() s.assert_grid() 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) errs = s.netcheck(pins, NC, ALLOW_SPLIT) errs += text_clash(s, exp) + label_clash(s) + flag_clash(s) plc = port_label_clash(s, place) print(u'端口标签叠压(只报告,下发前人工看)%d 条%s' % (len(plc), u':' if plc else u'')) for e in plc: print(u' ' + e) # 契约 §3 第 1 条的硬约束复核:HUB_RST 必须有一颗 10k 上拉到 +3V3_HUB assert PARTS['C25744']['name'] == '0402WGF1002TCE', u'C25744 不再是 10k 电阻' r716 = [it for it in place if it[0] == 'R716'] assert len(r716) == 1 and r716[0][1] == 'C25744', u'R716 不是 10k(HUB_RST 上拉没了)' assert pins['R716']['1'] == (1130, 320) and pins['R716']['2'] == (1130, 280), \ u'R716(HUB_RST 上拉)位置变了,重新核对 §3 第 1 条' # 第 6 个下行口确实接出去了 for net, y in USB6: assert any(it[1] == net and it[0] == 'BI' for it in s.PT), u'%s 端口没画' % net print(u'R1 C2-HUB 原样搬 %d 件 + 新增 %d 件(U31 块)+ %d 件(strap ×6 + R723 1MΩ)= %d 件' % (len(place) - len(NEW_PARTS) - len(STRAP_PARTS), len(NEW_PARTS), len(STRAP_PARTS), len(place))) s.report(len(place), errs) if not push: return call("await eda.dmt_Project.openProject('%s'); await new Promise(r=>setTimeout(r,2500)); return 1;" % R2_PROJECT) s.clear() s.place(place, exp) s.push_geometry() hide_strap_values() print(u'接着跑 verify_sch.py --nets') # ⚠ strap 电阻相邻两行只差 20 个单位:上一颗的阻值字(`Name` 属性 ={Value},在本体下方 15) # 正好落在下一颗的位号(本体上方 5)上 —— 2026-09-10 截图发现,R724 的「100kΩ」和 R725 的位号都在 y=500。 # 行距由 U32 引脚定死,挪不开 ⇒ 这六颗**隐藏阻值字、只留位号**(阻值写在 B 块说明里)。R723 那一行没邻居,照常显示。 # `sch_PrimitiveAttribute.modify(id, {valueVisible:false})` 2026-09-10 在 R725 上实测:改完立刻读回为 false。 # 清页重画会把属性冲回默认,所以这一步必须跟着每次下发走。 def hide_strap_values(): ds = [d for d, _y, _p in STRAP_RIGHT + STRAP_LEFT] r = call(u""" await eda.dmt_EditorControl.openDocument('%s'); await new Promise(r=>setTimeout(r,1500)); const DS = %s, out = {}; for (const c of await eda.sch_PrimitiveComponent.getAll()) { if (c.getState_ComponentType()!=='part' || !DS.includes(c.getState_Designator())) continue; const nm = (await eda.sch_PrimitiveAttribute.getAll(c.getState_PrimitiveId())).find(a=>a.getState_Key()==='Name'); if (!nm) { out[c.getState_Designator()] = 'no Name'; continue; } await eda.sch_PrimitiveAttribute.modify(nm.getState_PrimitiveId(), {valueVisible:false}); const back = await eda.sch_PrimitiveAttribute.get(nm.getState_PrimitiveId()); out[c.getState_Designator()] = back ? back.getState_ValueVisible() : 'gone'; } return out;""" % (PAGE, json.dumps(ds))) bad = [d for d in ds if r.get(d) is not False] if bad: raise SystemExit(u'!! 这几颗的阻值字没藏住:%s(读回 %s)—— 不存盘' % (bad, r)) call(u"return await eda.sch_Document.save();") print(u'strap 电阻阻值字已隐藏并存盘:%s' % ' '.join(sorted(ds))) if __name__ == '__main__': main()