KiCad / ESP32 / 3D printing

Custom
PCBs

I designed a circuit board for a toy, had it made, soldered it, and never finished the toy.

The toy was a treasure detector. Hold the button and it starts beeping. Get closer to something and the beeps speed up. A knob changes the sound. Inside is an ESP32, an ultrasonic distance sensor, a small amp, and a speaker.

The assembled board: an Arduino Nano ESP32, a boost converter and an I2S amp on a black PCB with white JST connectors

This started as a breadboard and a pile of jumper wires. It worked, so I kept going: a printed case, then a second case, then a schematic, then a real circuit board.

Getting the boards back was my favorite part of the whole project. Every part plugs into a labeled connector, and the mess of wires is gone. It's also why this page is about a toy that doesn't exist yet. The last step was making the cables that connect the board to the button, knob, sensor, and speaker, and I never did it.

The Board

Loading the board…

The 3D model straight out of KiCad. The Nano, amp, and boost converter don't have 3D models, so you see their outlines on the silkscreen instead.

Most of the parts are breakout boards I already had on the breadboard, so the PCB is mostly sockets. Everything that lives off the board, like the button, knob, and battery, gets a JST connector with its name printed next to it.

A1Arduino Nano ESP32Runs the sketch
U4HC-SR04 ultrasonic sensorMeasures how close you are
U2MAX98357A I2S ampDrives the speaker
U1HW-045 boost converterSteps the battery voltage up
SW3Push buttonHold to scan
SW1KY-040 rotary encoderThe sound knob
SW2Power switchOn and off
BT1Battery terminalLiPo in
D1RGB LED, R1–R3 100ΩColor shows how close you are

From Schematic to Board

Everything was drawn in KiCad 9. One schematic sheet, then a two-layer board laid out around the modules.

KiCad schematic connecting the Nano ESP32 to the amp, speaker, ultrasonic sensor, encoder, RGB LED, buttons and boost converter
The schematic. One sheet.
The PCB layout in KiCad's PCB editor, with red front-copper and blue back-copper traces
The layout. Red is front copper, blue is back.
55 × 80 mm
board
2
copper layers
39
nets
72
pads

Back From the Fab

A stack of bare black PCBs held in one hand
You can't really order just one, so I have a stack.
A bare board with the Nano ESP32 sitting on it to check the fit
Checking that the Nano fits before soldering anything.
The soldered board with the Nano, boost converter, amp and connectors
Soldered up. No loose wires anywhere.

Two Enclosures

Version 1

The first case was built around the breadboard parts. The ultrasonic sensor's two transducers became the eyes, and the scan button went on the side.

Yellow printed case with the ultrasonic sensor's two round transducers on the front
The yellow case from the side, with a green push button, next to the breadboard prototype

Version 2

Once the PCB was on its way, I designed a handheld case around it. The board screws to the bottom shell, the speaker sits under a grille on top, the knob sticks out the front, and the scan button is where your thumb lands.

CAD render of the tan handheld case with a black speaker grille
CAD view of the case opened up, with the PCB mounted in the bottom shell
The tan case printing on the 3D printer bed
Two of the case parts on the print bed.
The printed tan case held in one hand, showing the speaker grille and knob
The grille, the knob shaft, and the green scan button.
The tan case held at an angle, with the soldered PCB on the desk behind it
The finished board is on the desk behind it.

The Code

The firmware is one Arduino sketch. While the button is held, the loop reads the distance sensor, picks a beep speed and an LED color, and plays a short sine-wave beep out of the ESP32's DAC.

This is the breadboard version, which ran the speaker through the DAC and a transistor. The PCB moved to a Nano ESP32 and an I2S amp, and the sketch never got updated for it. The knob is read, but not hooked up to the sound yet.

DistanceLEDBeep
Over 24 inRedEvery 800 ms
4 to 24 inBlueEvery 150 ms
Under 4 inGreenEvery 50 ms
beeping_scanner.ino
C++ / Arduino · 258 lines
#include <RotaryEncoder.h>
#include <Arduino.h>

