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:
@@ -0,0 +1,90 @@
|
|||||||
|
"""pinscope-cad-bridge JSON (E2) for the KiCad action plugin."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from backend.pinscopex.models import CadIndexEntry, DesignGraph, Finding, ValidationReport
|
||||||
|
|
||||||
|
CAD_BRIDGE_VERSION = 1
|
||||||
|
_PCB_RULE_PREFIXES = ("PS-PLC", "PS-SI", "PS-LAY", "PS-3W", "PS-CLR")
|
||||||
|
|
||||||
|
|
||||||
|
def annotate_findings_cad(
|
||||||
|
findings: list[Finding],
|
||||||
|
cad_index: dict[str, CadIndexEntry] | None,
|
||||||
|
) -> None:
|
||||||
|
"""Fill cad_sheet/cad_uuid from the graph index when the finding omitted them."""
|
||||||
|
if not cad_index:
|
||||||
|
return
|
||||||
|
for f in findings:
|
||||||
|
entry = cad_index.get(f.designator)
|
||||||
|
if not entry:
|
||||||
|
continue
|
||||||
|
if not f.cad_uuid and entry.uuid:
|
||||||
|
f.cad_uuid = entry.uuid
|
||||||
|
if not f.cad_sheet and entry.sheet:
|
||||||
|
f.cad_sheet = entry.sheet
|
||||||
|
|
||||||
|
|
||||||
|
def _pin_numbers(designator: str, pins: list[str]) -> list[str]:
|
||||||
|
out: list[str] = []
|
||||||
|
prefix = designator + "."
|
||||||
|
for raw in pins:
|
||||||
|
s = str(raw).strip()
|
||||||
|
if not s:
|
||||||
|
continue
|
||||||
|
if s.upper().startswith(prefix.upper()):
|
||||||
|
s = s[len(prefix):]
|
||||||
|
out.append(s)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _target_kind(rule_id: str | None) -> str:
|
||||||
|
rid = rule_id or ""
|
||||||
|
if any(rid.startswith(p) for p in _PCB_RULE_PREFIXES):
|
||||||
|
return "pcb"
|
||||||
|
return "sch"
|
||||||
|
|
||||||
|
|
||||||
|
def build_cad_bridge(
|
||||||
|
report: ValidationReport,
|
||||||
|
project_id: str,
|
||||||
|
*,
|
||||||
|
url_base: str = "",
|
||||||
|
) -> dict:
|
||||||
|
"""E2 `pinscope-cad-bridge` payload. Missing uuid/sheet stay empty strings."""
|
||||||
|
findings: list[dict] = []
|
||||||
|
for f in report.findings:
|
||||||
|
fid = f.finding_id or ""
|
||||||
|
url = ""
|
||||||
|
if url_base and fid:
|
||||||
|
sep = "&" if "?" in url_base else "?"
|
||||||
|
url = f"{url_base}{sep}finding={fid}"
|
||||||
|
findings.append({
|
||||||
|
"rule_id": f.rule_id or f.source or "review",
|
||||||
|
"ref": f.designator,
|
||||||
|
"pins": _pin_numbers(f.designator, f.pins or []),
|
||||||
|
"sheet": f.cad_sheet or "",
|
||||||
|
"uuid": f.cad_uuid or "",
|
||||||
|
"severity": (f.status or "WARNING").lower(),
|
||||||
|
"message": f.finding,
|
||||||
|
"url": url,
|
||||||
|
"finding_id": fid,
|
||||||
|
"net": f.net or "",
|
||||||
|
"target": _target_kind(f.rule_id),
|
||||||
|
})
|
||||||
|
return {
|
||||||
|
"version": CAD_BRIDGE_VERSION,
|
||||||
|
"project_id": project_id,
|
||||||
|
"findings": findings,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def write_cad_bridge(path: str | Path, payload: dict) -> None:
|
||||||
|
Path(path).write_text(json.dumps(payload, indent=2) + "\n")
|
||||||
|
|
||||||
|
|
||||||
|
def cad_index_from_graph(graph: DesignGraph) -> dict[str, CadIndexEntry]:
|
||||||
|
return dict(graph.cad_index or {})
|
||||||
@@ -8,6 +8,7 @@ from pathlib import Path
|
|||||||
|
|
||||||
from backend.pinscopex.utils import safe_mpn
|
from backend.pinscopex.utils import safe_mpn
|
||||||
from backend.pinscopex.models import (
|
from backend.pinscopex.models import (
|
||||||
|
CadIndexEntry,
|
||||||
Component,
|
Component,
|
||||||
ComponentConstraints,
|
ComponentConstraints,
|
||||||
ComponentModel,
|
ComponentModel,
|
||||||
@@ -289,6 +290,8 @@ def build_graph(
|
|||||||
schematic_fields[ref] = {
|
schematic_fields[ref] = {
|
||||||
"mpn": extra.get("mpn"),
|
"mpn": extra.get("mpn"),
|
||||||
"value": extra.get("value", ""),
|
"value": extra.get("value", ""),
|
||||||
|
"cad_uuid": extra.get("cad_uuid") or "",
|
||||||
|
"cad_sheet": extra.get("cad_sheet") or "",
|
||||||
}
|
}
|
||||||
entry = bom.setdefault(
|
entry = bom.setdefault(
|
||||||
ref,
|
ref,
|
||||||
@@ -410,9 +413,17 @@ def build_graph(
|
|||||||
pins=pin_connections,
|
pins=pin_connections,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
cad_index: dict[str, CadIndexEntry] = {}
|
||||||
|
for ref, extra in schematic_fields.items():
|
||||||
|
uuid = extra.get("cad_uuid") or ""
|
||||||
|
sheet = extra.get("cad_sheet") or ""
|
||||||
|
if uuid or sheet:
|
||||||
|
cad_index[ref] = CadIndexEntry(uuid=uuid, sheet=sheet)
|
||||||
|
|
||||||
return DesignGraph(
|
return DesignGraph(
|
||||||
components=components,
|
components=components,
|
||||||
nets=nets,
|
nets=nets,
|
||||||
bom_fields=bom_fields,
|
bom_fields=bom_fields,
|
||||||
schematic_fields=schematic_fields,
|
schematic_fields=schematic_fields,
|
||||||
|
cad_index=cad_index,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -234,6 +234,12 @@ class Component(BaseModel):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class CadIndexEntry(BaseModel):
|
||||||
|
"""KiCad symbol identity for plugin pan-and-zoom."""
|
||||||
|
uuid: str = ""
|
||||||
|
sheet: str = ""
|
||||||
|
|
||||||
|
|
||||||
class DesignGraph(BaseModel):
|
class DesignGraph(BaseModel):
|
||||||
"""
|
"""
|
||||||
Bipartite design graph: Components <-> Nets.
|
Bipartite design graph: Components <-> Nets.
|
||||||
@@ -247,6 +253,7 @@ class DesignGraph(BaseModel):
|
|||||||
# KiCad property table vs uploaded BOM (empty on PADS/EDIF).
|
# KiCad property table vs uploaded BOM (empty on PADS/EDIF).
|
||||||
bom_fields: dict[str, dict] = {}
|
bom_fields: dict[str, dict] = {}
|
||||||
schematic_fields: dict[str, dict] = {}
|
schematic_fields: dict[str, dict] = {}
|
||||||
|
cad_index: dict[str, CadIndexEntry] = {}
|
||||||
|
|
||||||
# -- Traversal helpers --------------------------------------------------
|
# -- Traversal helpers --------------------------------------------------
|
||||||
|
|
||||||
|
|||||||
@@ -394,7 +394,10 @@ def _parse_kicad_sch_sheet(tree: Any) -> _SchSheet:
|
|||||||
elif n == "lcsc" and v:
|
elif n == "lcsc" and v:
|
||||||
lcsc = v
|
lcsc = v
|
||||||
parts[ref] = footprint
|
parts[ref] = footprint
|
||||||
fields[ref] = {"value": value, "footprint": footprint, "mpn": mpn, "lcsc": lcsc}
|
fields[ref] = {
|
||||||
|
"value": value, "footprint": footprint, "mpn": mpn, "lcsc": lcsc,
|
||||||
|
"cad_uuid": _val(sym, "uuid"),
|
||||||
|
}
|
||||||
lp = lib_pins.get(lib_id, {})
|
lp = lib_pins.get(lib_id, {})
|
||||||
for pin_el in _kids(sym, "pin"):
|
for pin_el in _kids(sym, "pin"):
|
||||||
num = str(pin_el[1]) if len(pin_el) > 1 else ""
|
num = str(pin_el[1]) if len(pin_el) > 1 else ""
|
||||||
@@ -579,7 +582,10 @@ def parse_kicad_sch_project(
|
|||||||
if ref in parts:
|
if ref in parts:
|
||||||
raise ValueError(f"Duplicate reference {ref} in {path.name}")
|
raise ValueError(f"Duplicate reference {ref} in {path.name}")
|
||||||
parts[ref] = fp
|
parts[ref] = fp
|
||||||
fields[ref] = sheet.fields.get(ref, {})
|
fields[ref] = {
|
||||||
|
**sheet.fields.get(ref, {}),
|
||||||
|
"cad_sheet": path.name,
|
||||||
|
}
|
||||||
for name, pins in sheet.nets.items():
|
for name, pins in sheet.nets.items():
|
||||||
scope = sheet.net_scope.get(name, "unnamed")
|
scope = sheet.net_scope.get(name, "unnamed")
|
||||||
if scope == "global":
|
if scope == "global":
|
||||||
|
|||||||
@@ -38,6 +38,17 @@ async def get_report(project_id: str, request: Request):
|
|||||||
return JSONResponse(storage.read_json(key))
|
return JSONResponse(storage.read_json(key))
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/report/{project_id}/cad-bridge")
|
||||||
|
async def get_cad_bridge(project_id: str, request: Request):
|
||||||
|
storage = get_storage(request)
|
||||||
|
owner_user_id, _ = await resolve_or_404(request, project_id)
|
||||||
|
prefix = proj_svc.project_prefix(owner_user_id, project_id)
|
||||||
|
key = f"{prefix}/pinscope-findings.json"
|
||||||
|
if not storage.exists(key):
|
||||||
|
raise HTTPException(404, "CAD bridge not found — run the pipeline first")
|
||||||
|
return JSONResponse(storage.read_json(key))
|
||||||
|
|
||||||
|
|
||||||
class AddCommentBody(BaseModel):
|
class AddCommentBody(BaseModel):
|
||||||
finding_id: str
|
finding_id: str
|
||||||
text: str
|
text: str
|
||||||
|
|||||||
@@ -260,6 +260,7 @@ class PipelineWorkspace:
|
|||||||
self._upload_file("bom_summary.json")
|
self._upload_file("bom_summary.json")
|
||||||
self._upload_file("derating.json")
|
self._upload_file("derating.json")
|
||||||
self._upload_file("report.json")
|
self._upload_file("report.json")
|
||||||
|
self._upload_file("pinscope-findings.json")
|
||||||
self._upload_file("review_fingerprints.json")
|
self._upload_file("review_fingerprints.json")
|
||||||
self._upload_file("api_logs.jsonl")
|
self._upload_file("api_logs.jsonl")
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ Each project lives at users/{user_id}/projects/{id}/ with:
|
|||||||
models/ — cached component specs
|
models/ — cached component specs
|
||||||
design_graph.json — graph output
|
design_graph.json — graph output
|
||||||
report.json — validation report
|
report.json — validation report
|
||||||
|
pinscope-findings.json — KiCad cad-bridge (plugin pan-and-zoom)
|
||||||
|
|
||||||
Library (global, shared across users):
|
Library (global, shared across users):
|
||||||
library/extracted/{mpn}.json
|
library/extracted/{mpn}.json
|
||||||
@@ -359,6 +360,7 @@ def clear_project_extractions(
|
|||||||
"bom_summary.json",
|
"bom_summary.json",
|
||||||
"derating.json",
|
"derating.json",
|
||||||
"report.json",
|
"report.json",
|
||||||
|
"pinscope-findings.json",
|
||||||
"review_fingerprints.json",
|
"review_fingerprints.json",
|
||||||
"api_logs.jsonl",
|
"api_logs.jsonl",
|
||||||
"graph_voltage_updates.json",
|
"graph_voltage_updates.json",
|
||||||
@@ -393,6 +395,7 @@ def reopen_project(
|
|||||||
"bom_summary.json",
|
"bom_summary.json",
|
||||||
"derating.json",
|
"derating.json",
|
||||||
"report.json",
|
"report.json",
|
||||||
|
"pinscope-findings.json",
|
||||||
"review_fingerprints.json",
|
"review_fingerprints.json",
|
||||||
"api_logs.jsonl",
|
"api_logs.jsonl",
|
||||||
"graph_voltage_updates.json",
|
"graph_voltage_updates.json",
|
||||||
|
|||||||
@@ -51,6 +51,7 @@ from backend.pinscopex.passive_rail_check import (
|
|||||||
)
|
)
|
||||||
from backend.pinscopex.bom_match_check import check_bom_schematic_match
|
from backend.pinscopex.bom_match_check import check_bom_schematic_match
|
||||||
from backend.pinscopex.hf_coverage_check import check_hf_decoupling_coverage
|
from backend.pinscopex.hf_coverage_check import check_hf_decoupling_coverage
|
||||||
|
from backend.pinscopex.cad_bridge import annotate_findings_cad, build_cad_bridge, write_cad_bridge
|
||||||
from backend.pinscopex.filter_check import check_filters
|
from backend.pinscopex.filter_check import check_filters
|
||||||
from backend.pinscopex.thermal_check import check_thermal
|
from backend.pinscopex.thermal_check import check_thermal
|
||||||
from backend.pinscopex.power_margin_check import check_power_margin
|
from backend.pinscopex.power_margin_check import check_power_margin
|
||||||
@@ -761,6 +762,7 @@ async def validate_design_async(
|
|||||||
return clean
|
return clean
|
||||||
|
|
||||||
def _write_report(paused: bool = False) -> ValidationReport:
|
def _write_report(paused: bool = False) -> ValidationReport:
|
||||||
|
annotate_findings_cad(all_findings, graph.cad_index)
|
||||||
assign_finding_ids(all_findings)
|
assign_finding_ids(all_findings)
|
||||||
summary = {"total": len(all_findings), "ERROR": 0, "WARNING": 0, "INFO": 0}
|
summary = {"total": len(all_findings), "ERROR": 0, "WARNING": 0, "INFO": 0}
|
||||||
for f in all_findings:
|
for f in all_findings:
|
||||||
@@ -792,6 +794,12 @@ async def validate_design_async(
|
|||||||
if paused:
|
if paused:
|
||||||
report_dict["partial"] = True
|
report_dict["partial"] = True
|
||||||
existing_path.write_text(json.dumps(report_dict, indent=2))
|
existing_path.write_text(json.dumps(report_dict, indent=2))
|
||||||
|
try:
|
||||||
|
prefix_id = (project_prefix or "").rstrip("/").rsplit("/", 1)[-1]
|
||||||
|
bridge = build_cad_bridge(report, prefix_id or report.project)
|
||||||
|
write_cad_bridge(existing_path.with_name("pinscope-findings.json"), bridge)
|
||||||
|
except Exception:
|
||||||
|
log.exception("cad bridge write failed")
|
||||||
return report
|
return report
|
||||||
|
|
||||||
git_commit = (run_meta or {}).get("git_commit", "unknown")
|
git_commit = (run_meta or {}).get("git_commit", "unknown")
|
||||||
|
|||||||
@@ -2,6 +2,14 @@
|
|||||||
|
|
||||||
What's new in Pinscope.
|
What's new in Pinscope.
|
||||||
|
|
||||||
|
## 2.16.0 — 2026-09-10 — KiCad cad-bridge
|
||||||
|
|
||||||
|
Pinscope writes `pinscope-findings.json` next to the report so a KiCad 9/10 action plugin can pan to the symbol uuid on the right sheet.
|
||||||
|
|
||||||
|
- [New] E2 cad-bridge JSON (`version`, `ref`, `pins`, `sheet`, `uuid`, `severity`). Layout rule ids target pcbnew; others target eeschema.
|
||||||
|
- [New] `.kicad_sch` symbols keep `cad_uuid` + `cad_sheet` in `cad_index` (child sheet, not the empty root).
|
||||||
|
- [New] Action plugin in `plugins/kicad/` reads the JSON beside `.kicad_pro` / `.kicad_pcb`.
|
||||||
|
|
||||||
## 2.15.0 — 2026-09-10 — Power margin, sequencing, DNP enable
|
## 2.15.0 — 2026-09-10 — Power margin, sequencing, DNP enable
|
||||||
|
|
||||||
Schema checks now compare regulator load to Iout_max, look at PG→EN when a sequence is declared, and treat DNP as a fitted-variant graph.
|
Schema checks now compare regulator load to Iout_max, look at PG→EN when a sequence is declared, and treat DNP as a fitted-variant graph.
|
||||||
|
|||||||
@@ -95,7 +95,8 @@ export interface DesignGraph {
|
|||||||
components: Record<string, Component>;
|
components: Record<string, Component>;
|
||||||
nets: Record<string, Net>;
|
nets: Record<string, Net>;
|
||||||
bom_fields?: Record<string, { mpn?: string | null; value?: string; dnp?: boolean; variant?: string | null }>;
|
bom_fields?: Record<string, { mpn?: string | null; value?: string; dnp?: boolean; variant?: string | null }>;
|
||||||
schematic_fields?: Record<string, { mpn?: string | null; value?: string }>;
|
schematic_fields?: Record<string, { mpn?: string | null; value?: string; cad_uuid?: string; cad_sheet?: string }>;
|
||||||
|
cad_index?: Record<string, { uuid?: string; sheet?: string }>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface BomSummaryRow {
|
export interface BomSummaryRow {
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
"""Plugins namespace — KiCad action plugin lives in ``plugins/kicad/``."""
|
||||||
@@ -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
|
||||||
@@ -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 []),
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
"""E2 cad-bridge JSON and KiCad plugin focus helpers."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from backend.pinscopex.cad_bridge import annotate_findings_cad, build_cad_bridge
|
||||||
|
from backend.pinscopex.models import CadIndexEntry, Finding, ValidationReport
|
||||||
|
from backend.pinscopex.parsers_kicad import kicad_part_fields, parse_kicad
|
||||||
|
from plugins.kicad.focus import find_bridge_file, focus_target, load_bridge
|
||||||
|
|
||||||
|
|
||||||
|
def _f(**kwargs) -> Finding:
|
||||||
|
defaults = dict(designator="U3", finding="mux", status="WARNING")
|
||||||
|
defaults.update(kwargs)
|
||||||
|
return Finding(**defaults)
|
||||||
|
|
||||||
|
|
||||||
|
def test_bridge_exports_version_and_strips_pin_prefix():
|
||||||
|
report = ValidationReport(
|
||||||
|
project="p", timestamp="t",
|
||||||
|
findings=[_f(
|
||||||
|
finding_id="U3-001", rule_id="PS-MUX-001",
|
||||||
|
pins=["U3.12"], cad_sheet="power.kicad_sch",
|
||||||
|
cad_uuid="aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa",
|
||||||
|
net="UART5_TX",
|
||||||
|
)],
|
||||||
|
summary={"total": 1},
|
||||||
|
)
|
||||||
|
payload = build_cad_bridge(report, "proj-1", url_base="https://app/report")
|
||||||
|
assert payload["version"] == 1
|
||||||
|
assert payload["project_id"] == "proj-1"
|
||||||
|
row = payload["findings"][0]
|
||||||
|
assert row["pins"] == ["12"]
|
||||||
|
assert row["sheet"] == "power.kicad_sch"
|
||||||
|
assert row["uuid"].startswith("aaaa")
|
||||||
|
assert row["severity"] == "warning"
|
||||||
|
assert "finding=U3-001" in row["url"]
|
||||||
|
assert row["target"] == "sch"
|
||||||
|
|
||||||
|
|
||||||
|
def test_missing_uuid_stays_empty_and_pcb_rule_targets_board():
|
||||||
|
report = ValidationReport(
|
||||||
|
project="p", timestamp="t",
|
||||||
|
findings=[_f(rule_id="PS-PLC-001", pins=["1"], status="ERROR")],
|
||||||
|
summary={"total": 1},
|
||||||
|
)
|
||||||
|
row = build_cad_bridge(report, "x")["findings"][0]
|
||||||
|
assert row["uuid"] == ""
|
||||||
|
assert row["sheet"] == ""
|
||||||
|
assert row["target"] == "pcb"
|
||||||
|
assert row["severity"] == "error"
|
||||||
|
|
||||||
|
|
||||||
|
def test_annotate_does_not_overwrite_existing_cad_fields():
|
||||||
|
idx = {"U3": CadIndexEntry(uuid="from-index", sheet="child.kicad_sch")}
|
||||||
|
f = _f(cad_uuid="already", cad_sheet=None)
|
||||||
|
annotate_findings_cad([f], idx)
|
||||||
|
assert f.cad_uuid == "already"
|
||||||
|
assert f.cad_sheet == "child.kicad_sch"
|
||||||
|
|
||||||
|
|
||||||
|
def test_kicad_sch_fields_include_uuid_and_child_sheet(tmp_path: Path):
|
||||||
|
from tests.test_kicad_parser import _resistor, _sch
|
||||||
|
|
||||||
|
child = tmp_path / "analog.kicad_sch"
|
||||||
|
child.write_text(_sch(
|
||||||
|
_resistor("U3", "MCU"),
|
||||||
|
"""
|
||||||
|
(global_label "GND" (at 0 3.81 0) (uuid "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"))
|
||||||
|
""",
|
||||||
|
))
|
||||||
|
root = tmp_path / "root.kicad_sch"
|
||||||
|
root.write_text(_sch(
|
||||||
|
_resistor("R1", "10k"),
|
||||||
|
"""
|
||||||
|
(global_label "GND" (at 0 3.81 0) (uuid "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"))
|
||||||
|
(sheet
|
||||||
|
(at 50 0)
|
||||||
|
(size 20 20)
|
||||||
|
(property "Sheetname" "Analog" (at 50 0 0) (effects (font (size 1.27 1.27))))
|
||||||
|
(property "Sheetfile" "analog.kicad_sch" (at 50 0 0) (effects (font (size 1.27 1.27))))
|
||||||
|
)
|
||||||
|
""",
|
||||||
|
))
|
||||||
|
parse_kicad(root)
|
||||||
|
fields = kicad_part_fields(root)
|
||||||
|
assert fields["U3"]["cad_sheet"] == "analog.kicad_sch"
|
||||||
|
assert fields["R1"]["cad_sheet"] == "root.kicad_sch"
|
||||||
|
assert fields["U3"].get("cad_uuid")
|
||||||
|
assert fields["U3"]["cad_uuid"] != fields["R1"]["cad_uuid"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_plugin_finds_bridge_and_pcb_target(tmp_path: Path):
|
||||||
|
(tmp_path / "pinscope-findings.json").write_text(
|
||||||
|
'{"version":1,"project_id":"p","findings":[]}\n'
|
||||||
|
)
|
||||||
|
nested = tmp_path / "board"
|
||||||
|
nested.mkdir()
|
||||||
|
found = find_bridge_file(nested / "x.kicad_pcb")
|
||||||
|
assert found == tmp_path / "pinscope-findings.json"
|
||||||
|
assert load_bridge(found)["version"] == 1
|
||||||
|
t = focus_target({"ref": "U1", "rule_id": "PS-PLC-001", "uuid": "x", "sheet": ""})
|
||||||
|
assert t["kind"] == "pcb" and t["ref"] == "U1"
|
||||||
|
missing = tmp_path / "nowhere"
|
||||||
|
missing.mkdir()
|
||||||
|
assert find_bridge_file(missing, max_up=0) is None
|
||||||
@@ -137,12 +137,13 @@ _LIB_R = """
|
|||||||
|
|
||||||
|
|
||||||
def _resistor(ref: str, value: str, x: float = 0, y: float = 0) -> str:
|
def _resistor(ref: str, value: str, x: float = 0, y: float = 0) -> str:
|
||||||
|
uid = "aaaaaaaa-aaaa-aaaa-aaaa-" + ref.encode().hex()[:12].ljust(12, "0")
|
||||||
return f"""
|
return f"""
|
||||||
(symbol
|
(symbol
|
||||||
(lib_id "Device:R")
|
(lib_id "Device:R")
|
||||||
(at {x} {y} 0)
|
(at {x} {y} 0)
|
||||||
(unit 1)
|
(unit 1)
|
||||||
(uuid "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa")
|
(uuid "{uid}")
|
||||||
(property "Reference" "{ref}" (at 0 0 0) (effects (font (size 1.27 1.27))))
|
(property "Reference" "{ref}" (at 0 0 0) (effects (font (size 1.27 1.27))))
|
||||||
(property "Value" "{value}" (at 0 0 0) (effects (font (size 1.27 1.27))))
|
(property "Value" "{value}" (at 0 0 0) (effects (font (size 1.27 1.27))))
|
||||||
(pin "1" (uuid "p1"))
|
(pin "1" (uuid "p1"))
|
||||||
|
|||||||
Reference in New Issue
Block a user