# -*- coding: utf-8 -*- u"""R2 底板 **A3-CTRL** 页:ESP32-S3 主控 + TCA9548A I²C 多路 + DAC63204 ×2 + 应急 UART。 /c/Python314/python .claude/r2/a3_ctrl.py # 只算 + 本地自检 /c/Python314/python .claude/r2/a3_ctrl.py --push # 清页 + 重画(幂等) 四页并行设计里的第三页。跨页网络名 / 位号区段 / 端口方向以 `.claude/r2/A-CONTRACT.md` 为准。 ## 页面分区 | 区 | 位置 | 内容 | |---|---|---| | A | 左上 x37..600 y370..812 | `U20` ESP32-S3-WROOM-1 + 3V3 去耦(C601/C602)+ EN 上电复位(R601/C603)+ 26 根信号引出 | | B | 左下 x35..245 y85..380 | 上拉电阻组 R602~R611(I²C 主干 ×2、INT#、INA_ALERT、PRSNT1#~5#、MUX_RST#)| | C | 中部 x303..570 y395..480 | `J33` 应急 UART 排针(GND/TXD0/RXD0/EN/IO0),塞在 U20 与 U35 之间那条空带 | | D | 中下 x246..612 y185..440 | `U35` TCA9548A(0x70)+ VCC 去耦 C604 | | E | 右下 x613..1133 y200..440 | `U6` DAC63204(0x4A)整块从 R1 P3B 搬来 | | F | 右上 x613..1133 y470..710 | `U7` DAC63204(0x4B)整块从 R1 P3B 搬来 | ## 从 R1 搬了什么、丢了什么 - **ESP32 那一块没有整块 `load()` 搬**,只照抄了 U20 / C601 / C602 / R601 / C603 五件的**电气拓扑**(3V3 去耦 + EN 的 10k/100nF 上电复位),导线重画。 理由:R2 的信号集合和 R1 完全不是一回事(去掉 J22、RT601/602、KEY_*、VBUS_EN*、 BUCK_PG,加上 PRSNT#×5、MUX_RST#、HUB_RST),26 根引出线要重新指派引脚, 把 R1 的线搬过来再逐条改名比重画更容易出错。位置也重排过:R1 的 R601/C603 在 x=120, 搬到 x=130 之后位号文字(画在符号右侧)会顶到 U20 的符号体(x≥205),所以整列重摆。 - **两颗 DAC 是真·整块搬**(`load()`,见下),U23 PCA9555 及其外围一件不要。 ## DAC 两块的平移量与「为什么不能并排放在底部」 R1 `P3B-EXPAND` 的 DAC 区是一条 **x33..1123 × y220..460 的横带**(含旗/端口符号), 整条搬过来会横穿 U20 的地线(U20 的 GND/EPAD 引出到 x=320、y380..440), 而且源坐标 `(310,430)` 那条竖线的**端点**正好落在 U20 地线的 y=430 横段上 —— 那不是"交叉",是 **T 型相连 = 把 GND 短到 DAC 的 EP 网络上**。所以必须拆成两块竖着摞: | 块 | 平移 | 落位 | |---|---|---| | U6(源 x33..553 / y220..460)| dx=+580 dy=−20 | x613..1133 / y200..440 | | U7(源 x563..1123 / y220..460)| dx=+50 dy=+250 | x613..1133 / y470..710 | dy 的下界是**标题栏**:U6 的 `VDAC4` 端口在源坐标 (480,260),dy 再小 8 个单位 它就掉进 `x>455 且 y<192` 的标题栏里(skill §8c)。`build()` 里逐个断言了这一条。 ## ⭐ GPIO 重新指派(本页最要紧的一件事) R1 的 `.claude/_esp32_pinmap.json:new` **不能直接用**:那张表里 5 个 `VBUS_EN*` 和 4 个 `KEY_*` 的脚在 R2 全部空出来了(D9 作废 74HC595、VBUS_EN 挪到刀片), 而 R2 新增了 `PRSNT1#~5#`、`MUX_RST#`。逐条复核后重排,结果写进 `PINMAP` 表, 并同步写回 `_esp32_pinmap.json` 的 `"r2_base"` 键(不动 `new`/`old`/`pin2io`)。 硬约束(逐条对着 ESP32-S3 数据手册核过): 1. **`NTC1`/`NTC2` 必须落在 ADC1**(IO1~IO10;ADC2 与 WiFi 共用,开 WiFi 后读不到)。 ⇒ `NTC1=IO1`(模组脚 39)、`NTC2=IO2`(脚 38),和 R1 一致。 2. **flash/PSRAM 占用的脚**:ESP32-S3-WROOM-1 模组把 SPI flash 的 IO26~IO32 根本没引出 ——符号 41 个脚里查不到这几个(实测 `_p3_live.json`),所以不用躲。 **IO35/IO36/IO37 引出来了,但 R8(八线 PSRAM)版本用它们做 PSRAM 的 D4~D7。** ⚠ **本页按「可能上 R8 版」保守处理:IO35/36/37 一律不接**(进 NC 表)。 ⇒ **待确认项**:若最终确定选 N8(四线 PSRAM 或无 PSRAM)版,这三个脚可放出来当 GPIO。 不接的代价只是少三个备用脚;接了再换 R8 版就是死机 —— 所以往保守一侧倒。 3. **strapping 脚**:IO0 / IO3 / IO45 / IO46。 - `IO0` 给 J33 第 5 脚(进下载模式用)—— 模组内部有上拉,排针悬空 = 高 = 正常启动 ✓ - `IO3`(JTAG_SEL) / `IO45`(VDD_SPI 电压) / `IO46`(ROM 打印) **一律不接**, 悬空时靠内部弱下拉取默认值,是出厂推荐状态。 4. **只输入的脚**:IO46 在 S3 上是 input-only,且它是 strapping —— 已归入不接。 5. **原生 USB 硬连线**:`IO19 = USB_D−`、`IO20 = USB_D+`(模组脚 13 / 14,脚名与脚号 实测自 `_p3_live.json`,没有猜)。这两根不走 GPIO matrix,改不了。 ⇒ `USB6_DM=IO19`、`USB6_DP=IO20`(契约 §1.2 同样这么写)。 6. 其余信号全部走 GPIO matrix,落哪个脚都行 ⇒ 按「出线方向 = 目的地」排: 低位脚(IO38~IO42,符号下缘)给往页面下方去的 I²C 与 MUX / 中断簇, 高位脚(IO4~IO18,符号上缘)给 EFUSE_EN / PRSNT / FAN 这些纯端口信号。 **引脚够不够**:模组引出 34 个 IO,扣掉 6 个 strapping/PSRAM 保留 + 2 个 USB, 剩 26 个,减去 23 根信号 ⇒ 只剩 **IO47 / IO48 两个备用脚**。一个不多。 ## 契约里少写了一根:`HUB_RST` 契约 §1.2 明写 `HUB_RST` 由 **A3 的 ESP32** 发出去 A4,但任务书列 GPIO 时漏了它。 本页按契约办:`HUB_RST = IO8`(模组脚 12),不是 strapping 脚。 硬约束 §3.1 的"不驱动 = 不复位"由 A4 侧 10k 上拉到 `+3V3_HUB` 保证,本页只出一个端口。 ## 外部上拉 vs ESP32 内部上拉(PRSNT1#~5#) `PRSNT#` 完全可以用 ESP32 的内部上拉(~45kΩ)省掉 5 颗电阻。**本项目仍然用外部 10k**: 1. ESP32 复位期间(EN 拉低到固件跑起来之间)内部上拉是关的,PRSNT# 会浮着 —— 而这几根线跨了金手指、外面挂着五片可插拔的刀片,浮空输入在这种长走线上会乱跳。 2. 45kΩ 对一条穿过金手指、带插拔弹跳的线来说太软;10k 的噪声容限和 `INT#` / `INA_ALERT` 那两条开漏线一致,调试时可以用同一套判据。 3. 以后若要把 PRSNT# 改成开漏 wire-OR 或接到别的芯片上,外部上拉不用改板。 同理 `INT#`(五槽 wire-OR,契约 §1.2 钉死上拉在本页)和 `INA_ALERT`(A1 的 U13 开漏) 也各一颗 10k。刀片上 B2-CTRL 已有本槽 I²C 分支的 4.7k,**底板只管主干这一对**(R602/R603)。 ## 踩过 / 绕开的坑 - **TCA9548A 的 `SDn` 脚号在 `SCn` 前面**(4=SD0 5=SC0 6=SD1 7=SC1 …)。按 SCL/SDA 的 书写习惯接过去就是整排反的,而且几何验证一路 PASS。本页 `U35_CH` 把 (SDn, SCn) 脚号 写成显式二元组,不靠顺序推。 - **端口标签往锚点某一侧伸出去约 40+6.7×字符数**(skill §7 第 8 条 / §8b)。本页三处 险些相撞:上拉组标签右边界 245 vs U35 的 `MUX_RST#` 标签左边界 256(留 11); U35 右侧标签右边界 612 vs DAC 块左边界 613(留 1,就是靠 dx=+580/+50 顶出来的); ESP32 第二列标签右边界 590 vs 同上。改任何一个 x 都要重算这三处 —— `label_clash()` 会兜住同一 y 上的相叠,跨块的间距靠 `build()` 里那条 `bx[0] >= 613` 断言。 - **`dir` 决定标签朝哪边**(skill §8b):`IN` 的标签在引脚**左**侧,`BI`/`OUT` 在右侧。 所以本页用 `port_rot(dir, side)`,`side` 说的是"标签往哪边伸",rot 由 dir 反推。 一律写 rot=0 会让所有 IN 口的标签整排倒过去压到 U20 身上。 - **TXD0 / RXD0 两根从 U20 左侧并排引出的线怎么不打架**:判据是"**里侧那根拐得更低**"。 TXD0(脚 37,y=585)走里侧竖线 x=185 且拐在 y=410,RXD0(脚 36,y=595)走外侧 x=175 拐在 y=430。反过来排就会在 (185,585) 出一个交叉点 —— 第一版就是这么写的, `crossings()` 当场报出来了。 """ 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, pins_of, PARTS # noqa: E402 PAGE, NAME = '38327f806bc13133', 'A3-CTRL' R2_PROJECT = '05a65c22f14846b8b95360ef8d3c1e2e' SRC_DAC = os.path.join(HERE, '_p3b_live2.json') # R1 P3B-EXPAND KEEP_U6 = set(['U6', 'C607', 'C608', 'C609', 'R614', 'R615']) KEEP_U7 = set(['U7', 'C610', 'C611', 'C612', 'R616', 'R617']) OFF_U6 = (580, -20) OFF_U7 = (50, 250) # R1 上这两颗 DAC 都有空着的通道:U6 只用 3 路(VDAC1/2/4,通道 3 空), # U7 只用 2 路(VDAC3/5,通道 2/3 空)。源页上这些 OUTx/FBx 本来就没接线。 # ⚠ datasheet 要求"FBx 接回 OUTx"只对**启用的**通道成立;空通道上电默认关闭、 # 输出高阻,R1 就是这么留的,本页照搬不动。写成显式表,免得被当成漏连。 DAC_NC = {'U6': ['1', '2'], 'U7': ['1', '2', '3', '4']} # FB3/OUT3 (+U7 的 OUT2/FB2) # ── 器件键(`.claude/eda-parts.json`)─────────────────────────────────── K_ESP = 'C9900163599' # ESP32-S3-WROOM-1 K_MUX = 'C130026' # TCA9548APWR K_HDR = 'C50951' # 2.54mm 1*5P 直排针 K_10K = 'C25744' # 0402 10kΩ (0402WGF1002TCE) K_4K7 = 'C25900' # 0402 4.7kΩ (0402WGF4701TCE = 470×10^1) K_100N = 'C1525' # 0402 100nF K_22U = 'C12891' # 1206 22µF(引脚 ±15,不是 ±20) U20X, U20Y = 250, 600 # ESP32 中心 U35X, U35Y = 450, 280 # TCA9548A 中心 J33X, J33Y = 430, 455 # 应急 UART 排针中心(挪到 U20 与 U35 之间那条空带) COL1, COL2 = 340, 470 # ESP32 信号端口的两列锚点 x # ── U20 引脚相对坐标(实测自 `_p3_live.json`,rot 0)──────────────────── U20_REL = { '1': (45, -160), '2': (-45, 15), '3': (-45, 5), '4': (45, 160), '5': (45, 150), '6': (45, 140), '7': (45, 130), '8': (45, 50), '9': (45, 40), '10': (45, 30), '11': (45, 20), '12': (45, 120), '13': (45, 10), '14': (45, 0), '15': (45, 170), '16': (45, -110), '17': (45, 110), '18': (45, 100), '19': (45, 90), '20': (45, 80), '21': (45, 70), '22': (45, 60), '23': (45, -10), '24': (45, -120), '25': (45, -130), '26': (45, -100), '27': (45, 200), '28': (45, -20), '29': (45, -30), '30': (45, -40), '31': (45, -50), '32': (45, -60), '33': (45, -70), '34': (45, -80), '35': (45, -90), '36': (-45, -5), '37': (-45, -15), '38': (45, 180), '39': (45, 190), '40': (45, -170), '41': (45, -200), } # 模组脚号 -> IOxx(与 `_esp32_pinmap.json:pin2io` 一致,缺的几条是本页按符号脚名补的) PIN2IO = { '4': 'IO4', '5': 'IO5', '6': 'IO6', '7': 'IO7', '8': 'IO15', '9': 'IO16', '10': 'IO17', '11': 'IO18', '12': 'IO8', '13': 'IO19', '14': 'IO20', '15': 'IO3', '16': 'IO46', '17': 'IO9', '18': 'IO10', '19': 'IO11', '20': 'IO12', '21': 'IO13', '22': 'IO14', '23': 'IO21', '24': 'IO47', '25': 'IO48', '26': 'IO45', '27': 'IO0', '28': 'IO35', '29': 'IO36', '30': 'IO37', '31': 'IO38', '32': 'IO39', '33': 'IO40', '34': 'IO41', '35': 'IO42', '36': 'IO44', '37': 'IO43', '38': 'IO2', '39': 'IO1', } ADC1 = set('IO%d' % i for i in range(1, 11)) # ── ⭐ R2 底板 GPIO 分配表 ────────────────────────────────────────────── # (模组脚号, 网络名, dir, 端口列, 理由) # dir 是**本页这一侧**的方向:ESP32 输出 = OUT,ESP32 采样 = IN,双向 = BI。 PINMAP = [ ('27', 'IO0', 'BI', COL1, u'strapping/下载;接 J33 第 5 脚,悬空=正常启动'), ('39', 'NTC1', 'IN', COL2, u'IO1 = ADC1_CH0 —— 必须 ADC1,ADC2 与 WiFi 冲突'), ('38', 'NTC2', 'IN', COL1, u'IO2 = ADC1_CH1 —— 同上'), ('4', 'FAN_TACH_OUT', 'IN', COL2, u'IO4 测速脉冲输入,任意 GPIO'), ('5', 'FAN_TACH_IN', 'IN', COL1, u'IO5 测速脉冲输入,任意 GPIO'), ('6', 'FAN_PWM', 'OUT', COL2, u'IO6 LEDC PWM,任意 GPIO'), ('7', 'FAN_EN', 'OUT', COL1, u'IO7 复位高阻=关电,由 A6 的 100k 下拉保证'), ('12', 'HUB_RST', 'OUT', COL2, u'IO8 非 strapping;不复位由 A4 的 10k 上拉保证'), ('17', 'EFUSE_EN5', 'OUT', COL1, u'IO9 直驱刀片 eFuse EN'), ('18', 'EFUSE_EN4', 'OUT', COL2, u'IO10 直驱刀片 eFuse EN'), ('19', 'EFUSE_EN3', 'OUT', COL1, u'IO11 直驱刀片 eFuse EN'), ('20', 'EFUSE_EN2', 'OUT', COL2, u'IO12 直驱刀片 eFuse EN'), ('21', 'EFUSE_EN1', 'OUT', COL1, u'IO13 直驱刀片 eFuse EN'), ('22', 'PRSNT5#', 'IN', COL2, u'IO14 在位检测,本页外部 10k 上拉'), ('8', 'PRSNT4#', 'IN', COL1, u'IO15 在位检测,本页外部 10k 上拉'), ('9', 'PRSNT3#', 'IN', COL2, u'IO16 在位检测,本页外部 10k 上拉'), ('10', 'PRSNT2#', 'IN', COL1, u'IO17 在位检测,本页外部 10k 上拉'), ('11', 'PRSNT1#', 'IN', COL2, u'IO18 在位检测,本页外部 10k 上拉'), ('13', 'USB6_DM', 'BI', COL1, u'IO19 ⚠ 原生 USB PHY 硬连线 D−,不可改'), ('14', 'USB6_DP', 'BI', COL2, u'IO20 ⚠ 原生 USB PHY 硬连线 D+,不可改'), ('23', 'UP_VBUS_DET', 'IN', COL1, u'IO21 电脑在不在,A4 分压后送来'), ('31', 'INA_ALERT', 'IN', COL2, u'IO38 A1 的 U13 开漏,本页 10k 上拉'), ('32', 'INT#', 'IN', COL1, u'IO39 五槽 wire-OR 开漏,本页唯一一颗 10k 上拉'), ('33', 'MUX_RST#', 'BI', COL2, u'IO40 U35 复位;本页 10k 上拉 ⇒ 高阻=不复位'), ('34', 'SDA', 'BI', COL1, u'IO41 I²C 主干(不经 mux)'), ('35', 'SCL', 'BI', COL2, u'IO42 I²C 主干(不经 mux)'), ] # 有意不接(进 NC 表,不算悬空) U20_NC = ['15', '16', '24', '25', '26', '28', '29', '30'] U20_NC_WHY = { '15': u'IO3 strapping(JTAG_SEL)', '16': u'IO46 strapping + input-only', '24': u'IO47 备用', '25': u'IO48 备用', '26': u'IO45 strapping(VDD_SPI)', '28': u'IO35 留给 R8 版八线 PSRAM', '29': u'IO36 同上', '30': u'IO37 同上', } # ── TCA9548A 引脚相对坐标(契约 §4,实测)。⚠ SDn 脚号在 SCn 前面 ─────── U35_REL = { '1': (-50, 55), '2': (-50, 45), '3': (-50, 35), '4': (-50, 25), '5': (-50, 15), '6': (-50, 5), '7': (-50, -5), '8': (-50, -15), '9': (-50, -25), '10': (-50, -35), '11': (-50, -45), '12': (-50, -55), '13': (50, -55), '14': (50, -45), '15': (50, -35), '16': (50, -25), '17': (50, -15), '18': (50, -5), '19': (50, 5), '20': (50, 15), '21': (50, 25), '22': (50, 35), '23': (50, 45), '24': (50, 55), } # (SDn 脚号, SCn 脚号, 槽号)。通道 0~3 在左列,通道 4 在右列(脚 13=SD4 / 14=SC4) U35_CH_L = [('4', '5', 1), ('6', '7', 2), ('8', '9', 3), ('10', '11', 4)] U35_NC = ['15', '16', '17', '18', '19', '20'] # 通道 5~7 悬空 # ── 上拉电阻组(左下):(y, 位号, 器件键, 网名, dir, 说明) ─────────────── BANK_X_BUS, BANK_X_R, BANK_X_PT = 45, 85, 145 # ⚠ 行距 30 而不是 20:0402 的**位号 + 值是两行文字,画在符号右侧**,占竖直方向 # 约 20 个单位。20 行距时上下两颗的文字会贴在一起(几何验证抓不到,只有渲染出来才看得见)。 BANK_PITCH, BANK_TOP = 30, 380 BANK = [ ('R602', K_4K7, 'SDA', 'BI', u'I²C 主干上拉 4.7k'), ('R603', K_4K7, 'SCL', 'BI', u'I²C 主干上拉 4.7k'), ('R604', K_10K, 'INT#', 'IN', u'五槽 wire-OR 的唯一一颗上拉'), ('R605', K_10K, 'INA_ALERT', 'IN', u'A1 的 U13 开漏'), ('R606', K_10K, 'PRSNT1#', 'IN', u'刀片在位时把它拉到地'), ('R607', K_10K, 'PRSNT2#', 'IN', u'刀片在位时把它拉到地'), ('R608', K_10K, 'PRSNT3#', 'IN', u'刀片在位时把它拉到地'), ('R609', K_10K, 'PRSNT4#', 'IN', u'刀片在位时把它拉到地'), ('R610', K_10K, 'PRSNT5#', 'IN', u'刀片在位时把它拉到地'), ('R611', K_10K, 'MUX_RST#', 'BI', u'高阻 = 不复位'), ] BANK_Y = dict((b[0], BANK_TOP - BANK_PITCH * i) for i, b in enumerate(BANK)) # ── J33 应急 UART:脚号 -> 网名(脚 1 是 GND,直接挂地旗)─────────────── J33_MAP = [('2', 'TXD0'), ('3', 'RXD0'), ('4', 'EN'), ('5', 'IO0')] J33_REL = {'1': (-20, 20), '2': (-20, 10), '3': (-20, 0), '4': (-20, -10), '5': (-20, -20)} TEXTS = [ (620, 805, u'【A3-CTRL】ESP32-S3 主控 + TCA9548A I²C 多路 + DAC63204 ×2 + 应急 UART', 12), (620, 782, u'【E/F】DAC63204 ×2(U6=0x4A / A0→SDA,U7=0x4B / A0→SCL),挂 I²C 主干、不经 mux。', 10), (620, 766, u'三处 datasheet 强制要求(图上看不出来、接错也不报错):FBx 必须接回 OUTx;', 10), (620, 750, u'CAP 接 2.2µF 到 AGND;VREF 空置时 100nF 到地 + 10k 上拉 VDD。', 10), (620, 734, u'内部基准 1.212V ×2 ⇒ 满量程 2.424V(不是 2.5V)。U6 出 VDAC1/2/4,U7 出 VDAC3/5。', 10), (620, 718, u'⚠ U23 PCA9555 留在刀片上(B2-CTRL),本页一件不搬。', 10), # ⚠ 2026-09-08 主代理改:第一行原来在 y=96,**压住 R611(85,110) 的「10kΩ」值标签** # (值画在电阻符号下方,占到 y≈90)。`text_clash` 只查文字 vs 导线/引脚、 # `label_clash` 只查端口标签 vs 旗,两者都够不着「文字 vs 元件的位号/值」这一类。 # 把六个区(A ESP32 / B 上拉组 / C J33 / D U35 / E·F 两颗 DAC)和标题栏 # (x>455 且 y<192)当障碍全页扫过:本页能放下这一块的只有左下角 y 10~80 那条, # ⇒ 五行压进 74/58/42/26/10,字号 10→9(字号 10 的五行放不进 70 的高度)。 (90, 74, u'【B】上拉组:SDA/SCL 主干 4.7k(分支的那对在刀片 B2-CTRL),', 9), (90, 58, u'INT# 是五槽 wire-OR 唯一的一颗 10k,INA_ALERT 对 A1 的 U13 开漏。', 9), (90, 42, u'PRSNT1#~5# 用外部 10k 而非 ESP32 内部上拉:复位期间内部上拉是关的,', 9), (90, 26, u'跨金手指的长线浮空会乱跳,45k 对插拔弹跳也太软。', 9), (90, 10, u'【C】J33 = D13 的保命口:控制台挂在 Hub 后面,Hub 一复位就自断后路。', 9), ] # ══════════════════════════════════════════════════════════════════════ # 从 R1 页面整块搬运(照抄 a2_aux.py 的 load(),本页不需要改名表) # ══════════════════════════════════════════════════════════════════════ def load(path, keep, off, src_nc=None): u"""读回一页,按 keep 过滤,整块平移。返回 (导线[(a,b,net)], 旗, 端口, place, expect)。 ⚠ 过滤的判据是「导线组里的引脚属于谁」,不是坐标框 —— 坐标框会把别人的线一起切走。 `src_nc = {位号: [脚号...]}` 是**源页上本来就没接**的脚(R1 两颗 DAC 都有空着的通道), 不列进来就会被当成"源页悬空引脚"而终止。 """ src_nc = src_nc or {} 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, gowner, gpins = defaultdict(set), 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]) for p in src['parts']: for q in p[9]: if str(q[0]) in src_nc.get(p[0], []): continue 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])] assert hit, u'%s 源页悬空引脚 %s.%s' % (path, p[0], q[0]) gowner[find(hit[0])].add(p[0]) gpins[find(hit[0])].add('%s.%s' % (p[0], q[0])) keepgrp = {} for r_, owners in gowner.items(): inside, outside = owners & keep, owners - keep assert not (inside and outside), \ u'%s 的导线组同时挂着 %s 和 %s,切不开' % (path, sorted(inside), sorted(outside)) keepgrp[r_] = bool(inside) W, F, PT = [], [], [] for r_, ok in keepgrp.items(): if not ok: continue names = gname.get(r_, set()) assert len(names) <= 1, u'组 %s 名字不唯一:%s' % (sorted(gpins[r_])[:3], sorted(names)) # 无名的短节点沿用 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)) def kept_at(xy): hit = [i for i in range(n) if _on(xy, segs[i][0], segs[i][1])] return keepgrp[find(hit[0])] if hit else False for f in src['flags']: xy = (int(round(f[2])) + dx, int(round(f[3])) + dy) if kept_at(xy): F.append([f[0].split('-')[0], f[1], xy[0], xy[1], f[4]]) for p in src['ports']: xy = (int(round(p[2])) + dx, int(round(p[3])) + dy) if kept_at(xy): PT.append([p[0].replace('Netport-', ''), p[1], xy[0], xy[1], p[4]]) 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] if d not in keep: continue 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 # ══════════════════════════════════════════════════════════════════════ # 本页手画的部分 # ══════════════════════════════════════════════════════════════════════ def rel(cx, cy, table): return dict((k, (cx + dx, cy + dy)) for k, (dx, dy) in table.items()) def hz(cx, cy, half=20): u"""横放两脚件的期望(非极性件只比坐标集合,翻转只换脚号,无害)。""" return {'?': set([(cx - half, cy), (cx + half, cy)])} def vt(cx, cy, half=20): u"""竖放两脚件的期望。""" return {'?': set([(cx, cy - half), (cx, cy + half)])} def port_rot(d, side): u"""side='R' 标签往右伸、'L' 往左伸。skill §8b:IN 的方向和 BI/OUT 相反。""" if side == 'R': return 180 if d == 'IN' else 0 return 0 if d == 'IN' else 180 def build_local(s): u"""A/B/C/D 四区(ESP32、上拉组、J33、TCA9548A)。返回 (place, expect)。""" place, exp = [], {} U = rel(U20X, U20Y, U20_REL) # U20 绝对引脚坐标 M = rel(U35X, U35Y, U35_REL) # U35 绝对引脚坐标 # ── A 区:ESP32 ──────────────────────────────────────────────────── place.append(['U20', K_ESP, U20X, U20Y, 0]) exp['U20'] = dict(U) # 3V3 干线 x=160(U20 脚2 -> 上到旗),C601/C602 去耦挂在上面 place.append(['C601', K_22U, 100, 760, 0]) # 1206,引脚 ±15(skill §8d 实测) place.append(['C602', K_100N, 100, 710, 0]) # 0402,引脚 ±20 exp['C601'] = hz(100, 760, 15) exp['C602'] = hz(100, 710, 20) s.w('+3V3', (160, 615), (160, 790)) s.pwr('+3V3', 160, 790, 0) # 符号在上、引脚在下,导线从下方 ✓ s.w('+3V3', (115, 760), (160, 760)) s.w('+3V3', (120, 710), (160, 710)) s.w('+3V3', (160, 615), (205, 615)) # -> U20 脚2 (3V3) # EN 上电复位:R601 10k 上拉 + C603 100nF 到地 place.append(['R601', K_10K, 130, 660, 270]) # rot270 -> 引脚 (x, y±20) # ⚠ C603 摆在 x=100 而不是和 R601 同一条 x=130:0402 的**型号文字画在符号右侧** # (skill §8b-3),x=130 时它横着占到 x≈200,正好压在 RXD0 那根竖线(x=175)上。 place.append(['C603', K_100N, 100, 480, 270]) exp['R601'] = vt(130, 660) exp['C603'] = vt(100, 480) s.w('+3V3', (130, 680), (160, 680)) # R601 上脚 -> 3V3 干线(T) s.w('EN', (130, 500), (130, 640)) # R601 下脚往下的 EN 竖线 s.w('EN', (100, 500), (130, 500)) # -> C603 上脚 s.w('EN', (130, 605), (205, 605)) # T 到 U20 脚3 (EN) s.w('EN', (90, 520), (130, 520)) # T 出一小段给端口(避开 GND 干线) s.port('BI', 'EN', 90, 520, port_rot('BI', 'L')) # 地:C601/C602 走 x=60 干线;C603 单独一条,让开 EN 端口的标签 s.w('GND', (60, 690), (60, 760)) s.w('GND', (60, 760), (85, 760)) s.w('GND', (60, 710), (80, 710)) s.gnd(60, 690, 0) # 符号在下、引脚在上,导线从上方 ✓ s.w('GND', (60, 460), (100, 460)) s.w('GND', (60, 440), (60, 460)) s.gnd(60, 440, 0) # TXD0 / RXD0:外圈先拐的嵌套走法,避免两条互相交叉(见文档串) # 判据:**里侧那根(x 大的)必须拐得更低**,它的横段才不会切到外侧那根的竖段。 # 反过来排(TXD0 走 x=175 在 y=430 拐)会在 (185,585) 造出一个交叉点。 s.w('TXD0', (185, 585), (205, 585)) # 脚 37,里侧竖线 x=185,拐在 y=410 s.w('TXD0', (185, 410), (185, 585)) s.w('TXD0', (160, 410), (185, 410)) s.port('BI', 'TXD0', 160, 410, port_rot('BI', 'L')) s.w('RXD0', (175, 595), (205, 595)) # 脚 36,外侧竖线 x=175,拐在 y=430 s.w('RXD0', (175, 430), (175, 595)) s.w('RXD0', (160, 430), (175, 430)) s.port('BI', 'RXD0', 160, 430, port_rot('BI', 'L')) # U20 的地(脚 1 / 40 + EPAD 41) for pin in ('1', '40', '41'): s.w('GND', U[pin], (320, U[pin][1])) s.w('GND', (320, 400), (320, 440)) s.w('GND', (320, 370), (320, 400)) s.gnd(320, 370, 0) # 26 根信号引出线 + 端口 for pin, net, d, col, _why in PINMAP: y = U[pin][1] s.w(net, (295, y), (col, y)) s.port(d, net, col, y, port_rot(d, 'R')) # ── B 区:上拉电阻组 ─────────────────────────────────────────────── bot = BANK_Y[BANK[-1][0]] - 5 s.w('+3V3', (BANK_X_BUS, bot), (BANK_X_BUS, BANK_TOP)) s.pwr('+3V3', BANK_X_BUS, bot, 180) # 符号在下、引脚在上,导线从上方 ✓ for ref, key, net, d, _why in BANK: y = BANK_Y[ref] place.append([ref, key, BANK_X_R, y, 0]) exp[ref] = hz(BANK_X_R, y, 20) s.w('+3V3', (BANK_X_BUS, y), (BANK_X_R - 20, y)) s.w(net, (BANK_X_R + 20, y), (BANK_X_PT, y)) s.port(d, net, BANK_X_PT, y, port_rot(d, 'R')) # ── C 区:J33 应急 UART ──────────────────────────────────────────── place.append(['J33', K_HDR, J33X, J33Y, 0]) J = rel(J33X, J33Y, J33_REL) exp['J33'] = dict(J) # 脚1 的地往左走到 x=115 再下去 —— 往右走会贴着连接器符号的上沿画过去, # 往下走会撞上第 5 脚那一排端口标签(x73..140)。 # 脚1 的地从**第 2~5 脚那排引出线的上方**往左走再下去 —— 从下面走会切到那四根线。 s.w('GND', J['1'], (340, J['1'][1])) # 地旗的引脚落在 y=415 而不是 440:地符号往下伸约 20,440 时它会压住 # 第 5 脚(IO0)那个端口的标签(x310..370 @ y435)。 s.w('GND', (340, 415), (340, J['1'][1])) s.gnd(340, 415, 0) # 符号在下、引脚在上,导线从上方 ✓ for pin, net in J33_MAP: s.w(net, (370, J[pin][1]), J[pin]) s.port('BI', net, 370, J[pin][1], port_rot('BI', 'L')) # ── D 区:TCA9548A ───────────────────────────────────────────────── place.append(['U35', K_MUX, U35X, U35Y, 0]) exp['U35'] = dict(M) # 通道 0~3 -> SDA1/SCL1 ~ SDA4/SCL4(左列) for sd, sc, ch in U35_CH_L: for pin, net in ((sd, 'SDA%d' % ch), (sc, 'SCL%d' % ch)): s.w(net, (350, M[pin][1]), M[pin]) s.port('BI', net, 350, M[pin][1], port_rot('BI', 'L')) # 通道 4 -> SDA5/SCL5(右列:脚 13 = SD4、脚 14 = SC4) for pin, net in (('13', 'SDA5'), ('14', 'SCL5')): s.w(net, M[pin], (545, M[pin][1])) s.port('BI', net, 545, M[pin][1], port_rot('BI', 'R')) # RESET# -> MUX_RST#(10k 上拉在 B 区) s.w('MUX_RST#', (350, M['3'][1]), M['3']) s.port('BI', 'MUX_RST#', 350, M['3'][1], port_rot('BI', 'L')) # A0 / A1 / A2 全接地 -> 地址 0x70 s.w('GND', (370, M['1'][1]), M['1']) s.w('GND', (370, M['2'][1]), M['2']) s.w('GND', (370, 325), (370, 355)) s.gnd(370, 355, 180) # 符号在上、引脚在下,导线从下方 ✓ s.w('GND', M['21'], (585, M['21'][1])) # A2 从右侧绕出去,避开 SDA5/SCL5 s.w('GND', (585, 270), (585, 305)) s.gnd(585, 270, 0) # GND 脚 s.w('GND', (370, M['12'][1]), M['12']) s.w('GND', (370, 205), (370, 225)) s.gnd(370, 205, 0) # 上游 I²C 主干 for pin, net in (('22', 'SCL'), ('23', 'SDA')): s.w(net, M[pin], (545, M[pin][1])) s.port('BI', net, 545, M[pin][1], port_rot('BI', 'R')) # VCC + 去耦 C604 place.append(['C604', K_100N, 570, 385, 0]) exp['C604'] = hz(570, 385, 20) s.w('+3V3', M['24'], (530, M['24'][1])) s.w('+3V3', (530, 335), (530, 405)) s.w('+3V3', (530, 385), (550, 385)) s.pwr('+3V3', 530, 405, 0) s.w('GND', (590, 385), (590, 415)) s.gnd(590, 415, 180) return place, exp # ══════════════════════════════════════════════════════════════════════ # 排版自检(照抄 a1_input.py / a2_aux.py,加了一条端口标签相叠) # ══════════════════════════════════════════════════════════════════════ def text_clash(s, exp): errs, boxes, pts = [], [], [] 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)) boxes.append((x, y - size * 0.8, x + w, y + size * 0.8, txt)) for d, ps in exp.items(): 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 boxes: 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 flag_clash(s, gx=20, gy=10): 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 label_clash(s): u"""**不同列**的端口标签横向对撞。skill §8b:符号+标签往某一侧伸 40+6.7×字符。 `flag_clash` 只看锚点距离(横向 <20),够不着这一类 —— P3B 上就出过 「左边器件的 rot=0 标签和右边器件的 rot=180 标签在中间撞上」,两列看着离得很远。 ⚠ **同一列**(锚点 x 相同)按 10 的间距排下来是正常的(skill §8b-2, R1 的 P3 一列排了 21 个),所以这里只比不同 x 的。 """ errs, boxes = [], [] for d, net, x, y, rot in s.PT: w = 40 + 6.7 * len(net) left = (rot == 0) if d == 'IN' else (rot == 180) lo, hi = (x - w, x) if left else (x, x + w) boxes.append((lo, hi, y - 8, y + 8, net, x, True)) # 旗的符号:横向约 ±10,纵向从引脚往符号那一侧伸约 20(Ground rot0 / Power rot180 # 是往下,另两种往上)。**端口标签压住地旗**在本页真的发生过一次(J33 的 IO0)。 for kind, net, x, y, rot in s.F: down = (kind == 'Ground') == (rot == 0) boxes.append((x - 10, x + 10, y - 20 if down else y, y if down else y + 20, net, None, False)) for i in range(len(boxes)): for j in range(i + 1, len(boxes)): a, b = boxes[i], boxes[j] if a[6] and b[6] and a[5] == b[5]: # 同一列的端口,10 间距是正常的 continue if a[0] < b[1] and b[0] < a[1] and a[2] < b[3] and b[2] < a[3]: errs.append(u'符号/标签相叠: %s 与 %s(x %.0f..%.0f × %.0f..%.0f)' % (a[4], b[4], max(a[0], b[0]), min(a[1], b[1]), max(a[2], b[2]), min(a[3], b[3]))) return sorted(set(errs)) def bbox(W, F, PT, exp): xs, ys = [], [] for a, b, _ in W: xs += [a[0], b[0]] ys += [a[1], b[1]] for it in F + PT: xs.append(it[2]) ys.append(it[3]) for ps in exp.values(): for xy in ps.values(): xs.append(xy[0]) ys.append(xy[1]) return min(xs), max(xs), min(ys), max(ys) def assert_no_overlap(n1, b1, n2, b2, gap=30): ax0, ax1, ay0, ay1 = b1 bx0, bx1, by0, by1 = b2 sep_x = ax1 + gap <= bx0 or bx1 + gap <= ax0 sep_y = ay1 + gap <= by0 or by1 + gap <= ay0 assert sep_x or sep_y, u'%s 与 %s 重叠:x%d..%d y%d..%d vs x%d..%d y%d..%d' % ( n1, n2, ax0, ax1, ay0, ay1, bx0, bx1, by0, by1) def build(): s = Sheet(PAGE, NAME) place, exp = build_local(s) a = load(SRC_DAC, KEEP_U6, OFF_U6, DAC_NC) b = load(SRC_DAC, KEEP_U7, OFF_U7, DAC_NC) assert_no_overlap(u'U6 块', bbox(*a[:3], exp=a[4]), u'U7 块', bbox(*b[:3], exp=b[4])) for tag, blk in ((u'U6 块', a), (u'U7 块', b)): bx = bbox(*blk[:3], exp=blk[4]) assert bx[0] >= 613, u'%s 左边界 %d 顶到 ESP32/U35 的端口标签' % (tag, bx[0]) assert bx[3] <= 700, u'%s 上边界 %d 顶到说明文字' % (tag, bx[3]) for it in blk[1] + blk[2]: # 旗/端口不许掉进标题栏 assert not (it[2] > 455 and it[3] < 192), \ u'%s 的 %s @(%d,%d) 掉进标题栏' % (tag, it[1], it[2], it[3]) # 端口标签算进来的真实左右边界(skill §7 第 8 条:符号+标签约 40+6.7×字符) for d, net, x, _y, rot in blk[2]: w = 40 + 6.7 * len(net) left = (rot == 0) if d == 'IN' else (rot == 180) lo, hi = (x - w, x) if left else (x, x + w) assert lo >= 600, u'%s 的 %s 标签左边界 %.0f 顶到左边的块' % (tag, net, lo) assert hi <= 1145, u'%s 的 %s 标签右边界 %.0f 出框' % (tag, net, hi) for W, F, PT, pl, ex in (a, b): s.W += list(W) s.F += F s.PT += PT dup = set(ex) & set(exp) assert not dup, u'位号撞车:%s' % sorted(dup) place += pl exp.update(ex) for t in TEXTS: s.txt(t[0], t[1], t[2], t[3]) return s, place, exp # 靠同名旗/端口成网、导线上天然分块的网络(契约 §7 第 10 条) ALLOW_SPLIT = set(['GND', '+3V3', 'SDA', 'SCL', 'EN', 'TXD0', 'RXD0', 'IO0', 'MUX_RST#', 'INT#', 'INA_ALERT', 'PRSNT1#', 'PRSNT2#', 'PRSNT3#', 'PRSNT4#', 'PRSNT5#']) def check_pinmap(): u"""GPIO 分配表本身的自检:不撞 NC、不重号、NTC 在 ADC1、USB 是硬连线那两个。""" used = [p for p, _n, _d, _c, _w in PINMAP] assert len(set(used)) == len(used), u'PINMAP 有重复脚号' assert not (set(used) & set(U20_NC)), u'PINMAP 用到了 NC 表里的脚' # 电源 / 地 / EN / UART 这几个脚不走 PINMAP,单独列出来兜底 HARD = set(['1', '2', '3', '36', '37', '40', '41']) # GND 3V3 EN RXD0 TXD0 GND EPAD miss = set(U20_REL) - set(used) - set(U20_NC) - HARD assert not miss, u'有脚既不在 PINMAP 也不在 NC 表里:%s' % sorted(miss) by = dict((n, p) for p, n, _d, _c, _w in PINMAP) for net in ('NTC1', 'NTC2'): assert PIN2IO[by[net]] in ADC1, \ u'%s 落在 %s,不是 ADC1(IO1~IO10)' % (net, PIN2IO[by[net]]) assert PIN2IO[by['USB6_DM']] == 'IO19', u'USB6_DM 必须是 IO19(原生 USB D−)' assert PIN2IO[by['USB6_DP']] == 'IO20', u'USB6_DP 必须是 IO20(原生 USB D+)' # 契约 §1.4:R1 有、R2 没有的网络,写了就是错 dead = set(['BUCK_PG', 'IOEXP_INT#', 'USB_DP', 'USB_DM']) for _p, n, _d, _c, _w in PINMAP: assert n not in dead and not n.startswith('KEY_') and not n.startswith('VBUS_EN'), \ u'%s 是契约 §1.4 明确不存在的网络' % n def dump_pinmap(): u"""把本页的分配写回 `.claude/_esp32_pinmap.json` 的 "r2_base" 键(不动 new/old/pin2io)。""" path = os.path.join(os.path.dirname(HERE), '_esp32_pinmap.json') d = json.load(io.open(path, encoding='utf-8')) d['r2_base'] = dict((net, int(pin)) for pin, net, _d, _c, _w in PINMAP) d['r2_base_nc'] = dict((p, U20_NC_WHY[p]) for p in U20_NC) with io.open(path, 'w', encoding='utf-8') as f: f.write(json.dumps(d, ensure_ascii=False, indent=1, sort_keys=True)) print(u'已更新 %s 的 r2_base(%d 根)' % (os.path.basename(path), len(PINMAP))) def main(): push = '--push' in sys.argv check_pinmap() s, place, exp = build() s.assert_grid() nc = {'U20': U20_NC, 'U35': U35_NC} nc.update(DAC_NC) pins = pins_of(exp) # 把 {'?': {两个坐标}} 摊平成 {脚号: (x,y)} errs = s.netcheck(pins, nc, ALLOW_SPLIT) errs += text_clash(s, pins) + flag_clash(s) + label_clash(s) print(u'ESP32:用掉 %d 脚,有意不接 %d 脚(其中 IO47/IO48 是仅剩的备用)' % (len(PINMAP), len(U20_NC))) s.report(len(place), errs) dump_pinmap() 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() print(u'接着跑 verify_sch.py --nets') if __name__ == '__main__': main()