diff --git a/periscope/src/backend/periscopex/graph.py b/periscope/src/backend/periscopex/graph.py index 99750f3..6814329 100644 --- a/periscope/src/backend/periscopex/graph.py +++ b/periscope/src/backend/periscopex/graph.py @@ -289,8 +289,9 @@ def build_graph( pcb_path: str | Path | None = None, extra_pdf_dirs: tuple[Path | str, ...] | list[Path | str] | None = None, ) -> DesignGraph: - """Deterministic graph: BOM → netlist → optional board nets → datasheets → DesignGraph.""" - bom = parse_bom(bom_path, reference_col=reference_col, mpn_col=mpn_col) + """Deterministic graph: schematic/netlist → optional BOM CSV → datasheets.""" + 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) parts, raw_nets, fmt = parse_netlist_any( netlist_path, @@ -321,7 +322,7 @@ def build_graph( mpn_subtype: dict[str, str] = {} 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: mpn_subtype[rp.mpn] = rp.component_subtype diff --git a/periscope/src/backend/periscopex/kicad_project.py b/periscope/src/backend/periscopex/kicad_project.py new file mode 100644 index 0000000..fc5006e --- /dev/null +++ b/periscope/src/backend/periscopex/kicad_project.py @@ -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") diff --git a/periscope/src/backend/periscopex/netlist_bundle.py b/periscope/src/backend/periscopex/netlist_bundle.py index 126d7b1..f34d26e 100644 --- a/periscope/src/backend/periscopex/netlist_bundle.py +++ b/periscope/src/backend/periscopex/netlist_bundle.py @@ -28,7 +28,9 @@ class NetlistUpload: 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": return "zip" 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" if "*PADS-PCB*" in head.upper() or head.lstrip().startswith("*PART*"): return "pads" + from backend.periscopex.kicad_project import sniff_kicad_pro + + if sniff_kicad_pro(content, name): + return "kicad_pro" return "unknown" @@ -112,7 +118,7 @@ def _extract_zip(data: bytes, dest: Path) -> None: def _write_named(name: str, data: bytes, dest: Path) -> None: rel = _safe_rel(name) - kind = sniff_netlist_kind(data) + kind = sniff_netlist_kind(data, name) if kind == "zip": _extract_zip(data, dest) return @@ -153,30 +159,48 @@ def _sheetfiles_of(path: Path) -> list[str]: 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: + 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] = [] exported: list[Path] = [] for p in work.rglob("*"): if not p.is_file(): continue - kind = sniff_netlist_kind(p.read_bytes()[:2048]) + kind = sniff_netlist_kind(p.read_bytes()[:2048], p.name) if kind == "kicad_sch": schs.append(p) elif kind in ("kicad_xml", "kicad_sexp", "edif", "pads"): exported.append(p) - if exported: - pref = [ - p for p in exported - if sniff_netlist_kind(p.read_bytes()[:2048]) in ( - "kicad_xml", "kicad_sexp", "edif", - ) - ] - return (pref or exported)[0] - + # Schematic in the same folder beats a leftover PADS .asc (HubAudio U13 ghosts). if not schs: + if exported: + pref = [ + p for p in exported + if sniff_netlist_kind(p.read_bytes()[:2048], p.name) in ( + "kicad_xml", "kicad_sexp", "edif", + ) + ] + return (pref or exported)[0] 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() @@ -220,30 +244,59 @@ def materialize_netlist_upload( if len(files) == 1: name, data = files[0] - kind = sniff_netlist_kind(data) + kind = sniff_netlist_kind(data, name) if kind == "kicad_pcb": raise ValueError( - "This is a board file. Drop the KiCad project folder, or put " - "the .kicad_pcb on the optional board step." + "Questo è il circuito stampato. Apri la cartella del progetto " + "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"): raise ValueError( - "Not a netlist. Drop the KiCad project folder, a zip, or a " - "PADS / EDIF / KiCad netlist." + "Apri la cartella del progetto KiCad (quella con il file " + ".kicad_pro), non un singolo file." ) for name, data in files: _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) pcb = find_kicad_pcb(dest) - bom = find_bom(dest) extras = [ p for p in work_sch_files(dest) if p.resolve() != root.resolve() ] 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), ) diff --git a/periscope/src/backend/periscopex/parsers.py b/periscope/src/backend/periscopex/parsers.py index 8b3f574..90a7689 100644 --- a/periscope/src/backend/periscopex/parsers.py +++ b/periscope/src/backend/periscopex/parsers.py @@ -237,7 +237,10 @@ def parse_bom( mpn_col: str = "Manufacturer Part Number", ) -> 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} has_dnp_col = bool(colnames & {"dnp", "dni", "fitted", "populate"}) has_variant_col = bool(colnames & {"variant"}) diff --git a/periscope/src/backend/routers/projects.py b/periscope/src/backend/routers/projects.py index 8bd24d0..5c34614 100644 --- a/periscope/src/backend/routers/projects.py +++ b/periscope/src/backend/routers/projects.py @@ -537,7 +537,23 @@ async def upload_netlist( if parsed.pcb is not None: proj_svc.save_pcb(storage, owner_id, project_id, parsed.pcb.read_bytes()) 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) if bom_bytes: proj_svc.save_bom(storage, owner_id, project_id, bom_bytes) diff --git a/periscope/src/frontend/content/changelog.md b/periscope/src/frontend/content/changelog.md index 91af1f5..a6e2540 100644 --- a/periscope/src/frontend/content/changelog.md +++ b/periscope/src/frontend/content/changelog.md @@ -2,6 +2,14 @@ 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 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. diff --git a/periscope/src/frontend/src/components/dashboard/create-project-dialog.tsx b/periscope/src/frontend/src/components/dashboard/create-project-dialog.tsx index 6ca4fc0..fe985e6 100644 --- a/periscope/src/frontend/src/components/dashboard/create-project-dialog.tsx +++ b/periscope/src/frontend/src/components/dashboard/create-project-dialog.tsx @@ -360,6 +360,36 @@ function extensionOf(name: string): string { 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[]): { netlist: File[]; pcb: File[]; @@ -878,62 +908,34 @@ export function CreateProjectDialog({ } }, []); - const onNetlistFiles = useCallback((incoming: File[]) => { - const { netlist, pcb, bom } = sortIncomingFiles(incoming); - if (pcb[0]) { - setPcbFile(pcb[0]); - setPcbFromBundle(false); - } - if (bom[0]) { - onBomFiles([bom[0]]); - setBomFromBundle(false); - } - const files = netlist; - const file = files[0] || null; - setNetlistFiles(files); + const onKicadProjectFolder = useCallback((incoming: File[]) => { + setBomFromBundle(true); + setPcbFromBundle(true); + setPcbFile(null); + setBomFile(null); + const kept = filesBesideKicadPro(incoming); + setNetlistFiles(kept); setNetlistNetCount(null); - setNetlistError(null); - // Invalidate any cached netlist preview, since it was relative to the - // previous file. setNetlistPreview(null); setNetlistPreviewError(null); - // Re-uploads invalidate any prior EDIF sub-design data + selection. setEdifSubDesigns([]); setSelectedSubdesignIds(null); setNetlistUploadedEarly(false); setNetlistIsEdif(false); - if (!file) return; - const lower = file.name.toLowerCase(); - if (lower.endsWith(".zip") || files.length > 1) { - setNetlistPreview([]); + if (!kept.some((f) => f.name.toLowerCase().endsWith(".kicad_pro"))) { + setNetlistError( + "In questa cartella non c'è un progetto KiCad (.kicad_pro).", + ); 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", - ); - } - }); - }, [onBomFiles]); + if (!kept.some((f) => f.name.toLowerCase().endsWith(".kicad_sch"))) { + setNetlistError( + "Manca lo schematico nella stessa cartella del .kicad_pro.", + ); + return; + } + setNetlistError(null); + }, []); const closeAndClear = useCallback(() => { setOpen(false); @@ -1552,6 +1554,7 @@ export function CreateProjectDialog({ subDesigns: EdifSubDesign[]; bomSaved?: boolean; pcbSaved?: boolean; + nextStep?: WizardStep | null; }> => { if (!netlistFile) return { ok: false, subDesigns: [] }; setEarlyUploading(true); @@ -1572,11 +1575,25 @@ export function CreateProjectDialog({ } if (result.nets > 0) setNetlistNetCount(result.nets); if (result.pcb_saved) setPcbFromBundle(true); + let nextStep: WizardStep | null = "datasheets"; if (result.bom_saved) { 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); 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); setInitialNetlistFiles(netlistFiles); @@ -1585,6 +1602,7 @@ export function CreateProjectDialog({ subDesigns: result.sub_designs, bomSaved: Boolean(result.bom_saved), pcbSaved: Boolean(result.pcb_saved), + nextStep, }; } catch (e) { if (createdProjectIdHere) { @@ -1606,9 +1624,7 @@ export function CreateProjectDialog({ const wantsEarlyKicadUpload = Boolean( netlistFile && - (netlistFile.name.toLowerCase().endsWith(".zip") || - netlistFiles.length > 1 || - netlistFiles.some((f) => f.name.toLowerCase().endsWith(".kicad_sch"))), + netlistFiles.some((f) => f.name.toLowerCase().endsWith(".kicad_pro")), ); // ---- LCSC per-row passive resolve ---- // @@ -1724,11 +1740,10 @@ export function CreateProjectDialog({ const stepReady = (): boolean => { if (step === "details") { - const hasBomSlot = - Boolean(bomFile) || - bomFromBundle || - Boolean(netlistFile?.name.toLowerCase().endsWith(".zip")); - return !!(name.trim() && hasBomSlot && netlistFile && !netlistError); + const kicadReady = + netlistFiles.some((f) => f.name.toLowerCase().endsWith(".kicad_pro")) && + netlistFiles.some((f) => f.name.toLowerCase().endsWith(".kicad_sch")); + return !!(name.trim() && kicadReady && !netlistError); } if (step === "columns") return !!(refCol && mpnCol); if (step === "subdesigns") @@ -1765,16 +1780,19 @@ export function CreateProjectDialog({ !rerunProject && !netlistUploadedEarly && netlistFile && - (netlistIsEdif || wantsEarlyKicadUpload) + wantsEarlyKicadUpload ) { const res = await uploadNetlistEarly(); if (!res.ok) return; - if (wantsEarlyKicadUpload && !bomFile && !res.bomSaved) { + if (!res.bomSaved) { 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; } + const next = res.nextStep ?? routeAfterColumns() ?? "datasheets"; + setStep(next); + return; } setStep("columns"); } @@ -2127,69 +2145,27 @@ export function CreateProjectDialog({ onKeyDown={(e) => e.key === "Enter" && stepReady() && advanceStep()} /> -
-
- { - setBomFromBundle(false); - onBomFiles(files); - }} - /> -

- {bomFromBundle - ? "Taken from the KiCad zip" - : ".csv / .xlsx — or inside the KiCad zip"} +

+ + {netlistError ? ( +

+ + {netlistError}

-
-
- - {netlistError ? ( -

- - {netlistError} -

- ) : netlistNetCount !== null ? ( -

- {netlistNetCount} nets -

- ) : ( -

- Zip del progetto, o tutti i .kicad_sch -

- )} -
-
- { - 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 - } - /> -

- Optional — or inside the zip + ) : ( +

+ Scegli la cartella del progetto (quella dove sta il file + .kicad_pro). Carichiamo schematico e circuito stampato da lì. + Non serve un netlist.

-
+ )}
)} diff --git a/periscope/src/frontend/src/components/upload/file-upload-zone.tsx b/periscope/src/frontend/src/components/upload/file-upload-zone.tsx index c01bc9a..bd45a41 100644 --- a/periscope/src/frontend/src/components/upload/file-upload-zone.tsx +++ b/periscope/src/frontend/src/components/upload/file-upload-zone.tsx @@ -8,6 +8,7 @@ export type FileUploadZoneProps = { label: string; accept: string; multiple?: boolean; + directory?: boolean; files: File[]; onFilesChange: (files: File[]) => void; preloaded?: string[]; @@ -88,8 +89,8 @@ async function filesFromDropEvent(event: DragEvent): Promise { return Array.from(event.dataTransfer?.files ?? []); } -function mergeSelection(current: File[], incoming: File[], multiple: boolean): File[] { - if (!multiple) return incoming.slice(0, 1); +function mergeSelection(current: File[], incoming: File[], multiple: boolean, directory: boolean): File[] { + if (directory || !multiple) return incoming; return [...current, ...incoming]; } @@ -97,6 +98,7 @@ export function FileUploadZone({ label, accept, multiple = false, + directory = false, files, onFilesChange, preloaded, @@ -109,18 +111,18 @@ export function FileUploadZone({ event.preventDefault(); setHover(false); 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( (event: ChangeEvent) => { 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 ( @@ -163,6 +165,11 @@ export function FileUploadZone({ ); }) + ) : directory ? ( + + {files.find((f) => f.name.toLowerCase().endsWith(".kicad_pro"))?.name + ?? `${files.length} file`} + ) : ( {files.length} files )} @@ -170,8 +177,11 @@ export function FileUploadZone({ ) : null} ) + : {})} className="hidden" onChange={onPick} /> diff --git a/periscope/src/frontend/src/lib/api.ts b/periscope/src/frontend/src/lib/api.ts index f51ed47..25b9fc0 100644 --- a/periscope/src/frontend/src/lib/api.ts +++ b/periscope/src/frontend/src/lib/api.ts @@ -273,11 +273,8 @@ export async function uploadNetlist(projectId: string, files: File | File[]): Pr const rel = (f as File & { webkitRelativePath?: string }).webkitRelativePath; 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); - form.append("paths", JSON.stringify(rels)); - } + for (const f of list) form.append("files", f, f.name); + form.append("paths", JSON.stringify(rels)); const res = await send(`/api/projects/${projectId}/upload/netlist`, { method: "POST", body: form }); const data = await jsonOrThrow>(res, "Failed to upload netlist"); return { diff --git a/tests/test_kicad_project.py b/tests/test_kicad_project.py new file mode 100644 index 0000000..c79f532 --- /dev/null +++ b/tests/test_kicad_project.py @@ -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 diff --git a/tests/test_netlist_bundle.py b/tests/test_netlist_bundle.py index 8c07fd7..c929b84 100644 --- a/tests/test_netlist_bundle.py +++ b/tests/test_netlist_bundle.py @@ -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): - with pytest.raises(ValueError, match="board"): + with pytest.raises(ValueError, match="circuito stampato|board"): materialize_netlist_upload( [("board.kicad_pcb", b"(kicad_pcb (version 1)\n")], tmp_path / "work",