Skip to the content
2 layers84 × 53 mmCyberdeck, the printed circuit board in 3D

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.

Size
84 × 53 mm
Copper layers
2
Thickness
1.6 mm
Parts
8
Nets
5
Pads
18
Tracks, vias
16, 0
Routed
Yes
KiCad
KiCad 10.0 (file 20260206)
Created
6 Aug 2026
Updated
6 Aug 2026

Overview

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.

README

hardware/max98357hat/README.md

max98357hat — mono I2S amplifier HAT for Raspberry Pi 3B+

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:

  • Single-sided. All copper on F.Cu (top). No plated holes, no vias.
  • Entirely through-hole. Not one SMD part.
  • 0.4 mm clearance, 0.2 mm minimum track (see Routing — two escapes need it).

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.

How it goes together

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.

SideParts
Underneath (soldered from top)J1, U1, U2
On top (soldered on top)everything else

⚠ Verify before you cut copper

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:

text
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  - MEASURED

2. 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.


Files

FileWhat it is
max98357hat.kicad_schFull schematic. ERC clean.
max98357hat.kicad_pcbBoard outline, mounting holes, all parts placed. Unrouted.
max98357hat.kicad_druCustom rules that enforce single-sided: no copper on B.Cu, no vias.
max98357hat.kicad_proProject settings.
gen_sch.py / gen_pcb.pyRegenerate the schematic / board.
check_gaps.pyClassify every copper gap into the kerf bands. The real pre-design check — DRC only answers "is this gap ≥ the rule".
preflight.pyRe-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.pyRegenerate the symbol + footprints into KI9NG.

New parts in your KI9NG library:

  • MAX98357A_Module — 7-pin symbol for the module, not the bare IC
  • MAX98357A_Module_7pin_THT — the one this board uses
  • MAX98357A_Module_9pin_THT — variant that also breaks out the speaker pins
  • SolderPad_Power_2pos_P10mm_THT — 5 V input pads
  • WireJumper_P{5.08,7.62,10.16,15.24}mm_THT — for crossings

Workflow

  1. Open max98357hat.kicad_pro.
  2. Pcbnew → Tools → Update PCB from Schematic (F8). Everything is already placed, so this only assigns nets.
  3. Route on 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.


Channel selection — nothing fitted

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 / R2V_SDChannel
not fitted0.45 V(L+R)/2 mono
100 k1.81 VLeft
330 k1.07 VRight

Gain

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.


Power

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+).

Raspberry Pi side

SignalGPIOHeader pin
BCLKGPIO18 / PCM_CLK12
LRCLKGPIO19 / PCM_FS35
DINGPIO21 / PCM_DOUT40

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:

text
dtparam=audio=off
dtoverlay=hifiberry-dac

Both modules will play the same mono mix.


BOM

That's the whole thing:

RefValuePackageNotes
U1, U2MAX98357A modulegeneric breakoutunderneath, soldered direct, both mono
J12x20 female socket2.54 mm THTunderneath, soldered from top
J4—solder pads5 V input
R1, R2not fittedaxial 1/4 Wchannel select — see below
JP1, JP2wire linkWireJumper_P5.08mm_THTthe two unavoidable crossings
H1–H4—M2.5, 2.75 mmnon-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.

Routing

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.

How it fits on one layer

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.

NetU1 pinlaneU2 pin
+5V_MOD23.24y 23.542.24
GND20.70y 26.039.70
DIN13.08y 28.032.08
BCLK10.54y 29.529.54
LRCLK8.00y 31.027.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.

The two wire links

Rev 4 needs two, down from three on rev 3 — the stagger pays for itself.

LinkSpansWhy
JP1x 10.54, y 10.2 → 15.28BCLK 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.
JP2x 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.

The two 0.2 mm escapes

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 padChannel each sideMargin over K (0.304)Annular ring
1.7 mm0.320+0.0160.350
1.5 mm0.420+0.1160.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.

Header pins used

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.

CAM files

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-.

FileWhat
max98357hat-F_Cu.gbrthe only copper layer
max98357hat-Edge_Cuts.gbroutline + display notch + 2 speaker cutouts
max98357hat-PTH.drl58 x 1.00 mm, 2 x 2.50 mm
max98357hat-NPTH.drl4 x 2.70 mm mounting holes, must stay unplated

Settings that matter, and why:

  • Not mirrored. Copper is on top and the board is clamped copper-up, so what Pcbnew shows is what the spindle cuts. Verified by checking that J4's square pad plots at the lower X, not the higher one.
  • Origin on the board's bottom-left corner, not the page corner. All four files come out 0..65 x 0..56 with Y up, so no negative coordinates reach GRBL.
  • X1 Gerber, no netlist attributes. FlatCAM and pcb2gcode both dislike X2.
  • PTH and NPTH separate, so the mounting holes stay unplated and isolated.

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.

