#!/usr/bin/env python3 # -*- coding: utf-8 -*- r"""netlist_sem.py - KiCad Pcbnew 导出网表 (.tel) 语义化工具 将精简导出网表($PACKAGES/$NETS,无料号、无引脚功能名)结合引脚知识库 (pinout_kb.json) 与规则引擎,生成紧凑的结构化语义图(Markdown), 供 LLM / 工程师快速理解板级连接。 用法(仓库根目录,统一 venv 解释器): & '.venv\Scripts\python.exe' tools/netlist_semantics/netlist_sem.py dump-pins docs/硬件平台/V0.0.tel & '.venv\Scripts\python.exe' tools/netlist_semantics/netlist_sem.py semantic docs/硬件平台/V0.0.tel -o docs/硬件平台/V0.0_网表语义图.md 纯标准库实现,无第三方依赖。 """ import argparse import json import os import re import sys from collections import OrderedDict, defaultdict # ---------------------------------------------------------------- 解析 REF_RE = re.compile(r'^[A-Z]{1,4}\d+$') def _group_entries(lines, is_start=None): """按条目分组。is_start(ln) 判断是否新条目起始行;缺省:非空白起始行=新条目。""" if is_start is None: def is_start(ln): return ln[0] not in ' \t' entries = [] cur = [] for ln in lines: if ln.strip() == '': continue if is_start(ln): if cur: entries.append('\n'.join(cur)) cur = [ln] else: if not cur: cur = [ln.strip()] else: cur.append(ln.strip()) if cur: entries.append('\n'.join(cur)) return entries def _tokens_after(text, sep=';'): head, _, tail = text.partition(sep) tail = tail.replace(',', ' ') return head.strip(), [t for t in tail.split() if t] def _parse_package(entry): """解析单个 PACKAGE 条目,返回 (fp, value, [refs])。 兼容两种行格式: 单行: FP ! FP ! '{Value}' ; REF1 REF2 ... 多行: FP ! FP ! '{Value}' , 然后续行列 REF 字段(封装/值)取首行按 ! 分割;refs 取首行 ';' 之后 + 所有续行。 """ lines = entry.split('\n') first = lines[0].strip() head = first.split(';', 1)[0] if ';' in first else first fields = [x.strip() for x in head.split('!')] fp = fields[0] if fields else '' value = fields[2].strip().rstrip(',') if len(fields) > 2 else '' refpart = first.split(';', 1)[1] if ';' in first else '' if len(lines) > 1: refpart = (refpart + '\n' if refpart else '') + '\n'.join(lines[1:]) toks = re.split(r'[\s,]+', refpart.replace(',', ' ')) refs = [t for t in toks if t and REF_RE.match(t)] return fp, value, refs def parse_netlist(path): """解析 .tel。返回 (packages, nets) packages: {ref: {'fp': 封装, 'value': 值}} nets: {net: [(ref, pin), ...]} """ with open(path, encoding='utf-8', errors='replace') as f: text = f.read() sections = {} name, buf = None, [] for ln in text.splitlines(): if ln.startswith('$'): if name: sections[name] = buf name = ln.strip().lstrip('$') buf = [] elif name: buf.append(ln) if name: sections[name] = buf packages = {} for entry in _group_entries(sections.get('PACKAGES', []), lambda ln: ' ! ' in ln): fp, value, refs = _parse_package(entry) if not fp: continue for r in refs: packages[r] = {'fp': fp, 'value': value} nets = {} for entry in _group_entries(sections.get('NETS', [])): head, toks = _tokens_after(entry) name = head.strip().strip("'\"") if not name: continue pins = [] for t in toks: ref, _, pin = t.rpartition('.') if REF_RE.match(ref) and pin: pins.append((ref, pin)) if pins: nets[name] = pins return packages, nets def build_pin_index(packages, nets): """pin_index[ref][pin] = [net, ...];unused_refs = 有封装无网络的位号""" pin_index = defaultdict(lambda: defaultdict(list)) seen_refs = set() for net, pins in nets.items(): for ref, pin in pins: pin_index[ref][pin].append(net) seen_refs.add(ref) for ref in list(pin_index): if ref not in packages: pass # 网络中出现但 PACKAGES 缺失(如被 DNP 隐藏),仍保留 unused = [r for r in packages if r not in seen_refs] return pin_index, sorted(unused) # ---------------------------------------------------------------- 知识库 def load_kb(path): """返回 (instances, nets) instances: {ref: {mpn, pkg, desc}} nets: {net: {func, gpio, note}} 说明:本工程 KiCad 符号引脚号为任意编号(非数据手册脚号,如 U17.133=PB3), 故语义标注落在【网络名】与【位号】两级,不做引脚级断言。 """ with open(path, encoding='utf-8') as f: kb = json.load(f) return kb.get('instances', {}), kb.get('nets', {}) # ---------------------------------------------------------------- 分类规则 GROUND_NETS = {'GND', 'AGND', 'PGND', 'CGND'} RAIL_NETS = {'3.3V', '5V', 'C3.3V', 'G3.3V', 'AVCC', 'AVDDL', 'STM_VDDA', 'VDD50'} CLOCK_NETS = {'XI', 'XO', 'RXI', 'RXO', 'CLOCK'} def classify_net(name, pins=None, pin_index=None): """返回 (类别, 说明)。类别: GND / POWER / CLOCK / SIGNAL 串行时钟 (SCK/SCL/SCLK) 归 SIGNAL(接口信号),仅系统/晶振时钟归 CLOCK。""" if name in GROUND_NETS or name.endswith('_GND'): return 'GND', '地' if name in RAIL_NETS or re.fullmatch(r'\d+\.\d+V', name) or name.startswith('VDD'): return 'POWER', '电源轨' if name in CLOCK_NETS: return 'CLOCK', '时钟' return 'SIGNAL', '信号' # 接口分组规则:按网名匹配(顺序敏感,先匹配先归组) INTERFACE_RULES = [ ('以太网 MDI', 'U2 RTL8305 双口 MDI → T1 网络变压器 + J1/J2 RJ45(端口A/B);U1 CH395F 侧为无名网', lambda n: n in ('RX_P', 'RX_N', 'TX_P', 'TX_N') or re.fullmatch(r'RX[AB]_[PN]|TX[AB]_[PN]', n) is not None), ('SPI-NAND (SPI1)', 'U17 SPI1 → U18 74LVC16245 → U19 GD5F2GQ5UE NAND(双向缓冲)', lambda n: n.startswith('GD_') or n in ('SPI1_SOMI', 'SPI1_SIMO')), ('SPI-ETH (SPI2)', 'U17 SPI2 → U1 CH395F(SPI 以太网)', lambda n: n.startswith('CH395F_')), ('I2C-时钟', 'U17 I2C → U16 SD2506 时钟/RTC 芯片', lambda n: n in ('SD_SDA', 'SD_SCL')), ('UART-ST', 'U17 USART → U9/U10/U11 隔离器 → RS232/RS485 通道', lambda n: re.fullmatch(r'ST_(TX|RX)\d', n) is not None or n == 'ST_DIR4'), ('RS-485', 'U11 信号隔离 + U8 光耦(DIR) → U15 TP8485E → U12 ESD → CN1 485 总线', lambda n: n in ('RS485_A', 'RS485_B', 'TX4', 'RX4', 'DIR4')), ('RS-232', 'U9/U10 隔离 → U13 双通道 232 收发器 → RS232 总线(CN1 / U14)', lambda n: re.fullmatch(r'RS232_(TX|RX)\d', n) is not None or re.fullmatch(r'[TR]X\d', n) is not None), ('LCD 8080 并口', 'U17 FSMC → H1 16P 排针 → 板外 LCD(16bit 8080 并口)', lambda n: n.startswith('LCD_')), ('ADC 并口', 'U17 FSMC 读 ↔ U3 TPAFE5160(16bit + 控制线)', lambda n: n.startswith('TP_') or re.fullmatch(r'DB\d+', n) is not None), ('ADC 输入', 'U3 TPAFE5160 8 通道输入(经 R 分压/保护至 AIN)', lambda n: n.startswith('AIN') or n.startswith('ADC')), ('按键', 'U17 GPIO 按键(上拉 3.3V,低有效)', lambda n: re.fullmatch(r'KEY\d+', n) is not None), ('子板接口 (U18)', 'U17 ↔ U18 74LVC16245 ↔ U5 WINGTAT 子板(继电器/告警/状态/外键)', lambda n: n.startswith('EXTK') or n.startswith('HT_') or n.startswith('ST_IN_') or n.endswith('_LED') or n in ('RELAY_STO', 'H16', 'V15', 'J3', 'F3', 'K3', 'RAS', 'DQM0', 'DQM1', 'WAIT1', 'SDCKE', 'CF_P', 'CF_Q', 'V_ZL1', 'V_ZL2', 'R16')), ('SWD/JTAG 调试', 'U17 调试口 → H2 3P 排针(SWCLK/SWDIO/GND)', lambda n: n in ('SWCLK', 'SWDIO', 'NRST')), ('状态指示', '板级 LED / PHY 中断 / 显示控制', lambda n: re.fullmatch(r'LED\d+', n) is not None or n in ('PBLED', 'DIS_EEE', 'INT')), ] def group_interfaces(signals): groups = OrderedDict() ungrouped = [] for net in signals: placed = False for gname, gdesc, pat in INTERFACE_RULES: if pat(net): groups.setdefault(gname, {'desc': gdesc, 'nets': []})['nets'].append(net) placed = True break if not placed: ungrouped.append(net) return groups, ungrouped # ---------------------------------------------------------------- 输出 def render_semantic(packages, nets, pin_index, instances, nets_kb): L = [] ap = L.append def net_ann(net): """网络语义注释(来自知识库 nets 表);无名网($..)返回空。""" if net.startswith('$'): return '' ann = nets_kb.get(net) if not ann: return '' bits = [] if ann.get('func'): bits.append(ann['func']) if ann.get('gpio'): bits.append(ann['gpio']) if ann.get('note'): bits.append(ann['note']) return ' '.join(bits) ics = sorted((r for r in pin_index if r.startswith('U')), key=lambda r: int(re.sub(r'\D', '', r) or 0)) oths = sorted((r for r in pin_index if not r.startswith('U'))) ap('# V0.0 网表语义图(自动生成)') ap('') ap('> 由 `tools/netlist_semantics/netlist_sem.py` 从 `V0.0.tel` + `pinout_kb.json` 生成。') ap('> 网表仅含位号/引脚号/网名;MPN 与信号语义由知识库补全。') ap('> 注意:KiCad 符号引脚号为任意编号(非数据手册脚号,如 U17.133=PB3),引脚功能以【网名+知识库】为准。') ap('') ap('## 1. 概览') ap('') ap(f'- 元件位号: {len(packages)} 组 / 网络: {len(nets)} 条') n_gnd = sum(1 for n in nets if n in GROUND_NETS or n.endswith('_GND')) n_rail = sum(1 for n in nets if n in RAIL_NETS) n_unnamed = sum(1 for n in nets if n.startswith('$')) ap(f'- 地网络: {n_gnd} / 电源轨: {n_rail} / 未命名(内部)网: {n_unnamed}') ap('') # --- 电源域 ap('## 2. 电源与地') ap('') ap('| 网络 | 类别 | 连接引脚数 | 主要去向 |') ap('|---|---|---|---|') for net in sorted(nets, key=lambda n: (n not in RAIL_NETS, n not in GROUND_NETS, n)): cat, _ = classify_net(net, nets[net], pin_index) if cat not in ('GND', 'POWER'): continue pins = nets[net] ics_hit = sorted(set(r for r, _ in pins if r.startswith('U'))) others = sorted(set(r for r, _ in pins if not r.startswith('U'))) dest = ', '.join(ics_hit[:4] + (['…'] if len(ics_hit) > 4 else [])) if others: dest += f" / {len(others)}×被动件" ap(f'| {net} | {cat} | {len(pins)} | {dest} |') ap('') # --- 接口 groups, ungrouped = group_interfaces( [n for n in nets if not n.startswith('$') and classify_net(n, nets[n], pin_index)[0] == 'SIGNAL']) ap('## 3. 接口与总线') ap('') for gname, g in groups.items(): ap(f'### {gname}') ap('') ap(f'{g["desc"]}') ap('') ap('| 网络 | 语义(知识库) | 连接 |') ap('|---|---|---|') for net in g['nets']: pins = nets[net] desc = ', '.join(f'{r}.{p}' for r, p in pins[:8]) + (' …' if len(pins) > 8 else '') ap(f'| {net} | {net_ann(net) or "—"} | {desc} |') ap('') if ungrouped: ap('### 未归类信号') ap('') ap(', '.join(ungrouped)) ap('') # --- 芯片引脚 ap('## 4. 芯片引脚') ap('') for ref in ics: info = instances.get(ref, {}) mpn = info.get('mpn', '?') desc = info.get('desc', '') fp = packages.get(ref, {}).get('fp', '?') pins = pin_index[ref] ap(f'### {ref} — {mpn}') ap('') if desc: ap(f'{desc}') ap('') ap(f'封装: `{fp}`({len(pins)} 脚入网)') ap('') ap('| Pin | 网络 | 语义(知识库) |') ap('|---|---|---|') def _key(kv): p = kv[0] m = re.fullmatch(r'(\d+)', p) return (0, int(m.group(1))) if m else (1, p) for pin, nets_h in sorted(pins.items(), key=_key): uniq = sorted(set(nets_h)) ann = ';'.join(a for a in (net_ann(n) for n in uniq) if a) or '—' ap(f'| {pin} | {", ".join(uniq)} | {ann} |') ap('') # --- 其余器件 ap('## 5. 连接件/晶振/变压器/无源/分立') ap('') ap('| 位号 | 器件(知识库 MPN) | 引脚→网络 |') ap('|---|---|---|') for ref in oths: info = instances.get(ref, {}) mpn = info.get('mpn', '') fp = packages.get(ref, {}).get('fp', '?') what = f'{mpn}({fp})' if mpn else fp pins = pin_index[ref] desc = '; '.join(f'{p}→{", ".join(sorted(set(ns)))}' for p, ns in sorted(pins.items(), key=lambda kv: kv[0])) ap(f'| {ref} | {what} | {desc} |') ap('') # 文件结尾必须恰好一个换行:pre-commit end-of-file-fixer 同时禁止 # “无结尾换行”和“结尾空行”(双换行)。rstrip 归一化末尾多余换行 # (含上方收尾的 ap('')),今后增删末尾行也不会破坏提交门禁。 return '\n'.join(L).rstrip('\n') + '\n' def dump_pins(packages, nets): pin_index, unused = build_pin_index(packages, nets) print(f'== PACKAGES: {len(packages)} refs; NETS: {len(nets)} ==') print(f'== refs in packages but on no net: {unused} ==') ics = sorted((r for r in pin_index if r.startswith('U')), key=lambda r: int(re.sub(r'\D', '', r) or 0)) oths = sorted(r for r in pin_index if not r.startswith('U')) for ref in ics + oths: fp = packages.get(ref, {}).get('fp', '?') pins = pin_index[ref] print(f'\n### {ref} [{fp}] ({len(pins)} pins)') def _key(kv): p = kv[0] m = re.fullmatch(r'(\d+)', p) return (0, int(m.group(1))) if m else (1, p) for pin, nets_h in sorted(pins.items(), key=_key): print(f' {pin:>4} {", ".join(sorted(set(nets_h)))}') # ---------------------------------------------------------------- main def main(): here = os.path.dirname(os.path.abspath(__file__)) ap = argparse.ArgumentParser( description='KiCad Pcbnew .tel 网表语义化(解析 + 引脚知识库 + 规则引擎)') sub = ap.add_subparsers(dest='cmd', required=True) p1 = sub.add_parser('dump-pins', help='按位号列出 引脚→网络(用于编制知识库)') p1.add_argument('netlist') p2 = sub.add_parser('semantic', help='生成语义图 Markdown') p2.add_argument('netlist') p2.add_argument('-o', '--out', default=None, help='输出文件(缺省打印到 stdout)') p2.add_argument('--kb', default=os.path.join(here, 'pinout_kb.json')) a = ap.parse_args() packages, nets = parse_netlist(a.netlist) if a.cmd == 'dump-pins': dump_pins(packages, nets) else: pin_index, unused = build_pin_index(packages, nets) instances, nets_kb = load_kb(a.kb) doc = render_semantic(packages, nets, pin_index, instances, nets_kb) if a.out: with open(a.out, 'w', encoding='utf-8', newline='\n') as f: f.write(doc) print(f'written: {a.out} ({len(doc)} chars)') else: sys.stdout.write(doc) print(f'[stats] packages={len(packages)} nets={len(nets)} ' f'kb_instances={len(instances)} kb_nets={len(nets_kb)} unused={unused}', file=sys.stderr) if __name__ == '__main__': main()