molecule
AM/FM Receiver Molecule
Public Made by Adomby adom
USB-powered AM/FM receiver on an Adom Molecule: discrete BJT and op-amp superhet (10.7 MHz and 455 kHz IFs), STM32G0B1 tuning, self-calibration and USB audio over one USB-C cable. 96 x 96 mm pin grid, 4 layers, KiCad 10.
← Commit history
amfm-receiver-molecule: KiCad project, fab files, 3D and README (7/7)
7 files changed
+949−73
package.jsonadded+153@@ -0,0 +1,153 @@+{+ "slug": "amfm-receiver-molecule",+ "title": "AM/FM Receiver Molecule",+ "version": "0.1.0",+ "type": "hardware",+ "kind": "molecule",+ "description": "USB-powered AM/FM receiver on an Adom Molecule: discrete BJT and op-amp superhet (10.7 MHz and 455 kHz IFs), STM32G0B1 tuning, self-calibration and USB audio over one USB-C cable. 96 x 96 mm pin grid, 4 layers, KiCad 10.",+ "tags": [+ "molecule",+ "board",+ "kicad",+ "radio",+ "am",+ "fm",+ "superhet",+ "stm32g0b1",+ "usb-audio",+ "adom-machine-pins",+ "jlcpcb"+ ],+ "license": "CC-BY-SA-4.0",+ "component": {+ "mpn": "amfm-receiver-molecule",+ "manufacturer": "Adom",+ "package": "PCBA 102.5 x 102.5 mm, 96 x 96 mm large-pin grid, 4-layer",+ "category": "board",+ "model_3d_path": "amfm-receiver-molecule.glb"+ },+ "dependencies": {+ "adom/samsung-cl21a-x5r-0805": "^0.1.0",+ "adom/cc0603-100nf": "^0.1.0",+ "adom/cl10a105kb8nnnc": "^1.0.0",+ "adom/fenghua-0603-mlcc": "^0.1.0",+ "adom/cl10c180jb8nnnc": "^1.0.0",+ "adom/murata-grm18-gcm18-c0g-0603": "^0.1.0",+ "adom/samsung-cl10c-c0g-0603": "^0.1.0",+ "adom/cl10b102kb8nnnc": "^1.0.0",+ "adom/yageo-cc0603-npo": "^0.1.0",+ "adom/0603n222j500ct": "^0.1.0",+ "barrett-land/kt-0603r": "^0.1.0",+ "adom/b5819w": "^0.1.0",+ "adom/kt-0603g": "^0.1.0",+ "adom/kt-0603y": "^0.1.0",+ "barrett-land/kt-0603b": "^0.1.0",+ "adom/bb910": "^0.1.0",+ "adom/bat54ws-7-f": "^0.1.0",+ "adom/bat54s": "^0.1.0",+ "adom/nsmd050-33v": "^0.1.0",+ "adom/gz1608d601tf": "^0.1.0",+ "adom/hccf3-10-700-ltcvs3l": "^0.1.0",+ "adom/hccf2-455-000-ltwcgl": "^0.1.0",+ "adom/type-c-31-m-12": "^1.0.0",+ "adom/bwsma-kwe-z001": "^0.1.0",+ "adom/nlv32t-2r2j-pf": "^0.1.0",+ "adom/yscm322522-8r2j": "^0.1.0",+ "adom/murata-lqw18an": "^0.1.0",+ "adom/mmbt3906": "^0.1.0",+ "adom/mmbth10": "^0.1.0",+ "adom/mmbt3904": "^0.1.0",+ "adom/bc847bs": "^0.1.0",+ "adom/0603waf5101t5e": "^1.0.0",+ "adom/0603waf1002t5e": "^1.0.0",+ "adom/uniroyal-0603waf": "^0.1.0",+ "adom/0603waf1001t5e": "^1.0.0",+ "adom/0603waf1000t5e": "^1.0.0",+ "adom/0603waf4701t5e": "^1.0.0",+ "adom/0603waf4702t5e": "^1.0.0",+ "adom/0603waf5102t5e": "^1.0.0",+ "ray/usblc6-2sc6": "^0.1.0",+ "adom/ap2112k-3-3trg1": "^1.0.0",+ "adom/stm32g0b1ccu7": "^0.1.0",+ "adom/tlv9062idr": "^0.1.0",+ "adom/sx32y040000bc1t001": "^0.1.0",+ "adom/0132m4-10-240f12dzng": "^0.1.0",+ "adom/machinecontactmedium": "^1.2.3",+ "adom/machinepinlargestandard": "^1.2.3"+ },+ "hero": {+ "type": "image",+ "path": "docs/hero.png"+ },+ "visibility": "public",+ "files": [+ "LICENSE",+ "README.md",+ "amfm-receiver-molecule.glb",+ "amfm-receiver-molecule_footprint.json",+ "amfm-receiver-molecule_symbol.json",+ "docs/hero.png",+ "fab/amfm_receiver_BOM.csv",+ "fab/amfm_receiver_BOM_JLCPCB.csv",+ "fab/amfm_receiver_CPL_JLCPCB.csv",+ "fab/amfm_receiver_gerbers.zip",+ "fab/amfm_receiver_pos.csv",+ "fab/amfm_receiver_schematic.pdf",+ "kicad/am_frontend.kicad_sch",+ "kicad/am_if.kicad_sch",+ "kicad/amfm_receiver.kicad_pcb",+ "kicad/amfm_receiver.kicad_pro",+ "kicad/amfm_receiver.kicad_sch",+ "kicad/fm_frontend.kicad_sch",+ "kicad/fm_if.kicad_sch",+ "kicad/fp-lib-table",+ "kicad/if_10m7.kicad_sch",+ "kicad/lib/AMFM.3dshapes/HCCF2_SMD-4P_11.9x6.5mm.step",+ "kicad/lib/AMFM.3dshapes/HCCF3_SMD-3P_7.0x3.0mm.step",+ "kicad/lib/AMFM.3dshapes/SMA_BWSMA-KWE-Z001.step",+ "kicad/lib/AMFM.3dshapes/UFQFPN-48_7x7mm_P0.5mm.step",+ "kicad/lib/AMFM.3dshapes/USB_C_HRO_TYPE-C-31-M-12.step",+ "kicad/lib/AMFM.kicad_sym",+ "kicad/lib/AMFM.pretty/HCCF2_SMD-4P_11.9x6.5mm.kicad_mod",+ "kicad/lib/AMFM.pretty/HCCF3_SMD-3P_7.0x3.0mm.kicad_mod",+ "kicad/lib/AMFM.pretty/LTM455xW_5pin.kicad_mod",+ "kicad/lib/AMFM.pretty/Murata_SFELF_3pin_P2.50mm.kicad_mod",+ "kicad/lib/AMFM.pretty/QFN-48-1EP_7x7mm_P0.5mm_EP5.6x5.6mm_ThermalVias0.3.kicad_mod",+ "kicad/lib/AMFM.pretty/SMA_BAT_Wireless_BWSMA-KWE-Z001_Edge.kicad_mod",+ "kicad/lib/AMFM.pretty/SW_XKB_TS-1187A_5.1x5.1mm.kicad_mod",+ "kicad/lib/AMFM.pretty/USB_C_Receptacle_HRO_TYPE-C-31-M-12_EdgeMount.kicad_mod",+ "kicad/lib/AdomMachine.3dshapes/MachineContactMedium.step",+ "kicad/lib/AdomMachine.3dshapes/MachinePinLargeStandard.step",+ "kicad/lib/AdomMachine.3dshapes/MachinePinMediumStandard.step",+ "kicad/lib/AdomMachine.kicad_sym",+ "kicad/lib/AdomMachine.pretty/MachineContactMedium.kicad_mod",+ "kicad/lib/AdomMachine.pretty/MachinePinLargeStandard.kicad_mod",+ "kicad/lib/AdomMachine.pretty/MachinePinMediumStandard.kicad_mod",+ "kicad/molecule_io.kicad_sch",+ "kicad/power_mcu.kicad_sch",+ "kicad/sym-lib-table",+ "kicad/tuning_ui.kicad_sch",+ "tools/audit_jlc.py",+ "tools/check_sch.py",+ "tools/design.py",+ "tools/drc_report.py",+ "tools/floorplan.py",+ "tools/gen_pcb.py",+ "tools/gen_sch.py",+ "tools/jlc.py",+ "tools/make_bom.py",+ "tools/make_fab.sh",+ "tools/make_jlc.py",+ "tools/make_lib.py",+ "tools/pcbgeom.py",+ "tools/placer.py",+ "tools/route_fix.py",+ "tools/schgen.py",+ "tools/select_parts.py",+ "tools/sexp.py",+ "tools/symlib.py",+ "verification.json",+ "package.json",+ "page.json"+ ]+}
page.json+115−73@@ -1,76 +1,118 @@ {- "schema_version": 1,- "slug": "amfm-receiver-molecule",- "type": "component",- "kind": "molecule",- "title": "AM/FM Receiver Molecule",- "brief": "USB-powered AM/FM receiver on an Adom Molecule: discrete BJT and op-amp superhet (10.7 MHz and 455 kHz IFs), STM32G0B1 tuning, self-calibration and USB audio over one USB-C cable. 96 x 96 mm pin grid, 4 layers, KiCad 10.",- "version": "0.1.0",- "because": "No wiki page holds this board: it is a new design (the AM/FM receiver built in this project), and its parts link to their own component pages.",- "hero": {- "type": "billboard",- "headline": "AM/FM Receiver Molecule",- "subhead": "Discrete superhet radio with STM32 USB audio, tuning and DFU over one USB-C cable",- "screenshot": "docs/hero.png",- "chrome": false+ "schema_version": 1,+ "type": "hardware",+ "kind": "molecule",+ "slug": "amfm-receiver-molecule",+ "title": "AM/FM Receiver Molecule",+ "brief": "USB-powered AM/FM receiver on an Adom Molecule: discrete BJT and op-amp superhet (10.7 MHz and 455 kHz IFs), STM32G0B1 tuning, self-calibration and USB audio over one USB-C cable. 96 x 96 mm pin grid, 4 layers, KiCad 10.",+ "version": "0.1.0",+ "license": "CC-BY-SA-4.0",+ "tags": [+ "molecule",+ "board",+ "kicad",+ "radio",+ "am",+ "fm",+ "superhet",+ "stm32g0b1",+ "usb-audio",+ "adom-machine-pins",+ "jlcpcb"+ ],+ "component": {+ "mpn": "amfm-receiver-molecule",+ "manufacturer": "Adom",+ "package": "PCBA 102.5 x 102.5 mm, 96 x 96 mm large-pin grid, 4-layer",+ "category": "board",+ "model_3d_path": "amfm-receiver-molecule.glb",+ "parts": {+ "model_3d": "amfm-receiver-molecule.glb",+ "footprint_json": "amfm-receiver-molecule_footprint.json",+ "symbol_json": "amfm-receiver-molecule_symbol.json",+ "schematic": "fab/amfm_receiver_schematic.pdf",+ "board": "kicad/amfm_receiver.kicad_pcb",+ "gerbers": "fab/amfm_receiver_gerbers.zip",+ "bom": "fab/amfm_receiver_BOM_JLCPCB.csv",+ "cpl": "fab/amfm_receiver_CPL_JLCPCB.csv" },- "component": {- "mpn": "amfm-receiver-molecule",- "manufacturer": "Adom",- "package": "PCBA 102.5 x 102.5 mm, 96 x 96 mm large-pin grid, 4-layer",- "pin_count": null,- "category": "board",- "subcategory": "",- "body_size": null,- "parts": {- "model_3d": "amfm-receiver-molecule.glb",- "footprint_json": "amfm-receiver-molecule_footprint.json",- "symbol_json": "amfm-receiver-molecule_symbol.json",- "schematic": "fab/amfm_receiver_schematic.pdf",- "board": "kicad/amfm_receiver.kicad_pcb",- "gerbers": "fab/amfm_receiver_gerbers.zip",- "bom": "fab/amfm_receiver_BOM_JLCPCB.csv",- "cpl": "fab/amfm_receiver_CPL_JLCPCB.csv"- },- "distributor_links": {},- "model_3d_path": "amfm-receiver-molecule.glb",- "source": {- "tool": "kicad",- "version": "10.0"- }- },- "tags": [- "molecule",- "board",- "kicad",- "radio",- "am",- "fm",- "superhet",- "stm32g0b1",- "usb-audio",- "adom-machine-pins",- "jlcpcb"- ],- "discovery_triggers": [- "am fm receiver",- "am/fm radio molecule",- "discrete superhet radio",- "stm32 usb audio radio",- "fm radio pcb"- ],- "license": "CC-BY-SA-4.0",- "visibility": {- "public": true- },- "author": {- "name": "noah",- "email": "[email protected]"- },- "sample_prompts": [],- "discovery_pitch": null,- "metadata": {},- "created_at": "2026-10-08T16:04:27.311Z",- "updated_at": "2026-10-08T16:04:27.311Z",- "org": "adom"+ "source": {+ "tool": "kicad",+ "version": "10.0"+ }+ },+ "discovery_triggers": [+ "am fm receiver",+ "am/fm radio molecule",+ "discrete superhet radio",+ "stm32 usb audio radio",+ "fm radio pcb"+ ],+ "discovery_pitch": "AM/FM receiver molecule: discrete superhet radio with STM32 USB audio, tuning and DFU over one USB-C cable.",+ "author": {+ "name": "Noah",+ "email": "[email protected]"+ },+ "dependencies": {+ "adom/samsung-cl21a-x5r-0805": "^0.1.0",+ "adom/cc0603-100nf": "^0.1.0",+ "adom/cl10a105kb8nnnc": "^1.0.0",+ "adom/fenghua-0603-mlcc": "^0.1.0",+ "adom/cl10c180jb8nnnc": "^1.0.0",+ "adom/murata-grm18-gcm18-c0g-0603": "^0.1.0",+ "adom/samsung-cl10c-c0g-0603": "^0.1.0",+ "adom/cl10b102kb8nnnc": "^1.0.0",+ "adom/yageo-cc0603-npo": "^0.1.0",+ "adom/0603n222j500ct": "^0.1.0",+ "barrett-land/kt-0603r": "^0.1.0",+ "adom/b5819w": "^0.1.0",+ "adom/kt-0603g": "^0.1.0",+ "adom/kt-0603y": "^0.1.0",+ "barrett-land/kt-0603b": "^0.1.0",+ "adom/bb910": "^0.1.0",+ "adom/bat54ws-7-f": "^0.1.0",+ "adom/bat54s": "^0.1.0",+ "adom/nsmd050-33v": "^0.1.0",+ "adom/gz1608d601tf": "^0.1.0",+ "adom/hccf3-10-700-ltcvs3l": "^0.1.0",+ "adom/hccf2-455-000-ltwcgl": "^0.1.0",+ "adom/type-c-31-m-12": "^1.0.0",+ "adom/bwsma-kwe-z001": "^0.1.0",+ "adom/nlv32t-2r2j-pf": "^0.1.0",+ "adom/yscm322522-8r2j": "^0.1.0",+ "adom/murata-lqw18an": "^0.1.0",+ "adom/mmbt3906": "^0.1.0",+ "adom/mmbth10": "^0.1.0",+ "adom/mmbt3904": "^0.1.0",+ "adom/bc847bs": "^0.1.0",+ "adom/0603waf5101t5e": "^1.0.0",+ "adom/0603waf1002t5e": "^1.0.0",+ "adom/uniroyal-0603waf": "^0.1.0",+ "adom/0603waf1001t5e": "^1.0.0",+ "adom/0603waf1000t5e": "^1.0.0",+ "adom/0603waf4701t5e": "^1.0.0",+ "adom/0603waf4702t5e": "^1.0.0",+ "adom/0603waf5102t5e": "^1.0.0",+ "ray/usblc6-2sc6": "^0.1.0",+ "adom/ap2112k-3-3trg1": "^1.0.0",+ "adom/stm32g0b1ccu7": "^0.1.0",+ "adom/tlv9062idr": "^0.1.0",+ "adom/sx32y040000bc1t001": "^0.1.0",+ "adom/0132m4-10-240f12dzng": "^0.1.0",+ "adom/machinecontactmedium": "^1.2.3",+ "adom/machinepinlargestandard": "^1.2.3"+ },+ "hero": {+ "type": "image",+ "path": "docs/hero.png"+ },+ "sections": [+ "hero",+ "viewers",+ "readme",+ "deps",+ "breadcrumbs",+ "info"+ ],+ "visibility": "public" }
tools/schgen.pyadded+373@@ -0,0 +1,373 @@+"""KiCad 10 schematic writer: places symbols, wire stubs, labels, power ports.++Coordinates are in mm, schematic space (+y down). All placement is snapped to+the 1.27 mm grid so that KiCad sees the connections.+"""+import math, uuid as _uuid+from collections import OrderedDict+from sexp import Sym, dumps, find, findall+import symlib++S = Sym+GRID = 1.27+NS = _uuid.UUID('6f1d3c2a-9a51-4a0e-bb52-2f0c0de4a7f0')+++def uid(*parts):+ return str(_uuid.uuid5(NS, '/'.join(str(p) for p in parts)))+++def snap(v):+ return round(round(v / GRID) * GRID, 4)+++def rot(dx, dy, r):+ """Rotate a screen-space vector by r degrees counter-clockwise (visual)."""+ r %= 360+ if r == 0:+ return dx, dy+ if r == 90:+ return dy, -dx+ if r == 180:+ return -dx, -dy+ if r == 270:+ return -dy, dx+ raise ValueError(r)+++def font(size=1.27, bold=False, italic=False):+ f = [S('font'), [S('size'), size, size]]+ if bold:+ f.append([S('bold'), S('yes')])+ if italic:+ f.append([S('italic'), S('yes')])+ return f+++def effects(size=1.27, justify=None, bold=False):+ e = [S('effects'), font(size, bold)]+ if justify:+ e.append([S('justify')] + [S(j) for j in justify.split()])+ return e+++def prop(name, value, x, y, angle=0, hide=False, justify=None, size=1.27):+ p = [S('property'), name, value, [S('at'), x, y, angle]]+ if hide:+ p.append([S('hide'), S('yes')])+ p.append(effects(size, justify))+ return p+++POWER_UP = {'+5V', '+3V3', 'VBUS', '+5VA', '+3.3VA'}+POWER_DOWN = {'GND'}+POWER_LIB = {'+5V': 'power:+5V', '+3V3': 'power:+3V3', 'VBUS': 'power:VBUS',+ '+5VA': 'power:+5VA', '+3.3VA': 'power:+3.3VA', 'GND': 'power:GND'}++++DIRV = {'R': (1, 0), 'L': (-1, 0), 'U': (0, -1), 'D': (0, 1)}+++def plan_runs(part, pins_here, net_of_pin, stub):+ """Return (no_connect points, runs). run = (side, members, stub_len, stub_ends, anchor, net)."""+ by_loc = OrderedDict()+ for p, ax, ay, d in pins_here:+ by_loc.setdefault((ax, ay), []).append((p, d))+ ends, ncs = [], []+ for (ax, ay), plist in by_loc.items():+ nets = set()+ for p, d in plist:+ n = net_of_pin.get(p['number'])+ if n is not None:+ nets.add(n)+ visible = [pd for pd in plist if not pd[0]['hidden']]+ if len(nets) > 1:+ raise ValueError('%s: stacked pins at same location on different nets %s' % (part.ref, nets))+ if not visible:+ if nets:+ raise ValueError('%s: hidden pin(s) %s carry net %s but have no visible partner'+ % (part.ref, [p['number'] for p, _ in plist], nets))+ continue+ p, d = visible[0]+ if not nets:+ if p['type'] != 'no_connect':+ ncs.append((ax, ay))+ continue+ ends.append((ax, ay, d, nets.pop()))+ runs = []+ for side in ('L', 'R', 'U', 'D'):+ sp = [e for e in ends if e[2] == side]+ if not sp:+ continue+ along = (lambda e: e[1]) if side in ('L', 'R') else (lambda e: e[0])+ perp = (lambda e: e[0]) if side in ('L', 'R') else (lambda e: e[1])+ sp.sort(key=lambda e: (perp(e), along(e)))+ cur = []+ for e in sp:+ if cur and cur[-1][3] == e[3] and perp(cur[-1]) == perp(e) and \+ abs(along(e) - along(cur[-1])) <= 2.54 + 1e-6:+ cur.append(e)+ else:+ if cur:+ runs.append([side, cur])+ cur = [e]+ if cur:+ runs.append([side, cur])+ out = []+ for side in ('L', 'R', 'U', 'D'):+ rs = [r for r in runs if r[0] == side]+ along = (lambda e: e[1]) if side in ('L', 'R') else (lambda e: e[0])+ rs.sort(key=lambda r: min(along(e) for e in r[1]))+ prev_end, prev_long = None, False+ for side_, members in rs:+ net = members[0][3]+ is_pwr = net in POWER_LIB+ lo = min(along(e) for e in members)+ hi = max(along(e) for e in members)+ long_ = bool(is_pwr and prev_end is not None and lo - prev_end <= 5.08 + 1e-6 and not prev_long)+ L = stub * 3 if long_ else stub+ if is_pwr:+ prev_end, prev_long = hi, long_+ else:+ prev_end, prev_long = None, False+ dx, dy = DIRV[side]+ stub_ends = [(snap(m[0] + dx * L), snap(m[1] + dy * L)) for m in members]+ if side in ('L', 'R'):+ anchor = min(stub_ends, key=lambda t: t[1])+ else:+ anchor = min(stub_ends, key=lambda t: t[0])+ out.append((side, members, L, stub_ends, anchor, net))+ return ncs, out+++class LibCache:+ def __init__(self, extra_libs=None):+ self.extra = extra_libs or {}+ self.syms = {}++ def get(self, lib_id):+ if lib_id not in self.syms:+ self.syms[lib_id] = symlib.get_symbol(lib_id, self.extra)+ return self.syms[lib_id]++ def pins(self, lib_id):+ return symlib.pins(self.get(lib_id))++ def lib_entry(self, lib_id):+ """Symbol definition for the schematic's lib_symbols section."""+ import copy+ s = copy.deepcopy(self.get(lib_id))+ s[1] = lib_id+ return s+++class SheetWriter:+ def __init__(self, project, root_uuid, sheet_path, file_uuid, libs, title='', paper='A3',+ page_label=''):+ self.project = project+ self.root_uuid = root_uuid+ self.sheet_path = sheet_path # e.g. "/<root>/<sheet>" or "/<root>"+ self.file_uuid = file_uuid+ self.libs = libs+ self.items = []+ self.used = set()+ self.title = title+ self.paper = paper+ self.page_label = page_label+ self.pwr_count = 0+ self.flag_count = 0+ self.global_nets = set()+ self.pin_net_seen = {} # debug: (ref, pin) -> net+ self.label_points = []++ # ---------------------------------------------------------------- primitives+ def wire(self, x1, y1, x2, y2):+ self.items.append([S('wire'), [S('pts'), [S('xy'), x1, y1], [S('xy'), x2, y2]],+ [S('stroke'), [S('width'), 0], [S('type'), S('default')]],+ [S('uuid'), uid(self.file_uuid, 'w', x1, y1, x2, y2)]])++ def no_connect(self, x, y):+ self.items.append([S('no_connect'), [S('at'), x, y], [S('uuid'), uid(self.file_uuid, 'nc', x, y)]])++ def text(self, s, x, y, size=1.27, bold=False, angle=0):+ self.items.append([S('text'), s, [S('exclude_from_sim'), S('no')], [S('at'), x, y, angle],+ effects(size, 'left bottom', bold), [S('uuid'), uid(self.file_uuid, 't', s, x, y)]])++ def rect(self, x1, y1, x2, y2, dash=True):+ self.items.append([S('rectangle'), [S('start'), x1, y1], [S('end'), x2, y2],+ [S('stroke'), [S('width'), 0.254], [S('type'), S('dash' if dash else 'default')]],+ [S('fill'), [S('type'), S('none')]],+ [S('uuid'), uid(self.file_uuid, 'r', x1, y1, x2, y2)]])++ def label(self, net, x, y, direction, is_global):+ """direction: outward screen direction 'R','L','U','D' of the stub end."""+ angle = {'R': 0, 'U': 90, 'L': 180, 'D': 270}[direction]+ just = {'R': 'left', 'U': 'left', 'L': 'right', 'D': 'right'}[direction]+ self.label_points.append((x, y, net))+ if is_global:+ self.global_nets.add(net)+ self.items.append([S('global_label'), net, [S('shape'), S('bidirectional')],+ [S('at'), x, y, angle], [S('fields_autoplaced'), S('yes')],+ effects(1.27, just), [S('uuid'), uid(self.file_uuid, 'gl', net, x, y)],+ [S('property'), 'Intersheetrefs', '${INTERSHEET_REFS}', [S('at'), x, y, 0],+ [S('hide'), S('yes')], effects(1.27, just)]])+ else:+ self.items.append([S('label'), net, [S('at'), x, y, angle], [S('fields_autoplaced'), S('yes')],+ effects(1.27, just + ' bottom'), [S('uuid'), uid(self.file_uuid, 'l', net, x, y)]])++ def power_port(self, net, x, y, direction):+ lib_id = POWER_LIB[net]+ self.used.add(lib_id)+ self.pwr_count += 1+ ref = '#PWR%s%03d' % (self.page_label, self.pwr_count)+ # natural orientation (supply up, ground down) unless that would fold back onto the stub+ if net in POWER_DOWN:+ r = 180 if direction == 'U' else 0+ else:+ r = 180 if direction == 'D' else 0+ su = uid(self.file_uuid, 'pwr', ref)+ # value text placed beyond the symbol+ vx, vy = rot(0, -3.2 if net not in POWER_DOWN else 3.8, r)+ self.items.append([S('symbol'), [S('lib_id'), lib_id], [S('at'), x, y, r], [S('unit'), 1],+ [S('exclude_from_sim'), S('no')], [S('in_bom'), S('yes')], [S('on_board'), S('yes')],+ [S('dnp'), S('no')], [S('uuid'), su],+ prop('Reference', ref, x, y, 0, hide=True),+ prop('Value', net, snap(x + vx), snap(y + vy), 0),+ prop('Footprint', '', x, y, 0, hide=True),+ prop('Datasheet', '', x, y, 0, hide=True),+ prop('Description', '', x, y, 0, hide=True),+ [S('pin'), '1', [S('uuid'), uid(su, 'p1')]],+ [S('instances'), [S('project'), self.project,+ [S('path'), self.sheet_path, [S('reference'), ref], [S('unit'), 1]]]]])++ def pwr_flag(self, net, x, y):+ """PWR_FLAG attached by a short stub + power port / label."""+ self.used.add('power:PWR_FLAG')+ self.flag_count += 1+ ref = '#FLG%s%02d' % (self.page_label, self.flag_count)+ su = uid(self.file_uuid, 'flg', ref)+ self.items.append([S('symbol'), [S('lib_id'), 'power:PWR_FLAG'], [S('at'), x, y, 0], [S('unit'), 1],+ [S('exclude_from_sim'), S('no')], [S('in_bom'), S('yes')], [S('on_board'), S('yes')],+ [S('dnp'), S('no')], [S('uuid'), su],+ prop('Reference', ref, x, y - 5, 0, hide=True),+ prop('Value', 'PWR_FLAG', x, y - 3.8, 0),+ prop('Footprint', '', x, y, 0, hide=True),+ prop('Datasheet', '', x, y, 0, hide=True),+ prop('Description', '', x, y, 0, hide=True),+ [S('pin'), '1', [S('uuid'), uid(su, 'p1')]],+ [S('instances'), [S('project'), self.project,+ [S('path'), self.sheet_path, [S('reference'), ref], [S('unit'), 1]]]]])+ # stub down to the net connection+ self.wire(x, y, x, y + 2.54)+ self.attach_net(net, x, y + 2.54, 'D', is_global=True)++ def attach_net(self, net, x, y, direction, is_global):+ if net in POWER_LIB:+ self.power_port(net, x, y, direction)+ else:+ self.label(net, x, y, direction, is_global)++ # ---------------------------------------------------------------- symbols+ def pin_geometry(self, lib_id, unit, x, y, r):+ """Yield (pin dict, abs_x, abs_y, outward_direction)."""+ out = []+ for p in self.libs.pins(lib_id):+ if p['unit'] not in (0, unit) or p['convert'] not in (0, 1):+ continue+ dx, dy = rot(p['x'], -p['y'], r)+ ax, ay = snap(x + dx), snap(y + dy)+ a = math.radians(p['angle'] + 180)+ ox, oy = rot(round(math.cos(a)), -round(math.sin(a)), r)+ d = {(1, 0): 'R', (-1, 0): 'L', (0, -1): 'U', (0, 1): 'D'}[(ox, oy)]+ out.append((p, ax, ay, d))+ return out++ def place(self, part, unit, x, y, r, net_of_pin, is_global, stub=2.54, ref_pos=None, val_pos=None,+ show_value=True):+ """Place a symbol unit and wire every visible pin to its net (label/power port) or NC flag."""+ lib_id = part.lib_id+ self.used.add(lib_id)+ su = uid(self.file_uuid, 'sym', part.ref, unit)+ # Property text positions (default: to the right of the symbol)+ rx, ry, ra, rj = ref_pos if ref_pos else (x + 3.0, y - 1.3, 0, 'left')+ vx, vy, va, vj = val_pos if val_pos else (x + 3.0, y + 1.3, 0, 'left')+ props = [prop('Reference', part.ref, round(rx, 3), round(ry, 3), ra, justify=rj),+ prop('Value', part.value, round(vx, 3), round(vy, 3), va, justify=vj, hide=not show_value),+ prop('Footprint', part.footprint, x, y, 0, hide=True),+ prop('Datasheet', part.datasheet or '', x, y, 0, hide=True),+ prop('Description', part.description or '', x, y, 0, hide=True)]+ for k, v in part.fields.items():+ props.append(prop(k, v, x, y, 0, hide=True))+ pins_here = self.pin_geometry(lib_id, unit, x, y, r)+ pin_entries = []+ seen_numbers = set()+ for p, ax, ay, d in pins_here:+ if p['number'] not in seen_numbers:+ pin_entries.append([S('pin'), p['number'], [S('uuid'), uid(su, 'pin', p['number'])]])+ seen_numbers.add(p['number'])+ self.items.append([S('symbol'), [S('lib_id'), lib_id], [S('at'), x, y, r], [S('unit'), unit],+ [S('exclude_from_sim'), S('no')], [S('in_bom'), S('yes' if part.in_bom else 'no')],+ [S('on_board'), S('yes')], [S('dnp'), S('no')],+ [S('uuid'), su]] + props + pin_entries ++ [[S('instances'), [S('project'), self.project,+ [S('path'), self.sheet_path, [S('reference'), part.ref],+ [S('unit'), unit]]]]])+ self.connect_pins(part, pins_here, net_of_pin, is_global, stub)+ return pins_here++ def connect_pins(self, part, pins_here, net_of_pin, is_global, stub):+ ncs, runs = plan_runs(part, pins_here, net_of_pin, stub)+ for ax, ay in ncs:+ self.no_connect(ax, ay)+ for side, members, L, stub_ends, (ex, ey), net in runs:+ for (ax, ay, d, n), (sx, sy) in zip(members, [(snap(m[0] + DIRV[side][0] * L),+ snap(m[1] + DIRV[side][1] * L)) for m in members]):+ self.wire(ax, ay, sx, sy)+ if len(stub_ends) > 1:+ se = sorted(stub_ends)+ for (x1, y1), (x2, y2) in zip(se, se[1:]):+ self.wire(x1, y1, x2, y2)+ for jx, jy in se[1:-1]:+ self.junction(jx, jy)+ if net in POWER_LIB:+ self.power_port(net, ex, ey, side)+ else:+ self.label(net, ex, ey, side if side in ('L', 'R') else 'R', is_global(net))++ def junction(self, x, y):+ self.items.append([S('junction'), [S('at'), x, y], [S('diameter'), 0], [S('color'), 0, 0, 0, 0],+ [S('uuid'), uid(self.file_uuid, 'j', x, y)]])++ # ---------------------------------------------------------------- output+ def sheet_symbol(self, name, fname, x, y, w, h, sheet_uuid, page):+ self.items.append([S('sheet'), [S('at'), x, y], [S('size'), w, h],+ [S('exclude_from_sim'), S('no')], [S('in_bom'), S('yes')], [S('on_board'), S('yes')],+ [S('dnp'), S('no')], [S('fields_autoplaced'), S('yes')],+ [S('stroke'), [S('width'), 0.1524], [S('type'), S('solid')]],+ [S('fill'), [S('color'), 0, 0, 0, 0.0]],+ [S('uuid'), sheet_uuid],+ [S('property'), 'Sheetname', name, [S('at'), x, snap(y - 0.7) if False else y - 0.7112, 0],+ effects(1.27, 'left bottom')],+ [S('property'), 'Sheetfile', fname, [S('at'), x, y + h + 0.5938, 0],+ effects(1.27, 'left top')],+ [S('instances'), [S('project'), self.project,+ [S('path'), '/' + self.root_uuid, [S('page'), str(page)]]]]])++ def render(self, is_root=False, title_block=None):+ head = [S('kicad_sch'), [S('version'), 20251024], [S('generator'), 'eeschema'],+ [S('generator_version'), '10.0'], [S('uuid'), self.file_uuid], [S('paper'), self.paper]]+ if title_block:+ tb = [S('title_block')]+ for k in ('title', 'date', 'rev', 'company'):+ if title_block.get(k):+ tb.append([S(k), title_block[k]])+ for i, c in enumerate(title_block.get('comments', []), 1):+ tb.append([S('comment'), i, c])+ head.append(tb)+ libsec = [S('lib_symbols')] + [self.libs.lib_entry(l) for l in sorted(self.used)]+ body = head + [libsec] + self.items+ if is_root:+ body.append([S('sheet_instances'), [S('path'), '/', [S('page'), '1']]])+ body.append([S('embedded_fonts'), S('no')])+ return dumps(body) + '\n'
tools/select_parts.pyadded+137@@ -0,0 +1,137 @@+"""Pick a JLCPCB (LCSC) part for every passive BOM group, with value/package/dielectric checks."""+import json, os, re, sys+from collections import OrderedDict+from design import DESIGN+import jlc++OUT = os.path.join(os.path.dirname(os.path.abspath(__file__)), '..', 'build', 'jlc_passives.json')++# capacitors that must be C0G/NP0 even above 10 nF (none) / may be X7R even below (coupling / decoupling)+C0G_MAX = 10e-9 # every capacitor <= 10 nF is C0G/NP0 unless listed below+X7R_OK_SMALL = {'10n'} # 10 nF parts are coupling/bypass: X7R fine+++def val_to_float(v, unit):+ m = re.match(r'^([\d.]+)([pnumkM]?)$', v)+ mult = {'p': 1e-12, 'n': 1e-9, 'u': 1e-6, 'm': 1e-3, 'k': 1e3, 'M': 1e6, '': 1.0}+ return float(m.group(1)) * mult[m.group(2)]+++def jlc_value_text(v, unit):+ """Design value ('4.7k', '100n') -> JLC attribute text ('4.7kΩ', '100nF')."""+ if unit == 'Ω':+ return v + 'Ω'+ if unit == 'F':+ return v.replace('u', 'u') + 'F'+ if unit == 'H':+ return v + 'H'+ return v+++def attr(p, *names):+ for n in names:+ if n in p['attrs']:+ return p['attrs'][n]+ return None+++def voltage(p):+ v = attr(p, 'Voltage Rating', 'Rated Voltage')+ m = re.search(r'([\d.]+)\s*V', v or p['describe'])+ return float(m.group(1)) if m else 0.0+++def rank(p):+ return (p['type'] == 'base', p['preferred'], p['stock'])+++def pick_resistor(value, pkg='0603'):+ want = jlc_value_text(value, 'Ω')+ cands = []+ for q in ('%s %s' % (want, pkg), '%s %s resistor 1%%' % (want, pkg)):+ for p in jlc.search(q, size=50):+ if p['package'] != pkg or 'Resistor' not in (p['category'] or ''):+ continue+ if attr(p, 'Resistance') != want or p['stock'] < 500:+ continue+ if '±1%' not in p['describe']:+ continue+ cands.append(p)+ return sorted({c['code']: c for c in cands}.values(), key=rank, reverse=True)+++def pick_capacitor(value, pkg, need_c0g, vmin):+ want = jlc_value_text(value, 'F')+ cands = []+ for q in ('%s %s' % (want, pkg), '%s %s %s' % (want, pkg, 'C0G' if need_c0g else 'X7R')):+ for p in jlc.search(q, size=50):+ if p['package'] != pkg or 'Capacitor' not in (p['category'] or ''):+ continue+ if attr(p, 'Capacitance') != want or p['stock'] < 500:+ continue+ d = p['describe']+ diel = 'C0G' in d or 'NP0' in d or 'NPO' in d+ if need_c0g and not diel:+ continue+ if not need_c0g and not (diel or 'X7R' in d or 'X5R' in d):+ continue+ if voltage(p) < vmin:+ continue+ cands.append(p)+ return sorted({c['code']: c for c in cands}.values(), key=rank, reverse=True)+++def pick_inductor(value, pkg, wirewound):+ want = jlc_value_text(value, 'H')+ cands = []+ for q in ('%s %s' % (want, pkg), '%s %s inductor' % (want, pkg)):+ for p in jlc.search(q, size=50):+ if p['package'] != pkg or 'nductor' not in (p['category'] or '') + p['describe']:+ continue+ if attr(p, 'Inductance') != want and (' ' + want + ' ') not in (' ' + p['describe'] + ' '):+ continue+ if p['stock'] < 100:+ continue+ if wirewound and not ('LQW' in (p['mpn'] or '') or 'ire' in p['describe'] or 'CS' in (p['mpn'] or '')):+ continue+ cands.append(p)+ return sorted({c['code']: c for c in cands}.values(), key=rank, reverse=True)+++def groups():+ g = OrderedDict()+ for p in DESIGN.parts:+ if not p.in_bom:+ continue+ g.setdefault((p.lib_id, p.value, p.footprint), []).append(p)+ return g+++def main():+ res = OrderedDict()+ for (lib, value, fp), parts in groups().items():+ refs = [p.ref for p in parts]+ pkg = re.search(r'_(\d{4})_', fp)+ pkg = pkg.group(1) if pkg else None+ notes = ' '.join(p.description for p in parts)+ if lib == 'Device:R':+ c = pick_resistor(value, pkg)+ elif lib == 'Device:C':+ f = val_to_float(value, 'F')+ need_c0g = f <= C0G_MAX and value not in X7R_OK_SMALL+ vmin = 16 if f < 4e-6 else 10+ c = pick_capacitor(value, pkg, need_c0g, vmin)+ elif lib == 'Device:L':+ c = pick_inductor(value, pkg, wirewound=pkg == '0603')+ else:+ continue+ key = '%s|%s|%s' % (lib, value, fp)+ res[key] = dict(refs=refs, candidates=c[:4])+ best = c[0] if c else None+ print('%-8s %-6s %-5s x%-2d -> %s' % (lib.split(':')[1], value, pkg, len(refs),+ jlc.fmt(best) if best else 'NONE FOUND'))+ json.dump(res, open(OUT, 'w'), indent=1)+++if __name__ == '__main__':+ main()
tools/sexp.pyadded+78@@ -0,0 +1,78 @@+"""Minimal S-expression reader/writer for KiCad files."""+import re++class Sym(str):+ """Unquoted atom (keyword / number / bare word)."""+ __slots__ = ()++_tok = re.compile(r'\s*(?:(\()|(\))|("(?:[^"\\]|\\.)*")|([^\s()"]+))', re.S)++def parse(text):+ pos = 0+ stack = [[]]+ n = len(text)+ while pos < n:+ m = _tok.match(text, pos)+ if not m:+ if text[pos:].strip() == '':+ break+ raise ValueError('bad token at %d: %r' % (pos, text[pos:pos+40]))+ pos = m.end()+ if m.group(1):+ stack.append([])+ elif m.group(2):+ lst = stack.pop()+ stack[-1].append(lst)+ elif m.group(3) is not None:+ s = m.group(3)[1:-1]+ s = s.replace('\\"', '"').replace('\\n', '\n').replace('\\\\', '\\')+ stack[-1].append(s)+ else:+ stack[-1].append(Sym(m.group(4)))+ assert len(stack) == 1, 'unbalanced'+ return stack[0][0] if len(stack[0]) == 1 else stack[0]++def q(s):+ return '"' + str(s).replace('\\', '\\\\').replace('"', '\\"').replace('\n', '\\n') + '"'++def dumps(node, indent=0):+ """Serialize: lists -> (..), Sym -> bare, str -> quoted, numbers -> bare."""+ if isinstance(node, list):+ if not node:+ return '()'+ # short lists on one line+ parts = [dumps(x, indent + 1) for x in node]+ oneline = '(' + ' '.join(parts) + ')'+ if len(oneline) < 100 and '\n' not in oneline:+ return oneline+ head = parts[0]+ out = '(' + head+ i = 1+ # keep leading atoms on the first line+ while i < len(node) and not isinstance(node[i], list):+ out += ' ' + parts[i]+ i += 1+ for p in parts[i:]:+ out += '\n' + '\t' * (indent + 1) + p+ out += '\n' + '\t' * indent + ')'+ return out+ if isinstance(node, Sym):+ return str(node)+ if isinstance(node, bool):+ return 'yes' if node else 'no'+ if isinstance(node, int):+ return str(node)+ if isinstance(node, float):+ s = ('%.6f' % node).rstrip('0').rstrip('.')+ return s if s not in ('-0', '') else '0'+ return q(node)++def find(node, key):+ """First child list whose head == key."""+ for x in node:+ if isinstance(x, list) and x and x[0] == key:+ return x+ return None++def findall(node, key):+ return [x for x in node if isinstance(x, list) and x and x[0] == key]
tools/symlib.pyadded+87@@ -0,0 +1,87 @@+"""Load KiCad symbols from .kicad_sym libraries, flatten 'extends', report pins."""+import copy, os+from sexp import parse, find, findall, Sym++SYMDIR = '/usr/share/kicad/symbols'+_cache = {}++def load_lib(path):+ if path not in _cache:+ with open(path) as f:+ _cache[path] = parse(f.read())+ return _cache[path]++def get_symbol(lib_id, extra_libs=None):+ """Return flattened symbol s-expr (list) named like the lib item (no lib prefix)."""+ lib, name = lib_id.split(':')+ path = (extra_libs or {}).get(lib, os.path.join(SYMDIR, lib + '.kicad_sym'))+ root = load_lib(path)+ syms = {s[1]: s for s in findall(root, 'symbol')}+ s = copy.deepcopy(syms[name])+ ext = find(s, 'extends')+ if ext:+ parent = copy.deepcopy(syms[ext[1]])+ # parent may itself extend+ while find(parent, 'extends'):+ gp = copy.deepcopy(syms[find(parent, 'extends')[1]])+ parent = merge(gp, parent)+ s = merge(parent, s)+ return s++def merge(parent, child):+ """Child properties override parent's; graphics/pins from parent (units renamed)."""+ pname, cname = parent[1], child[1]+ out = [Sym('symbol'), cname]+ child_props = {p[1]: p for p in findall(child, 'property')}+ for item in parent[2:]:+ if isinstance(item, list) and item and item[0] == 'property':+ out.append(child_props.pop(item[1], item))+ elif isinstance(item, list) and item and item[0] == 'symbol':+ sub = copy.deepcopy(item)+ sub[1] = sub[1].replace(pname, cname, 1)+ out.append(sub)+ else:+ out.append(item)+ for p in child_props.values():+ out.append(p)+ # other child-level keys (e.g. in_bom) override+ for item in child[2:]:+ if isinstance(item, list) and item and item[0] not in ('property', 'extends', 'symbol'):+ for i, o in enumerate(out):+ if isinstance(o, list) and o and o[0] == item[0]:+ out[i] = item+ break+ return out++def pins(sym):+ """List of dicts: unit, convert, number, name, type, x, y, angle, length."""+ res = []+ for sub in findall(sym, 'symbol'):+ nm = sub[1]+ parts = nm.rsplit('_', 2)+ unit, conv = int(parts[-2]), int(parts[-1])+ for p in findall(sub, 'pin'):+ at = find(p, 'at')+ res.append(dict(unit=unit, convert=conv, type=str(p[1]), shape=str(p[2]),+ x=float(at[1]), y=float(at[2]), angle=float(at[3]) if len(at) > 3 else 0.0,+ length=float(find(p, 'length')[1]),+ name=find(p, 'name')[1], number=find(p, 'number')[1],+ hidden=any(isinstance(z, Sym) and z == 'hide' for z in p) or+ bool(find(p, 'hide') and find(p, 'hide')[1] == 'yes')))+ return res++def prop(sym, key):+ for p in findall(sym, 'property'):+ if p[1] == key:+ return p[2]+ return None++if __name__ == '__main__':+ import sys+ for lid in sys.argv[1:]:+ s = get_symbol(lid)+ print('==', lid, '| footprint:', prop(s, 'Footprint'), '| ref:', prop(s, 'Reference'))+ units = sorted(set(p['unit'] for p in pins(s)))+ for p in sorted(pins(s), key=lambda p: (p['unit'], int(p['number']) if p['number'].isdigit() else 999, p['number'])):+ print(' u%d c%d pin %-4s %-14s %-14s at(%7.2f,%7.2f) ang %3d len %.2f %s' % (+ p['unit'], p['convert'], p['number'], p['name'], p['type'], p['x'], p['y'], p['angle'], p['length'], 'HIDDEN' if p['hidden'] else ''))
verification.jsonadded+6@@ -0,0 +1,6 @@+{+ "schema": "adom/verification@1",+ "part": "AM/FM Receiver Molecule",+ "variants": [],+ "entries": []+}