
Max98357hat
ki9ng/cyberdeck
Portable ham/SDR cyberdeck in a Harbor Freight Apache 1800: PCBs, the CNC toolchain that mills them at home, case bezels, and the software that runs on it. CC0.

Portable ham/SDR cyberdeck in a Harbor Freight Apache 1800: PCBs, the CNC toolchain that mills them at home, case bezels, and the software that runs on it. CC0.
Cyberdeck is an open-source printed circuit board by ki9ng, from the ki9ng/cyberdeck repository on GitHub, published under the CC0-1.0 licence. It is a 2-layer board of 84 × 53 mm with 8 parts on 5 nets, routed with 16 tracks and 0 vias.
A Raspberry Pi HAT carrying two generic MAX98357A I2S modules, both set to mono ((L+R)/2), sharing one I2S bus. Each module keeps its own speaker connector — the HAT never touches the amplifier outputs. 5 V is fed into the whole stack through a pair of solder pads.
Built for CNC milling at home:
F.Cu (top). No plated holes, no vias.Current revision: rev 6 (2026-08-04) — staged, not yet cut. See Rev 6
at the end. CAM lives in cam-rev6/; every generated .nc is prefixed r6-.
Rev 6 is a CAM-only revision: the board design is byte-for-byte rev 5. It
exists because rev 5's clearing pass milled every trace off the board. One
decision is deliberately still open — the whisker feed rate. Runbook and the
question are on the Optiplex wiki at projects/max98357hat-rev6.md.
The GPIO socket has to face down to meet the Pi, so its body sits underneath the board and it is soldered from the top — which is why the copper is on top. The two amplifier modules ride along underneath in the ~8 mm gap that socket creates, soldered directly (a socket would add ~8 mm and would not fit).
Everything else mounts on top, the same side as the copper, with each lead soldered to the ring around its hole. That is why the footprints use a generous 2.0 mm pad on a 1.0 mm drill — on an unplated board that ring is the whole joint. 2.0 also happens to be the best milling choice: it leaves a 0.54 mm pad-to-pad gap, which one isolation pass evacuates completely.
Both modules mount the same way up, so both speaker connectors face the same edge of the board. Neither is flipped — see Rev 4.
| Side | Parts |
|---|---|
| Underneath (soldered from top) | J1, U1, U2 |
| On top (soldered on top) | everything else |
1. MODULE_H is still inferred, not measured. As of rev 5 the module is the
user's real purple board, 18.0 × 18.0 mm, 7-pin control row 2.0 mm in
from the near edge, speaker connector 13.0 mm from the control row and
centred left-right. MODULE_W and SPK_ROW_DY are measured; MODULE_H is
derived (2.0 inset + 13.0 + 2.0 half-connector = 17.0 minimum, so the original
13.0 was impossible — it put the connector outside the module).
MODULE_H is the number most worth putting calipers on: module-to-module
spacing and the 2 mm gaps both derive from it. Constants live at the top of
~/kicad-libs/gen_max98357.py:
MODULE_W 18.0 # along the pin row - MEASURED
MODULE_H 18.0 # across the module - INFERRED, check this
CTRL_ROW_INSET 2.0 # pin row centre -> nearest module edge
SPK_ROW_DY 13.0 # control row -> speaker connector - MEASURED2. Check the pin order on your module's silkscreen. This design assumes,
left to right: LRC BCLK DIN GAIN SD GND VIN. Some clones print it
reversed — the holes will still line up but every net will be wrong.
3. Check the module's height. It hangs under the HAT in roughly 8 mm of clearance. Bare module PCB plus its tallest part is usually ~4–5 mm, which is fine, but measure before you commit.
| File | What it is |
|---|---|
max98357hat.kicad_sch | Full schematic. ERC clean. |
max98357hat.kicad_pcb | Board outline, mounting holes, all parts placed. Unrouted. |
max98357hat.kicad_dru | Custom rules that enforce single-sided: no copper on B.Cu, no vias. |
max98357hat.kicad_pro | Project settings. |
gen_sch.py / gen_pcb.py | Regenerate the schematic / board. |
check_gaps.py | Classify every copper gap into the kerf bands. The real pre-design check — DRC only answers "is this gap ≥ the rule". |
preflight.py | Re-read finished G-code and measure it against copper from the .kicad_pcb. The real pre-cut check. Must say PASSED. |
~/kicad-libs/gen_max98357.py | Regenerate the symbol + footprints into KI9NG. |
New parts in your KI9NG library:
MAX98357A_Module — 7-pin symbol for the module, not the bare ICMAX98357A_Module_7pin_THT — the one this board usesMAX98357A_Module_9pin_THT — variant that also breaks out the speaker pinsSolderPad_Power_2pos_P10mm_THT — 5 V input padsWireJumper_P{5.08,7.62,10.16,15.24}mm_THT — for crossingsmax98357hat.kicad_pro.F.Cu and jumper the crossings. There is no GND pour — the
field copper is milled away instead (gen_clear.py).Board outline, the four M2.5 mounting holes (58 × 49 mm grid, 2.75 mm drill, non-plated, not connected to GND) and the GPIO socket position come from KiCad's official Raspberry Pi HAT template — don't move J1 or H1–H4.
Both modules run mono, and that needs no components. The module's own 1 MΩ
SD pullup to VIN, working against the chip's internal 100 kΩ pulldown, parks
SD at 5 × 100/1100 = 0.45 V — inside the 0.16–0.77 V window that the
datasheet defines as (Left+Right)/2. That is why these boards play mono out of
the bag.
R1 and R2 exist only so you can change your mind later. One resistor from SD
to +3V3 per module, no divider needed:
| R1 / R2 | V_SD | Channel |
|---|---|---|
| not fitted | 0.45 V | (L+R)/2 mono |
| 100 k | 1.81 V | Left |
| 330 k | 1.07 V | Right |
GAIN is left floating on both modules = 9 dB, the module default. If you
want more, tack a resistor from the module's own GAIN pin to GND: 0 Ω = 12 dB,
100 k = 15 dB.
J4 is a pair of 6 mm pads on a 10 mm pitch with 2.5 mm holes — thread the wire
through and solder it. It feeds the 5 V rail directly: GPIO pins 2 and 4, and
both modules' VIN.
Nothing is in series with it. See the warning in the BOM.
Two amps plus a Pi is a real load; use a 5 V supply with headroom (3 A+).
| Signal | GPIO | Header pin |
|---|---|---|
| BCLK | GPIO18 / PCM_CLK | 12 |
| LRCLK | GPIO19 / PCM_FS | 35 |
| DIN | GPIO21 / PCM_DOUT | 40 |
Pins 27/28 (ID_SD/ID_SC) are left unconnected as the HAT spec requires.
No ID EEPROM, so enable the device yourself in /boot/firmware/config.txt:
dtparam=audio=off
dtoverlay=hifiberry-dacBoth modules will play the same mono mix.
That's the whole thing:
| Ref | Value | Package | Notes |
|---|---|---|---|
| U1, U2 | MAX98357A module | generic breakout | underneath, soldered direct, both mono |
| J1 | 2x20 female socket | 2.54 mm THT | underneath, soldered from top |
| J4 | — | solder pads | 5 V input |
| R1, R2 | not fitted | axial 1/4 W | channel select — see below |
| JP1, JP2 | wire link | WireJumper_P5.08mm_THT | the two unavoidable crossings |
| H1–H4 | — | M2.5, 2.75 mm | non-plated, isolated from GND |
Nothing else. No decoupling — the modules carry their own. No bulk cap, no fuse, no reverse-polarity diode.
There is no reverse-polarity protection and no fuse. Get the wires on J4 backwards and you will kill the Pi. D1 was a crowbar, which only works by blowing a fuse, so it had to go when the fuse did.
A bulk cap on the 5 V rail is the one omission that might bite: two class-D amps draw current in pulses, and without one the rail can sag on bass transients. If you hear it, tack a 220–470 µF across J4.
Speaker wiring is not on this board — it comes off each module's own connector.
Routed. DRC: 0 errors, 0 unconnected, and check_gaps.py reports no whiskers
and nothing below the kerf. All copper on F.Cu, 43 segments, two wire links.
Regenerate with gen_pcb.py (placement) then gen_route.py
(nets + copper) — the second also does the job of "update PCB from schematic",
which kicad-cli has no command for.
Both modules sit the same way up. U1 is at (8, 18), U2 at (27, 33) — 19 mm right and 15 mm down. That stagger is what makes single-layer routing work.
Rev 3 got there differently: it rotated U2 by 180°, which reversed the pin order so the five nets nested as arcs that never cross. It routed beautifully and it pointed the two module bodies — and their speaker connectors — in opposite directions, which is a nuisance to wire.
Turn U2 back to 0° and the five module-to-module runs become parallel instead of nested. Parallel chords at the same y all cross each other: five of them is ten crossings, and ten crossings is four or five extra wire links. Staggering in both axes fixes it. Every net now leaves U1 going down, runs across in its own lane, and drops onto U2 — an L per net — and the whole block is crossing-free provided the lanes are ordered by one rule:
the net whose U1 pin is furthest right gets the shallowest lane.
| Net | U1 pin | lane | U2 pin |
|---|---|---|---|
| +5V_MOD | 23.24 | y 23.5 | 42.24 |
| GND | 20.70 | y 26.0 | 39.70 |
| DIN | 13.08 | y 28.0 | 32.08 |
| BCLK | 10.54 | y 29.5 | 29.54 |
| LRCLK | 8.00 | y 31.0 | 27.00 |
With that order, a net's first vertical only ever meets lanes belonging to nets further left — which are deeper — and its second vertical only meets lanes of nets further right, which are shallower and already behind it. Nothing crosses.
Lane spacing is set by the kerf bands, not by habit. The tightest is LRCLK to U2's pad row at 0.8 mm; nothing in the stack is under 0.4 mm.
Rev 4 needs two, down from three on rev 3 — the stagger pays for itself.
| Link | Spans | Why |
|---|---|---|
| JP1 | x 10.54, y 10.2 → 15.28 | BCLK enters the header at x=21.07, in the middle of LRCLK's span, and lands at x=10.54, also inside it. It has to cross LRCLK's trunk exactly once however it is drawn. |
| JP2 | x 48, y 32.5 → 37.58 | +5V comes from J4 at bottom left, GND from pin 39 at top right. They have to cross once somewhere; this puts it in open board, clear of the camera slot. |
GND_MOD and DIN_ESC are gone as separate nets. With the modules staggered
the module ground reaches header ground directly and DIN never has to be lifted
over anything, so two of rev 3's three links simply disappear.
Solder a plain wire across each. They are ordinary 2-pad footprints, so DRC counts them as connected and the netlist stays honest.
BCLK (pin 12) and DIN (pin 40) are both on the header's outer (even) row, with only 1.38 mm of board beyond them, so each has to go inward through the odd row: BCLK between pins 11/13 at x=22.34, DIN between pins 37/39 at x=55.36, on a 0.2 mm track.
Those four flanking pads are shrunk from KiCad's default 1.7 mm to 1.5 mm, which is what makes the escape comfortable:
| Header pad | Channel each side | Margin over K (0.304) | Annular ring |
|---|---|---|---|
| 1.7 mm | 0.320 | +0.016 | 0.350 |
| 1.5 mm | 0.420 | +0.116 | 0.250 |
Both channels are under 2K, so pass 1 cuts them from both sides and clears them completely — a single pass here is the correct outcome, not a compromise. Only pin 39 of the four carries a net (GND); 11, 13 and 37 are unconnected, so the ring they give up costs nothing.
This is the tightest thing on the board. Cut a test coupon and measure your kerf before committing — the 0.24 mm tip diameter is still bracketed 0.17–0.27, and that bracket is wider than the margin above.
Only pins that can be escaped on one layer: 1 and 17 (+3V3) are unused, and GND lands on pin 39 alone — the other outer-row GND pins are boxed in behind BCLK's fan-out. One pin carries the Pi's own return fine; the amplifier current never crosses J1, it comes straight off J4.
python3 gen_cam.py --out cam-rev4 writes the rev-4 set. Always pass
--out: the .gbr/.drl are inputs to FlatCAM, and replacing them under a
job you are part way through cutting is an easy way to mill the wrong board.
Old revisions stay on the SD card too, which is why every generated .nc is
prefixed r5-.
| File | What |
|---|---|
max98357hat-F_Cu.gbr | the only copper layer |
max98357hat-Edge_Cuts.gbr | outline + display notch + 2 speaker cutouts |
max98357hat-PTH.drl | 58 x 1.00 mm, 2 x 2.50 mm |
max98357hat-NPTH.drl | 4 x 2.70 mm mounting holes, must stay unplated |
Settings that matter, and why:
Sanity check after regenerating: Edge_Cuts should span exactly 65.00 x 56.00,
and NPTH should land on 3.50/61.50 x 3.50/52.50 — the HAT's 58 x 49 mm grid.
Everything below is already generated in cam-rev6/ except the probe map and
the levelled files, which can only be made with the board on the machine.
Run ./preflight.py --cam cam-rev6 before uploading anything. It must say
PASSED. It reads the finished G-code back and measures it against copper parsed
from the .kicad_pcb - not from the isolation file that generated the paths,
because a generator checked against its own offset arithmetic always agrees with
itself. That is exactly how rev 5 passed every check it had and still cut every
trace off the board.
| File | Tool | What |
|---|---|---|
r6-iso.nc | V-bit | 4 isolation passes, merged innermost-first, 79 plunges / 2544 mm |
r6-whisker.nc | V-bit | leftover islands, 100.00% covered, 7618 mm (~63 min at 120 mm/min - feed decision still open) |
r6-drill-1.0.nc | 1.0 mm drill | 58 holes |
r6-clear.nc | 1.5 mm end mill | mills the field copper away, 17 rings / 2246 mm, ~7.5 min. Cutting edge stands off 0.500 mm from copper |
r6-cutout.nc | 1.5 mm end mill | 4× M2.5 + 2× 2.5 mm helical, the two speaker ovals, then the outline to −1.80 |
r6-drill-2.5.nc and r6-drill-2.7.nc are not used — those holes are
milled by r6-cutout.nc with the end mill. They exist for the day a drill that
size turns up.
Operations are grouped by TOOL: three tools, two changes, two Z re-zeros. Never re-zero X/Y. The cutout is always last — after it the board is on tape alone.
./probe_scan.py --margins 0,65,0,56 --n 14,13 -o cam-rev6/r6-hat.probe
Probe before cutting any copper. After isolation every trace is an island
and a grid probe dies on ALARM:5../level_gcode.py -i cam-rev6/r6-iso.nc -p cam-rev6/r6-hat.probe -o cam-rev6/r6-iso-lvl.nc./level_gcode.py -i cam-rev6/r6-whisker.nc -p cam-rev6/r6-hat.probe -o cam-rev6/r6-whisker-lvl.ncr6-iso-lvl.ncr6-whisker-lvl.nc (same tool, no change, no re-zero)r6-drill-1.0.nc (not levelled — it goes clean through)map − map(x0,y0), (0,0) unless you chose otherwise),
level r6-clear.nc, run itr6-cutout.nc (same tool, no change, no re-zero. Not levelled — it cuts through)Never use bCNC's Autolevel: GCode.compile() re-applies the map at send time
and double-corrects. After levelling, check that commanded_Z − map(x,y) is
constant at the target depth.
Upload over HTTP to the SD card, compare the size byte-for-byte against the
local file, then $SD/Run=<file>. bCNC hogs the single telnet slot — disconnect
it first.
Learned the hard way 2026-07-29. FlatCAM 8.994 BETA predates shapely 2.x (this laptop has 2.0.3) and silently fails to draw Gerber region fills. The object loads, the Properties panel shows correct dimensions, aperture list and copper area - and the canvas shows only the selection box. Switching the graphic engine between 3D and Legacy(2D) makes no difference, because it is not a canvas problem.
Diagnostic: count regions. grep -c '^G36\*$' file.gbr. Zero renders, nonzero
does not.
Neither max98357hat-F_Cu.gbr nor either Edge_Cuts has any regions, which is
why those open fine. The coupon originally had 12 (its clearance blocks were
filled rectangles) and now has none - they are rectangular aperture flashes
instead, via SMD pads rather than gr_rect.
This will bite again if you ever add a copper pour. A filled zone plots as regions. If a future revision of this board gets a GND pour, its Gerber will stop rendering in FlatCAM. Options at that point: keep using tracks, or export and check before wondering what broke.
python3 gen_coupon.py builds coupon.kicad_pcb and cam-coupon/. Cut this
before the real board — it tells you what your machine actually resolves,
and the answer feeds straight back into the isolation tool diameter.
60 x 40 mm, three sections, everything labelled in copper so it still means something later:
| Tests | Read it as | |
|---|---|---|
| A | The real geometry: two 1.7 mm pads on 2.54 mm pitch (J1's odd row) with a track threading the 0.84 mm gap. Widths 0.15 / 0.20 / 0.25 / 0.30 mm. | Does 0.20 survive with copper intact on both sides? That is the board's tightest feature. |
| B | Clearance ladder, 0.20 → 0.50 mm. | The smallest gap your cutter actually separates. Must be ≤ 0.30 mm for this board. |
| C | Track width ladder, 0.15 → 0.50 mm. | Measure the finished width. The difference from nominal is your kerf, and that is the number FlatCAM/pcb2gcode wants. |
Plus eight 1.00 mm holes, so the drill gets a look-in too.
What you are looking for on section A: the track unbroken, and clean copper
still on both pads. If the 0.20 column is marginal, you have three options —
slow the feed, level more finely, or go back to gen_pcb.py and give BCLK and
DIN a two-layer escape instead.
F.Cu. max98357hat.kicad_dru makes tracks or zones on B.Cu
an error and bans vias outright.KI9NG:WireJumper_P*mm_THT). Expect a
handful, mostly around GND and the three I2S lines fanning out from J1.F.Cu.Full details live in the Optiplex wiki at projects/cnc-3018-fluidnc. What
matters when running this job:
Connection is wireless. The controller is an ESP32 on WiFi at
192.168.1.238 (Web UI on port 80). No USB cable needed. bCNC talks to it
through pyserial's URL handling - put socket://192.168.1.238:23 in the
port field instead of a /dev/tty* device. Confirm FluidNC's telnet: block is
enabled in config.yaml first; only port 80 has ever shown up in network scans.
Stream the probe, run the cut from SD. Probing is interactive and low rate,
so streaming over WiFi is fine. The isolation pass is thousands of short
segments over a long run, and a dropout stalls it - the controller has a
documented history of falling off the network. So: probe over WiFi, apply the
map, save the corrected file, upload to SD, run from SD. Flash is not an
alternative; /files is 192 KB and isolation gcode is far bigger.
Two FlatCAM output settings that will otherwise cost you a run - both learned the hard way, both recorded in the wiki:
1. The relay: spindle has no speed_map, so
FluidNC caps S at 1. M3 S1000 is rejected, not clamped. Set FlatCAM's
spindle speed to 1 so it emits M3 S1.), then parses the rest as code - error 2, ALARM:17, halt mid-file.
FlatCAM writes a header comment block, so grep the .nc for nested parens
before running it.There is no homing. No limit switches, must_home: false, no limit pins.
$H returns error:5 and always will. Zero manually with
G10 L20 P1 X0 Y0 Z0 or the Web UI buttons. Over-travel raises no alarm either
Probe input is already wired: gpio.22, put there for exactly this job.
Other limits worth knowing: X/Y max 1500 mm/min, Z max 600, Z 400 steps/mm (correct - aftermarket 4 mm lead screw, do not "fix" it to 800).
Tape on the surfaced spoilboard, tape on the back of the copper-clad, thin CA between the two tape layers. Not edge clamps.
This matters for more than convenience: adhesion across the whole area makes the board conform to the flat spoilboard instead of bridging between clamps, so most of the stock's own variation gets pushed to the top surface where the probe can see and correct it. Nothing sits in the tool path either.
Consequences:
CAM-only. The board design is byte-for-byte rev 5 — same schematic, layout,
gerbers and isolation passes. Do not re-run gen_pcb.py / gen_route.py /
gen_cam.py unless the layout is genuinely changing.
The clearing pass destroyed it. Worth being precise about why, because every check that existed at the time passed:
commanded_Z − map(x,y) was verified constant at −0.1200
with 0.1 µm spread across every filegen_clear.py placed its first ring so the cutter's inner edge landed on the
outer edge of the pass-1 isolation kerf, so the two operations would "meet
with no ridge". That put the cutting edge 0.254 mm from every trace, and
0.254 mm is inside this machine's backlash.
Two mistakes. The first is arithmetic: four isolation passes had run, not one, so in open field the V-bit had already cleared out to ~1.08 mm and the end mill had a mile of empty space to work in. The second is the one that generalises:
The V-bit may run 0.152 mm from copper because the same tool in the same setup is what defined where the copper edge is — that cut is self-referencing and cannot be misregistered. The end mill is a different tool seated after a tool change and inherits none of that guarantee. Backlash on the direction reversals of a contour-parallel path, collet runout and diameter tolerance all have to fit inside its margin.
So the threshold is per-tool, and it is checked at the cutting edge, not the path centre:
| Tool | Path-to-copper | Basis |
|---|---|---|
| V-bit (isolation, whisker) | ≥ K/2 = 0.152 mm | self-referencing |
| end mill (clear, cutout) | ≥ tool_r + 0.5 mm | machine tolerance |
| Rev 5 | Rev 6 | |
|---|---|---|
| Board design | — | unchanged |
| End mill standoff from copper | 0.254 mm | 0.500 mm (measured 0.493) |
gen_clear.py | --iso-kerf / --meet | --margin, default 0.5 |
| Bad file written? | yes, silently | refuses to write |
| Whisker leftover | 99 mm² | 289 mm², 0.00% uncovered |
| Independent check | none | preflight.py |
check_gaps.py CLEAR_REACH | 2.008 mm | 2.500 mm = 2*(tool_r + margin) |
preflight.py is new and is the important part. It reads finished G-code back
and measures it against copper parsed from max98357hat.kicad_pcb — not
from the isolation file that generated the paths. A generator checked against
its own offset arithmetic always agrees with itself, which is precisely how rev
5 passed everything.
Correcting CLEAR_REACH immediately exposed three whiskers at 2.200 mm gaps
that had been classified as MILLED. r6-whisker.nc covers them; the two checks
now agree.
Raising the margin left 289 mm² more waste copper standing and grew the whisker pass from 2752 mm to 7618 mm of cutting.
Waste copper left standing near a trace is the whisker pass's job and it is cosmetic. A clipped trace is fatal. Never trade machine margin to avoid a ridge.
The V-bit can safely absorb that work: gen_whisker.py's pocket() keeps the
cutter centre inside island.buffer(-kerf/2), so it cannot reach a trace even
if the geometry is wrong. Always give it --clear, or it will not know what the
end mill no longer reaches and will leave the handoff band standing.
Still open: the whisker feed rate. Left deliberately for the owner, because
7.6 m of cutting in FR4 wears the V-bit tip and the tip diameter is what the
whole kerf calibration rests on. Options and the runbook are on the Optiplex
wiki at projects/max98357hat-rev6.md.
Three changes, all from measurements off the real modules.
Each module's speaker connector faces up into the HAT, so the board now has
an 8 × 4 mm oval through it over each one, centred on the pin row
left-to-right and 13 mm from the row across the module. Measured values, set
at the top of gen_pcb.py as SPK_OVAL_W / SPK_OVAL_H / SPK_OFFSET.
They are drawn as closed gr_poly stadiums, not arc-plus-line. Arcs are more
idiomatic but KiCad rounds each element's endpoints to the gerber grid
independently, and a sub-micron mismatch stops polygonize() closing the shape —
which is exactly the failure that had gen_cutout.py milling the camera slot
instead of the board. A polygon has no seams to mismatch.
Verified after generating: both ovals milled at the right size, tool centre 0.75 mm inside each wall, and nearest copper 1.30 mm (U1) and 0.50 mm (U2).
The camera slot is gone — no camera on this build, and it was expensive: a 2 × 17 mm hole through the middle-right of the board that no track could cross. It is what forced GND's bottom run up to y=35 on rev 4. The display-flex notch stays.
MODULE_W = 18.0 is now measured and confirmed (7-pin row spanning
15.24 mm, 1.38 mm of PCB either side).
MODULE_H was 13.0 and that was impossible: a speaker connector 13 mm from the
pin row with a 4 mm body needs at least 2.0 + 13.0 + 2.0 = 17 mm of module.
It is 18.0 now — inferred, not measured, so it is still the number most
worth checking with calipers. Everything about the module spacing derives from it.
The consequence: at rev 4's 15 mm stagger the two module PCBs would have overlapped by 3 mm. U2 drops from y=33 to y=38, restoring a 2 mm gap (U1 body y 16–34, U2 body y 36–54). All five routing lanes move up into y 20–28, because U1's speaker cutout at y 29–33 now occupies where three of them used to run.
Still two wire links, still 0 DRC errors, 0 unconnected, no whiskers and nothing below the kerf.
The board is nearly full. U2's body reaches y=54 with 2 mm to the edge, and the module-to-module gap is 2 mm. Both figures rest on the inferred
MODULE_H. Measure before milling.
Both amplifier modules now mount the same way up.
On rev 3 they did not look it. U2 was rotated 180°, which is an in-plane turn, not a flip — both modules were on the same side with the same face down, and neither was upside down. But the rotation pointed the two module bodies, and therefore their speaker connectors, at opposite edges of the board. U1's faced the bottom edge; U2's faced back over the Pi. That is a real assembly nuisance and it is what rev 4 fixes.
The 180° was not decoration. It reversed U2's pin order, which made the five module nets nest — arcs that never cross, so the whole board routed on one layer with three wire links. Undo it and the five runs become parallel, and parallel chords at the same y cross each other in all ten pairs, which is four or five extra links.
The fix is to stagger U2 in both axes rather than sit it alongside: U1 at (8, 18), U2 at (27, 33). Now every net leaves U1 downward, crosses in its own lane, and drops onto U2 — an L per net, no crossings, provided the lanes are ordered so the net whose U1 pin is furthest right gets the shallowest lane. See How it fits on one layer.
The result costs two wire links, not five, and not rev 3's three:
GND_MOD and DIN_ESC disappear as separate nets.
Going double-sided was considered and rejected, and the reason is worth recording because the orientation question keeps leading back to it:
B.Cu ring on their pins sits
under the part body where an iron cannot reach it.Double-sided would have increased the hand-soldered connection count. The links that remain are topological crossings, and no second layer removes those without plated vias.
gen_pcb.py had a stale mirror_x() docstring claiming "U2 needs this so
the three I2S chords nest instead of interleaving." U2 never used it — it used
rot=180. Mirroring a physical THT module means flipping it component-face-up
against the HAT underside, which is the genuinely upside-down case. Deleted.MODULE_H is UNVERIFIED, and rev 4 leans on it harder than rev 3 did: the
stagger leaves exactly 2 mm between the two module PCBs (U1 body ends at
y=29, U2 starts at y=31). Measure a real module before milling — if it is taller
than 13 mm across, the two will foul each other.
Rev 3 is largely rev 2 undone. Rev 2 was designed under a clearance rule
that has since been retracted, and almost everything it changed was damage.
Process detail lives in the OptiPlex wiki at projects/pcb-isolation-milling,
under Gap width: the rule is a BAND, not a minimum; what follows is what it
means for this board.
Rev 2's premise was "no net may be routed through a gap narrower than 0.6 mm, so two isolation passes fit", ratcheted to 0.83 mm for measurement slop. It came from a real observation — rev 1 was close to unsolderable — but the diagnosis was wrong.
A gap does not need to fit two passes. The tool cuts from both sides at once, so at the measured kerf K = 0.304:
| Gap | What happens |
|---|---|
| < 0.304 | not cut at all — the features stay shorted. The only true failure. |
| 0.304 – 0.608 | fully cleared by pass 1. Ideal. |
| 0.608 – 0.821 | a whisker survives (pass 2 cannot enter) |
| 0.821 – 1.125 | clear again |
Small gaps are the best case. What actually made rev 1 unsolderable was the
continuous copper field left standing outside the isolation moat, and the fix
for that is to mill the field away with an end mill (gen_clear.py), which
is now a house rule for every board. Once that exists the clearance floor has
no job left to do.
The floor was kept anyway, and it cost: the board grew 5 mm, the display-flex notch was deleted, two extra wire links appeared, and the module pads were shrunk. All of it reverted here.
| rev 2 | rev 3 | why | |
|---|---|---|---|
| Outline | 65 × 61 | 65 × 56 | HAT spec; the extension only existed to escape pins 12/40 radially |
| Display-flex notch | deleted | restored | at 0.4 mm clearance +5V fits its lane with 0.5 mm either side |
| Wire links | 5 (JP1–JP5) | 3 (JP1–JP3) | JP4/JP5 only hopped the pad rows the extension created |
| Header escapes | radial, 0.4 mm | through the pad gap, 0.2 mm | rev 1's escape, with real margin this time |
| Pads 11/13/37/39 | 1.7 mm | 1.5 mm | channel 0.320 → 0.420, margin over K +0.016 → +0.116 |
| Module pads | 1.6 mm | 2.0 mm | ring 0.30 → 0.50 and gap 0.94 → 0.54 (pass 1 clears it) |
| Netclass clearance | 0.83 mm | 0.4 mm | the band rule, not a floor |
| Min track | 0.4 mm | 0.2 mm | the two escapes, and nothing else |
DRU milling_isolation | 0.83 mm | 0.4 mm | |
| JP3 | y 43.24 | y 43.04 | its pad sat 1.800 mm off the DIN arc: too wide for pass 4 (needs 1.854), too narrow for the end mill (needs 2.008). 2.000 mm is fully cleared by pass 4 |
| Isolation passes | 3 | 4 | 4 is what this board's gap table needs; see iso.tcl |
The generalisable lesson, twice over: pad diameter is local, board outline is global. When something will not fit through a gap, shrink what bounds the gap before moving anything else. Rev 2 got this backwards on the header escapes and again on the module pads, and it cost a whole revision.
./check_gaps.py classifies every copper-to-copper gap on the board into the
bands above. Run it after any change to pad diameters, track widths or the
netclass — DRC only answers "is this gap ≥ the rule", which is not the question
a milled board asks.
Rev 3 reports: no gap below the kerf, and no whiskers. 14 gaps land in the ideal band (the 0.420 escapes, the 0.500 +5V lane, the 0.540 module pads), 91 are cleared by a later isolation pass, and the rest are open field for the end mill. KiCad DRC is clean too: 0 errors, 0 unconnected.
The leftover copper the whisker pass still picks out (~31 mm², 65 islands) is field copper in places a 1.5 mm end mill cannot enter — mostly between header pads on 2.54 mm pitch. That is a tooling limit, not a layout defect. A 0.8 mm end mill would reach most of it.
All boards get rounded corners. The template's 3 mm arcs are used as-is now
that rev 2's extend_top_edge() is gone.
gen_cutout.py had a bug that only appears with the notch restored. KiCad
rounds each Edge.Cuts element to the gerber grid independently, so the notch
fillet met the left edge at 0.499100 on one element and 0.500000 on the
other — a 0.9 µm gap. polygonize would not bridge it, the board profile
never closed, and the script silently picked the largest thing that did
close: the 2 × 17 mm camera-flex slot. It would have milled that and called it
the outline.
Fixed two ways: endpoints within 5 µm are snapped together, and the result is now checked against the gerber's full extent — if the largest closed polygon does not span everything drawn, the script refuses to emit a file instead of guessing. Verified after the fix: 65.00 × 56.00, corner path radius 3.75 mm throughout (3.0 + tool radius), and 568 points down inside the notch.
| Qty | Value | Footprint | References |
|---|---|---|---|
| 4 | MountingHole_2.7mm_M2.5 | MountingHole:MountingHole_2.7mm_M2.5 | H1, H2, H3, H4 |
| 2 | Deans_Ultra_T-Plug | KI9NG:Deans_Ultra_T-Plug_Male_Vertical_THT | J2, J3 |
| 1 | BMS_3S_20A | cyberdeck:BMS_3S_20A | BMS1 |
| 1 | Conn_01x04 | KI9NG:JST_XH_B4B-XH-A_1x04_P2.50mm_Vertical_Milled | J1 |

