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,
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
@@ -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
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)
# 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]) in (
if sniff_netlist_kind(p.read_bytes()[:2048], p.name) in (
"kicad_xml", "kicad_sexp", "edif",
)
]
return (pref or exported)[0]
if not schs:
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),
)
+4 -1
View File
@@ -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"})
+17 -1
View File
@@ -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)
@@ -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.
@@ -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([]);
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",
if (!kept.some((f) => f.name.toLowerCase().endsWith(".kicad_pro"))) {
setNetlistError(
"In questa cartella non c'è un progetto KiCad (.kicad_pro).",
);
return;
}
});
}, [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,70 +2145,28 @@ export function CreateProjectDialog({
onKeyDown={(e) => e.key === "Enter" && stepReady() && advanceStep()}
/>
</div>
<div className="grid grid-cols-3 gap-3">
<div className="space-y-1.5">
<FileUploadZone
label="BOM"
accept=".csv,.xlsx"
files={bomFile ? [bomFile] : []}
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"
label="Apri progetto KiCad"
accept=""
directory
multiple
files={netlistFiles}
onFilesChange={onNetlistFiles}
onFilesChange={onKicadProjectFolder}
/>
{netlistError ? (
<p className="text-[11px] text-rose-600 dark:text-rose-400 leading-tight flex items-start gap-1 px-1">
<AlertCircle className="h-3 w-3 mt-0.5 shrink-0" />
<p className="text-sm text-rose-600 dark:text-rose-400 leading-tight flex items-start gap-1">
<AlertCircle className="h-4 w-4 mt-0.5 shrink-0" />
{netlistError}
</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">
Zip del progetto, o tutti i .kicad_sch
<p className="text-sm text-muted-foreground leading-tight">
Scegli la cartella del progetto (quella dove sta il file
.kicad_pro). Carichiamo schematico e circuito stampato da .
Non serve un netlist.
</p>
)}
</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>
)}
@@ -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<File[]> {
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<HTMLInputElement>) => {
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({
</span>
);
})
) : directory ? (
<span>
{files.find((f) => f.name.toLowerCase().endsWith(".kicad_pro"))?.name
?? `${files.length} file`}
</span>
) : (
<span>{files.length} files</span>
)}
@@ -170,8 +177,11 @@ export function FileUploadZone({
) : null}
<input
type="file"
accept={accept}
multiple={multiple}
accept={directory ? undefined : accept}
multiple={multiple || directory}
{...(directory
? ({ webkitdirectory: "", directory: "" } as Record<string, string>)
: {})}
className="hidden"
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;
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));
}
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");
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):
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",