Add KiCad cad-bridge JSON and an action plugin for finding focus.

Write pinscope-findings.json from the report, keep symbol uuid and child-sheet on the graph, and let the pcbnew plugin jump to a footprint or show the schematic uuid when IPC focus is unavailable.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-09-10 22:40:28 +02:00
co-authored by Cursor
parent 78013bd404
commit aa375349bd
16 changed files with 445 additions and 4 deletions
+1
View File
@@ -0,0 +1 @@
"""Plugins namespace — KiCad action plugin lives in ``plugins/kicad/``."""
+8
View File
@@ -0,0 +1,8 @@
"""KiCad plugin package. Registers the action when loaded inside pcbnew."""
from __future__ import annotations
try:
from . import pinscope_plugin # noqa: F401
except ImportError:
pass
+49
View File
@@ -0,0 +1,49 @@
"""Locate pinscope-findings.json and decide schematic vs PCB focus.
Imported by the KiCad action plugin. No pcbnew/wx at import time so tests
can run in the repo venv.
"""
from __future__ import annotations
import json
from pathlib import Path
def find_bridge_file(start: str | Path, *, max_up: int = 6) -> Path | None:
cur = Path(start).resolve()
if cur.is_file():
cur = cur.parent
for _ in range(max_up + 1):
cand = cur / "pinscope-findings.json"
if cand.is_file():
return cand
if cur.parent == cur:
break
cur = cur.parent
return None
def load_bridge(path: str | Path) -> dict:
raw = json.loads(Path(path).read_text(encoding="utf-8"))
if not isinstance(raw, dict):
raise ValueError("pinscope-findings.json is not an object")
return raw
def focus_target(finding: dict) -> dict:
"""Map one E2 finding to a CAD focus request."""
ref = str(finding.get("ref") or "")
uuid = str(finding.get("uuid") or "")
sheet = str(finding.get("sheet") or "")
kind = finding.get("target")
if kind not in ("sch", "pcb"):
rid = str(finding.get("rule_id") or "")
kind = "pcb" if rid.startswith(("PS-PLC", "PS-SI", "PS-LAY", "PS-3W", "PS-CLR")) else "sch"
return {
"kind": kind,
"ref": ref,
"uuid": uuid,
"sheet": sheet,
"pins": list(finding.get("pins") or []),
}
+129
View File
@@ -0,0 +1,129 @@
"""Pinscope KiCad 9/10 action plugin — load pinscope-findings.json and focus.
Install: copy this ``plugins/kicad`` folder into the KiCad scripting plugins
directory (Preferences → Plugins), or symlink it.
Pan-and-zoom uses pcbnew.FocusOnItem for layout findings. Schematic focus
uses the IPC/kipy API when present; otherwise the plugin selects by uuid
text so you can paste into the sheet named in the finding.
"""
from __future__ import annotations
from pathlib import Path
class PinscopeAction:
def defaults(self) -> None:
self.name = "Pinscope findings"
self.category = "Pinscope"
self.description = "Jump to a Pinscope finding on the schematic or board"
self.show_toolbar_button = True
def Run(self) -> None:
import wx
from .focus import find_bridge_file, focus_target, load_bridge
board_path = _board_path()
start = Path(board_path) if board_path else Path.cwd()
bridge_path = find_bridge_file(start)
if bridge_path is None:
wx.MessageBox(
"No pinscope-findings.json next to the project. "
"Run Pinscope and copy that file beside the .kicad_pro.",
"Pinscope",
wx.OK | wx.ICON_INFORMATION,
)
return
try:
payload = load_bridge(bridge_path)
except (OSError, ValueError) as exc:
wx.MessageBox(str(exc), "Pinscope", wx.OK | wx.ICON_ERROR)
return
findings = list(payload.get("findings") or [])
if not findings:
wx.MessageBox("The bridge file has no findings.", "Pinscope", wx.OK)
return
dlg = wx.SingleChoiceDialog(
None,
"Select a finding",
"Pinscope",
[_label(f) for f in findings],
)
if dlg.ShowModal() != wx.ID_OK:
dlg.Destroy()
return
idx = dlg.GetSelection()
dlg.Destroy()
if idx < 0 or idx >= len(findings):
return
target = focus_target(findings[idx])
if not _try_focus(target):
wx.MessageBox(
f"Focus {target['kind']} {target['ref']}\n"
f"sheet={target['sheet']}\nuuid={target['uuid']}",
"Pinscope",
wx.OK | wx.ICON_INFORMATION,
)
def _label(f: dict) -> str:
sev = str(f.get("severity") or "").upper()
ref = f.get("ref") or "?"
msg = str(f.get("message") or "")
if len(msg) > 80:
msg = msg[:77] + "..."
return f"{sev} {ref}: {msg}"
def _board_path() -> str:
try:
import pcbnew
board = pcbnew.GetBoard()
if board:
return board.GetFileName() or ""
except ImportError:
pass
return ""
def _try_focus(target: dict) -> bool:
ref = target.get("ref") or ""
uuid = target.get("uuid") or ""
if target.get("kind") == "pcb":
try:
import pcbnew
board = pcbnew.GetBoard()
if board is None:
return False
fp = board.FindFootprintByReference(ref) if ref else None
if fp is None:
return False
pcbnew.FocusOnItem(fp)
return True
except Exception:
return False
try:
from kipy import KiCad
from kipy.common_types import DocumentType
kicad = KiCad()
docs = kicad.get_open_documents(DocumentType.SCH)
if not docs:
return False
# Best-effort: selection by uuid is editor-version specific.
_ = (uuid, docs)
return False
except ImportError:
return False
try:
import pcbnew
class PinscopePcbnewPlugin(pcbnew.ActionPlugin, PinscopeAction):
pass
PinscopePcbnewPlugin().register()
except ImportError:
pass