初始导入:PhoneRail 五路手机直供电源板
原理图/PCB 已在 EasyEDA 里做到 12 页跨页 0 漏接,固件刚完成骨架包 A1。 这次是把已有的一切一次性纳入版本控制,没有改动任何设计内容。 纳入: - 设计与需求文档、PROGRESS.md、小米8 参考原理图 PDF 与追脚脚本 - .claude/ 下的 EasyEDA 桥接工具链与几何/网表缓存(eda-parts.json、 pcb-parts.json、pcb-pads.json、p2-ch1.json 等,是重画各页的唯一事实来源) - 117 张验证截图(本项目所有方向问题都是靠人眼看截图发现的,属于证据不是垃圾) - firmware/ 的 A1 骨架与 site/ 文档站源码 .gitignore 排除:构建产物(firmware/build、host/build、sdkconfig)、 site/node_modules、__pycache__、桥接日志 _bridge.log、脚本自标的 *.stale。 .gitattributes 默认 `* -text`:现有 .py/.json 大多是 CRLF,原样存, 不让 git 重写行尾。唯一例外是 *.sh 强制 LF —— 带 \r 的 shell 脚本 bash 跑不了。 Co-Authored-By: Claude Opus 5 <[email protected]> Claude-Session: https://claude.ai/code/session_01QYuExfgyYa6DzRRcUso8M8
这个提交包含在:
文件差异内容过多而无法显示
加载差异
@@ -0,0 +1,118 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""本地布线检查器:在推送到 EasyEDA 之前先验证几何连通性,避免反复返工。"""
|
||||
import json
|
||||
from collections import defaultdict
|
||||
|
||||
PINS = json.load(open('pins.json'))
|
||||
|
||||
def pin(d, n):
|
||||
x, y, _ = PINS[d][str(n)]
|
||||
return (x, y)
|
||||
|
||||
SEGS = [] # [(p1, p2, net)]
|
||||
FLAGS = [] # [(kind, net, x, y, rot)] kind: Power/Ground
|
||||
PORTS = [] # [(dir, net, x, y, rot)]
|
||||
TEXTS = [] # [(s, x, y, size)]
|
||||
|
||||
def w(net, *pts):
|
||||
"""折线:逐点相连,拆成 2 点段。坐标必须是 5 的倍数。"""
|
||||
for p in pts:
|
||||
assert p[0] % 5 == 0 and p[1] % 5 == 0, f'{net}: {p} 不是 5 的倍数'
|
||||
for a, b in zip(pts, pts[1:]):
|
||||
assert a[0] == b[0] or a[1] == b[1], f'{net}: {a}->{b} 不是正交'
|
||||
if a != b:
|
||||
SEGS.append((a, b, net))
|
||||
|
||||
def gnd(x, y):
|
||||
FLAGS.append(('Ground', 'GND', x, y, 0))
|
||||
|
||||
def pwr(net, x, y):
|
||||
FLAGS.append(('Power', net, x, y, 0))
|
||||
|
||||
def port(net, x, y, rot, d='BI'):
|
||||
PORTS.append((d, net, x, y, rot))
|
||||
|
||||
def txt(s, x, y, size=12):
|
||||
TEXTS.append((s, x, y, size))
|
||||
|
||||
# ---------------- 连通性检查 ----------------
|
||||
def _on(p, a, b, tol=0.6):
|
||||
(px, py), (ax, ay), (bx, by) = p, a, b
|
||||
if abs(ax - bx) < tol: # 竖直
|
||||
return abs(px - ax) < tol and min(ay, by) - tol <= py <= max(ay, by) + tol
|
||||
if abs(ay - by) < tol: # 水平
|
||||
return abs(py - ay) < tol and min(ax, bx) - tol <= px <= max(ax, bx) + tol
|
||||
return False
|
||||
|
||||
def check():
|
||||
"""按几何反推网络,和声明的 net 名比对。返回 (错误列表, 网络表)。"""
|
||||
errs = []
|
||||
n = len(SEGS)
|
||||
parent = list(range(n))
|
||||
def find(i):
|
||||
while parent[i] != i:
|
||||
parent[i] = parent[parent[i]]; i = parent[i]
|
||||
return i
|
||||
def union(i, j):
|
||||
a, b = find(i), find(j)
|
||||
if a != b: parent[a] = b
|
||||
|
||||
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):
|
||||
union(i, j)
|
||||
|
||||
groups = defaultdict(list)
|
||||
for i in range(n):
|
||||
groups[find(i)].append(i)
|
||||
|
||||
nets = {}
|
||||
for root, idxs in groups.items():
|
||||
names = sorted({SEGS[i][2] for i in idxs})
|
||||
if len(names) > 1:
|
||||
detail = []
|
||||
for i in idxs:
|
||||
detail.append('%s%s-%s' % (SEGS[i][2], SEGS[i][0], SEGS[i][1]))
|
||||
errs.append('短路: ' + ' + '.join(names) + ' :: ' + ' | '.join(detail))
|
||||
nets[names[0]] = idxs
|
||||
|
||||
# 同名网络被拆成多块 = 漏连
|
||||
seen = defaultdict(int)
|
||||
for root, idxs in groups.items():
|
||||
seen[SEGS[idxs[0]][2]] += 1
|
||||
for name, cnt in seen.items():
|
||||
if cnt > 1:
|
||||
errs.append(f'网络 {name} 被拆成 {cnt} 块(漏连)')
|
||||
|
||||
# 每个元件引脚必须落在某条线段上
|
||||
for d, pd in PINS.items():
|
||||
for num, (x, y, nm) in pd.items():
|
||||
if not any(_on((x, y), s[0], s[1]) for s in SEGS):
|
||||
errs.append(f'悬空引脚: {d}.{num} ({nm}) @ ({x},{y})')
|
||||
|
||||
# 旗/端口引脚也必须落在线段上
|
||||
for kind, net, x, y, rot in FLAGS:
|
||||
if not any(_on((x, y), s[0], s[1]) for s in SEGS):
|
||||
errs.append(f'悬空旗: {kind} {net} @ ({x},{y})')
|
||||
for d, net, x, y, rot in PORTS:
|
||||
if not any(_on((x, y), s[0], s[1]) for s in SEGS):
|
||||
errs.append(f'悬空端口: {net} @ ({x},{y})')
|
||||
return errs
|
||||
|
||||
def crossings():
|
||||
"""列出所有交叉点(不相连),供人工确认可读性。"""
|
||||
out = []
|
||||
for i in range(len(SEGS)):
|
||||
for j in range(i + 1, len(SEGS)):
|
||||
a1, a2, na = SEGS[i]; b1, b2, nb = SEGS[j]
|
||||
if na == nb: continue
|
||||
hz_a = a1[1] == a2[1]; hz_b = b1[1] == b2[1]
|
||||
if hz_a == hz_b: continue
|
||||
h, v = ((a1, a2), (b1, b2)) if hz_a else ((b1, b2), (a1, a2))
|
||||
y = h[0][1]; x = 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
|
||||
@@ -0,0 +1,230 @@
|
||||
#!/usr/bin/env python3
|
||||
"""几何反推 EasyEDA 原理图当前页的网表,检查悬空引脚与孤立网络。
|
||||
|
||||
EasyEDA 不通过 API 暴露编译后的网表(引脚没有 getState_Net,新建导线的
|
||||
getState_Net 恒为空串),所以只能按几何自己推。
|
||||
|
||||
用法:
|
||||
python verify_sch.py # 自动扫描 49620-49629 找桥接服务
|
||||
python verify_sch.py --port 49620
|
||||
python verify_sch.py --nets # 同时打印每个网络包含的引脚
|
||||
python verify_sch.py --nc nc.json # 忽略清单里"有意不接"的引脚
|
||||
python verify_sch.py --dump geom.json # 只抓几何数据存文件,不分析
|
||||
|
||||
退出码: 0 = 全部通过, 1 = 发现悬空引脚或单引脚网络, 2 = 连不上桥接服务
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import sys
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from collections import defaultdict
|
||||
|
||||
TOL = 0.6 # 单位 0.01 inch;坐标都对齐到 5 的倍数,0.6 足够宽松也不会误判
|
||||
|
||||
FETCH_JS = (
|
||||
"const ws=await eda.sch_PrimitiveWire.getAll();"
|
||||
"const wires=ws.map(w=>w.getState_Line());"
|
||||
"const comps=await eda.sch_PrimitiveComponent.getAll();"
|
||||
"const parts=[];"
|
||||
"for(const c of comps){"
|
||||
" const d=c.getState_Designator();"
|
||||
" const pins=await c.getAllPins();"
|
||||
" let nn=null; try{nn=c.getState_Net?c.getState_Net():null;}catch(e){}"
|
||||
" parts.push({d:d===undefined?null:d,t:c.getState_ComponentType(),net:nn,"
|
||||
" p:(pins||[]).map(q=>[q.getState_PinNumber(),Math.round(q.getState_X()),Math.round(q.getState_Y())])});"
|
||||
"}"
|
||||
"return {wires,parts};"
|
||||
)
|
||||
|
||||
|
||||
def find_port(explicit=None):
|
||||
ports = [explicit] if explicit else range(49620, 49630)
|
||||
for p in ports:
|
||||
try:
|
||||
with urllib.request.urlopen(f"http://127.0.0.1:{p}/health", timeout=1) as r:
|
||||
if json.loads(r.read()).get("service") == "easyeda-bridge":
|
||||
return p
|
||||
except Exception:
|
||||
continue
|
||||
return None
|
||||
|
||||
|
||||
def fetch(port):
|
||||
req = urllib.request.Request(
|
||||
f"http://127.0.0.1:{port}/execute",
|
||||
data=json.dumps({"code": FETCH_JS}).encode(),
|
||||
headers={"Content-Type": "application/json"},
|
||||
)
|
||||
with urllib.request.urlopen(req, timeout=90) as r:
|
||||
body = json.loads(r.read())
|
||||
if not body.get("success"):
|
||||
raise RuntimeError(body.get("error") or body)
|
||||
return body["result"]
|
||||
|
||||
|
||||
def to_segments(wires):
|
||||
"""getState_Line() 返回的是端点对,不是折线路径 —— 每两个点一段。
|
||||
|
||||
当成折线解析会凭空造出不存在的斜线段,进而误报短路。
|
||||
"""
|
||||
segs = []
|
||||
for w in wires:
|
||||
pts = [(round(w[i]), round(w[i + 1])) for i in range(0, len(w), 2)]
|
||||
for i in range(0, len(pts) - 1, 2):
|
||||
segs.append((pts[i], pts[i + 1]))
|
||||
return segs
|
||||
|
||||
|
||||
def on_seg(p, a, b, tol=TOL):
|
||||
(px, py), (ax, ay), (bx, by) = p, a, b
|
||||
dx, dy = bx - ax, by - ay
|
||||
l2 = dx * dx + dy * dy
|
||||
if l2 == 0:
|
||||
return abs(px - ax) <= tol and abs(py - ay) <= tol
|
||||
t = ((px - ax) * dx + (py - ay) * dy) / l2
|
||||
if t < -1e-9 or t > 1 + 1e-9:
|
||||
return False
|
||||
return abs(px - (ax + t * dx)) <= tol and abs(py - (ay + t * dy)) <= tol
|
||||
|
||||
|
||||
class DSU:
|
||||
def __init__(self):
|
||||
self.p = {}
|
||||
|
||||
def find(self, x):
|
||||
self.p.setdefault(x, x)
|
||||
while self.p[x] != x:
|
||||
self.p[x] = self.p[self.p[x]]
|
||||
x = self.p[x]
|
||||
return x
|
||||
|
||||
def union(self, a, b):
|
||||
ra, rb = self.find(a), self.find(b)
|
||||
if ra != rb:
|
||||
self.p[ra] = rb
|
||||
|
||||
|
||||
def analyse(data, show_nets=False, nc=None):
|
||||
nc = nc or {}
|
||||
segs = to_segments(data["wires"])
|
||||
parts = data["parts"]
|
||||
|
||||
def label(c):
|
||||
if c["d"]:
|
||||
return c["d"]
|
||||
return "[" + (c.get("net") or c.get("t") or "flag") + "]"
|
||||
|
||||
floating, nc_hit = [], []
|
||||
for c in parts:
|
||||
for n, x, y in c["p"]:
|
||||
if any(on_seg((x, y), a, b) for a, b in segs):
|
||||
continue
|
||||
(nc_hit if str(n) in nc.get(c["d"] or "", []) else floating).append(
|
||||
f"{label(c)}.{n}@({x},{y})")
|
||||
|
||||
dsu = DSU()
|
||||
for a, b in segs:
|
||||
dsu.union(a, b)
|
||||
# T 型连接:某段的端点落在另一段内部才算相连;纯交叉(无端点)不连
|
||||
for a, b in segs:
|
||||
for c, e in segs:
|
||||
if (c, e) == (a, b):
|
||||
continue
|
||||
for p in (a, b):
|
||||
if on_seg(p, c, e):
|
||||
dsu.union(p, c)
|
||||
dsu.union(p, e)
|
||||
|
||||
nets = defaultdict(list)
|
||||
for c in parts:
|
||||
for n, x, y in c["p"]:
|
||||
for a, b in segs:
|
||||
if on_seg((x, y), a, b):
|
||||
tag = label(c) if c["d"] else label(c)
|
||||
nets[dsu.find(a)].append(tag if not c["d"] else f"{tag}.{n}")
|
||||
break
|
||||
|
||||
singles = [v for v in nets.values() if len(v) == 1]
|
||||
|
||||
# 名字层面的检查:几何连对了,网络名仍可能是错的。
|
||||
# 移动 netflag / netport 会把网络名重置成符号默认值(Power -> "+5V",netport -> "")。
|
||||
nameless = [f'{c["t"]}@({c["p"][0][1]},{c["p"][0][2]})'
|
||||
for c in parts if not c["d"] and c["p"] and not (c.get("net") or "").strip()]
|
||||
# 每个网络里出现的旗/端口名字必须一致,否则是两个不同名字被短在一起
|
||||
conflicts = []
|
||||
for _, v in nets.items():
|
||||
names = {x[1:-1] for x in v if x.startswith("[") and x.endswith("]")}
|
||||
if len(names) > 1:
|
||||
conflicts.append(sorted(names))
|
||||
|
||||
n_parts = sum(1 for c in parts if c["d"])
|
||||
print(f"元件 {n_parts} 导线段 {len(segs)} 局部网络 {len(nets)}")
|
||||
print(f"悬空引脚: {len(floating)}"
|
||||
+ (f"(另有 {len(nc_hit)} 个已在 NC 清单里,有意不接)" if nc_hit else ""))
|
||||
for f in floating:
|
||||
print(f" ! {f}")
|
||||
print(f"单引脚网络: {len(singles)}")
|
||||
for s in singles:
|
||||
print(f" ! {s[0]}")
|
||||
print(f"无网络名的旗/端口: {len(nameless)}")
|
||||
for s in nameless:
|
||||
print(f" ! {s}")
|
||||
print(f"同一网络出现多个名字: {len(conflicts)}")
|
||||
for s in conflicts:
|
||||
print(f" ! {' <-> '.join(s)}")
|
||||
|
||||
used = defaultdict(int)
|
||||
for _, v in nets.items():
|
||||
for x in v:
|
||||
if x.startswith("[") and x.endswith("]"):
|
||||
used[x[1:-1]] += len(v) - 1
|
||||
print("\n网络名清单(核对有没有笔误或意外的默认名,例如 +5V):")
|
||||
for n, cnt in sorted(used.items(), key=lambda z: (-z[1], z[0])):
|
||||
print(f" {n:<10s} {cnt} 个引脚")
|
||||
|
||||
if show_nets:
|
||||
print("\n网络明细(按引脚数降序):")
|
||||
for _, v in sorted(nets.items(), key=lambda z: -len(z[1])):
|
||||
print(f" [{len(v):2d}] " + " ".join(sorted(v)))
|
||||
|
||||
ok = not floating and not singles and not nameless and not conflicts
|
||||
print("\n" + ("PASS —— 几何连接完整。仍需人眼截图确认方向与排版。" if ok
|
||||
else "FAIL —— 见上面标 ! 的项。"))
|
||||
return ok
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("--port", type=int)
|
||||
ap.add_argument("--nets", action="store_true", help="打印每个网络的引脚清单")
|
||||
ap.add_argument("--dump", metavar="FILE", help="把几何数据存成 JSON,不做分析")
|
||||
ap.add_argument("--load", metavar="FILE", help="从 JSON 文件读,不连桥接服务")
|
||||
ap.add_argument("--nc", metavar="FILE",
|
||||
help='有意不接的引脚清单 JSON:{"U20": ["27","15"], ...},'
|
||||
"这些引脚不计入悬空")
|
||||
args = ap.parse_args()
|
||||
|
||||
if args.load:
|
||||
data = json.load(open(args.load, encoding="utf-8"))
|
||||
data = data.get("result", data)
|
||||
else:
|
||||
port = find_port(args.port)
|
||||
if port is None:
|
||||
print("找不到 easyeda-bridge(已扫 49620-49629)。"
|
||||
"先启动 bridge-server.mjs 并在 EasyEDA 里加载 run-api-gateway 扩展。",
|
||||
file=sys.stderr)
|
||||
return 2
|
||||
data = fetch(port)
|
||||
if args.dump:
|
||||
json.dump(data, open(args.dump, "w", encoding="utf-8"), ensure_ascii=False)
|
||||
print(f"已写入 {args.dump}")
|
||||
return 0
|
||||
|
||||
nc = json.load(open(args.nc, encoding="utf-8")) if args.nc else {}
|
||||
return 0 if analyse(data, args.nets, nc) else 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
在新工单中引用
屏蔽一个用户