Files
boxsim-fileformat-rev/boxsim/cli.py
T
magdevandClaude Fable 5 4737a5bae8 Add boxsim CLI: pure Python codec, netlist extraction, KiCad project generation
Replaces the bpj2ini.py prototype with a packaged tool. The A2F codec is now
reimplemented in Python, so neither Boxsim.exe nor a CPU emulator is needed to
decode a container; the emulator survives as a development-only oracle that the
codec is differentially tested against.

Verified:
- 134 containers decode byte-identically to Boxsim's own decoder, 270 pre-2.0
  files rejected by both implementations (tools/difftest.py --self-test)
- Default.BPJ: 22 components, 14 nets, no dangling terminal
- 22 of 22 parts resolve to library symbols; one series inferred from a
  sibling, two DC resistance deviations reported
- ERC clean and the netlist KiCad extracts from the generated sheet is an
  identical partition to the one derived from the Boxsim grid
- container build byte-identical to the host build
- 32 tests passing, ruff clean

Decode time went 69 s -> 14.5 s (binding emulator hooks to trap addresses)
-> 1.6 s (pure Python).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-29 11:10:43 +02:00

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