ki9ng/cyberdeck
Portable ham/SDR cyberdeck in a Harbor Freight Apache 1800: PCBs, the CNC toolchain that mills them at home, case bezels, and the software that runs on it. CC0.


joeycastillo/Sensor-Watch
A board replacement for the classic Casio F-91W wristwatch

rusefi/rusefi
rusefi - GPL internal combustion engine control unit

hoglet67/RGBtoHDMI
Bare-metal Raspberry Pi project that provides pixel-perfect sampling of Retro Computer RGB/YUV video and conversion to HDMI

shanteacontrols/OpenDeck
A platform for building interactive control and sensing devices using MIDI and OSC built with Zephyr RTOS.

hackclub/OnBoard
💡 Join 1,000 teenagers and make your first PCB with a $100 grant!

dangiu/PicoMemcard
Emulating PSX Memory Card (or controller) using a Raspberry Pi Pico

OLIMEX/DIY-LAPTOP
Do It Yourself Open Source Hardware and Software Modular Hacker's Friendly Laptop
It measures 84 × 53 mm and has 2 copper layers, 1.6 mm thick.
8 parts. The full list of its parts is on this page, with links to each part's symbol and footprint where PCBKit's library has them.
From the ki9ng/cyberdeck repository on GitHub, at the commit this page was made from: the board is hardware/max98357hat/cyberdeck/cyberdeck.kicad_pcb and its schematic hardware/max98357hat/cyberdeck/cyberdeck.kicad_sch. PCBKit opens the board in the browser, with nothing to install.
Its repository publishes it under the CC0-1.0 licence: you may reuse it on that licence's terms (keep the author's notice, and for a copyleft licence share your changes alike).