"""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())