Initial commit: MIKILAB KiCad personal library

Self-contained KiCad library (symbols, footprints, 3D models, docs,
import/check tooling) with no dependency on kicad-personal-library.

- Fixed sym-lib-table/fp-lib-table: single (version 7) header, one entry
  per library, MIKILAB_<name> nicknames, ${KIPRJMOD}-relative URIs
- Resolved the SOT95P280X145-5N footprint collision (TPS7A2012PDBVR vs
  TPS7A2018PDBVR): confirmed byte-identical modulo KiCad's internal
  tedit timestamp, unified into one shared footprint
- Resolved a case-insensitive filename collision between the official
  Diode.kicad_sym and a custom diode.kicad_sym
- Wrapped standalone footprints (ESP32-S31-WROOM-3, IC_TPS63020DSJT,
  SOT95P280X145-5N) into their own .pretty libraries so they're
  actually registered in fp-lib-table
- Fixed broken symbol->footprint references (including one pointing at
  a nonexistent easyeda2kicad library)
- Added scripts/check_library.py, import_component.py, add_component.py,
  import_batch.py, and lib_common.py
This commit is contained in:
2026-08-16 13:08:59 +02:00
commit 93a521777d
618 changed files with 13362142 additions and 0 deletions
+36
View File
@@ -0,0 +1,36 @@
#!/usr/bin/env python3
"""
add_component.py
=================
The simple, one-command way to add a component to the MIKILAB library.
python3 scripts/add_component.py \\
--name TPS7A2018PDBVR \\
--symbol /path/to/TPS7A2018PDBVR.kicad_sym \\
--footprint /path/to/SOT95P280X145-5N.kicad_mod \\
--model /path/to/TPS7A2018PDBVR.step \\
--category power
This does not reimplement anything: it is a thin, friendlier front-end
over import_component.py's core logic (same validation, same collision
handling, same lib-table regeneration). Use import_component.py directly
if you want the more explicit/verbose interface.
"""
from __future__ import annotations
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent))
import import_component
def main() -> int:
return import_component.main()
if __name__ == "__main__":
sys.exit(main())
+446
View File
@@ -0,0 +1,446 @@
#!/usr/bin/env python3
"""
check_library.py
=================
Full consistency check of the MIKILAB KiCad library.
Checks performed:
A. Directory structure (unexpected files, empty directories)
B. Symbols (syntax, duplicate names/files, footprint references)
C. Footprints (syntax, duplicate filenames, filename collisions, 3D refs)
D. 3D models (files exist, references valid, relative/portable paths)
E. Lib tables (syntax, single "(version 7)", all libs exist, no dup nicknames)
F. Cross check: symbol -> footprint -> 3D model
Exit code is 0 if there are no ERROR-level findings, 1 otherwise.
WARN-level findings (e.g. known/documented gaps) never fail the run.
"""
from __future__ import annotations
import argparse
import re
import sys
from collections import defaultdict
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent))
import lib_common as lc
ROOT = lc.LIBRARY_ROOT
EXPECTED_TOP_LEVEL = {
"symbols", "footprints", "3dmodels", "docs", "legacy", "scripts",
"sym-lib-table", "fp-lib-table", "MANIFEST.csv", "MANIFEST.md",
"README.md", ".git", ".claude", ".gitignore", ".DS_Store",
}
KNOWN_UNRESOLVED_MODEL_VARS = ("${KISBLIB}",)
_STRING_LITERAL_RE = re.compile(r'"(?:[^"\\]|\\.)*"')
def parens_balanced(text: str) -> bool:
"""Check s-expression paren balance, ignoring parens inside string
literals (KiCad symbol/footprint descriptions routinely contain
literal '(' / ')' characters, e.g. "Vin (typ)")."""
stripped = _STRING_LITERAL_RE.sub('""', text)
return stripped.count("(") == stripped.count(")")
class Report:
def __init__(self):
self.errors: list[str] = []
self.warnings: list[str] = []
self.info: list[str] = []
def error(self, msg: str):
self.errors.append(msg)
def warn(self, msg: str):
self.warnings.append(msg)
def note(self, msg: str):
self.info.append(msg)
def ok(self) -> bool:
return not self.errors
def check_directory_structure(report: Report):
for entry in ROOT.iterdir():
if entry.name not in EXPECTED_TOP_LEVEL:
report.warn(f"[DIR] Unexpected top-level entry: {entry.name}")
for sub in ("symbols", "footprints", "3dmodels"):
base = ROOT / sub
if not base.exists():
report.error(f"[DIR] Missing expected directory: {sub}/")
continue
for d in sorted(base.rglob("*")):
if d.is_dir() and not any(d.iterdir()):
report.warn(f"[DIR] Empty directory: {d.relative_to(ROOT)}")
for cat_dir in (ROOT / "symbols").iterdir() if (ROOT / "symbols").exists() else []:
if cat_dir.is_dir() and cat_dir.name not in lc.CATEGORIES:
report.warn(f"[DIR] Symbol category not in known list: {cat_dir.name}")
for cat_dir in (ROOT / "footprints").iterdir() if (ROOT / "footprints").exists() else []:
if cat_dir.is_dir() and cat_dir.name not in lc.CATEGORIES:
report.warn(f"[DIR] Footprint category not in known list: {cat_dir.name}")
def check_symbols(report: Report) -> list[tuple[str, Path]]:
"""Returns [(symbol_name, defining .kicad_sym file), ...] for cross-check.
Note: the same symbol name can legitimately appear in multiple files
(each file is an independent library), so all occurrences are kept."""
symbol_entries: list[tuple[str, Path]] = []
seen_hash: dict[str, Path] = {}
files = lc.discover_symbol_libraries(ROOT)
if not files:
report.warn("[SYM] No .kicad_sym files found under symbols/")
basenames = defaultdict(list)
for path in files:
basenames[path.name.lower()].append(path)
text = path.read_text(encoding="utf-8", errors="replace")
if not text.lstrip().startswith("(kicad_symbol_lib"):
report.error(f"[SYM] {path.relative_to(ROOT)}: does not start with (kicad_symbol_lib")
if not parens_balanced(text):
report.error(f"[SYM] {path.relative_to(ROOT)}: unbalanced parentheses")
digest = lc.sha256_file(path)
if digest in seen_hash:
report.warn(
f"[SYM] Duplicate content: {path.relative_to(ROOT)} "
f"is byte-identical to {seen_hash[digest].relative_to(ROOT)}"
)
else:
seen_hash[digest] = path
# Each .kicad_sym file is an independent KiCad library: symbols are
# always addressed as "LibNickname:SymbolName", so the *same* name
# appearing in two different library files is normal and expected
# (this whole library mirrors upstream KiCad libraries on purpose).
# A duplicate is only a real problem if it appears twice inside the
# *same* file, which KiCad would refuse to load correctly.
names_in_this_file: set[str] = set()
for m in re.finditer(r'\(symbol\s+"([^"]+)"', text):
sym_name = m.group(1)
if sym_name in names_in_this_file:
report.error(
f"[SYM] {path.relative_to(ROOT)}: symbol name "
f"'{sym_name}' defined more than once in the same file"
)
names_in_this_file.add(sym_name)
if ":" in sym_name:
# Sub-unit / alternate-body reference, not a top-level symbol.
continue
symbol_entries.append((sym_name, path))
for name, paths in basenames.items():
if len(paths) > 1:
report.error(
f"[SYM] Filename collision for '{name}': "
+ ", ".join(str(p.relative_to(ROOT)) for p in paths)
)
return symbol_entries
def check_footprints(report: Report) -> dict[str, Path]:
"""Returns map 'LIBNICK:footprint_name' -> defining .kicad_mod path."""
footprint_owner: dict[str, Path] = {}
seen_hash: dict[str, list[Path]] = defaultdict(list)
pretty_dirs = lc.discover_footprint_libraries(ROOT)
if not pretty_dirs:
report.warn("[FP] No .pretty directories found under footprints/")
# Loose .kicad_mod files directly under a category (not inside .pretty)
# are invisible to fp-lib-table and must not exist.
if (ROOT / "footprints").exists():
for path in (ROOT / "footprints").glob("*/*.kicad_mod"):
report.error(
f"[FP] Standalone footprint not inside a .pretty library: "
f"{path.relative_to(ROOT)}"
)
basenames = defaultdict(list)
for pretty in pretty_dirs:
nickname = lc.fp_nickname(pretty)
for mod in sorted(pretty.glob("*.kicad_mod")):
basenames[mod.name.lower()].append(mod)
text = mod.read_text(encoding="utf-8", errors="replace")
if not text.lstrip().startswith("(footprint") and not text.lstrip().startswith("(module"):
report.error(f"[FP] {mod.relative_to(ROOT)}: does not start with (footprint ...)")
if not parens_balanced(text):
report.error(f"[FP] {mod.relative_to(ROOT)}: unbalanced parentheses")
digest = lc.sha256_file(mod)
seen_hash[digest].append(mod)
key = f"{nickname}:{mod.stem}"
footprint_owner[key] = mod
for model_m in re.finditer(r'\(model\s+([^\s)]+)', text):
model_ref = model_m.group(1)
check_model_reference(report, mod, model_ref)
for digest, paths in seen_hash.items():
if len(paths) > 1:
names = {p.name for p in paths}
if len(names) == 1:
report.note(
f"[FP] Identical footprint content shared across libraries: "
+ ", ".join(str(p.relative_to(ROOT)) for p in paths)
)
for name, paths in basenames.items():
if len(paths) > 1:
hashes = {lc.sha256_file(p) for p in paths}
if len(hashes) > 1:
report.error(
f"[FP] REAL collision: filename '{name}' has different content in: "
+ ", ".join(str(p.relative_to(ROOT)) for p in paths)
)
# If all hashes match, it is intentional library duplication
# across independent .pretty libraries -- not an error.
return footprint_owner
def check_model_reference(report: Report, footprint_path: Path, model_ref: str):
if any(var in model_ref for var in KNOWN_UNRESOLVED_MODEL_VARS):
report.warn(
f"[3D] {footprint_path.relative_to(ROOT)}: references undefined "
f"env var in '{model_ref}' (known gap: vendor-imported footprint, "
f"3D file was never present locally -- see README)"
)
return
if model_ref.startswith("/") or re.match(r"^[A-Za-z]:[\\/]", model_ref):
report.error(
f"[3D] {footprint_path.relative_to(ROOT)}: absolute 3D model path '{model_ref}'"
)
return
if "${KIPRJMOD}" in model_ref:
resolved = lc.resolve_uri(model_ref, ROOT)
if not resolved.exists():
report.error(
f"[3D] {footprint_path.relative_to(ROOT)}: missing 3D model file "
f"'{model_ref}' -> {resolved}"
)
return
if "${KISYS3DMOD}" in model_ref:
# Standard KiCad env var pointing at the official bundled 3D model
# package -- defined automatically by every KiCad install, out of
# scope for this library (same as standard footprint libraries).
return
report.warn(
f"[3D] {footprint_path.relative_to(ROOT)}: model path uses unrecognized "
f"variable/form '{model_ref}'"
)
def check_3dmodels(report: Report):
models_dir = ROOT / "3dmodels"
if not models_dir.exists():
report.error("[3D] Missing 3dmodels/ directory")
return
for path in models_dir.rglob("*"):
if path.is_file() and path.suffix.lower() not in lc.MODEL_EXTENSIONS:
report.warn(f"[3D] Unexpected file type in 3dmodels/: {path.relative_to(ROOT)}")
def check_lib_tables(report: Report):
sym_table = ROOT / "sym-lib-table"
fp_table = ROOT / "fp-lib-table"
sym_entries, sym_versions = lc.parse_lib_table(sym_table)
fp_entries, fp_versions = lc.parse_lib_table(fp_table)
if not sym_table.exists():
report.error("[TABLE] sym-lib-table is missing")
elif sym_versions != 1:
report.error(f"[TABLE] sym-lib-table has {sym_versions} (version N) entries, expected exactly 1")
if not fp_table.exists():
report.error("[TABLE] fp-lib-table is missing")
elif fp_versions != 1:
report.error(f"[TABLE] fp-lib-table has {fp_versions} (version N) entries, expected exactly 1")
# Duplicate nicknames
for kind, entries in (("sym-lib-table", sym_entries), ("fp-lib-table", fp_entries)):
seen = defaultdict(int)
for e in entries:
seen[e.nickname] += 1
for nick, count in seen.items():
if count > 1:
report.error(f"[TABLE] {kind}: nickname '{nick}' registered {count} times")
# Every referenced library must exist on disk
for e in sym_entries:
resolved = lc.resolve_uri(e.uri, ROOT)
if not resolved.exists():
report.error(f"[TABLE] sym-lib-table: '{e.nickname}' -> {resolved} does not exist")
for e in fp_entries:
resolved = lc.resolve_uri(e.uri, ROOT)
if not resolved.exists() or not resolved.is_dir():
report.error(f"[TABLE] fp-lib-table: '{e.nickname}' -> {resolved} does not exist")
# Every library on disk must be referenced
registered_sym_uris = {lc.resolve_uri(e.uri, ROOT) for e in sym_entries}
for path in lc.discover_symbol_libraries(ROOT):
if path not in registered_sym_uris:
report.error(f"[TABLE] {path.relative_to(ROOT)} exists but is not registered in sym-lib-table")
registered_fp_uris = {lc.resolve_uri(e.uri, ROOT) for e in fp_entries}
for path in lc.discover_footprint_libraries(ROOT):
if path not in registered_fp_uris:
report.error(f"[TABLE] {path.relative_to(ROOT)} exists but is not registered in fp-lib-table")
return sym_entries, fp_entries
def check_cross_references(report: Report, symbol_entries: list[tuple[str, Path]], fp_entries):
"""Verify symbol -> footprint links that MIKILAB actually owns.
Symbols mirrored from the official KiCad symbol libraries legitimately
reference KiCad's *global/standard* footprint libraries (e.g.
'Package_SO', 'RF_Module', 'Package_DFN_QFN') by their upstream
nickname. Those libraries are not part of MIKILAB -- they ship with
every KiCad install and are resolved via the user's global
fp-lib-table, not this project's. Only nicknames claiming to be a
MIKILAB library (prefix 'MIKILAB_') are actually within our control,
so only those are checked strictly.
"""
fp_nicknames = {e.nickname for e in fp_entries}
external_refs = 0
by_file: dict[Path, list[str]] = defaultdict(list)
for sym_name, sym_path in symbol_entries:
by_file[sym_path].append(sym_name)
for sym_path, names in by_file.items():
text = sym_path.read_text(encoding="utf-8", errors="replace")
for sym_name in names:
pattern = re.compile(
r'\(symbol\s+"' + re.escape(sym_name) + r'"'
)
m = pattern.search(text)
if not m:
continue
window = text[m.end(): m.end() + 4000]
fp_m = re.search(
r'"Footprint"\s*\n?\s*"([^"]*)"', window
)
if not fp_m:
continue
fp_value = fp_m.group(1).strip()
if not fp_value:
continue
if ":" not in fp_value:
continue
nick, _, fp_name = fp_value.partition(":")
if not nick.startswith("MIKILAB_"):
# Reference to a standard/global KiCad footprint library,
# outside MIKILAB's scope.
external_refs += 1
continue
if nick not in fp_nicknames:
report.error(
f"[CROSS] {sym_path.relative_to(ROOT)} symbol '{sym_name}': "
f"Footprint references unknown MIKILAB library '{nick}' (footprint '{fp_value}')"
)
if external_refs:
report.note(
f"[CROSS] {external_refs} symbols reference standard/global KiCad "
f"footprint libraries (not MIKILAB_-prefixed) -- resolved via the "
f"user's global fp-lib-table, out of scope for this library"
)
def main():
parser = argparse.ArgumentParser(description="Check the MIKILAB KiCad library for consistency.")
parser.add_argument("--quiet", action="store_true", help="Only print the summary")
args = parser.parse_args()
report = Report()
check_directory_structure(report)
symbol_entries = check_symbols(report)
footprint_owner = check_footprints(report)
check_3dmodels(report)
sym_entries, fp_entries = check_lib_tables(report)
check_cross_references(report, symbol_entries, fp_entries)
if not args.quiet:
if report.info:
print(f"\n=== INFO ({len(report.info)}) ===")
for line in report.info:
print(f" {line}")
if report.warnings:
print(f"\n=== WARNINGS ({len(report.warnings)}) ===")
for line in report.warnings:
print(f" {line}")
if report.errors:
print(f"\n=== ERRORS ({len(report.errors)}) ===")
for line in report.errors:
print(f" {line}")
print()
print(f"Symbol libraries: {len(lc.discover_symbol_libraries(ROOT))}")
print(f"Footprint libraries: {len(lc.discover_footprint_libraries(ROOT))}")
print(f"Symbols defined: {len(symbol_entries)}")
print(f"Footprints defined: {len(footprint_owner)}")
print()
print(f"Errors: {len(report.errors)}")
print(f"Warnings: {len(report.warnings)}")
print(f"Info: {len(report.info)}")
print()
if report.ok():
print("RESULT: OK (no errors)")
return 0
else:
print("RESULT: FAILED")
return 1
if __name__ == "__main__":
sys.exit(main())
+130
View File
@@ -0,0 +1,130 @@
#!/usr/bin/env python3
"""
import_batch.py
================
Import every component found under a source directory into the MIKILAB
library, one subdirectory per component:
batch_source/
TPS7A2018PDBVR/
TPS7A2018PDBVR.kicad_sym
SOT95P280X145-5N.kicad_mod
TPS7A2018PDBVR.step
ESP32-S3-WROOM-1/
ESP32-S3-WROOM-1.kicad_sym
ESP32-S3-WROOM-1.kicad_mod
Usage:
python3 scripts/import_batch.py --source /path/to/batch_source [--category power]
Each subdirectory name is used as the component --name. Exactly one
.kicad_sym is required per subdirectory; a .kicad_mod and a 3D model
(.step/.stp/.wrl/.wrz) are optional, matching import_component.py's
"symbol only / symbol+footprint / symbol+footprint+model" support.
This is a thin loop around import_component's core functions -- no
separate collision/validation logic is reimplemented here, so behaviour
(refuse existing names, dedupe identical footprints, rename real
collisions, regenerate lib tables) is identical to a single
import_component.py run, just repeated per subdirectory. The lib tables
are regenerated once at the end rather than after every component, for
speed.
"""
from __future__ import annotations
import argparse
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent))
import lib_common as lc
import import_component as ic
ROOT = lc.LIBRARY_ROOT
MODEL_EXTS = (".step", ".stp", ".wrl", ".wrz")
def find_one(directory: Path, suffixes: tuple[str, ...]) -> Path | None:
matches = [p for p in sorted(directory.rglob("*")) if p.is_file() and p.suffix.lower() in suffixes]
return matches[0] if matches else None
def main() -> int:
parser = argparse.ArgumentParser(description="Batch-import components into the MIKILAB library.")
parser.add_argument("--source", required=True, help="Directory containing one subdirectory per component")
parser.add_argument("--category", choices=lc.CATEGORIES, help="Force this category for every component (default: auto-detect per component)")
args = parser.parse_args()
source = Path(args.source).expanduser().resolve()
if not source.is_dir():
print(f"ERROR: --source is not a directory: {source}")
return 1
component_dirs = sorted(p for p in source.iterdir() if p.is_dir())
if not component_dirs:
print(f"No component subdirectories found under {source}")
return 1
results = []
for comp_dir in component_dirs:
name = comp_dir.name
symbol = find_one(comp_dir, (".kicad_sym",))
footprint = find_one(comp_dir, (".kicad_mod",))
model = find_one(comp_dir, MODEL_EXTS)
if symbol is None:
results.append((name, "ERROR", "no .kicad_sym file found in subdirectory"))
continue
category = args.category or lc.classify(name)
manifest_rows: list[list[str]] = []
report: list[str] = []
try:
ic.import_symbol(name, category, symbol, manifest_rows, report)
fp_path = None
if footprint is not None:
fp_path, fp_nick, fp_name, _ = ic.import_footprint(name, category, footprint, manifest_rows, report)
if model is not None:
ic.import_model(name, category, model, fp_path, manifest_rows, report)
sym_path = ROOT / "symbols" / category / f"{name}.kicad_sym"
text = sym_path.read_text(encoding="utf-8")
new_text, changed = lc.set_symbol_footprint_property(text, f"{fp_nick}:{fp_name}")
if changed:
sym_path.write_text(new_text, encoding="utf-8")
lc.append_manifest_rows(ROOT, manifest_rows)
results.append((name, "OK", f"category={category}"))
except ic.ImportError_ as e:
lc.append_manifest_rows(ROOT, manifest_rows)
results.append((name, "ERROR", str(e)))
sym_entries = lc.write_sym_lib_table(ROOT)
fp_entries = lc.write_fp_lib_table(ROOT)
print(f"Batch import from {source}")
print()
for name, status, note in results:
print(f" {status:6} {name:30} {note}")
print()
print(f"Regenerated sym-lib-table ({len(sym_entries)} libraries) and fp-lib-table ({len(fp_entries)} libraries)")
ok = sum(1 for _, status, _ in results if status == "OK")
errors = sum(1 for _, status, _ in results if status == "ERROR")
print(f"\n{ok} imported, {errors} failed, out of {len(results)} component directories")
return 1 if errors else 0
if __name__ == "__main__":
sys.exit(main())
+240
View File
@@ -0,0 +1,240 @@
#!/usr/bin/env python3
"""
import_component.py
====================
Import a single component (symbol + optional footprint + optional 3D model)
into the MIKILAB KiCad library.
Usage:
python3 scripts/import_component.py \\
--name TPS7A2018PDBVR \\
--symbol /path/to/TPS7A2018PDBVR.kicad_sym \\
--footprint /path/to/SOT95P280X145-5N.kicad_mod \\
--model /path/to/TPS7A2018PDBVR.step \\
--category power
A component may be imported with just a symbol, symbol+footprint, or
symbol+footprint+3D model. --category is optional; if omitted it is
inferred from --name using the same classification rules used elsewhere
in this library.
Never overwrites an existing component. Never silently duplicates a
footprint that already exists byte-for-byte elsewhere in the library --
it is reused instead. A footprint with a colliding filename but different
content is given a distinct, semantically-derived name and the collision
is documented in the report and in MANIFEST.csv.
After a successful import, sym-lib-table and fp-lib-table are fully
regenerated from the contents of the library directory tree, which
guarantees there is never more than one "(version 7)" entry and that
every library on disk is registered exactly once.
"""
from __future__ import annotations
import argparse
import shutil
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent))
import lib_common as lc
ROOT = lc.LIBRARY_ROOT
class ImportError_(Exception):
pass
def validate_args(args) -> None:
if not args.name.strip():
raise ImportError_("--name must not be empty")
for label, value in (("--symbol", args.symbol), ("--footprint", args.footprint), ("--model", args.model)):
if value is not None and not Path(value).expanduser().is_file():
raise ImportError_(f"{label} does not exist or is not a file: {value}")
if args.model and not args.footprint:
raise ImportError_("--model requires --footprint (a 3D model needs a footprint to attach to)")
if args.category and args.category not in lc.CATEGORIES:
raise ImportError_(
f"--category '{args.category}' is not one of the known categories: "
+ ", ".join(lc.CATEGORIES)
)
def import_symbol(name: str, category: str, src: Path, manifest_rows: list, report: list) -> Path:
existing = lc.find_symbol_by_name(ROOT, name)
if existing is not None:
raise ImportError_(
f"A component named '{name}' already exists: {existing.relative_to(ROOT)}. "
f"Refusing to overwrite -- choose a different --name or remove the existing "
f"component first."
)
dst = ROOT / "symbols" / category / f"{name}.kicad_sym"
dst.parent.mkdir(parents=True, exist_ok=True)
digest = lc.sha256_file(src)
shutil.copy2(src, dst)
manifest_rows.append(["symbol", str(src), str(dst.relative_to(ROOT)), "NEW", digest, f"category={category}"])
report.append(f" SYMBOL NEW {dst.relative_to(ROOT)}")
return dst
def import_footprint(name: str, category: str, src: Path, manifest_rows: list, report: list):
"""Returns (footprint_path_or_None_if_reused, fp_nickname, fp_name, status)."""
digest = lc.sha256_file(src)
existing = lc.find_footprint_by_hash(ROOT, digest)
if existing is not None:
pretty_dir = existing.parent
nickname = lc.fp_nickname(pretty_dir)
manifest_rows.append([
"footprint", str(src), str(existing.relative_to(ROOT)), "DUPLICATE", digest,
f"reused existing identical footprint instead of duplicating; category={category}",
])
report.append(f" FOOTPRINT DUPLICATE reused {existing.relative_to(ROOT)}")
return None, nickname, existing.stem, "DUPLICATE"
pretty_dir = ROOT / "footprints" / category / f"{name}.pretty"
basename = src.name
colliding = lc.find_footprints_by_basename(ROOT, basename)
if colliding:
new_basename = f"{name}_{src.stem}{src.suffix}"
note = (
f"filename collision with {', '.join(str(p.relative_to(ROOT)) for p in colliding)} "
f"(different content, verified by SHA256) -> renamed to '{new_basename}'"
)
target = pretty_dir / new_basename
status = "RENAMED_COLLISION"
else:
target = pretty_dir / basename
status = "NEW"
note = f"category={category}"
pretty_dir.mkdir(parents=True, exist_ok=True)
shutil.copy2(src, target)
manifest_rows.append(["footprint", str(src), str(target.relative_to(ROOT)), status, digest, note])
report.append(f" FOOTPRINT {status:11} {target.relative_to(ROOT)}")
nickname = lc.fp_nickname(pretty_dir)
return target, nickname, target.stem, status
def import_model(name: str, category: str, src: Path, footprint_path: Path | None, manifest_rows: list, report: list):
dst = ROOT / "3dmodels" / category / f"{name}{src.suffix.lower()}"
dst.parent.mkdir(parents=True, exist_ok=True)
digest = lc.sha256_file(src)
final, status = lc.unique_destination(dst, digest)
if status != "DUPLICATE":
shutil.copy2(src, final)
manifest_rows.append(["3d-model", str(src), str(final.relative_to(ROOT)), status, digest, f"category={category}"])
report.append(f" 3D MODEL {status:11} {final.relative_to(ROOT)}")
if footprint_path is None:
report.append(
" NOTE: footprint was reused from an existing shared library; the 3D model "
"was copied but NOT embedded in that footprint (it may already be used by "
"another component with a different 3D body -- assign it manually per-instance "
"in the PCB editor if needed)."
)
return
text = footprint_path.read_text(encoding="utf-8")
model_uri = "${KIPRJMOD}/" + str(final.relative_to(ROOT))
if "(model " in text:
report.append(
f" NOTE: {footprint_path.relative_to(ROOT)} already has a 3D model reference; "
f"leaving it untouched. New model is available at {final.relative_to(ROOT)}."
)
return
block = (
f" (model {model_uri}\n"
f" (offset (xyz 0 0 0))\n"
f" (scale (xyz 1 1 1))\n"
f" (rotate (xyz 0 0 0))\n"
f" )\n"
)
assert text.rstrip().endswith(")")
idx = text.rstrip().rfind(")")
text = text.rstrip()[:idx] + block + ")\n"
footprint_path.write_text(text, encoding="utf-8")
report.append(f" Linked 3D model into {footprint_path.relative_to(ROOT)}")
def main() -> int:
parser = argparse.ArgumentParser(description="Import a single component into the MIKILAB library.")
parser.add_argument("--name", required=True, help="Component name (used as the symbol/footprint base name)")
parser.add_argument("--category", choices=lc.CATEGORIES, help="MIKILAB category (auto-detected from --name if omitted)")
parser.add_argument("--symbol", required=True, help="Path to the source .kicad_sym file")
parser.add_argument("--footprint", help="Path to the source .kicad_mod file")
parser.add_argument("--model", help="Path to the source 3D model (.step/.stp/.wrl/.wrz)")
args = parser.parse_args()
try:
validate_args(args)
except ImportError_ as e:
print(f"ERROR: {e}")
return 1
name = args.name.strip()
category = args.category or lc.classify(name)
manifest_rows: list[list[str]] = []
report: list[str] = [f"Importing '{name}' into category '{category}'"]
try:
import_symbol(name, category, Path(args.symbol).expanduser().resolve(), manifest_rows, report)
except ImportError_ as e:
print(f"ERROR: {e}")
lc.append_manifest_rows(ROOT, [["symbol", args.symbol, "", "ERROR", "", str(e)]])
return 1
fp_path = None
if args.footprint:
fp_path, fp_nick, fp_name, _ = import_footprint(
name, category, Path(args.footprint).expanduser().resolve(), manifest_rows, report
)
if args.model:
model_target = fp_path if fp_path is not None else None
import_model(name, category, Path(args.model).expanduser().resolve(), model_target, manifest_rows, report)
sym_path = ROOT / "symbols" / category / f"{name}.kicad_sym"
text = sym_path.read_text(encoding="utf-8")
new_ref = f"{fp_nick}:{fp_name}"
new_text, changed = lc.set_symbol_footprint_property(text, new_ref)
if changed:
sym_path.write_text(new_text, encoding="utf-8")
report.append(f" Linked symbol Footprint property -> {new_ref}")
else:
report.append(
f" NOTE: could not find a 'Footprint' property in {sym_path.relative_to(ROOT)} "
f"to update automatically -- set it manually to '{new_ref}'"
)
lc.append_manifest_rows(ROOT, manifest_rows)
sym_entries = lc.write_sym_lib_table(ROOT)
fp_entries = lc.write_fp_lib_table(ROOT)
report.append(f"Regenerated sym-lib-table ({len(sym_entries)} libraries) and fp-lib-table ({len(fp_entries)} libraries)")
print("\n".join(report))
print("\nOK.")
return 0
if __name__ == "__main__":
sys.exit(main())
+373
View File
@@ -0,0 +1,373 @@
"""
lib_common.py
=============
Shared helpers for the MIKILAB KiCad library scripts
(check_library.py, import_component.py, add_component.py, import_batch.py).
This module knows nothing about any external/source repository. It only
operates on the MIKILAB library rooted at LIBRARY_ROOT (the directory that
contains sym-lib-table, fp-lib-table, symbols/, footprints/, 3dmodels/).
"""
from __future__ import annotations
import hashlib
import re
from dataclasses import dataclass, field
from pathlib import Path
LIBRARY_ROOT = Path(__file__).resolve().parent.parent
SYMBOL_EXT = ".kicad_sym"
FOOTPRINT_EXT = ".kicad_mod"
MODEL_EXTENSIONS = {".step", ".stp", ".wrl", ".wrz"}
CATEGORIES = [
"analog",
"audio",
"display",
"fpga_cpld",
"interface",
"logic",
"mechanical",
"memory",
"microcontrollers",
"other",
"power",
"rf",
]
CATEGORY_RULES = [
("microcontrollers", [
"esp32", "esp8266", "esp32-s31", "mcu", "microcontroller",
"cpu", "processor",
]),
("audio", [
"adau", "si4684", "codec", "audio", "dsp", "amplifier_audio",
]),
("power", [
"power", "regulator", "converter", "battery", "charger",
"bq25896", "bq27441", "ap63203", "tps7a", "tps22918",
"ina218", "tca9555",
]),
("rf", [
"bluetooth", "bt1035", "wifi", "wlan", "antenna",
"gps", "gnss", "nfc", "transceiver", "phy", "ethernet",
"rf",
]),
("interface", [
"interface", "connector", "usb", "uart", "spi", "i2c", "i3c",
"can", "hdmi", "displayport", "line_driver",
]),
("memory", [
"memory", "eeprom", "flash", "sram", "dram", "nand", "nor",
]),
("logic", [
"logic", "74xx", "4xxx", "buffer", "gate", "timer",
"comparator", "mux", "demux", "flipflop", "counter",
]),
("analog", [
"analog", "sensor", "diode", "transistor", "fet", "mosfet",
"opamp", "operational", "reference", "filter", "switch",
"relay",
]),
("fpga_cpld", [
"fpga", "cpld", "altera", "xilinx", "lattice",
]),
("display", [
"display", "lcd", "oled", "led_display", "driver_display",
]),
("mechanical", [
"mechanical", "mount", "bracket", "jumper",
]),
]
def norm(value: str) -> str:
"""Normalize a string into a safe KiCad library-table nickname token."""
return re.sub(r"[^a-zA-Z0-9]+", "_", value).strip("_")
def classify(name: str) -> str:
"""Classify a component/library name into a MIKILAB category."""
text = norm(name).lower()
for category, tokens in CATEGORY_RULES:
for token in tokens:
if norm(token).lower() in text:
return category
return "other"
def sha256_file(path: Path) -> str:
h = hashlib.sha256()
with path.open("rb") as f:
for chunk in iter(lambda: f.read(1024 * 1024), b""):
h.update(chunk)
return h.hexdigest()
def sym_nickname(path: Path) -> str:
"""Nickname for a symbol library file (one .kicad_sym == one library)."""
return "MIKILAB_" + norm(path.stem)
def fp_nickname(pretty_dir: Path) -> str:
"""Nickname for a footprint library directory (one .pretty == one library)."""
name = pretty_dir.name
if name.endswith(".pretty"):
name = name[: -len(".pretty")]
return "MIKILAB_" + norm(name)
def unique_nickname(base: str, used: set[str]) -> str:
nickname = base
index = 2
while nickname in used:
nickname = f"{base}_{index}"
index += 1
used.add(nickname)
return nickname
def discover_symbol_libraries(root: Path) -> list[Path]:
return sorted((root / "symbols").glob("**/*.kicad_sym"))
def discover_footprint_libraries(root: Path) -> list[Path]:
return sorted(
p for p in (root / "footprints").glob("**/*.pretty") if p.is_dir()
)
@dataclass
class LibEntry:
nickname: str
uri: str
descr: str = ""
lib_type: str = "KiCad"
options: str = ""
def build_sym_table_entries(root: Path) -> list[LibEntry]:
used: set[str] = set()
entries = []
for path in discover_symbol_libraries(root):
relative = path.relative_to(root)
nickname = unique_nickname(sym_nickname(path), used)
uri = "${KIPRJMOD}/" + str(relative)
entries.append(LibEntry(nickname=nickname, uri=uri, descr=path.stem))
return entries
def build_fp_table_entries(root: Path) -> list[LibEntry]:
used: set[str] = set()
entries = []
for path in discover_footprint_libraries(root):
relative = path.relative_to(root)
nickname = unique_nickname(fp_nickname(path), used)
uri = "${KIPRJMOD}/" + str(relative)
entries.append(LibEntry(nickname=nickname, uri=uri, descr=path.name))
return entries
def render_lib_table(kind: str, entries: list[LibEntry]) -> str:
"""kind is 'sym_lib_table' or 'fp_lib_table'."""
lines = [f"({kind}", " (version 7)"]
for e in entries:
if kind == "sym_lib_table":
lines.append(
f' (lib (name "{e.nickname}")(type "{e.lib_type}")'
f'(uri "{e.uri}")(options "{e.options}")(descr "{e.descr}"))'
)
else:
lines.append(
f' (lib (name "{e.nickname}")(type "{e.lib_type}")'
f'(uri "{e.uri}")(options "{e.options}")(descr "{e.descr}"))'
)
lines.append(")")
return "\n".join(lines) + "\n"
def write_sym_lib_table(root: Path) -> list[LibEntry]:
entries = build_sym_table_entries(root)
(root / "sym-lib-table").write_text(
render_lib_table("sym_lib_table", entries), encoding="utf-8"
)
return entries
def write_fp_lib_table(root: Path) -> list[LibEntry]:
entries = build_fp_table_entries(root)
(root / "fp-lib-table").write_text(
render_lib_table("fp_lib_table", entries), encoding="utf-8"
)
return entries
LIB_ENTRY_RE = re.compile(
r'\(lib\s*\(name\s*"([^"]*)"\)\s*\(type\s*"([^"]*)"\)\s*'
r'\(uri\s*"([^"]*)"\)\s*\(options\s*"([^"]*)"\)\s*'
r'\(descr\s*"([^"]*)"\)\)'
)
# Also accept the legacy/alternate KiCad lib-table syntax:
# (lib "NICK" "URI" (descr "..."))
LIB_ENTRY_RE_LEGACY = re.compile(
r'\(lib\s+"([^"]*)"\s+"([^"]*)"(?:\s*\(descr\s*"([^"]*)"\))?\s*\)'
)
def parse_lib_table(path: Path) -> tuple[list[LibEntry], int]:
"""Return (entries, version_count). Tolerates either KiCad lib-table
syntax variant. version_count is the number of top-level (version N)
occurrences found -- must be exactly 1 for a valid table."""
if not path.exists():
return [], 0
text = path.read_text(encoding="utf-8")
version_count = len(re.findall(r"\(version\s+\d+\)", text))
entries: list[LibEntry] = []
for m in LIB_ENTRY_RE.finditer(text):
nickname, lib_type, uri, options, descr = m.groups()
entries.append(
LibEntry(
nickname=nickname,
uri=uri,
descr=descr,
lib_type=lib_type,
options=options,
)
)
if not entries:
for m in LIB_ENTRY_RE_LEGACY.finditer(text):
nickname, uri, descr = m.groups()
entries.append(
LibEntry(nickname=nickname, uri=uri, descr=descr or "")
)
return entries, version_count
def resolve_uri(uri: str, root: Path) -> Path:
"""Resolve a lib-table URI (using ${KIPRJMOD}) to an absolute path."""
resolved = uri.replace("${KIPRJMOD}", str(root))
return Path(resolved)
# ---------------------------------------------------------------------------
# Helpers shared by import_component.py / add_component.py / import_batch.py
# ---------------------------------------------------------------------------
def find_symbol_by_name(root: Path, name: str) -> Path | None:
"""Any symbols/**/<name>.kicad_sym, regardless of category."""
for path in discover_symbol_libraries(root):
if path.stem == name:
return path
return None
def find_footprint_by_hash(root: Path, digest: str) -> Path | None:
for pretty in discover_footprint_libraries(root):
for mod in pretty.glob("*.kicad_mod"):
if sha256_file(mod) == digest:
return mod
return None
def find_footprints_by_basename(root: Path, basename: str) -> list[Path]:
matches = []
for pretty in discover_footprint_libraries(root):
candidate = pretty / basename
if candidate.exists():
matches.append(candidate)
return matches
_FOOTPRINT_PROP_SINGLELINE_RE = re.compile(
r'(\(property\s+"Footprint"\s+)"([^"]*)"'
)
_FOOTPRINT_PROP_MULTILINE_RE = re.compile(
r'(\(property\s*\n\s*"Footprint"\s*\n\s*)"([^"]*)"'
)
def set_symbol_footprint_property(text: str, new_value: str) -> tuple[str, bool]:
"""Replace the value of the (first) 'Footprint' property in a symbol
file's text, supporting both single-line and multi-line KiCad property
syntax. Returns (new_text, changed)."""
if _FOOTPRINT_PROP_SINGLELINE_RE.search(text):
new_text, n = _FOOTPRINT_PROP_SINGLELINE_RE.subn(
lambda m: f'{m.group(1)}"{new_value}"', text, count=1
)
return new_text, n > 0
if _FOOTPRINT_PROP_MULTILINE_RE.search(text):
new_text, n = _FOOTPRINT_PROP_MULTILINE_RE.subn(
lambda m: f'{m.group(1)}"{new_value}"', text, count=1
)
return new_text, n > 0
return text, False
def unique_destination(dst: Path, digest: str) -> tuple[Path, str]:
"""Given a desired destination path and the sha256 of the source file,
return (final_path, status) where status is one of NEW / DUPLICATE /
RENAMED_COLLISION. Mirrors the collision policy used across the
library: identical content is deduplicated, differing content gets a
distinct suffixed name rather than silently overwriting."""
if not dst.exists():
return dst, "NEW"
if sha256_file(dst) == digest:
return dst, "DUPLICATE"
stem = dst.stem
suffix = dst.suffix
index = 2
while True:
candidate = dst.with_name(f"{stem}_{index}{suffix}")
if not candidate.exists():
return candidate, "RENAMED_COLLISION"
if sha256_file(candidate) == digest:
return candidate, "DUPLICATE"
index += 1
MANIFEST_HEADER = ["type", "source", "destination", "status", "hash", "notes"]
def append_manifest_rows(root: Path, rows: list[list[str]]):
import csv
manifest = root / "MANIFEST.csv"
is_new = not manifest.exists()
with manifest.open("a", newline="", encoding="utf-8") as f:
writer = csv.writer(f)
if is_new:
writer.writerow(MANIFEST_HEADER)
for row in rows:
writer.writerow(row)