Running the job

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.

FileToolWhat
r6-iso.ncV-bit4 isolation passes, merged innermost-first, 79 plunges / 2544 mm
r6-whisker.ncV-bitleftover islands, 100.00% covered, 7618 mm (~63 min at 120 mm/min - feed decision still open)
r6-drill-1.0.nc1.0 mm drill58 holes
r6-clear.nc1.5 mm end millmills the field copper away, 17 rings / 2246 mm, ~7.5 min. Cutting edge stands off 0.500 mm from copper
r6-cutout.nc1.5 mm end mill4× 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.

  1. V-bit — probe: ./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.
  2. V-bit — level: ./level_gcode.py -i cam-rev6/r6-iso.nc -p cam-rev6/r6-hat.probe -o cam-rev6/r6-iso-lvl.nc
  3. V-bit — level: ./level_gcode.py -i cam-rev6/r6-whisker.nc -p cam-rev6/r6-hat.probe -o cam-rev6/r6-whisker-lvl.nc
  4. V-bit — run r6-iso-lvl.nc
  5. V-bit — run r6-whisker-lvl.nc (same tool, no change, no re-zero)
  6. 1.0 mm drill — change tool, re-zero Z, run r6-drill-1.0.nc (not levelled — it goes clean through)
  7. 1.5 mm end mill — change tool, re-zero Z, re-reference the map to the touch-off point (map − map(x0,y0), (0,0) unless you chose otherwise), level r6-clear.nc, run it
  8. 1.5 mm end mill — run r6-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.

FlatCAM will not render G36/G37 regions

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.

Test coupon

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:

TestsRead it as
AThe 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.
BClearance ladder, 0.20 → 0.50 mm.The smallest gap your cutter actually separates. Must be ≤ 0.30 mm for this board.
CTrack 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.

Milling notes

  • All copper on F.Cu. max98357hat.kicad_dru makes tracks or zones on B.Cu an error and bans vias outright.
  • Cross signals with a wire link (KI9NG:WireJumper_P*mm_THT). Expect a handful, mostly around GND and the three I2S lines fanning out from J1.
  • Pour GND on F.Cu.
  • Mounting holes stay non-plated and isolated from GND.

The machine: Genmitsu 3018-PRO, MKS DLC32 / FluidNC v4.0.3

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:

  • Spindle speed must be 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.
  • No nested parentheses in comments. GRBL closes a comment at the first ), 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

  • the gantry just crashes into its own end.

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).

Workholding: painter's tape + CA glue

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:

  • No cutout tabs. The board is held over its whole area, so the finished piece cannot break loose. Cut the outline through in one continuous pass - cleaner edge, no tab stubs to file. Set cutout depth to board thickness + ~0.2 mm to get through the tape without gouging the spoilboard.
  • The tape is part of the probed surface. Two layers plus glue is roughly 0.25-0.35 mm and is not perfectly uniform, so the height map will not match caliper readings of bare stock. That is fine, the map corrects the real surface - just do not go hunting for a board defect that is actually tape.
  • Glue thin and even. Puddles of CA are local high spots. A sparse pattern of small dots pressed down firmly beats a bead.
  • Probe only after it has fully cured, or the surface settles between probing and cutting and the map goes stale.
  • To remove: flex it off gently, or isopropyl / CA debonder along the edges. Go slowly - a milled board with cutouts is weaker than a solid blank.
  • The template's PoE keepout has been removed at your request. The 3B+ does have the 4-pin PoE header (J14) and its pins stand proud — if yours is fitted, they sit under this board. Nothing of ours is on the underside there, but keep an eye on it when you pour GND, or put tape over the pins.

Rev 6 — 2026-08-04

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.

Rev 5 cut every trace off the board

The clearing pass destroyed it. Worth being precise about why, because every check that existed at the time passed:

  • the file was correct by its own arithmetic
  • it was uploaded and size-verified byte-for-byte against local
  • it was levelled, and commanded_Z − map(x,y) was verified constant at −0.1200 with 0.1 µm spread across every file
  • the correct 1.5 mm end mill was in the spindle

gen_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:

ToolPath-to-copperBasis
V-bit (isolation, whisker)≥ K/2 = 0.152 mmself-referencing
end mill (clear, cutout)≥ tool_r + 0.5 mmmachine tolerance

What changed

Rev 5Rev 6
Board design—unchanged
End mill standoff from copper0.254 mm0.500 mm (measured 0.493)
gen_clear.py--iso-kerf / --meet--margin, default 0.5
Bad file written?yes, silentlyrefuses to write
Whisker leftover99 mm²289 mm², 0.00% uncovered
Independent checknonepreflight.py
check_gaps.py CLEAR_REACH2.008 mm2.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.

