293 lines
9.7 KiB
Python
293 lines
9.7 KiB
Python
"""Command line interface.
|
|||
|
|
|
||
|
|
boxsim decode file.bpj raw INI payload
|
||
|
|
boxsim extract file.bpj structured JSON model
|
||
|
|
boxsim netlist file.bpj crossover netlist
|
||
|
|
boxsim build file.bpj outdir/ a KiCad 10 project
|
||
|
|
"""
|
||
|
|
|
||
|
|
from __future__ import annotations
|
||
|
|
|
||
|
|
import argparse
|
||
|
|
import json
|
||
|
|
import sys
|
||
|
|
from pathlib import Path
|
||
|
|
|
||
|
|
from . import catalog as catalog_mod
|
||
|
|
from . import container, ini, model
|
||
|
|
from . import project as project_mod
|
||
|
|
from . import symbols as symbols_mod
|
||
|
|
from .model import Project
|
||
|
|
|
||
|
|
|
||
|
|
def _add_library_args(p: argparse.ArgumentParser) -> None:
|
||
|
|
p.add_argument(
|
||
|
|
"--library",
|
||
|
|
help="path to the kicad-visaton-lib checkout or its Visaton.kicad_sym "
|
||
|
|
"(default: $BOXSIM_KICAD_LIB, else the copy bundled with this tool)",
|
||
|
|
)
|
||
|
|
p.add_argument(
|
||
|
|
"--catalog",
|
||
|
|
help="path to Visatonteile.txt (default: $BOXSIM_CATALOG, else the "
|
||
|
|
"copy bundled with this tool)",
|
||
|
|
)
|
||
|
|
|
||
|
|
|
||
|
|
def _read(path: str) -> bytes:
|
||
|
|
p = Path(path)
|
||
|
|
if not p.exists():
|
||
|
|
raise SystemExit(f"boxsim: {path}: no such file")
|
||
|
|
return p.read_bytes()
|
||
|
|
|
||
|
|
|
||
|
|
def _load_project(path: str) -> tuple[Project, str]:
|
||
|
|
raw = _read(path)
|
||
|
|
doc = ini.parse(container.decode_text(raw))
|
||
|
|
return model.from_ini(doc), Path(path).stem
|
||
|
|
|
||
|
|
|
||
|
|
def cmd_decode(args: argparse.Namespace) -> int:
|
||
|
|
raw = _read(args.file)
|
||
|
|
if args.oracle:
|
||
|
|
from . import _oracle
|
||
|
|
|
||
|
|
payload = _oracle.decode_bpj(raw)
|
||
|
|
else:
|
||
|
|
payload = container.decode(raw)
|
||
|
|
if args.output:
|
||
|
|
Path(args.output).write_bytes(payload)
|
||
|
|
print(f"{args.file}: {len(payload)} bytes -> {args.output}")
|
||
|
|
else:
|
||
|
|
sys.stdout.buffer.write(payload)
|
||
|
|
return 0
|
||
|
|
|
||
|
|
|
||
|
|
def _model_to_dict(project: Project, resolutions=None) -> dict:
|
||
|
|
by_slot = {r.part.slot: r for r in (resolutions or [])}
|
||
|
|
out: dict = {
|
||
|
|
"version": project.version,
|
||
|
|
"amp_count": project.amp_count,
|
||
|
|
"chassis_count": project.chassis_count,
|
||
|
|
"comments": project.comments,
|
||
|
|
"chassis": [
|
||
|
|
{
|
||
|
|
"index": c.index,
|
||
|
|
"name": c.name,
|
||
|
|
"comment": c.comment,
|
||
|
|
"re_ohm": c.re,
|
||
|
|
"fs_hz": c.fs,
|
||
|
|
"vas_m3": c.vas,
|
||
|
|
"qes": c.qes,
|
||
|
|
"qms": c.qms,
|
||
|
|
}
|
||
|
|
for c in project.chassis
|
||
|
|
],
|
||
|
|
"enclosures": [
|
||
|
|
{"index": e.index, "type": e.typ, "vb_m3": e.vb, "fb_hz": e.fb}
|
||
|
|
for e in project.enclosures
|
||
|
|
],
|
||
|
|
"baffles": [
|
||
|
|
{"index": b.index, "width_m": b.width, "height_m": b.height,
|
||
|
|
"shared_enclosure": b.shared}
|
||
|
|
for b in project.baffles
|
||
|
|
],
|
||
|
|
"crossovers": [],
|
||
|
|
}
|
||
|
|
for xo in project.crossovers:
|
||
|
|
netlist = xo.build_netlist()
|
||
|
|
parts = []
|
||
|
|
for p in xo.parts:
|
||
|
|
entry = {
|
||
|
|
"slot": p.slot,
|
||
|
|
"type": p.typ.name.lower(),
|
||
|
|
"orientation": p.orientation.name.lower(),
|
||
|
|
"row": p.row,
|
||
|
|
"col": p.col,
|
||
|
|
"nodes": [p.node_a, p.node_b],
|
||
|
|
"nets": [netlist.net_of(p.node_a), netlist.net_of(p.node_b)],
|
||
|
|
}
|
||
|
|
if p.value is not None:
|
||
|
|
entry["value_si"] = p.value
|
||
|
|
if p.label:
|
||
|
|
entry["series"] = p.label
|
||
|
|
if p.dcr is not None:
|
||
|
|
entry["dcr_ohm"] = p.dcr
|
||
|
|
if p.chassis_index is not None:
|
||
|
|
entry["chassis_index"] = p.chassis_index
|
||
|
|
if p.inverted:
|
||
|
|
entry["inverted"] = True
|
||
|
|
res = by_slot.get(p.slot)
|
||
|
|
if res is not None:
|
||
|
|
entry["symbol"] = res.symbol
|
||
|
|
entry["value_text"] = res.value_text
|
||
|
|
if res.notes:
|
||
|
|
entry["notes"] = res.notes
|
||
|
|
parts.append(entry)
|
||
|
|
out["crossovers"].append(
|
||
|
|
{
|
||
|
|
"index": xo.index,
|
||
|
|
"component_count": len(xo.components),
|
||
|
|
"wire_count": len(xo.wires),
|
||
|
|
"net_count": netlist.net_count,
|
||
|
|
"dangling_nets": netlist.dangling(),
|
||
|
|
"parts": parts,
|
||
|
|
}
|
||
|
|
)
|
||
|
|
return out
|
||
|
|
|
||
|
|
|
||
|
|
def cmd_extract(args: argparse.Namespace) -> int:
|
||
|
|
project, _ = _load_project(args.file)
|
||
|
|
resolutions = None
|
||
|
|
if not args.no_symbols:
|
||
|
|
try:
|
||
|
|
cat, lib = project_mod.load_parts(args.catalog, args.library)
|
||
|
|
resolver = symbols_mod.Resolver(cat, lib)
|
||
|
|
resolutions = [
|
||
|
|
r
|
||
|
|
for xo in project.crossovers
|
||
|
|
for r in resolver.resolve_all(xo.components)
|
||
|
|
]
|
||
|
|
except (symbols_mod.SymbolError, catalog_mod.CatalogError) as exc:
|
||
|
|
print(f"boxsim: symbol resolution skipped: {exc}", file=sys.stderr)
|
||
|
|
payload = json.dumps(_model_to_dict(project, resolutions), indent=2, sort_keys=False)
|
||
|
|
if args.output:
|
||
|
|
Path(args.output).write_text(payload + "\n", encoding="utf-8")
|
||
|
|
print(f"{args.file} -> {args.output}")
|
||
|
|
else:
|
||
|
|
print(payload)
|
||
|
|
return 0
|
||
|
|
|
||
|
|
|
||
|
|
def cmd_netlist(args: argparse.Namespace) -> int:
|
||
|
|
project, _ = _load_project(args.file)
|
||
|
|
for xo in project.crossovers:
|
||
|
|
netlist = xo.build_netlist()
|
||
|
|
print(f"# crossover {xo.index}: {len(xo.components)} components, "
|
||
|
|
f"{netlist.net_count} nets")
|
||
|
|
groups: dict[int, list[str]] = {}
|
||
|
|
for p in xo.components:
|
||
|
|
label = f"Part{p.slot}({p.typ.name.lower()}"
|
||
|
|
label += f" {p.value:g})" if p.value is not None else ")"
|
||
|
|
for node in (p.node_a, p.node_b):
|
||
|
|
groups.setdefault(netlist.net_of(node), []).append(label)
|
||
|
|
for net in sorted(groups):
|
||
|
|
print(f" net {net:3d}: {', '.join(sorted(groups[net]))}")
|
||
|
|
dangling = netlist.dangling()
|
||
|
|
if dangling:
|
||
|
|
print(f" WARNING dangling nets: {dangling}")
|
||
|
|
return 0
|
||
|
|
|
||
|
|
|
||
|
|
def cmd_build(args: argparse.Namespace) -> int:
|
||
|
|
project, name = _load_project(args.file)
|
||
|
|
try:
|
||
|
|
cat, lib = project_mod.load_parts(args.catalog, args.library)
|
||
|
|
except (symbols_mod.SymbolError, catalog_mod.CatalogError) as exc:
|
||
|
|
raise SystemExit(f"boxsim: {exc}") from exc
|
||
|
|
|
||
|
|
extras = symbols_mod.cross_check(cat, lib)
|
||
|
|
if extras and args.verbose:
|
||
|
|
print(f"note: library has {len(extras)} symbol(s) Boxsim cannot select: "
|
||
|
|
f"{', '.join(extras)}")
|
||
|
|
|
||
|
|
outdir = Path(args.outdir)
|
||
|
|
try:
|
||
|
|
result = project_mod.build(
|
||
|
|
project, args.name or name, outdir, cat, lib, pitch=args.pitch
|
||
|
|
)
|
||
|
|
except (symbols_mod.SymbolError, NotImplementedError) as exc:
|
||
|
|
raise SystemExit(f"boxsim: {exc}") from exc
|
||
|
|
|
||
|
|
print(f"{result.schematic.name}: {len(result.sheet.placements)} symbols, "
|
||
|
|
f"{len(result.sheet.wires)} wires, {len(result.sheet.junctions)} junctions, "
|
||
|
|
f"paper {result.sheet.paper}")
|
||
|
|
for note in result.notes:
|
||
|
|
print(f" note: {note}")
|
||
|
|
|
||
|
|
if args.verify:
|
||
|
|
from . import verify as verify_mod
|
||
|
|
|
||
|
|
try:
|
||
|
|
gate = verify_mod.run_gate(
|
||
|
|
result.schematic, project.crossovers[0], result.sheet.placements
|
||
|
|
)
|
||
|
|
except verify_mod.VerifyError as exc:
|
||
|
|
raise SystemExit(f"boxsim: {exc}") from exc
|
||
|
|
for m in gate.messages:
|
||
|
|
print(f" {m}")
|
||
|
|
status = "PASS" if gate.ok else "FAIL"
|
||
|
|
print(f" gate {status}: KiCad {gate.kicad_version}, ERC "
|
||
|
|
f"{'clean' if gate.erc_ok else 'failed'}, netlist "
|
||
|
|
f"{'matches' if gate.netlist_matches else 'differs'} "
|
||
|
|
f"({len(gate.reference_nets)} nets)")
|
||
|
|
if not gate.ok:
|
||
|
|
return 1
|
||
|
|
return 0
|
||
|
|
|
||
|
|
|
||
|
|
def build_parser() -> argparse.ArgumentParser:
|
||
|
|
ap = argparse.ArgumentParser(
|
||
|
|
prog="boxsim",
|
||
|
|
description="Read Boxsim loudspeaker projects and turn their crossovers "
|
||
|
|
"into KiCad projects.",
|
||
|
|
)
|
||
|
|
sub = ap.add_subparsers(dest="command", required=True)
|
||
|
|
|
||
|
|
p = sub.add_parser("decode", help="write the raw INI payload of a container")
|
||
|
|
p.add_argument("file")
|
||
|
|
p.add_argument("-o", "--output")
|
||
|
|
p.add_argument(
|
||
|
|
"--oracle",
|
||
|
|
action="store_true",
|
||
|
|
help="decode by emulating Boxsim.exe instead (development only)",
|
||
|
|
)
|
||
|
|
p.set_defaults(func=cmd_decode)
|
||
|
|
|
||
|
|
p = sub.add_parser("extract", help="write the structured project model as JSON")
|
||
|
|
p.add_argument("file")
|
||
|
|
p.add_argument("-o", "--output")
|
||
|
|
p.add_argument(
|
||
|
|
"--no-symbols", action="store_true", help="skip KiCad symbol resolution"
|
||
|
|
)
|
||
|
|
_add_library_args(p)
|
||
|
|
p.set_defaults(func=cmd_extract)
|
||
|
|
|
||
|
|
p = sub.add_parser("netlist", help="print the crossover netlist")
|
||
|
|
p.add_argument("file")
|
||
|
|
p.set_defaults(func=cmd_netlist)
|
||
|
|
|
||
|
|
p = sub.add_parser("build", help="write a KiCad project")
|
||
|
|
p.add_argument("file")
|
||
|
|
p.add_argument("outdir")
|
||
|
|
p.add_argument("--name", help="project name (default: input file stem)")
|
||
|
|
p.add_argument(
|
||
|
|
"--pitch", type=float, default=None,
|
||
|
|
help="grid pitch in mm (default 25.4, must exceed 22.86)",
|
||
|
|
)
|
||
|
|
p.add_argument(
|
||
|
|
"--verify", action="store_true",
|
||
|
|
help="run ERC and compare the extracted netlist against Boxsim "
|
||
|
|
"(requires kicad-cli 10)",
|
||
|
|
)
|
||
|
|
p.add_argument("-v", "--verbose", action="store_true")
|
||
|
|
_add_library_args(p)
|
||
|
|
p.set_defaults(func=cmd_build)
|
||
|
|
return ap
|
||
|
|
|
||
|
|
|
||
|
|
def main(argv: list[str] | None = None) -> int:
|
||
|
|
ap = build_parser()
|
||
|
|
args = ap.parse_args(argv)
|
||
|
|
if getattr(args, "pitch", None) is None and args.command == "build":
|
||
|
|
from .schematic import DEFAULT_PITCH_MM
|
||
|
|
|
||
|
|
args.pitch = DEFAULT_PITCH_MM
|
||
|
|
try:
|
||
|
|
return args.func(args)
|
||
|
|
except (container.ContainerError, model.ModelError) as exc:
|
||
|
|
raise SystemExit(f"boxsim: {exc}") from exc
|
||
|
|
|
||
|
|
|
||
|
|
if __name__ == "__main__":
|
||
|
|
raise SystemExit(main())
|