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:
2026-09-21 07:26:01 +02:00
parent ec2ffd6107
commit bfb781f94f
11 changed files with 677 additions and 159 deletions
+4 -3
View File
@@ -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),
) )
+4 -1
View File
@@ -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"})
+17 -1
View File
@@ -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 .
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}
/> />
-3
View File
@@ -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 {
+270
View File
@@ -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
+1 -1
View File
@@ -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",