// Amp Transistor
// amp L -> emitter (left)
// 14 -> resistor -> base (center)
// 25 (DAC) -> collector (right)

// Distance Sensor echo
// Echo -> 2k Resistor -> 22
// Echo 2k Resistor -> 1k Resistor -> Ground

// PINS
const int speakerPin = 25; // transistor collector
const int ampPin = 14; // transistor base
const int buttonPin = 32;
const int trigPin = 33;
const int echoPin = 22;
const int ledRedPin = 18;
const int ledGreenPin = 19;
const int ledBluePin = 21;
const int encoderClkPin = 17;
const int encoderDtPin = 15;
const int encoderSwPin = 16;

const int toneFrequency = 440;
const int farAwayInterval = 800;
const int farAwayVolume = 70;
const int closerInterval = 150;
const int closerVolume = 80;
const int superCloseInterval = 50;
const int superCloseVolume = 120;
const int LEDC_BASE_FREQ = 5000;
const int PWM_FREQ = 20000;     // 20 kHz
const int PWM_RES = 8;          // 8-bit resolution

// dynamic global variables
int currentLed = ledRedPin;
bool playing = false;
int encoderLastPos = -1;
float currentDistance = 0;
int currentVolume = farAwayVolume;
int currentSoundInterval = farAwayInterval;
float globalVolume = 0.3; // 80% normal volume

// classes
RotaryEncoder encoder(encoderClkPin, encoderDtPin);

void setup() {
  Serial.begin(115200);
  delay(1000);
  Serial.println("Starting sketch...");

  pinMode(buttonPin, INPUT_PULLUP);
  pinMode(trigPin, OUTPUT);
  pinMode(echoPin, INPUT);
  pinMode(ampPin, OUTPUT);
  pinMode(encoderSwPin, INPUT_PULLUP);
  pinMode(encoderClkPin, INPUT);
  pinMode(encoderDtPin, INPUT);
  ledcAttachChannelAdvanced(ledRedPin, 20000, 8, 0, 1);
  ledcAttachChannelAdvanced(ledGreenPin, 20000, 8, 1, 1);
  ledcAttachChannelAdvanced(ledBluePin, 20000, 8, 2, 1);

  // silence on the DAC
  silenceDac();

  startupSequence();
}

void loop() {
  handleEncoder();

  if (mainButtonDown()) {

    // We don't want to continuously turn on the amp pin
    if (!playing) {
      Serial.println("Button held");
      digitalWrite(ampPin, HIGH);
      playing = true;
    }

    float distance = measureDistanceInches();
    currentDistance = distance;

    Serial.print("Distance: ");
    Serial.print(distance);
    Serial.println(" inches");

    if (distance < 4.0 && distance > 0.0) {
      currentLed = ledGreenPin;
      currentVolume = superCloseVolume;
      currentSoundInterval = superCloseInterval;
    } else if (distance < 24.0) {
      currentLed = ledBluePin;
      currentVolume = closerVolume;
      currentSoundInterval = closerInterval;
    } else {
      currentLed = ledRedPin;
      currentVolume = farAwayVolume;
      currentSoundInterval = farAwayInterval;
    }

    playPattern();

  } else {
    if (playing) {
      Serial.println("Button released.");
      playing = false;
      silenceDac();
      delay(10); // debounce
    }

    if (digitalRead(ampPin) == HIGH) {
      digitalWrite(ampPin, LOW);
    }
  }
}

void startupSequence() {
  int delayMs = 200;
  ledReset();
  
  ledcWrite(ledRedPin, 255);
  delay(delayMs);
  ledReset();
  
  ledcWrite(ledGreenPin, 255);
  delay(delayMs);
  ledReset();
  
  ledcWrite(ledBluePin, 255);
  delay(delayMs);
  ledReset();

  digitalWrite(ampPin, HIGH);
  playSweepBeep(500);
  delay(500);
  silenceDac();
  digitalWrite(ampPin, LOW);
}

bool mainButtonDown() {
  return digitalRead(buttonPin) == LOW;
}