The cost, and why it is the right trade

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.

Rev 5 — 2026-08-01

Three changes, all from measurements off the real modules.

Speaker cutouts

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).

Camera-flex slot deleted, display notch kept

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_H was wrong, and the modules had to move

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.

Rev 4 — 2026-08-01

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.

What double-sided would not have fixed

Going double-sided was considered and rejected, and the reason is worth recording because the orientation question keeps leading back to it:

  • There is no plating. Every layer change would be a hand-fitted wire rivet soldered on both faces — the same bodge wire, more of them, harder to fit.
  • J1, U1 and U2 all mount underneath, so any B.Cu ring on their pins sits under the part body where an iron cannot reach it.
  • Both faces would need milling with flip registration, on a machine with no homing and no fiducial workflow.

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.

Two traps this revision hit

  • 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.
  • The camera-flex slot is at y 36–53, not 37–52 as this file used to say. DRC caught GND's run sitting exactly on its edge. The run is at y=35.0 now, 0.5 mm off U2's pad row and 0.5 mm off the slot.

Still to verify before cutting

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 — 2026-08-01

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.

The rule that was wrong

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:

GapWhat happens
< 0.304not cut at all — the features stay shorted. The only true failure.
0.304 – 0.608fully cleared by pass 1. Ideal.
0.608 – 0.821a whisker survives (pass 2 cannot enter)
0.821 – 1.125clear 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.

What changed from rev 2

rev 2rev 3why
Outline65 × 6165 × 56HAT spec; the extension only existed to escape pins 12/40 radially
Display-flex notchdeletedrestoredat 0.4 mm clearance +5V fits its lane with 0.5 mm either side
Wire links5 (JP1–JP5)3 (JP1–JP3)JP4/JP5 only hopped the pad rows the extension created
Header escapesradial, 0.4 mmthrough the pad gap, 0.2 mmrev 1's escape, with real margin this time
Pads 11/13/37/391.7 mm1.5 mmchannel 0.320 → 0.420, margin over K +0.016 → +0.116
Module pads1.6 mm2.0 mmring 0.30 → 0.50 and gap 0.94 → 0.54 (pass 1 clears it)
Netclass clearance0.83 mm0.4 mmthe band rule, not a floor
Min track0.4 mm0.2 mmthe two escapes, and nothing else
DRU milling_isolation0.83 mm0.4 mm
JP3y 43.24y 43.04its 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 passes344 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.

Verifying it

./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.

Rounded corners on Edge.Cuts — and a trap

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.

Parts

8
QtyValueFootprintReferences
4MountingHole_2.7mm_M2.5MountingHole:MountingHole_2.7mm_M2.5H1, H2, H3, H4
2Deans_Ultra_T-PlugKI9NG:Deans_Ultra_T-Plug_Male_Vertical_THTJ2, J3
1BMS_3S_20Acyberdeck:BMS_3S_20ABMS1
1Conn_01x04KI9NG:JST_XH_B4B-XH-A_1x04_P2.50mm_Vertical_MilledJ1

More from ki9ng/cyberdeck

1
  • 2 layers65 × 56 mmMax98357hat, the printed circuit board in 3D

    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.

    10 partsCC0-1.0

Similar boards

  • 2 layers20.5 × 24.8 mmPi interface, the printed circuit board in 3D

    Pi interface

    opulo-inc/lumenpnp

    The LumenPnP is an open source pick and place machine.

    8 parts3.7k
  • 2 layers59.9 × 25.7 mmBuffer, the printed circuit board in 3D

    Buffer

    hoglet67/RGBtoHDMI

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

    8 parts1kGPL-3.0
  • 2 layers52 × 12.6 mmUSB, the printed circuit board in 3D

    USB

    shanteacontrols/OpenDeck

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

    8 parts960Apache-2.0
  • 2 layers87.5 × 48.5 mm7 input macropad, the printed circuit board in 3D

    7 input macropad

    hackclub/OnBoard

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

    8 parts915
  • 2 layers36.2 × 63.1 mmPicoMemcard pico, the printed circuit board in 3D

    PicoMemcard pico

    dangiu/PicoMemcard

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

    8 parts694GPL-3.0
  • 2 layers36 × 10 mmPCB4-PWR BTN Rev.A, the printed circuit board in 3D

    PCB4-PWR BTN Rev.A

    OLIMEX/DIY-LAPTOP

    Do It Yourself Open Source Hardware and Software Modular Hacker's Friendly Laptop

    8 parts578Apache-2.0

Questions

How big is the Cyberdeck PCB?

It measures 84 × 53 mm and has 2 copper layers, 1.6 mm thick.

What parts does the Cyberdeck use?

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.

Where can I download the Cyberdeck KiCad files?

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.

Can I use the Cyberdeck design in my own project?

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).