Open a KiCad project folder: schematic + PCB, no netlist.
Apri progetto KiCad picks the folder that contains .kicad_pro and loads sibling hierarchical sheets and the matching .kicad_pcb. Connectivity and MPN/PNM/Value come from the schematic; a leftover PADS .asc is ignored. A lone .kicad_pro upload is rejected unless that path exists on disk.
This commit is contained in:
@@ -289,8 +289,9 @@ def build_graph(
|
|||||||
pcb_path: str | Path | None = None,
|
pcb_path: str | Path | None = None,
|
||||||
extra_pdf_dirs: tuple[Path | str, ...] | list[Path | str] | None = None,
|
extra_pdf_dirs: tuple[Path | str, ...] | list[Path | str] | None = None,
|
||||||
) -> DesignGraph:
|
) -> DesignGraph:
|
||||||
"""Deterministic graph: BOM → netlist → optional board nets → datasheets → DesignGraph."""
|
"""Deterministic graph: schematic/netlist → optional BOM CSV → datasheets."""
|
||||||
bom = parse_bom(bom_path, reference_col=reference_col, mpn_col=mpn_col)
|
bom_file = Path(bom_path)
|
||||||
|
bom = parse_bom(bom_file, reference_col=reference_col, mpn_col=mpn_col) if bom_file.is_file() else {}
|
||||||
bom_fields = _bom_rows(bom)
|
bom_fields = _bom_rows(bom)
|
||||||
parts, raw_nets, fmt = parse_netlist_any(
|
parts, raw_nets, fmt = parse_netlist_any(
|
||||||
netlist_path,
|
netlist_path,
|
||||||
@@ -321,7 +322,7 @@ def build_graph(
|
|||||||
mpn_subtype: dict[str, str] = {}
|
mpn_subtype: dict[str, str] = {}
|
||||||
|
|
||||||
for rp in resolve_bom(
|
for rp in resolve_bom(
|
||||||
bom_path, patterns_dir, reference_col=reference_col, mpn_col=mpn_col, skipped=skipped
|
bom_file, patterns_dir, reference_col=reference_col, mpn_col=mpn_col, skipped=skipped
|
||||||
):
|
):
|
||||||
if rp.component_subtype:
|
if rp.component_subtype:
|
||||||
mpn_subtype[rp.mpn] = rp.component_subtype
|
mpn_subtype[rp.mpn] = rp.component_subtype
|
||||||
|
|||||||
@@ -0,0 +1,184 @@
|
|||||||
|
"""Load a KiCad project from the folder that contains ``*.kicad_pro``.
|
||||||
|
|
||||||
|
A lone ``.kicad_pro`` file is a manifest (JSON). Schematic bytes live in
|
||||||
|
sibling ``.kicad_sch`` files in that same directory. Hierarchical
|
||||||
|
``Sheetfile`` children are followed only when they stay inside that folder.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import csv
|
||||||
|
import io
|
||||||
|
import json
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
_KEEP_SUFFIX = {
|
||||||
|
".kicad_sch",
|
||||||
|
".kicad_pcb",
|
||||||
|
".kicad_pro",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class KicadProject:
|
||||||
|
folder: Path
|
||||||
|
pro: Path
|
||||||
|
root_sch: Path
|
||||||
|
sheets: list[Path]
|
||||||
|
pcb: Path | None
|
||||||
|
extras: list[Path] = field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
def sniff_kicad_pro(content: bytes, name: str = "") -> bool:
|
||||||
|
if name.lower().endswith(".kicad_pro"):
|
||||||
|
return True
|
||||||
|
head = content.lstrip()[:64]
|
||||||
|
if not head.startswith(b"{"):
|
||||||
|
return False
|
||||||
|
sample = content[:8000]
|
||||||
|
return b"top_level_sheets" in sample or b'"meta"' in sample and b"kicad" in sample.lower()
|
||||||
|
|
||||||
|
|
||||||
|
def find_kicad_pro(folder: Path) -> Path:
|
||||||
|
hits = sorted(p for p in folder.glob("*.kicad_pro") if p.is_file())
|
||||||
|
if not hits:
|
||||||
|
raise ValueError(
|
||||||
|
"In questa cartella non c'è un progetto KiCad (.kicad_pro)."
|
||||||
|
)
|
||||||
|
if len(hits) == 1:
|
||||||
|
return hits[0]
|
||||||
|
named = [p for p in hits if p.stem.lower() == folder.name.lower()]
|
||||||
|
if len(named) == 1:
|
||||||
|
return named[0]
|
||||||
|
names = ", ".join(p.name for p in hits)
|
||||||
|
raise ValueError(f"Più file .kicad_pro nella cartella ({names}).")
|
||||||
|
|
||||||
|
|
||||||
|
def _top_level_sch_names(pro: Path) -> list[str]:
|
||||||
|
try:
|
||||||
|
data = json.loads(pro.read_text(encoding="utf-8"))
|
||||||
|
except json.JSONDecodeError as exc:
|
||||||
|
raise ValueError(f"File .kicad_pro non valido: {exc}") from exc
|
||||||
|
schematic = data.get("schematic") or {}
|
||||||
|
rows = schematic.get("top_level_sheets") or []
|
||||||
|
names: list[str] = []
|
||||||
|
for row in rows:
|
||||||
|
if not isinstance(row, dict):
|
||||||
|
continue
|
||||||
|
fn = str(row.get("filename") or "").strip()
|
||||||
|
if fn:
|
||||||
|
names.append(Path(fn.replace("\\", "/")).name)
|
||||||
|
if not names:
|
||||||
|
names.append(f"{pro.stem}.kicad_sch")
|
||||||
|
return names
|
||||||
|
|
||||||
|
|
||||||
|
def _jail(path: Path, folder: Path) -> Path:
|
||||||
|
resolved = path.resolve()
|
||||||
|
root = folder.resolve()
|
||||||
|
try:
|
||||||
|
resolved.relative_to(root)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise ValueError(f"Foglio fuori dalla cartella del progetto: {path.name}") from exc
|
||||||
|
return resolved
|
||||||
|
|
||||||
|
|
||||||
|
def collect_hierarchical_sheets(root_sch: Path, folder: Path) -> list[Path]:
|
||||||
|
from backend.periscopex.netlist_bundle import _sheetfiles_of
|
||||||
|
|
||||||
|
folder = folder.resolve()
|
||||||
|
ordered: list[Path] = []
|
||||||
|
seen: set[Path] = set()
|
||||||
|
queue = [root_sch]
|
||||||
|
while queue:
|
||||||
|
current = queue.pop(0)
|
||||||
|
path = _jail(current, folder)
|
||||||
|
if path in seen:
|
||||||
|
continue
|
||||||
|
if not path.is_file():
|
||||||
|
raise ValueError(f"Manca il foglio schematico {current.name} accanto al .kicad_pro.")
|
||||||
|
seen.add(path)
|
||||||
|
ordered.append(path)
|
||||||
|
for rel in _sheetfiles_of(path):
|
||||||
|
child = path.parent / rel.replace("\\", "/")
|
||||||
|
queue.append(child)
|
||||||
|
return ordered
|
||||||
|
|
||||||
|
|
||||||
|
def load_kicad_project(folder: str | Path) -> KicadProject:
|
||||||
|
"""Resolve ``*.kicad_pro`` and siblings in *folder* only — nowhere else."""
|
||||||
|
folder = Path(folder).resolve()
|
||||||
|
if not folder.is_dir():
|
||||||
|
raise ValueError(f"Cartella progetto non trovata: {folder}")
|
||||||
|
pro = find_kicad_pro(folder)
|
||||||
|
names = _top_level_sch_names(pro)
|
||||||
|
root_sch = None
|
||||||
|
for name in names:
|
||||||
|
candidate = folder / name
|
||||||
|
if candidate.is_file():
|
||||||
|
root_sch = candidate
|
||||||
|
break
|
||||||
|
if root_sch is None:
|
||||||
|
raise ValueError(
|
||||||
|
f"Manca lo schematico {names[0]} nella stessa cartella del .kicad_pro."
|
||||||
|
)
|
||||||
|
sheets = collect_hierarchical_sheets(root_sch, folder)
|
||||||
|
extras = [p for p in sheets if p.resolve() != root_sch.resolve()]
|
||||||
|
pcb_path = folder / f"{pro.stem}.kicad_pcb"
|
||||||
|
pcb = pcb_path if pcb_path.is_file() else None
|
||||||
|
return KicadProject(
|
||||||
|
folder=folder,
|
||||||
|
pro=pro,
|
||||||
|
root_sch=root_sch,
|
||||||
|
sheets=sheets,
|
||||||
|
pcb=pcb,
|
||||||
|
extras=extras,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def copy_kicad_siblings(pro: Path, dest: Path) -> KicadProject:
|
||||||
|
"""Copy ``.kicad_pro`` + same-folder sch/pcb into *dest* (dev/Mac path)."""
|
||||||
|
loaded = load_kicad_project(pro.parent)
|
||||||
|
dest.mkdir(parents=True, exist_ok=True)
|
||||||
|
mapping: dict[Path, Path] = {}
|
||||||
|
for src in [loaded.pro, *loaded.sheets]:
|
||||||
|
out = dest / src.name
|
||||||
|
out.write_bytes(src.read_bytes())
|
||||||
|
mapping[src.resolve()] = out
|
||||||
|
pcb = None
|
||||||
|
if loaded.pcb is not None:
|
||||||
|
pcb = dest / loaded.pcb.name
|
||||||
|
pcb.write_bytes(loaded.pcb.read_bytes())
|
||||||
|
root = mapping[loaded.root_sch.resolve()]
|
||||||
|
sheets = [mapping[p.resolve()] for p in loaded.sheets]
|
||||||
|
extras = [p for p in sheets if p.resolve() != root.resolve()]
|
||||||
|
return KicadProject(
|
||||||
|
folder=dest,
|
||||||
|
pro=dest / loaded.pro.name,
|
||||||
|
root_sch=root,
|
||||||
|
sheets=sheets,
|
||||||
|
pcb=pcb,
|
||||||
|
extras=extras,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def bom_csv_from_fields(fields: dict[str, dict]) -> bytes:
|
||||||
|
"""CSV of schematic Reference/Value/Footprint/MPN. Empty MPN stays empty."""
|
||||||
|
buf = io.StringIO()
|
||||||
|
writer = csv.writer(buf)
|
||||||
|
writer.writerow(
|
||||||
|
["Reference", "Value", "Footprint", "Manufacturer Part Number", "PNM"]
|
||||||
|
)
|
||||||
|
for ref, extra in sorted(fields.items()):
|
||||||
|
mpn = extra.get("mpn") or ""
|
||||||
|
writer.writerow(
|
||||||
|
[
|
||||||
|
ref,
|
||||||
|
extra.get("value") or "",
|
||||||
|
extra.get("footprint") or "",
|
||||||
|
mpn,
|
||||||
|
mpn,
|
||||||
|
]
|
||||||
|
)
|
||||||
|
return buf.getvalue().encode("utf-8")
|
||||||
@@ -28,7 +28,9 @@ class NetlistUpload:
|
|||||||
bom: Path | None = None
|
bom: Path | None = None
|
||||||
|
|
||||||
|
|
||||||
def sniff_netlist_kind(content: bytes) -> str:
|
def sniff_netlist_kind(content: bytes, name: str = "") -> str:
|
||||||
|
if name.lower().endswith(".kicad_pro"):
|
||||||
|
return "kicad_pro"
|
||||||
if content[:2] == b"PK":
|
if content[:2] == b"PK":
|
||||||
return "zip"
|
return "zip"
|
||||||
head = content[:2048].decode("utf-8", errors="replace").lstrip("\ufeff").lstrip()
|
head = content[:2048].decode("utf-8", errors="replace").lstrip("\ufeff").lstrip()
|
||||||
@@ -45,6 +47,10 @@ def sniff_netlist_kind(content: bytes) -> str:
|
|||||||
return "kicad_xml"
|
return "kicad_xml"
|
||||||
if "*PADS-PCB*" in head.upper() or head.lstrip().startswith("*PART*"):
|
if "*PADS-PCB*" in head.upper() or head.lstrip().startswith("*PART*"):
|
||||||
return "pads"
|
return "pads"
|
||||||
|
from backend.periscopex.kicad_project import sniff_kicad_pro
|
||||||
|
|
||||||
|
if sniff_kicad_pro(content, name):
|
||||||
|
return "kicad_pro"
|
||||||
return "unknown"
|
return "unknown"
|
||||||
|
|
||||||
|
|
||||||
@@ -112,7 +118,7 @@ def _extract_zip(data: bytes, dest: Path) -> None:
|
|||||||
|
|
||||||
def _write_named(name: str, data: bytes, dest: Path) -> None:
|
def _write_named(name: str, data: bytes, dest: Path) -> None:
|
||||||
rel = _safe_rel(name)
|
rel = _safe_rel(name)
|
||||||
kind = sniff_netlist_kind(data)
|
kind = sniff_netlist_kind(data, name)
|
||||||
if kind == "zip":
|
if kind == "zip":
|
||||||
_extract_zip(data, dest)
|
_extract_zip(data, dest)
|
||||||
return
|
return
|
||||||
@@ -153,30 +159,48 @@ def _sheetfiles_of(path: Path) -> list[str]:
|
|||||||
return _sheetfiles(tree)
|
return _sheetfiles(tree)
|
||||||
|
|
||||||
|
|
||||||
|
def _project_folder(work: Path) -> Path | None:
|
||||||
|
pros = [
|
||||||
|
p for p in work.rglob("*.kicad_pro")
|
||||||
|
if p.is_file() and "-backups" not in p.parts
|
||||||
|
]
|
||||||
|
if not pros:
|
||||||
|
return None
|
||||||
|
pros.sort(key=lambda p: (len(p.relative_to(work).parts), p.name.lower()))
|
||||||
|
return pros[0].parent
|
||||||
|
|
||||||
|
|
||||||
def _pick_root(work: Path) -> Path:
|
def _pick_root(work: Path) -> Path:
|
||||||
|
from backend.periscopex.kicad_project import load_kicad_project
|
||||||
|
|
||||||
|
folder = _project_folder(work)
|
||||||
|
if folder is not None:
|
||||||
|
return load_kicad_project(folder).root_sch
|
||||||
|
|
||||||
schs: list[Path] = []
|
schs: list[Path] = []
|
||||||
exported: list[Path] = []
|
exported: list[Path] = []
|
||||||
for p in work.rglob("*"):
|
for p in work.rglob("*"):
|
||||||
if not p.is_file():
|
if not p.is_file():
|
||||||
continue
|
continue
|
||||||
kind = sniff_netlist_kind(p.read_bytes()[:2048])
|
kind = sniff_netlist_kind(p.read_bytes()[:2048], p.name)
|
||||||
if kind == "kicad_sch":
|
if kind == "kicad_sch":
|
||||||
schs.append(p)
|
schs.append(p)
|
||||||
elif kind in ("kicad_xml", "kicad_sexp", "edif", "pads"):
|
elif kind in ("kicad_xml", "kicad_sexp", "edif", "pads"):
|
||||||
exported.append(p)
|
exported.append(p)
|
||||||
|
|
||||||
|
# Schematic in the same folder beats a leftover PADS .asc (HubAudio U13 ghosts).
|
||||||
|
if not schs:
|
||||||
if exported:
|
if exported:
|
||||||
pref = [
|
pref = [
|
||||||
p for p in exported
|
p for p in exported
|
||||||
if sniff_netlist_kind(p.read_bytes()[:2048]) in (
|
if sniff_netlist_kind(p.read_bytes()[:2048], p.name) in (
|
||||||
"kicad_xml", "kicad_sexp", "edif",
|
"kicad_xml", "kicad_sexp", "edif",
|
||||||
)
|
)
|
||||||
]
|
]
|
||||||
return (pref or exported)[0]
|
return (pref or exported)[0]
|
||||||
|
|
||||||
if not schs:
|
|
||||||
raise ValueError(
|
raise ValueError(
|
||||||
"No schematic found. Drop the KiCad project folder or a netlist."
|
"Nessuno schematico. Apri la cartella del progetto KiCad "
|
||||||
|
"(quella con il file .kicad_pro)."
|
||||||
)
|
)
|
||||||
|
|
||||||
referenced: set[Path] = set()
|
referenced: set[Path] = set()
|
||||||
@@ -220,30 +244,59 @@ def materialize_netlist_upload(
|
|||||||
|
|
||||||
if len(files) == 1:
|
if len(files) == 1:
|
||||||
name, data = files[0]
|
name, data = files[0]
|
||||||
kind = sniff_netlist_kind(data)
|
kind = sniff_netlist_kind(data, name)
|
||||||
if kind == "kicad_pcb":
|
if kind == "kicad_pcb":
|
||||||
raise ValueError(
|
raise ValueError(
|
||||||
"This is a board file. Drop the KiCad project folder, or put "
|
"Questo è il circuito stampato. Apri la cartella del progetto "
|
||||||
"the .kicad_pcb on the optional board step."
|
"KiCad (quella con il file .kicad_pro)."
|
||||||
|
)
|
||||||
|
if kind == "kicad_pro":
|
||||||
|
disk = Path(name).expanduser()
|
||||||
|
if disk.is_file():
|
||||||
|
from backend.periscopex.kicad_project import copy_kicad_siblings
|
||||||
|
|
||||||
|
loaded = copy_kicad_siblings(disk, dest)
|
||||||
|
return NetlistUpload(
|
||||||
|
root=loaded.root_sch,
|
||||||
|
work_dir=dest,
|
||||||
|
pcb=loaded.pcb,
|
||||||
|
extra_sch=loaded.extras,
|
||||||
|
bom=None,
|
||||||
|
)
|
||||||
|
raise ValueError(
|
||||||
|
"Il file .kicad_pro da solo non basta: non contiene lo "
|
||||||
|
"schematico. Apri la cartella del progetto."
|
||||||
)
|
)
|
||||||
if kind == "unknown" and not name.lower().endswith(".zip"):
|
if kind == "unknown" and not name.lower().endswith(".zip"):
|
||||||
raise ValueError(
|
raise ValueError(
|
||||||
"Not a netlist. Drop the KiCad project folder, a zip, or a "
|
"Apri la cartella del progetto KiCad (quella con il file "
|
||||||
"PADS / EDIF / KiCad netlist."
|
".kicad_pro), non un singolo file."
|
||||||
)
|
)
|
||||||
|
|
||||||
for name, data in files:
|
for name, data in files:
|
||||||
_write_named(name, data, dest)
|
_write_named(name, data, dest)
|
||||||
|
|
||||||
|
folder = _project_folder(dest)
|
||||||
|
if folder is not None:
|
||||||
|
from backend.periscopex.kicad_project import load_kicad_project
|
||||||
|
|
||||||
|
loaded = load_kicad_project(folder)
|
||||||
|
return NetlistUpload(
|
||||||
|
root=loaded.root_sch,
|
||||||
|
work_dir=dest,
|
||||||
|
pcb=loaded.pcb,
|
||||||
|
extra_sch=loaded.extras,
|
||||||
|
bom=None,
|
||||||
|
)
|
||||||
|
|
||||||
root = _pick_root(dest)
|
root = _pick_root(dest)
|
||||||
pcb = find_kicad_pcb(dest)
|
pcb = find_kicad_pcb(dest)
|
||||||
bom = find_bom(dest)
|
|
||||||
extras = [
|
extras = [
|
||||||
p for p in work_sch_files(dest)
|
p for p in work_sch_files(dest)
|
||||||
if p.resolve() != root.resolve()
|
if p.resolve() != root.resolve()
|
||||||
]
|
]
|
||||||
return NetlistUpload(
|
return NetlistUpload(
|
||||||
root=root, work_dir=dest, pcb=pcb, extra_sch=extras, bom=bom,
|
root=root, work_dir=dest, pcb=pcb, extra_sch=extras, bom=find_bom(dest),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -237,7 +237,10 @@ def parse_bom(
|
|||||||
mpn_col: str = "Manufacturer Part Number",
|
mpn_col: str = "Manufacturer Part Number",
|
||||||
) -> dict[str, dict]:
|
) -> dict[str, dict]:
|
||||||
result: dict[str, dict] = {}
|
result: dict[str, dict] = {}
|
||||||
reader = csv.DictReader(Path(path).read_text().splitlines())
|
path = Path(path)
|
||||||
|
if not path.is_file():
|
||||||
|
return result
|
||||||
|
reader = csv.DictReader(path.read_text().splitlines())
|
||||||
colnames = {n.lower() for n in (reader.fieldnames or []) if n}
|
colnames = {n.lower() for n in (reader.fieldnames or []) if n}
|
||||||
has_dnp_col = bool(colnames & {"dnp", "dni", "fitted", "populate"})
|
has_dnp_col = bool(colnames & {"dnp", "dni", "fitted", "populate"})
|
||||||
has_variant_col = bool(colnames & {"variant"})
|
has_variant_col = bool(colnames & {"variant"})
|
||||||
|
|||||||
@@ -537,7 +537,23 @@ async def upload_netlist(
|
|||||||
if parsed.pcb is not None:
|
if parsed.pcb is not None:
|
||||||
proj_svc.save_pcb(storage, owner_id, project_id, parsed.pcb.read_bytes())
|
proj_svc.save_pcb(storage, owner_id, project_id, parsed.pcb.read_bytes())
|
||||||
pcb_saved = True
|
pcb_saved = True
|
||||||
if parsed.bom is not None:
|
if fmt == "kicad_sch":
|
||||||
|
from backend.periscopex.kicad_project import bom_csv_from_fields
|
||||||
|
from backend.periscopex.parsers_kicad import kicad_part_fields
|
||||||
|
|
||||||
|
fields = kicad_part_fields(parsed.root)
|
||||||
|
bom_bytes = bom_csv_from_fields(fields)
|
||||||
|
if bom_bytes:
|
||||||
|
proj_svc.save_bom(storage, owner_id, project_id, bom_bytes)
|
||||||
|
proj_svc.update_project(
|
||||||
|
storage, owner_id, project_id,
|
||||||
|
bom_columns={
|
||||||
|
"reference": "Reference",
|
||||||
|
"mpn": "Manufacturer Part Number",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
bom_saved = True
|
||||||
|
elif parsed.bom is not None:
|
||||||
bom_bytes = _bom_file_to_csv_bytes(parsed.bom)
|
bom_bytes = _bom_file_to_csv_bytes(parsed.bom)
|
||||||
if bom_bytes:
|
if bom_bytes:
|
||||||
proj_svc.save_bom(storage, owner_id, project_id, bom_bytes)
|
proj_svc.save_bom(storage, owner_id, project_id, bom_bytes)
|
||||||
|
|||||||
@@ -2,6 +2,14 @@
|
|||||||
|
|
||||||
What's new in Periscope.
|
What's new in Periscope.
|
||||||
|
|
||||||
|
## 2.60.6 — 2026-09-21 — Apri progetto KiCad (cartella)
|
||||||
|
|
||||||
|
Un controllo: **Apri progetto KiCad**. Si sceglie la cartella dove sta il `.kicad_pro`. Carichiamo root + fogli hierarchical `.kicad_sch` e il `.kicad_pcb` fratello. Niente netlist, niente CSV. MPN solo da proprietà schematico (PNM/MPN/Value). Uno `.asc` PADS vecchio nella stessa cartella è ignorato.
|
||||||
|
|
||||||
|
- [New] Folder picker / drop della cartella progetto.
|
||||||
|
- [Fixed] `.kicad_pro` da solo non finge di avere lo schematico.
|
||||||
|
- [Fixed] Connectivity dallo schematico, non da un netlist stantio (niente U13 fantasma).
|
||||||
|
|
||||||
## 2.60.5 — 2026-09-21 — Hierarchical schematic MPN when BOM omits a ref
|
## 2.60.5 — 2026-09-21 — Hierarchical schematic MPN when BOM omits a ref
|
||||||
|
|
||||||
graph_build reads `PNM` on `.kicad_sch` symbols (all nested sheets). If the CSV has no row for a designator, the child-sheet `PNM`/`MPN`/IC `Value` still fills `mpn`. Empty child-sheet fields stay empty — no invented part numbers. A sibling `.kicad_sch` next to a PADS netlist is consulted the same way.
|
graph_build reads `PNM` on `.kicad_sch` symbols (all nested sheets). If the CSV has no row for a designator, the child-sheet `PNM`/`MPN`/IC `Value` still fills `mpn`. Empty child-sheet fields stay empty — no invented part numbers. A sibling `.kicad_sch` next to a PADS netlist is consulted the same way.
|
||||||
|
|||||||
@@ -360,6 +360,36 @@ function extensionOf(name: string): string {
|
|||||||
return i >= 0 ? name.slice(i).toLowerCase() : "";
|
return i >= 0 ? name.slice(i).toLowerCase() : "";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function relativeOf(file: File): string {
|
||||||
|
return (
|
||||||
|
(file as File & { webkitRelativePath?: string }).webkitRelativePath ||
|
||||||
|
file.name
|
||||||
|
).replaceAll("\\", "/");
|
||||||
|
}
|
||||||
|
|
||||||
|
const KICAD_PROJECT_EXTS = new Set([".kicad_pro", ".kicad_sch", ".kicad_pcb"]);
|
||||||
|
|
||||||
|
function filesBesideKicadPro(incoming: File[]): File[] {
|
||||||
|
const rows = incoming.map((f) => ({ f, rel: relativeOf(f) }));
|
||||||
|
const pro = rows.find(
|
||||||
|
(row) =>
|
||||||
|
row.rel.toLowerCase().endsWith(".kicad_pro") &&
|
||||||
|
!row.rel.includes("-backups/") &&
|
||||||
|
!row.rel.toLowerCase().includes("/__macosx/"),
|
||||||
|
);
|
||||||
|
if (!pro) return [];
|
||||||
|
const dir = pro.rel.includes("/") ? pro.rel.slice(0, pro.rel.lastIndexOf("/")) : "";
|
||||||
|
return rows
|
||||||
|
.filter((row) => {
|
||||||
|
const parent = row.rel.includes("/")
|
||||||
|
? row.rel.slice(0, row.rel.lastIndexOf("/"))
|
||||||
|
: "";
|
||||||
|
if (parent !== dir) return false;
|
||||||
|
return KICAD_PROJECT_EXTS.has(extensionOf(row.f.name));
|
||||||
|
})
|
||||||
|
.map((row) => row.f);
|
||||||
|
}
|
||||||
|
|
||||||
function sortIncomingFiles(incoming: File[]): {
|
function sortIncomingFiles(incoming: File[]): {
|
||||||
netlist: File[];
|
netlist: File[];
|
||||||
pcb: File[];
|
pcb: File[];
|
||||||
@@ -878,62 +908,34 @@ export function CreateProjectDialog({
|
|||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const onNetlistFiles = useCallback((incoming: File[]) => {
|
const onKicadProjectFolder = useCallback((incoming: File[]) => {
|
||||||
const { netlist, pcb, bom } = sortIncomingFiles(incoming);
|
setBomFromBundle(true);
|
||||||
if (pcb[0]) {
|
setPcbFromBundle(true);
|
||||||
setPcbFile(pcb[0]);
|
setPcbFile(null);
|
||||||
setPcbFromBundle(false);
|
setBomFile(null);
|
||||||
}
|
const kept = filesBesideKicadPro(incoming);
|
||||||
if (bom[0]) {
|
setNetlistFiles(kept);
|
||||||
onBomFiles([bom[0]]);
|
|
||||||
setBomFromBundle(false);
|
|
||||||
}
|
|
||||||
const files = netlist;
|
|
||||||
const file = files[0] || null;
|
|
||||||
setNetlistFiles(files);
|
|
||||||
setNetlistNetCount(null);
|
setNetlistNetCount(null);
|
||||||
setNetlistError(null);
|
|
||||||
// Invalidate any cached netlist preview, since it was relative to the
|
|
||||||
// previous file.
|
|
||||||
setNetlistPreview(null);
|
setNetlistPreview(null);
|
||||||
setNetlistPreviewError(null);
|
setNetlistPreviewError(null);
|
||||||
// Re-uploads invalidate any prior EDIF sub-design data + selection.
|
|
||||||
setEdifSubDesigns([]);
|
setEdifSubDesigns([]);
|
||||||
setSelectedSubdesignIds(null);
|
setSelectedSubdesignIds(null);
|
||||||
setNetlistUploadedEarly(false);
|
setNetlistUploadedEarly(false);
|
||||||
setNetlistIsEdif(false);
|
setNetlistIsEdif(false);
|
||||||
if (!file) return;
|
if (!kept.some((f) => f.name.toLowerCase().endsWith(".kicad_pro"))) {
|
||||||
const lower = file.name.toLowerCase();
|
setNetlistError(
|
||||||
if (lower.endsWith(".zip") || files.length > 1) {
|
"In questa cartella non c'è un progetto KiCad (.kicad_pro).",
|
||||||
setNetlistPreview([]);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
file.text().then((text) => {
|
|
||||||
// EDIF: skip browser-side preview — the net-count badge will populate
|
|
||||||
// after the server upload parses the file.
|
|
||||||
if (textStartsEdif(text)) {
|
|
||||||
setNetlistNetCount(null);
|
|
||||||
setNetlistPreview([]);
|
|
||||||
setNetlistIsEdif(true);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (textStartsKicad(text)) {
|
|
||||||
setNetlistNetCount(null);
|
|
||||||
setNetlistPreview([]);
|
|
||||||
setNetlistIsEdif(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setNetlistIsEdif(false);
|
|
||||||
setNetlistNetCount(signalBlockCount(text));
|
|
||||||
try {
|
|
||||||
setNetlistPreview(padsPreviewFromText(text));
|
|
||||||
} catch (e) {
|
|
||||||
setNetlistPreviewError(
|
|
||||||
e instanceof Error ? e.message : "Failed to parse netlist",
|
|
||||||
);
|
);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
});
|
if (!kept.some((f) => f.name.toLowerCase().endsWith(".kicad_sch"))) {
|
||||||
}, [onBomFiles]);
|
setNetlistError(
|
||||||
|
"Manca lo schematico nella stessa cartella del .kicad_pro.",
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setNetlistError(null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
const closeAndClear = useCallback(() => {
|
const closeAndClear = useCallback(() => {
|
||||||
setOpen(false);
|
setOpen(false);
|
||||||
@@ -1552,6 +1554,7 @@ export function CreateProjectDialog({
|
|||||||
subDesigns: EdifSubDesign[];
|
subDesigns: EdifSubDesign[];
|
||||||
bomSaved?: boolean;
|
bomSaved?: boolean;
|
||||||
pcbSaved?: boolean;
|
pcbSaved?: boolean;
|
||||||
|
nextStep?: WizardStep | null;
|
||||||
}> => {
|
}> => {
|
||||||
if (!netlistFile) return { ok: false, subDesigns: [] };
|
if (!netlistFile) return { ok: false, subDesigns: [] };
|
||||||
setEarlyUploading(true);
|
setEarlyUploading(true);
|
||||||
@@ -1572,11 +1575,25 @@ export function CreateProjectDialog({
|
|||||||
}
|
}
|
||||||
if (result.nets > 0) setNetlistNetCount(result.nets);
|
if (result.nets > 0) setNetlistNetCount(result.nets);
|
||||||
if (result.pcb_saved) setPcbFromBundle(true);
|
if (result.pcb_saved) setPcbFromBundle(true);
|
||||||
|
let nextStep: WizardStep | null = "datasheets";
|
||||||
if (result.bom_saved) {
|
if (result.bom_saved) {
|
||||||
const bom = await downloadProjectBom(projectId);
|
const bom = await downloadProjectBom(projectId);
|
||||||
onBomFiles([bom]);
|
const text = await bom.text();
|
||||||
|
const parsed = tableFromCsv(text);
|
||||||
|
setCsvData(parsed);
|
||||||
|
setRefCol("Reference");
|
||||||
|
setMpnCol("Manufacturer Part Number");
|
||||||
|
setBomFile(new File([text], "bom.csv", { type: "text/csv" }));
|
||||||
setBomFromBundle(true);
|
setBomFromBundle(true);
|
||||||
setBomUploadedEarly(true);
|
setBomUploadedEarly(true);
|
||||||
|
const cls = classifyBomRows(
|
||||||
|
parsed.rows,
|
||||||
|
"Reference",
|
||||||
|
"Manufacturer Part Number",
|
||||||
|
);
|
||||||
|
if (cls.icMpns.length > 0) nextStep = "datasheets";
|
||||||
|
else if (cls.simpleGroups.length > 0) nextStep = "simple";
|
||||||
|
else if (cls.passiveGroups.length > 0) nextStep = "passives";
|
||||||
}
|
}
|
||||||
setNetlistUploadedEarly(true);
|
setNetlistUploadedEarly(true);
|
||||||
setInitialNetlistFiles(netlistFiles);
|
setInitialNetlistFiles(netlistFiles);
|
||||||
@@ -1585,6 +1602,7 @@ export function CreateProjectDialog({
|
|||||||
subDesigns: result.sub_designs,
|
subDesigns: result.sub_designs,
|
||||||
bomSaved: Boolean(result.bom_saved),
|
bomSaved: Boolean(result.bom_saved),
|
||||||
pcbSaved: Boolean(result.pcb_saved),
|
pcbSaved: Boolean(result.pcb_saved),
|
||||||
|
nextStep,
|
||||||
};
|
};
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (createdProjectIdHere) {
|
if (createdProjectIdHere) {
|
||||||
@@ -1606,9 +1624,7 @@ export function CreateProjectDialog({
|
|||||||
|
|
||||||
const wantsEarlyKicadUpload = Boolean(
|
const wantsEarlyKicadUpload = Boolean(
|
||||||
netlistFile &&
|
netlistFile &&
|
||||||
(netlistFile.name.toLowerCase().endsWith(".zip") ||
|
netlistFiles.some((f) => f.name.toLowerCase().endsWith(".kicad_pro")),
|
||||||
netlistFiles.length > 1 ||
|
|
||||||
netlistFiles.some((f) => f.name.toLowerCase().endsWith(".kicad_sch"))),
|
|
||||||
);
|
);
|
||||||
// ---- LCSC per-row passive resolve ----
|
// ---- LCSC per-row passive resolve ----
|
||||||
//
|
//
|
||||||
@@ -1724,11 +1740,10 @@ export function CreateProjectDialog({
|
|||||||
|
|
||||||
const stepReady = (): boolean => {
|
const stepReady = (): boolean => {
|
||||||
if (step === "details") {
|
if (step === "details") {
|
||||||
const hasBomSlot =
|
const kicadReady =
|
||||||
Boolean(bomFile) ||
|
netlistFiles.some((f) => f.name.toLowerCase().endsWith(".kicad_pro")) &&
|
||||||
bomFromBundle ||
|
netlistFiles.some((f) => f.name.toLowerCase().endsWith(".kicad_sch"));
|
||||||
Boolean(netlistFile?.name.toLowerCase().endsWith(".zip"));
|
return !!(name.trim() && kicadReady && !netlistError);
|
||||||
return !!(name.trim() && hasBomSlot && netlistFile && !netlistError);
|
|
||||||
}
|
}
|
||||||
if (step === "columns") return !!(refCol && mpnCol);
|
if (step === "columns") return !!(refCol && mpnCol);
|
||||||
if (step === "subdesigns")
|
if (step === "subdesigns")
|
||||||
@@ -1765,16 +1780,19 @@ export function CreateProjectDialog({
|
|||||||
!rerunProject &&
|
!rerunProject &&
|
||||||
!netlistUploadedEarly &&
|
!netlistUploadedEarly &&
|
||||||
netlistFile &&
|
netlistFile &&
|
||||||
(netlistIsEdif || wantsEarlyKicadUpload)
|
wantsEarlyKicadUpload
|
||||||
) {
|
) {
|
||||||
const res = await uploadNetlistEarly();
|
const res = await uploadNetlistEarly();
|
||||||
if (!res.ok) return;
|
if (!res.ok) return;
|
||||||
if (wantsEarlyKicadUpload && !bomFile && !res.bomSaved) {
|
if (!res.bomSaved) {
|
||||||
setEarlyUploadError(
|
setEarlyUploadError(
|
||||||
"No BOM in that zip. Add a .csv in the BOM box, or include bom.csv in the project zip.",
|
"Nello schematico non ho trovato i codici pezzo (MPN). Non ne invento nessuno.",
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
const next = res.nextStep ?? routeAfterColumns() ?? "datasheets";
|
||||||
|
setStep(next);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
setStep("columns");
|
setStep("columns");
|
||||||
}
|
}
|
||||||
@@ -2127,70 +2145,28 @@ export function CreateProjectDialog({
|
|||||||
onKeyDown={(e) => e.key === "Enter" && stepReady() && advanceStep()}
|
onKeyDown={(e) => e.key === "Enter" && stepReady() && advanceStep()}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-3 gap-3">
|
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<FileUploadZone
|
<FileUploadZone
|
||||||
label="BOM"
|
label="Apri progetto KiCad"
|
||||||
accept=".csv,.xlsx"
|
accept=""
|
||||||
files={bomFile ? [bomFile] : []}
|
directory
|
||||||
onFilesChange={(files) => {
|
|
||||||
setBomFromBundle(false);
|
|
||||||
onBomFiles(files);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<p className="text-[11px] text-muted-foreground leading-tight px-1">
|
|
||||||
{bomFromBundle
|
|
||||||
? "Taken from the KiCad zip"
|
|
||||||
: ".csv / .xlsx — or inside the KiCad zip"}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-1.5">
|
|
||||||
<FileUploadZone
|
|
||||||
label="KiCad / netlist"
|
|
||||||
accept=".asc,.net,.NET,.txt,.edn,.edif,.edf,.xml,.kicad_sch,.kicad_net,.zip"
|
|
||||||
multiple
|
multiple
|
||||||
files={netlistFiles}
|
files={netlistFiles}
|
||||||
onFilesChange={onNetlistFiles}
|
onFilesChange={onKicadProjectFolder}
|
||||||
/>
|
/>
|
||||||
{netlistError ? (
|
{netlistError ? (
|
||||||
<p className="text-[11px] text-rose-600 dark:text-rose-400 leading-tight flex items-start gap-1 px-1">
|
<p className="text-sm text-rose-600 dark:text-rose-400 leading-tight flex items-start gap-1">
|
||||||
<AlertCircle className="h-3 w-3 mt-0.5 shrink-0" />
|
<AlertCircle className="h-4 w-4 mt-0.5 shrink-0" />
|
||||||
{netlistError}
|
{netlistError}
|
||||||
</p>
|
</p>
|
||||||
) : netlistNetCount !== null ? (
|
|
||||||
<p className="text-[11px] text-muted-foreground leading-tight px-1">
|
|
||||||
{netlistNetCount} nets
|
|
||||||
</p>
|
|
||||||
) : (
|
) : (
|
||||||
<p className="text-[11px] text-muted-foreground leading-tight px-1">
|
<p className="text-sm text-muted-foreground leading-tight">
|
||||||
Zip del progetto, o tutti i .kicad_sch
|
Scegli la cartella del progetto (quella dove sta il file
|
||||||
|
.kicad_pro). Carichiamo schematico e circuito stampato da lì.
|
||||||
|
Non serve un netlist.
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1.5">
|
|
||||||
<FileUploadZone
|
|
||||||
label="PCB"
|
|
||||||
accept=".kicad_pcb"
|
|
||||||
files={pcbFile ? [pcbFile] : []}
|
|
||||||
onFilesChange={(files) => {
|
|
||||||
setPcbFromBundle(false);
|
|
||||||
const picked =
|
|
||||||
sortIncomingFiles(files).pcb[0] ?? files[0] ?? null;
|
|
||||||
setPcbFile(
|
|
||||||
picked?.name.toLowerCase().endsWith(".kicad_pcb")
|
|
||||||
? picked
|
|
||||||
: null,
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
preloaded={
|
|
||||||
pcbFromBundle && !pcbFile ? ["from project zip"] : undefined
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<p className="text-[11px] text-muted-foreground leading-tight px-1">
|
|
||||||
Optional — or inside the zip
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ export type FileUploadZoneProps = {
|
|||||||
label: string;
|
label: string;
|
||||||
accept: string;
|
accept: string;
|
||||||
multiple?: boolean;
|
multiple?: boolean;
|
||||||
|
directory?: boolean;
|
||||||
files: File[];
|
files: File[];
|
||||||
onFilesChange: (files: File[]) => void;
|
onFilesChange: (files: File[]) => void;
|
||||||
preloaded?: string[];
|
preloaded?: string[];
|
||||||
@@ -88,8 +89,8 @@ async function filesFromDropEvent(event: DragEvent): Promise<File[]> {
|
|||||||
return Array.from(event.dataTransfer?.files ?? []);
|
return Array.from(event.dataTransfer?.files ?? []);
|
||||||
}
|
}
|
||||||
|
|
||||||
function mergeSelection(current: File[], incoming: File[], multiple: boolean): File[] {
|
function mergeSelection(current: File[], incoming: File[], multiple: boolean, directory: boolean): File[] {
|
||||||
if (!multiple) return incoming.slice(0, 1);
|
if (directory || !multiple) return incoming;
|
||||||
return [...current, ...incoming];
|
return [...current, ...incoming];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -97,6 +98,7 @@ export function FileUploadZone({
|
|||||||
label,
|
label,
|
||||||
accept,
|
accept,
|
||||||
multiple = false,
|
multiple = false,
|
||||||
|
directory = false,
|
||||||
files,
|
files,
|
||||||
onFilesChange,
|
onFilesChange,
|
||||||
preloaded,
|
preloaded,
|
||||||
@@ -109,18 +111,18 @@ export function FileUploadZone({
|
|||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
setHover(false);
|
setHover(false);
|
||||||
void filesFromDropEvent(event).then((dropped) => {
|
void filesFromDropEvent(event).then((dropped) => {
|
||||||
onFilesChange(mergeSelection(files, dropped, multiple));
|
onFilesChange(mergeSelection(files, dropped, multiple, directory));
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
[files, multiple, onFilesChange],
|
[files, multiple, directory, onFilesChange],
|
||||||
);
|
);
|
||||||
|
|
||||||
const onPick = useCallback(
|
const onPick = useCallback(
|
||||||
(event: ChangeEvent<HTMLInputElement>) => {
|
(event: ChangeEvent<HTMLInputElement>) => {
|
||||||
const picked = Array.from(event.target.files ?? []);
|
const picked = Array.from(event.target.files ?? []);
|
||||||
onFilesChange(mergeSelection(files, picked, multiple));
|
onFilesChange(mergeSelection(files, picked, multiple, directory));
|
||||||
},
|
},
|
||||||
[files, multiple, onFilesChange],
|
[files, multiple, directory, onFilesChange],
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -163,6 +165,11 @@ export function FileUploadZone({
|
|||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
})
|
})
|
||||||
|
) : directory ? (
|
||||||
|
<span>
|
||||||
|
{files.find((f) => f.name.toLowerCase().endsWith(".kicad_pro"))?.name
|
||||||
|
?? `${files.length} file`}
|
||||||
|
</span>
|
||||||
) : (
|
) : (
|
||||||
<span>{files.length} files</span>
|
<span>{files.length} files</span>
|
||||||
)}
|
)}
|
||||||
@@ -170,8 +177,11 @@ export function FileUploadZone({
|
|||||||
) : null}
|
) : null}
|
||||||
<input
|
<input
|
||||||
type="file"
|
type="file"
|
||||||
accept={accept}
|
accept={directory ? undefined : accept}
|
||||||
multiple={multiple}
|
multiple={multiple || directory}
|
||||||
|
{...(directory
|
||||||
|
? ({ webkitdirectory: "", directory: "" } as Record<string, string>)
|
||||||
|
: {})}
|
||||||
className="hidden"
|
className="hidden"
|
||||||
onChange={onPick}
|
onChange={onPick}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -273,11 +273,8 @@ export async function uploadNetlist(projectId: string, files: File | File[]): Pr
|
|||||||
const rel = (f as File & { webkitRelativePath?: string }).webkitRelativePath;
|
const rel = (f as File & { webkitRelativePath?: string }).webkitRelativePath;
|
||||||
return rel && rel.length > 0 ? rel : f.name;
|
return rel && rel.length > 0 ? rel : f.name;
|
||||||
});
|
});
|
||||||
if (list[0]) form.append("file", list[0], list[0].name);
|
|
||||||
if (list.length > 1) {
|
|
||||||
for (const f of list) form.append("files", f, f.name);
|
for (const f of list) form.append("files", f, f.name);
|
||||||
form.append("paths", JSON.stringify(rels));
|
form.append("paths", JSON.stringify(rels));
|
||||||
}
|
|
||||||
const res = await send(`/api/projects/${projectId}/upload/netlist`, { method: "POST", body: form });
|
const res = await send(`/api/projects/${projectId}/upload/netlist`, { method: "POST", body: form });
|
||||||
const data = await jsonOrThrow<Record<string, unknown>>(res, "Failed to upload netlist");
|
const data = await jsonOrThrow<Record<string, unknown>>(res, "Failed to upload netlist");
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -0,0 +1,270 @@
|
|||||||
|
"""KiCad project folder ingest: *.kicad_pro + siblings, no netlist."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from backend.periscopex.kicad_project import load_kicad_project, sniff_kicad_pro
|
||||||
|
from backend.periscopex.netlist_bundle import materialize_netlist_upload, sniff_netlist_kind
|
||||||
|
from backend.periscopex.parsers import parse_netlist_any
|
||||||
|
|
||||||
|
HUBAUDIO = Path("/Users/michelebigi/Development/HubAudio/hardware/kicad/HubAudio")
|
||||||
|
|
||||||
|
_LIB_R = """
|
||||||
|
(lib_symbols
|
||||||
|
(symbol "Device:R"
|
||||||
|
(pin passive (at 0 3.81 90) (length 2.54)
|
||||||
|
(name "~" (effects (font (size 1.27 1.27))))
|
||||||
|
(number "1" (effects (font (size 1.27 1.27))))
|
||||||
|
)
|
||||||
|
(pin passive (at 0 -3.81 90) (length 2.54)
|
||||||
|
(name "~" (effects (font (size 1.27 1.27))))
|
||||||
|
(number "2" (effects (font (size 1.27 1.27))))
|
||||||
|
)
|
||||||
|
)
|
||||||
|
(symbol "Device:U"
|
||||||
|
(pin passive (at 0 3.81 90) (length 2.54)
|
||||||
|
(name "~" (effects (font (size 1.27 1.27))))
|
||||||
|
(number "1" (effects (font (size 1.27 1.27))))
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def _sch(*body: str) -> str:
|
||||||
|
return (
|
||||||
|
"(kicad_sch (version 20250114) (uuid \"11111111-1111-1111-1111-111111111111\")"
|
||||||
|
+ _LIB_R
|
||||||
|
+ "".join(body)
|
||||||
|
+ "\n)\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _resistor(ref: str, value: str) -> str:
|
||||||
|
uid = "aaaaaaaa-aaaa-aaaa-aaaa-" + ref.encode().hex()[:12].ljust(12, "0")
|
||||||
|
return f"""
|
||||||
|
(symbol
|
||||||
|
(lib_id "Device:R")
|
||||||
|
(at 0 0 0)
|
||||||
|
(unit 1)
|
||||||
|
(uuid "{uid}")
|
||||||
|
(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))))
|
||||||
|
(pin "1" (uuid "p1"))
|
||||||
|
(pin "2" (uuid "p2"))
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def _ic(ref: str, value: str, pnm: str) -> str:
|
||||||
|
uid = "bbbbbbbb-bbbb-bbbb-bbbb-" + ref.encode().hex()[:12].ljust(12, "0")
|
||||||
|
return f"""
|
||||||
|
(symbol
|
||||||
|
(lib_id "Device:U")
|
||||||
|
(at 0 0 0)
|
||||||
|
(unit 1)
|
||||||
|
(uuid "{uid}")
|
||||||
|
(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 "PNM" "{pnm}" (at 0 0 0) (effects (font (size 1.27 1.27))))
|
||||||
|
(pin "1" (uuid "u1"))
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def _pro(root_name: str) -> str:
|
||||||
|
return json.dumps({
|
||||||
|
"meta": {"filename": root_name.replace(".kicad_sch", ".kicad_pro")},
|
||||||
|
"schematic": {
|
||||||
|
"top_level_sheets": [{"filename": root_name, "name": "root"}],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
def _write_project(folder: Path) -> None:
|
||||||
|
child = _sch(
|
||||||
|
_ic("U9", "TPD2E007DCKR", "TPD2E007DCKR"),
|
||||||
|
"""
|
||||||
|
(global_label "GND" (at 0 3.81 0) (uuid "cccccccccccccccccccccccccccccccccccc"))
|
||||||
|
""",
|
||||||
|
)
|
||||||
|
root = _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" "Codec" (at 50 0 0) (effects (font (size 1.27 1.27))))
|
||||||
|
(property "Sheetfile" "Codec.kicad_sch" (at 50 0 0) (effects (font (size 1.27 1.27))))
|
||||||
|
)
|
||||||
|
""",
|
||||||
|
)
|
||||||
|
folder.mkdir(parents=True, exist_ok=True)
|
||||||
|
(folder / "HubAudio.kicad_pro").write_text(_pro("HubAudio.kicad_sch"))
|
||||||
|
(folder / "HubAudio.kicad_sch").write_text(root)
|
||||||
|
(folder / "Codec.kicad_sch").write_text(child)
|
||||||
|
(folder / "HubAudio.kicad_pcb").write_text(
|
||||||
|
'(kicad_pcb (version 20240108) (generator pcbnew)\n (net 0 "")\n)\n'
|
||||||
|
)
|
||||||
|
(folder / "netlist.asc").write_text(
|
||||||
|
"*PADS-PCB*\n*PART*\nU13 SOT23\nR1 0603\n*NET*\n"
|
||||||
|
"*SIGNAL* GND\nU13.1 R1.1\n*END*\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_sniff_kicad_pro_json():
|
||||||
|
body = _pro("HubAudio.kicad_sch").encode()
|
||||||
|
assert sniff_kicad_pro(body, "HubAudio.kicad_pro")
|
||||||
|
assert sniff_netlist_kind(body, "HubAudio.kicad_pro") == "kicad_pro"
|
||||||
|
|
||||||
|
|
||||||
|
def test_lone_kicad_pro_bytes_are_rejected(tmp_path: Path):
|
||||||
|
with pytest.raises(ValueError, match="da solo non basta"):
|
||||||
|
materialize_netlist_upload(
|
||||||
|
[("HubAudio.kicad_pro", _pro("HubAudio.kicad_sch").encode())],
|
||||||
|
tmp_path / "work",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_folder_next_to_pro_loads_sheets_and_pcb(tmp_path: Path):
|
||||||
|
src = tmp_path / "HubAudio"
|
||||||
|
_write_project(src)
|
||||||
|
files = [
|
||||||
|
(str(p.relative_to(tmp_path)), p.read_bytes())
|
||||||
|
for p in src.iterdir()
|
||||||
|
if p.is_file()
|
||||||
|
]
|
||||||
|
parsed = materialize_netlist_upload(files, tmp_path / "work")
|
||||||
|
parts, _nets, fmt = parse_netlist_any(parsed.root)
|
||||||
|
assert fmt == "kicad_sch"
|
||||||
|
assert parsed.pcb is not None and parsed.pcb.name == "HubAudio.kicad_pcb"
|
||||||
|
assert "R1" in parts and "U9" in parts
|
||||||
|
assert "U13" not in parts
|
||||||
|
assert {p.name for p in parsed.extra_sch} == {"Codec.kicad_sch"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_stale_pads_asc_ignored_when_pro_present(tmp_path: Path):
|
||||||
|
src = tmp_path / "HubAudio"
|
||||||
|
_write_project(src)
|
||||||
|
loaded = load_kicad_project(src)
|
||||||
|
assert loaded.root_sch.name == "HubAudio.kicad_sch"
|
||||||
|
parts, _nets, fmt = parse_netlist_any(loaded.root_sch)
|
||||||
|
assert fmt == "kicad_sch"
|
||||||
|
assert "U13" not in parts
|
||||||
|
|
||||||
|
|
||||||
|
def test_disk_path_to_pro_copies_siblings(tmp_path: Path):
|
||||||
|
src = tmp_path / "HubAudio"
|
||||||
|
_write_project(src)
|
||||||
|
parsed = materialize_netlist_upload(
|
||||||
|
[(str(src / "HubAudio.kicad_pro"), (src / "HubAudio.kicad_pro").read_bytes())],
|
||||||
|
tmp_path / "work",
|
||||||
|
)
|
||||||
|
parts, _nets, _fmt = parse_netlist_any(parsed.root)
|
||||||
|
assert "U9" in parts and "U13" not in parts
|
||||||
|
assert parsed.pcb is not None
|
||||||
|
|
||||||
|
|
||||||
|
def test_graph_without_bom_csv_uses_schematic_pnm(tmp_path: Path):
|
||||||
|
from backend.periscopex.graph import build_graph
|
||||||
|
|
||||||
|
src = tmp_path / "HubAudio"
|
||||||
|
_write_project(src)
|
||||||
|
missing = tmp_path / "no-bom.csv"
|
||||||
|
g = build_graph(
|
||||||
|
src / "HubAudio.kicad_sch",
|
||||||
|
missing,
|
||||||
|
tmp_path / "ex",
|
||||||
|
tmp_path / "pat",
|
||||||
|
tmp_path / "mod",
|
||||||
|
)
|
||||||
|
assert g.components["U9"].mpn == "TPD2E007DCKR"
|
||||||
|
assert "U13" not in g.components
|
||||||
|
|
||||||
|
|
||||||
|
def test_empty_child_pnm_not_invented(tmp_path: Path):
|
||||||
|
from backend.periscopex.graph import build_graph
|
||||||
|
|
||||||
|
folder = tmp_path / "proj"
|
||||||
|
folder.mkdir()
|
||||||
|
child = _sch(
|
||||||
|
_ic("U15", "", ""),
|
||||||
|
"""
|
||||||
|
(global_label "GND" (at 0 3.81 0) (uuid "cccccccccccccccccccccccccccccccccccc"))
|
||||||
|
""",
|
||||||
|
)
|
||||||
|
root = _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" "Codec" (at 50 0 0) (effects (font (size 1.27 1.27))))
|
||||||
|
(property "Sheetfile" "Codec.kicad_sch" (at 50 0 0) (effects (font (size 1.27 1.27))))
|
||||||
|
)
|
||||||
|
""",
|
||||||
|
)
|
||||||
|
(folder / "p.kicad_pro").write_text(_pro("p.kicad_sch"))
|
||||||
|
(folder / "p.kicad_sch").write_text(root)
|
||||||
|
(folder / "Codec.kicad_sch").write_text(child)
|
||||||
|
g = build_graph(
|
||||||
|
folder / "p.kicad_sch",
|
||||||
|
tmp_path / "missing.csv",
|
||||||
|
tmp_path / "ex",
|
||||||
|
tmp_path / "pat",
|
||||||
|
tmp_path / "mod",
|
||||||
|
)
|
||||||
|
assert not (g.components["U15"].mpn or "").strip()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.skipif(not (HUBAUDIO / "HubAudio.kicad_pro").is_file(), reason="HubAudio tree not on disk")
|
||||||
|
def test_hubaudio_folder_siblings_only():
|
||||||
|
loaded = load_kicad_project(HUBAUDIO)
|
||||||
|
assert loaded.pro.name == "HubAudio.kicad_pro"
|
||||||
|
assert loaded.root_sch.name == "HubAudio.kicad_sch"
|
||||||
|
names = {p.name for p in loaded.sheets}
|
||||||
|
assert "Codec.kicad_sch" in names
|
||||||
|
assert "POWER.kicad_sch" in names
|
||||||
|
assert loaded.pcb is not None and loaded.pcb.name == "HubAudio.kicad_pcb"
|
||||||
|
assert loaded.folder == HUBAUDIO.resolve()
|
||||||
|
parts, _nets, fmt = parse_netlist_any(loaded.root_sch)
|
||||||
|
assert fmt == "kicad_sch"
|
||||||
|
assert "U13" not in parts
|
||||||
|
assert "U9" in parts
|
||||||
|
|
||||||
|
|
||||||
|
def test_api_folder_upload_no_csv(tmp_path: Path):
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
from backend.main import app
|
||||||
|
from backend.services.storage import LocalStorageBackend
|
||||||
|
|
||||||
|
app.state.storage = LocalStorageBackend(tmp_path)
|
||||||
|
client = TestClient(app)
|
||||||
|
pid = client.post("/api/projects", json={"name": "ha"}).json()["id"]
|
||||||
|
src = tmp_path / "src"
|
||||||
|
_write_project(src)
|
||||||
|
files = [
|
||||||
|
("files", (p.name, p.read_bytes(), "application/octet-stream"))
|
||||||
|
for p in src.iterdir()
|
||||||
|
if p.suffix in {".kicad_pro", ".kicad_sch", ".kicad_pcb"}
|
||||||
|
]
|
||||||
|
paths = [p.name for p in src.iterdir() if p.suffix in {".kicad_pro", ".kicad_sch", ".kicad_pcb"}]
|
||||||
|
resp = client.post(
|
||||||
|
f"/api/projects/{pid}/upload/netlist",
|
||||||
|
files=files,
|
||||||
|
data={"paths": json.dumps(paths)},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
body = resp.json()
|
||||||
|
assert body["format"] == "kicad_sch"
|
||||||
|
assert body["pcb_saved"] is True
|
||||||
|
assert body["bom_saved"] is True
|
||||||
|
assert "U13" not in str(body)
|
||||||
|
assert body["parts"] >= 2
|
||||||
@@ -256,7 +256,7 @@ def test_zip_pipeline_workspace_reparses_hierarchy(tmp_path: Path):
|
|||||||
|
|
||||||
|
|
||||||
def test_kicad_pcb_bytes_are_not_parsed_as_pads(tmp_path: Path):
|
def test_kicad_pcb_bytes_are_not_parsed_as_pads(tmp_path: Path):
|
||||||
with pytest.raises(ValueError, match="board"):
|
with pytest.raises(ValueError, match="circuito stampato|board"):
|
||||||
materialize_netlist_upload(
|
materialize_netlist_upload(
|
||||||
[("board.kicad_pcb", b"(kicad_pcb (version 1)\n")],
|
[("board.kicad_pcb", b"(kicad_pcb (version 1)\n")],
|
||||||
tmp_path / "work",
|
tmp_path / "work",
|
||||||
|
|||||||
Reference in New Issue
Block a user