422 lines
16 KiB
Python
422 lines
16 KiB
Python
#!/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()
|