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.
main
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697
"""JLCPCB assembly files: BOM (Comment/Designator/Footprint/LCSC Part #) and CPL (pick & place).
CPL rotations are converted from KiCad's footprint orientation to JLCPCB's (EasyEDA) orientation
with the JLCKicadTools correction table (matthewlai/JLCKicadTools, cpl_rotations_db.csv) plus a
rule for the EC11 encoder. Positions are the footprint origin (the body centre for SMD parts), or the pad
centre for through-hole parts whose origin is pin 1, measured from the board's drill/place origin
(MP1, the molecule's origin machine pin) (the drill/place origin used for the Gerbers), +Y up.
Always check the placement preview on the JLCPCB order page before paying.
"""
import csv, os, re, sys
from collections import OrderedDict
import pcbnew
from design import DESIGN
HERE = os.path.dirname(os.path.abspath(__file__))
PROJ = os.path.abspath(os.path.join(HERE, '..'))
FAB = os.path.join(PROJ, 'fab')
BOARD = os.path.join(PROJ, 'amfm_receiver.kicad_pcb')
ORIGIN = None # taken from the board's drill/place (aux) origin = MP1
# (footprint-name regex, rotation correction in degrees) - first match wins
ROTATION_DB = [
(r'^SOT-223', 180), (r'^SOT-23-5', -90), (r'^SOT-23-6', -90), (r'^SOT-23', -90), (r'^SOT-89', 180),
(r'^SOT-353', 180), (r'^SOT-363', 180), (r'^LQFP-', 270), (r'^TQFP-', 270), (r'^SOIC-', 270),
(r'^TSSOP-', 270), (r'^QFN-', 270), (r'^USB_C_Receptacle_HRO_TYPE-C-31-M-12', 180),
(r'^PinHeader_2x05_P1\.27mm_Vertical', 90),
(r'^RotaryEncoder_Alps_EC11E', -90), # KiCad Alps land pattern vs LCSC EC11 pattern
]
def correction(fpname):
for pat, rot in ROTATION_DB:
if re.search(pat, fpname):
return rot
return 0
def main():
os.makedirs(FAB, exist_ok=True)
parts = {p.ref: p for p in DESIGN.parts}
board = pcbnew.LoadBoard(BOARD)
ao = board.GetDesignSettings().GetAuxOrigin()
origin = (pcbnew.ToMM(ao.x), pcbnew.ToMM(ao.y))
rows = []
for fp in board.GetFootprints():
ref = fp.GetReference()
p = parts.get(ref)
if p is None or not p.in_bom or 'LCSC' not in p.fields:
continue # Adom machine pins/contacts are pressed in by the Adom Factory
pads = [q for q in fp.Pads() if q.GetAttribute() != pcbnew.PAD_ATTRIB_NPTH]
fpname0 = fp.GetFPID().GetLibItemName().wx_str()
pin1_origin = re.search(r'^(PinHeader_|RotaryEncoder_)', fpname0) is not None
if pads and pin1_origin: # origin at pin 1: use the centre of the pads
xs = [pcbnew.ToMM(q.GetPosition().x) for q in pads]
ys = [pcbnew.ToMM(q.GetPosition().y) for q in pads]
cx, cy = (min(xs) + max(xs)) / 2, (min(ys) + max(ys)) / 2
else:
pos = fp.GetPosition()
cx, cy = pcbnew.ToMM(pos.x), pcbnew.ToMM(pos.y)
fpname = fp.GetFPID().GetLibItemName().wx_str()
rot = (fp.GetOrientationDegrees() + correction(fpname)) % 360
rows.append((ref, cx - origin[0], origin[1] - cy, 'Top' if fp.GetLayer() == pcbnew.F_Cu else 'Bottom',
rot, fpname))
def natural(r):
m = re.match(r'([A-Z]+)(\d+)', r[0])
return (m.group(1), int(m.group(2)))
rows.sort(key=natural)
with open(os.path.join(FAB, 'amfm_receiver_CPL_JLCPCB.csv'), 'w', newline='') as f:
w = csv.writer(f)
w.writerow(['Designator', 'Mid X', 'Mid Y', 'Layer', 'Rotation'])
for ref, x, y, layer, rot, fpname in rows:
w.writerow([ref, '%.3fmm' % x, '%.3fmm' % y, layer, '%g' % round(rot, 3)])
# ---------------------------------------------------------------- BOM grouped by LCSC part
groups = OrderedDict()
for p in sorted(DESIGN.parts, key=lambda p: natural((p.ref,))):
if not p.in_bom or 'LCSC' not in p.fields:
continue
code = p.fields['LCSC']
g = groups.setdefault(code, dict(refs=[], parts=[]))
g['refs'].append(p.ref)
g['parts'].append(p)
with open(os.path.join(FAB, 'amfm_receiver_BOM_JLCPCB.csv'), 'w', newline='') as f:
w = csv.writer(f)
w.writerow(['Comment', 'Designator', 'Footprint', 'LCSC Part #'])
for code, g in groups.items():
p = g['parts'][0]
comment = p.fields.get('MPN') or p.value
w.writerow([comment, ','.join(g['refs']), p.footprint.split(':')[1], code])
tht = [r for r in rows if re.search(r'EC11|PinHeader|BWSMA', r[5])]
print('CPL: %d placements (%d through-hole: %s)' % (len(rows), len(tht), ' '.join(r[0] for r in tht)))
print('BOM: %d JLC line items, %d parts' % (len(groups), sum(len(g['refs']) for g in groups.values())))
return groups
if __name__ == '__main__':
main()