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
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361
"""Project-local library: ceramic filter symbols + footprints (AMFM)."""
import os
from sexp import Sym as S, dumps
from schgen import uid
HERE = os.path.dirname(os.path.abspath(__file__))
LIB = os.path.join(HERE, '..', 'lib')
def fx(size=1.27):
return [S('effects'), [S('font'), [S('size'), size, size]]]
def sprop(name, value, x, y, hide=False, angle=0):
p = [S('property'), name, value, [S('at'), x, y, angle], [S('show_name'), S('no')],
[S('do_not_autoplace'), S('no')]]
if hide:
p.append([S('hide'), S('yes')])
p.append(fx())
return p
def pin(num, name, x, y, ang, length=2.54, hidden=False, ptype='passive'):
p = [S('pin'), S(ptype), S('line'), [S('at'), x, y, ang], [S('length'), length]]
if hidden:
p.append([S('hide'), S('yes')])
p += [[S('name'), name, fx()], [S('number'), num, fx()]]
return p
def body(name):
g = [S('symbol'), name + '_0_1',
[S('rectangle'), [S('start'), -5.08, 3.81], [S('end'), 5.08, -2.54],
[S('stroke'), [S('width'), 0.254], [S('type'), S('default')]], [S('fill'), [S('type'), S('background')]]],
# stylised resonator plates
[S('polyline'), [S('pts'), [S('xy'), -1.27, 2.54], [S('xy'), -1.27, -0.635]],
[S('stroke'), [S('width'), 0.254], [S('type'), S('default')]], [S('fill'), [S('type'), S('none')]]],
[S('polyline'), [S('pts'), [S('xy'), 1.27, 2.54], [S('xy'), 1.27, -0.635]],
[S('stroke'), [S('width'), 0.254], [S('type'), S('default')]], [S('fill'), [S('type'), S('none')]]],
[S('rectangle'), [S('start'), -0.635, 2.286], [S('end'), 0.635, -0.381],
[S('stroke'), [S('width'), 0.254], [S('type'), S('default')]], [S('fill'), [S('type'), S('outline')]]],
[S('polyline'), [S('pts'), [S('xy'), -2.54, 1.27], [S('xy'), -1.27, 1.27]],
[S('stroke'), [S('width'), 0], [S('type'), S('default')]], [S('fill'), [S('type'), S('none')]]],
[S('polyline'), [S('pts'), [S('xy'), 1.27, 1.27], [S('xy'), 2.54, 1.27]],
[S('stroke'), [S('width'), 0], [S('type'), S('default')]], [S('fill'), [S('type'), S('none')]]]]
return g
def symbol(name, value, fp, descr, pins, keywords):
return [S('symbol'), name,
[S('pin_names'), [S('offset'), 0.254]],
[S('exclude_from_sim'), S('yes')], [S('in_bom'), S('yes')], [S('on_board'), S('yes')],
[S('in_pos_files'), S('yes')], [S('duplicate_pin_numbers_are_jumpers'), S('no')],
sprop('Reference', 'FL', 0, 6.35), sprop('Value', value, 0, 5.08),
sprop('Footprint', fp, 0, -8.89, hide=True), sprop('Datasheet', '', 0, 0, hide=True),
sprop('Description', descr, 0, 0, hide=True), sprop('ki_keywords', keywords, 0, 0, hide=True),
body(name),
[S('symbol'), name + '_1_1'] + pins,
[S('embedded_fonts'), S('no')]]
def write_symbols():
s3 = symbol('CeramicFilter_3pin', 'HCCF3-10.700-LTCVS3L', 'AMFM:HCCF3_SMD-3P_7.0x3.0mm',
'10.7 MHz ceramic band-pass filter, 3 terminals (In/GND/Out), 330 ohm',
[pin('1', 'IN', -7.62, 1.27, 0), pin('2', 'GND', 0, -5.08, 90), pin('3', 'OUT', 7.62, 1.27, 180)],
'ceramic filter IF 10.7MHz')
s5 = symbol('CeramicFilter_5pin', 'LTM455GW', 'AMFM:LTM455xW_5pin',
'455 kHz ceramic IF filter, 5 leads (In, Out, 3x GND), 2 kohm, +/-4.5 kHz @6 dB',
[pin('1', 'IN', -7.62, 1.27, 0), pin('2', 'OUT', 7.62, 1.27, 180),
pin('3', 'GND', 0, -5.08, 90), pin('4', 'GND', 0, -5.08, 90, hidden=True),
pin('5', 'GND', 0, -5.08, 90, hidden=True)],
'ceramic filter IF 455kHz')
s4 = symbol('CeramicFilter_4pin', 'HCCF2-455.000-LTWCGL', 'AMFM:HCCF2_SMD-4P_11.9x6.5mm',
'455 kHz SMD ceramic IF filter, 4 terminals (In, Out, 2x GND), 2 kohm, +/-4.5 kHz',
[pin('1', 'IN', -7.62, 1.27, 0), pin('2', 'OUT', 7.62, 1.27, 180),
pin('3', 'GND', 0, -5.08, 90), pin('4', 'GND', 0, -5.08, 90, hidden=True)],
'ceramic filter IF 455kHz')
lib = [S('kicad_symbol_lib'), [S('version'), 20251024], [S('generator'), 'amfm_make_lib'],
[S('generator_version'), '10.0'], s3, s4, s5]
with open(os.path.join(LIB, 'AMFM.kicad_sym'), 'w') as f:
f.write(dumps(lib) + '\n')
def stroke(w):
return [S('stroke'), [S('width'), w], [S('type'), S('solid')]]
def fp_text_prop(name, value, x, y, layer, hide=False):
p = [S('property'), name, value, [S('at'), x, y, 0], [S('layer'), layer]]
if hide:
p.append([S('hide'), S('yes')])
p.append([S('effects'), [S('font'), [S('size'), 1, 1], [S('thickness'), 0.15]]])
return p
def rect(x1, y1, x2, y2, layer, w):
return [S('fp_rect'), [S('start'), x1, y1], [S('end'), x2, y2], stroke(w), [S('fill'), S('no')],
[S('layer'), layer]]
def line(x1, y1, x2, y2, layer, w):
return [S('fp_line'), [S('start'), x1, y1], [S('end'), x2, y2], stroke(w), [S('layer'), layer]]
def tht_pad(num, x, y, size, drill, square=False):
return [S('pad'), num, S('thru_hole'), S('rect' if square else 'circle'), [S('at'), x, y],
[S('size'), size, size], [S('drill'), drill], [S('layers'), '*.Cu', '*.Mask'],
[S('remove_unused_layers'), S('no')]]
def silk_outline(x1, y1, x2, y2, pads, gap=0.25, w=0.12):
"""Rectangle outline on F.SilkS, broken wherever it would cross a pad (+gap)."""
boxes = []
for p in pads:
at = [q for q in p if isinstance(q, list) and q[0] == 'at'][0]
sz = [q for q in p if isinstance(q, list) and q[0] == 'size'][0]
boxes.append((at[1] - sz[1] / 2 - gap - w, at[2] - sz[2] / 2 - gap - w,
at[1] + sz[1] / 2 + gap + w, at[2] + sz[2] / 2 + gap + w))
out = []
def seg(ax, ay, bx, by):
# split an axis-aligned segment by the pad boxes
if ay == by:
lo, hi, fixed, horiz = min(ax, bx), max(ax, bx), ay, True
else:
lo, hi, fixed, horiz = min(ay, by), max(ay, by), ax, False
cuts = []
for b in boxes:
if horiz and b[1] <= fixed <= b[3]:
cuts.append((b[0], b[2]))
if not horiz and b[0] <= fixed <= b[2]:
cuts.append((b[1], b[3]))
cuts.sort()
pos = lo
for c0, c1 in cuts:
if c1 <= pos:
continue
if c0 > pos:
out.append((pos, min(c0, hi)))
pos = max(pos, c1)
if pos >= hi:
break
if pos < hi:
out.append((pos, hi))
res = []
for a, b in out:
if b - a > 0.2:
res.append(line(round(a, 3), fixed, round(b, 3), fixed, 'F.SilkS', w) if horiz
else line(fixed, round(a, 3), fixed, round(b, 3), 'F.SilkS', w))
out.clear()
return res
items = []
items += seg(x1, y1, x2, y1) + seg(x2, y1, x2, y2) + seg(x2, y2, x1, y2) + seg(x1, y2, x1, y1)
return items
def smd_pad(num, x, y, w, h):
return [S('pad'), num, S('smd'), S('roundrect'), [S('at'), x, y], [S('size'), w, h],
[S('layers'), 'F.Cu', 'F.Paste', 'F.Mask'], [S('roundrect_rratio'), 0.1]]
def pad_extent(pads):
xs, ys = [], []
for p in pads:
at = [q for q in p if isinstance(q, list) and q[0] == 'at'][0]
sz = [q for q in p if isinstance(q, list) and q[0] == 'size'][0]
xs += [at[1] - sz[1] / 2, at[1] + sz[1] / 2]
ys += [at[2] - sz[2] / 2, at[2] + sz[2] / 2]
return min(xs), min(ys), max(xs), max(ys)
def footprint(name, descr, tags, body_box, pads, extra=(), attr='through_hole', silk=True):
x1, y1, x2, y2 = body_box
px1, py1, px2, py2 = pad_extent(pads)
cx1, cy1, cx2, cy2 = (round(min(x1, px1) - 0.25, 2), round(min(y1, py1) - 0.25, 2),
round(max(x2, px2) + 0.25, 2), round(max(y2, py2) + 0.25, 2))
items = [S('footprint'), name, [S('version'), 20260206], [S('generator'), 'amfm_make_lib'],
[S('layer'), 'F.Cu'], [S('descr'), descr], [S('tags'), tags],
fp_text_prop('Reference', 'REF**', (x1 + x2) / 2, y1 - 1.5, 'F.SilkS'),
fp_text_prop('Value', name, (x1 + x2) / 2, y2 + 1.5, 'F.Fab'),
fp_text_prop('Datasheet', '', 0, 0, 'F.Fab', hide=True),
fp_text_prop('Description', descr, 0, 0, 'F.Fab', hide=True),
[S('attr'), S(attr)], [S('duplicate_pad_numbers_are_jumpers'), S('no')],
rect(x1, y1, x2, y2, 'F.Fab', 0.1),
rect(cx1, cy1, cx2, cy2, 'F.CrtYd', 0.05),
[S('fp_text'), S('user'), '${REFERENCE}', [S('at'), (x1 + x2) / 2, (y1 + y2) / 2, 0],
[S('layer'), 'F.Fab'], [S('effects'), [S('font'), [S('size'), 0.8, 0.8], [S('thickness'), 0.12]]]]]
if silk:
items += silk_outline(x1 - 0.12, y1 - 0.12, x2 + 0.12, y2 + 0.12, pads)
items += list(extra)
items += pads
items.append([S('embedded_fonts'), S('no')])
with open(os.path.join(LIB, 'AMFM.pretty', name + '.kicad_mod'), 'w') as f:
f.write(dumps(items) + '\n')
def write_footprints_jlc():
# HCI HCCF3-10.700-LTCVS3L (LCSC C19191232): SMD 7.0 x 3.0 mm, 1=In 2=GND 3=Out (LCSC land pattern)
footprint('HCCF3_SMD-3P_7.0x3.0mm',
'HCI HCCF3 10.7 MHz SMD ceramic filter, 7.0 x 3.0 mm, 3 terminals (1=In 2=GND 3=Out), LCSC C19191232',
'ceramic filter 10.7MHz SMD HCCF3',
(-3.5, -1.5, 3.5, 1.5),
[smd_pad('1', -2.97, 0, 1.6, 3.24), smd_pad('2', 0, 0, 1.4, 3.24), smd_pad('3', 2.97, 0, 1.6, 3.24)],
extra=[line(-3.9, 2.1, -3.9, 1.3, 'F.SilkS', 0.12)], attr='smd')
# HCI HCCF2-455.000-LTWCGL (LCSC C19186014): SMD 11.9 x 6.5 mm, 1=In 2=Out 3,4=GND
footprint('HCCF2_SMD-4P_11.9x6.5mm',
'HCI HCCF2 455 kHz SMD ceramic filter, 11.9 x 6.5 mm (1=In 2=Out 3,4=GND), LCSC C19186014',
'ceramic filter 455kHz SMD HCCF2 CFWLA',
(-5.95, -3.25, 5.95, 3.25),
[smd_pad('1', -2.75, 2.5, 1.6, 3.0), smd_pad('2', 2.75, 2.5, 1.6, 3.0),
smd_pad('3', 2.75, -2.5, 1.6, 3.0), smd_pad('4', -2.75, -2.5, 1.6, 3.0)],
extra=[line(-6.3, 3.9, -6.3, 2.2, 'F.SilkS', 0.12)], attr='smd')
# XKB TS-1187A-B-A-B (LCSC C318884, JLC basic): 5.1 x 5.1 mm SMD tact switch.
# Pads on the same row are one terminal: (1,1) = A/B, (2,2) = C/D.
footprint('SW_XKB_TS-1187A_5.1x5.1mm',
'XKB TS-1187A 5.1 x 5.1 mm SMD tactile switch (LCSC C318884); pads of a row are common',
'switch tactile SMD TS-1187A',
(-2.55, -2.55, 2.55, 2.55),
[smd_pad('1', -3.0, -1.85, 1.0, 0.75), smd_pad('1', 3.0, -1.85, 1.0, 0.75),
smd_pad('2', -3.0, 1.85, 1.0, 0.75), smd_pad('2', 3.0, 1.85, 1.0, 0.75)],
attr='smd')
def write_footprints():
# Murata SFELF10M7 (and SFELA/SFE10.7MA5 family): 3 leads, 2.5 mm pitch; body ~7.0 x 3.0 mm
footprint('Murata_SFELF_3pin_P2.50mm',
'Murata CERAFIL SFELF10M7 / SFELA10M7 10.7 MHz ceramic filter, 3 leads, 2.5 mm pitch (1=In 2=GND 3=Out)',
'ceramic filter 10.7MHz SFELF',
(-3.5 + 2.5, -1.6, 3.5 + 2.5, 1.6),
[tht_pad('1', 0, 0, 1.5, 0.8, square=True), tht_pad('2', 2.5, 0, 1.5, 0.8),
tht_pad('3', 5.0, 0, 1.5, 0.8)],
extra=[line(-1.2, 1.9, -0.2, 1.9, 'F.SilkS', 0.12)])
# LTM455xW / Murata CFWM455x: 5 leads, per Transko LTM455W drawing (bottom row 3x GND)
footprint('LTM455xW_5pin',
'455 kHz ceramic filter LTM455xW / CFWM455x, 5 leads, 9.5 x 6.5 mm body (1=In 2=Out 3,4,5=GND)',
'ceramic filter 455kHz LTM455 CFWM455',
(-0.75, -1.2, 8.75, 5.3),
[tht_pad('1', 0, 0, 1.6, 0.9, square=True), tht_pad('2', 7.1, 0, 1.6, 0.9),
tht_pad('3', 2.5, 4.3, 1.6, 0.9), tht_pad('4', 5.2, 4.3, 1.6, 0.9),
tht_pad('5', 8.0, 4.3, 1.6, 0.9)])
def write_adom_machine():
"""Project copy of Adom's machine parts library (adom/adom-machine-parts-library, KiCad).
Pads are the official ones; added: Reference/Value fields, silk ring, courtyard and fab outline like
Adom's example molecules, the STEP model from each component page, and passive pin type (ERC)."""
import copy
from sexp import parse, find, findall
src_sym = os.path.join(HERE, '..', 'build', 'AdomMachine_src.kicad_sym')
src_fp = os.path.join(HERE, '..', 'build', 'AdomMachine_src.pretty')
lib = parse(open(src_sym).read())
out = [S('kicad_symbol_lib'), [S('version'), 20251024], [S('generator'), 'amfm_make_lib'],
[S('generator_version'), '10.0']]
for sym in findall(lib, 'symbol'):
sym = copy.deepcopy(sym)
def fix(node):
for it in node:
if isinstance(it, list):
if it and it[0] == 'pin' and len(it) > 1 and it[1] == 'unspecified':
it[1] = S('passive')
fix(it)
fix(sym)
out.append(sym)
with open(os.path.join(LIB, 'AdomMachine.kicad_sym'), 'w') as f:
f.write(dumps(out) + '\n')
os.makedirs(os.path.join(LIB, 'AdomMachine.pretty'), exist_ok=True)
# name, silk ring radius, courtyard radius (= pincer keep-out, as on the component pages), STEP model
for name, ring, crt, model in (('MachinePinLargeStandard', 2.7, 3.0, 'MachinePinLargeStandard.step'),
('MachinePinMediumStandard', 0.95, 1.0, 'MachinePinMediumStandard.step'),
('MachineContactMedium', 0.8, 1.0, 'MachineContactMedium.step')):
fp = parse(open(os.path.join(src_fp, name + '.kicad_mod')).read())
pad = find(fp, 'pad')
items = [S('footprint'), name, [S('version'), 20260206], [S('generator'), 'amfm_make_lib'],
[S('layer'), 'F.Cu'],
[S('descr'), 'Adom %s (adom/adom-machine-parts-library); press-fit by the Adom Factory' % name],
[S('tags'), 'adom molecule machine pin contact'],
fp_text_prop('Reference', 'REF**', 0, 0, 'F.Fab'),
fp_text_prop('Value', name, 0, 1.6, 'F.Fab', hide=True),
fp_text_prop('Datasheet', 'https://wiki.adom.inc/adom/' + name.lower(), 0, 0, 'F.Fab', hide=True),
fp_text_prop('Description', 'Adom ' + name, 0, 0, 'F.Fab', hide=True),
[S('attr'), S('through_hole')], [S('duplicate_pad_numbers_are_jumpers'), S('no')],
[S('fp_circle'), [S('center'), 0, 0], [S('end'), ring, 0], stroke(0.12), [S('fill'), S('no')],
[S('layer'), 'F.SilkS']],
[S('fp_circle'), [S('center'), 0, 0], [S('end'), crt, 0], stroke(0.05), [S('fill'), S('no')],
[S('layer'), 'F.CrtYd']],
[S('fp_circle'), [S('center'), 0, 0], [S('end'), crt, 0], stroke(0.05), [S('fill'), S('no')],
[S('layer'), 'B.CrtYd']],
rect(-crt, -crt, crt, crt, 'F.Fab', 0.05),
pad,
[S('model'), '${KIPRJMOD}/lib/AdomMachine.3dshapes/' + model,
[S('offset'), [S('xyz'), 0, 0, 0]], [S('scale'), [S('xyz'), 1, 1, 1]],
[S('rotate'), [S('xyz'), 0, 0, 0]]],
[S('embedded_fonts'), S('no')]]
with open(os.path.join(LIB, 'AdomMachine.pretty', name + '.kicad_mod'), 'w') as f:
f.write(dumps(items) + '\n')
QFN_SRC = '/usr/share/kicad/footprints/Package_DFN_QFN.pretty/QFN-48-1EP_7x7mm_P0.5mm_EP5.6x5.6mm_ThermalVias.kicad_mod'
QFN_NAME = 'QFN-48-1EP_7x7mm_P0.5mm_EP5.6x5.6mm_ThermalVias0.3'
def write_qfn_thermal():
"""KiCad's 7x7 QFN-48 thermal-via land pattern, reworked for this board's 0.3 mm minimum drill: keep the
inner 3 x 3 vias of the 5 x 5 grid (the paste windows were drawn around them) as 0.6 / 0.3 mm vias."""
from sexp import parse, find
fp = parse(open(QFN_SRC).read())
fp[1] = QFN_NAME
out, kept = [], 0
for it in fp:
if isinstance(it, list) and it and it[0] == 'pad' and it[1] == '49' and it[2] == 'thru_hole':
at = find(it, 'at')
if max(abs(float(at[1])), abs(float(at[2]))) > 2.0:
continue
find(it, 'size')[1:3] = [0.6, 0.6]
find(it, 'drill')[1] = 0.3
kept += 1
out.append(it)
assert kept == 9, kept
with open(os.path.join(LIB, 'AMFM.pretty', QFN_NAME + '.kicad_mod'), 'w') as f:
f.write(dumps(out) + '\n')
# 3D models for the project footprints the KiCad library has no model for (LCSC/EasyEDA models, fetched with
# easyeda2kicad and aligned to our pads): footprint -> (model file in lib/AMFM.3dshapes, offset xyz, rotate xyz).
# Note: KiCad stores model Z rotation with the opposite sign to the usual CCW-positive maths angle.
MODELS = {
'USB_C_Receptacle_HRO_TYPE-C-31-M-12_EdgeMount': ('USB_C_HRO_TYPE-C-31-M-12.wrl', (0, -0.98, 0), (0, 0, 180)), # offset set by Noah in KiCad, 2026-10-02
'SMA_BAT_Wireless_BWSMA-KWE-Z001_Edge': ('SMA_BWSMA-KWE-Z001.wrl', (0, 0, 0), (0, 0, 270)),
'HCCF3_SMD-3P_7.0x3.0mm': ('HCCF3_SMD-3P_7.0x3.0mm.wrl', (0, 0, 0), (0, 0, 0)),
'HCCF2_SMD-4P_11.9x6.5mm': ('HCCF2_SMD-4P_11.9x6.5mm.wrl', (0, 0, 0), (0, 0, 0)),
QFN_NAME: ('UFQFPN-48_7x7mm_P0.5mm.wrl', (0, 0, 0), (0, 0, 0)),
}
def model_node(fname, offset, rotate):
return [S('model'), '${KIPRJMOD}/lib/AMFM.3dshapes/' + fname,
[S('offset'), [S('xyz')] + list(offset)], [S('scale'), [S('xyz'), 1, 1, 1]],
[S('rotate'), [S('xyz')] + list(rotate)]]
def attach_models():
"""Point each listed project footprint at its 3D model (replacing any stale KiCad-library reference)."""
from sexp import parse
for name, (fname, off, rot) in MODELS.items():
path = os.path.join(LIB, 'AMFM.pretty', name + '.kicad_mod')
fp = parse(open(path).read())
fp = [it for it in fp if not (isinstance(it, list) and it and it[0] == 'model')]
fp.append(model_node(fname, off, rot))
with open(path, 'w') as f:
f.write(dumps(fp) + '\n')
if __name__ == '__main__':
os.makedirs(os.path.join(LIB, 'AMFM.pretty'), exist_ok=True)
write_symbols()
write_footprints()
write_footprints_jlc()
write_adom_machine()
write_qfn_thermal()
attach_models()
print('library written to', os.path.abspath(LIB))