TriMixxx/parse_netlist.py
Samuel Prevost f4d8a61963 Update README with per-module schematic screenshots and BOM
Replace the single full-schematic screenshot with individual per-module
screenshots (audio, MIDI/Arduino, power delivery, USB, top case 3D).
Add a Bill of Materials section auto-generated from the KiCad netlist
using parse_netlist.py (sexpdata), listing 160 components grouped by
schematic sheet with human-readable descriptions.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-17 12:18:28 +01:00

183 lines
6.2 KiB
Python

# /// script
# requires-python = ">=3.10"
# dependencies = ["sexpdata"]
# ///
"""Parse KiCad netlist (.net) and output per-module component summary as Markdown."""
import json
import sys
from collections import defaultdict
from pathlib import Path
import sexpdata
def sym_str(val):
"""Convert sexpdata Symbol or string to plain string."""
if isinstance(val, sexpdata.Symbol):
return val.value()
return str(val)
def find_entries(sexpr, key):
"""Find all sub-lists whose first element matches key."""
results = []
if isinstance(sexpr, list):
if len(sexpr) > 0 and sym_str(sexpr[0]) == key:
results.append(sexpr)
for item in sexpr:
results.extend(find_entries(item, key))
return results
def find_entry(sexpr, key):
"""Find first sub-list whose first element matches key."""
entries = find_entries(sexpr, key)
return entries[0] if entries else None
def get_value(sexpr, key):
"""Get the string value of (key value) from a list."""
entry = find_entry(sexpr, key)
if entry and len(entry) > 1:
return sym_str(entry[1])
return ""
def get_property(comp, prop_name):
"""Get a named property value from a component."""
for item in comp:
if isinstance(item, list) and len(item) >= 3:
if sym_str(item[0]) == "property":
name_entry = find_entry(item, "name")
val_entry = find_entry(item, "value")
if name_entry and val_entry:
if sym_str(name_entry[1]) == prop_name:
return sym_str(val_entry[1])
return ""
def parse_netlist(net_path: Path):
with open(net_path) as f:
data = sexpdata.loads(f.read())
# Parse sheet names from the design section
design = find_entry(data, "design")
sheets = find_entries(design, "sheet")
sheet_map = {} # tstamp path -> sheet name
for sheet in sheets:
name = get_value(sheet, "name")
sheet_map[name] = name
# Parse components
components_section = find_entry(data, "components")
comps = find_entries(components_section, "comp")
modules = defaultdict(list)
for comp in comps:
ref = get_value(comp, "ref")
value = get_value(comp, "value")
description = get_value(comp, "description")
footprint = get_value(comp, "footprint")
# Get sheet path name
sheetpath = find_entry(comp, "sheetpath")
sheet_name = get_value(sheetpath, "names") if sheetpath else "/"
# Get libsource part name
libsource = find_entry(comp, "libsource")
part = get_value(libsource, "part") if libsource else ""
modules[sheet_name].append({
"ref": ref,
"value": value,
"part": part,
"description": description,
"footprint": footprint,
})
return modules
SHEET_DESCRIPTIONS = {
"/": "Root — Raspberry Pi CM5 compute module, status LEDs, fan connector, RTC battery, and top-level glue logic",
"/Audio Outputs/": "Audio Outputs — I2S DAC (PCM5242), RCA stereo pair, 6.35mm and 3.5mm headphone jacks, and analog output filtering",
"/Test Points/": "Test Points — Debug and measurement test points for key signals",
"/USB DJ Ports/": "USB DJ Ports — USB-A 3.0 (Rekordbox sticks), USB-A power-only, USB-C 2.0, orientation mux (HD3SS3220), signal switch (HD3SS3212), and ESD protection",
"/HDMI and Ethernet/": "HDMI and Ethernet — Dual micro-HDMI outputs (touchscreen + debug), gigabit Ethernet via RJ45",
"/Power Delivery/": "Power Delivery — USB-C PD input (CH224K negotiation), 20V-to-5V buck (SY8368AQQC), 3.3V LDO (AP2112K), USB power switches (AP2553W)",
"/Arduino MIDI/": "Arduino MIDI — ATmega32U4 USB MIDI controller, 16MHz crystal, JST connectors for buttons/encoders/jog wheel",
}
def format_markdown(modules: dict) -> str:
"""Format parsed modules as a Markdown section."""
lines = []
lines.append("## Bill of Materials by Module")
lines.append("")
lines.append(f"*Auto-generated from the KiCad netlist — {sum(len(v) for v in modules.values())} components total.*")
lines.append("")
# Sort: Root first, then alphabetical
def sort_key(name):
if name == "/":
return (0, "")
return (1, name)
for sheet_name in sorted(modules.keys(), key=sort_key):
comps = modules[sheet_name]
desc = SHEET_DESCRIPTIONS.get(sheet_name, sheet_name.strip("/"))
lines.append(f"### {desc}")
lines.append("")
# Group by reference prefix (letter part) for a summary
by_prefix = defaultdict(list)
for c in comps:
prefix = "".join(ch for ch in c["ref"] if ch.isalpha())
by_prefix[prefix].append(c)
# Filter out purely passive groups from detailed listing
# but still show notable ICs/connectors
notable = []
passives = defaultdict(int)
for c in sorted(comps, key=lambda x: x["ref"]):
prefix = "".join(ch for ch in c["ref"] if ch.isalpha())
if prefix in ("R", "C", "L"):
passives[prefix] += 1
else:
notable.append(c)
passive_summary = []
prefix_names = {"R": "resistors", "C": "capacitors", "L": "inductors"}
for p in ("C", "R", "L"):
if passives[p]:
passive_summary.append(f"{passives[p]} {prefix_names[p]}")
if notable:
lines.append("| Ref | Value | Description |")
lines.append("|-----|-------|-------------|")
for c in notable:
desc_text = c["description"] or c["part"]
# Truncate very long descriptions
if len(desc_text) > 80:
desc_text = desc_text[:77] + "..."
lines.append(f"| {c['ref']} | {c['value']} | {desc_text} |")
lines.append("")
if passive_summary:
lines.append(f"Plus {', '.join(passive_summary)}.")
lines.append("")
return "\n".join(lines)
def main():
net_path = Path(sys.argv[1]) if len(sys.argv) > 1 else Path("CDJ-MainBoard_2026-02-17.net")
modules = parse_netlist(net_path)
print(format_markdown(modules))
if __name__ == "__main__":
main()