bool mainButtonUp() {
  return digitalRead(buttonPin) == HIGH;
}

void handleEncoder() {
  encoder.tick();
  int newPos = encoder.getPosition();
  if (newPos != encoderLastPos) {
    encoderLastPos = newPos;
  }

  if (digitalRead(encoderSwPin) == LOW) {
    Serial.println("Button Pressed");
    delay(200);
  }
}

float measureDistanceInches() {
  digitalWrite(trigPin, LOW);
  delayMicroseconds(2);
  digitalWrite(trigPin, HIGH);
  delayMicroseconds(10);
  digitalWrite(trigPin, LOW);

  long duration = pulseIn(echoPin, HIGH, 30000);  // timeout at ~30ms
  float distanceCm = duration * 0.034 / 2.0;
  float distanceIn = distanceCm * 0.3937;
  return distanceIn;
}

void ledReset() {
  ledcWrite(ledRedPin, 0);
  ledcWrite(ledGreenPin, 0);
  ledcWrite(ledBluePin, 0);
}

void playPattern() {
  int interval = currentSoundInterval;
  unsigned long start = millis();
  unsigned long lastBeepTime = 0;
  float beepDuration = 0.8 * interval;
  bool ledOn = false;
  bool beepActive = false;

  while (millis() - start < interval) {
    handleEncoder();

    // while playing, if we release the button, break out of the loop
    if (mainButtonUp()) {
      playing = false;
      break;
    }

    unsigned long now = millis();

    if (!beepActive && now - lastBeepTime >= interval) {
      // Time to start the beep
      turnOnLed();

      playSweepBeep(beepDuration);
      lastBeepTime = now + interval;
      ledOn = true;
      beepActive = true;
    }

    // Turn off LED after interval
    if (beepActive && now - lastBeepTime >= interval) {
      if (ledOn) {
        turnOffLed();
        ledOn = false;
      }
      beepActive = false;
    }
  }

  ledReset();
}

void turnOnLed() {
  ledReset();
  ledcFade(currentLed, 0, 4095, currentSoundInterval / 2);
}

void turnOffLed() {
  ledcFade(currentLed, 4095, 0, currentSoundInterval / 2);
}

void silenceDac() {
  dacWrite(speakerPin, 128);
}

void playSweepBeep(int beepDuration) {
  // Center the DAC before playing
  dacWrite(speakerPin, 128);

  unsigned long start = millis();
  int freq = 400;  // Hz

  while (millis() - start < beepDuration) {
    for (int i = 0; i < 360; i += 10) {
      float radians = radians(i);
      int val = sin(radians) * 127 + 128;  // Full waveform swing

      val = constrain(val, 0, 255);
      dacWrite(speakerPin, val);
      delayMicroseconds(1000000 / (freq * 36));
    }
  }

  // Silence the DAC when done
  dacWrite(speakerPin, 128);
}

Where It Stopped

  • Done: Breadboard prototype that beeps faster as you get closer
  • Done: Arduino sketch for the sensor, LED, and sound
  • Done: Two 3D-printed enclosures
  • Done: KiCad schematic and PCB layout
  • Done: Boards ordered, delivered, and soldered
  • Not done: Wire harnesses from the board to the button, knob, sensor, and speaker
  • Not done: Put it in the case and hand it to a kid

Not Optimizing for Finishing

Every step I took made the toy better and moved the finish line further away.

The breadboard worked. Instead of putting it in a box, I designed a box. Instead of wiring that box by hand, I designed a circuit board. The board brought new parts, which meant the code needed changes, which meant the case needed changes. I learned something from every one of those steps. None of them got the toy into a kid's hands.

The step I skipped was the boring one: crimping a handful of connectors. If I had been trying to finish, I would have put the breadboard in the first case, called it done, and made the PCB for version two.

I still love these boards. Seeing your own silkscreen on a real PCB is a great feeling, and it's the cleanest way I know to get rid of a nest of wires. Now I just do it after the thing works in a box. The LED Matrix Visualizer is what that looks like when I actually finish.

Back to All Projects