As a smart home enthusiast, I rely on smart lighting quite a lot. But when I needed to equip every room in the house with a light controller, sometimes even multiple, I ran into a budget problem. Commercial smart controllers are quite expensive. So, I decided to use a 3D printer, add some electronics, and build a cool custom controller for a fraction of the price.

I’ve designed a small macro pad that uses switches for a mechanical keyboard, an ESP32 board, a rotary encoder for brightness adjustment, and a compact OLED display to show the currently active light scene. The rotary encoder also features a built-in push switch – pressing it toggles the display-dimming mode to adjust the OLED brightness, and clicking it again switches back to regular light control.

To make sure it was more than just another boring plastic box, I decided to combine multiple Prusament Woodfill materials to create nice color combinations that fit the wooden top of my desk. I had the chance to test one of the beta units of the Prusa CORE One+ INDX, so I took advantage of its multi-material capabilities. However, you can easily print this project on an Original Prusa XL, a printer equipped with MMU3, or even a standard single-tool machine without any multi-material capabilities. In that case, you will have to manually swap the filaments a few times during the print, but it is completely doable – though you should expect more waste. Thanks to using the INDX, I ended up with almost zero filament waste – a great bonus for the project, as I saved material that I can now use to print additional macropads or other things.

Hardware

To keep this project accessible, all electronic parts can be sourced for a few bucks. You can, of course, omit the display and the encoder and stick just to the switches, or even reduce the number of keys.

For the brain of the macropad, you can use almost any ESP32 or similar development board with enough inputs and a Wi-Fi connection. High-quality boards from brands like Adafruit (such as the compact QT Py ESP32-S3 or the classic Feather ESP32-S3) or SparkFun (the Thing Plus ESP32-S3) are perfect choices for this. In my case, before I went online shopping, I dug deep into my endless drawer of spare parts and found a similar board, a Xiao ESP32-C3 from Seeed Studio, so I decided to stick with it for this build.

When it comes to the keys, I decided to go with Blue mechanical keyboard switches. It might be a bit niche, and regular buttons would work perfectly fine, but using mechanical switches just works for me because of that satisfying clicky feel and sound.

Here is a quick breakdown of what you will need for the version featured in this article:

Component Approx. Price
ESP32 $8.00
0.91″ OLED Display $4
KY-040 Rotary Encoder $1.30
Mechanical Keyboard Switches (x6) $3.50
Filament ~$3.00
Total Hardware Cost ~$19.80

3D Printing & Design Customization

Now it’s time to prepare the 3D models for printing. You can create them yourself in 3D modeling software such as Fusion, or download the finished files for this project from Printables.

Import the files into PrusaSlicer. For a clean, elegant look, you can print a one-color chassis using Prusament Woodfill Linden Light. However, since imagination has no limits, you can also prepare highly colorful variants – perfect for a kids’ room, like an Avengers-themed style. Next, add SVG modifiers with simple symbols to the keycaps and the rotary knob by right-clicking the model in PrusaSlicer and selecting Add modifier > SVG. If you want a more detailed guide on how to apply SVG modifiers, check out our article on DIY sensory play accessories. Choosing a darker Prusament Chocolate Brown Woodfill for the buttons and using the same bright Linden Light for the symbols creates a very nice contrast. Thanks to multi-material printing on printers such as the Prusa CORE One+ INDX or Original Prusa XL, the result is incredibly precise with zero color contamination and an absolute minimum of waste. The best way to print the keycaps is face down on the bed. In the picture below, they are flipped face up just to demonstrate the SVG designs. Once all the parts are printed, it’s time for assembly.

Assembly & Wiring

First, install the mechanical keyboard switches by simply pushing them through the openings in the chassis until they click into place. Then, install the display, rotary encoder, and the ESP32, inserting its USB-C connector into the prepared opening on the side of the chassis so it faces outward. While the display and the encoder fit quite firmly on their own, securing the ESP32 requires a bit more care. I’ve used a simple printed spacer that you just slide between the chassis wall and the board to wedge it in place, ensuring the ESP32 doesn’t push backward when you plug in the USB-C cable.

The setup occupies a total of eleven pins: six switches, the encoder (2 pins for rotation, 1 for the switch), and the I2C display (2 pins for data/clock). This perfectly matches the number of digital inputs available on my ESP32 board. Below is the wiring diagram created in Wokwi, a great free editor that runs right in your browser. Wokwi also features a built-in simulator, so you can connect all the components and write testing code to ensure your wiring is correct before assembling the physical unit.

Be careful when looking at the board: the physical label printed on the PCB (D0-D10) almost always differs from the internal registers (GPIO) used in the code. Here is the hardware mapping for my ESP32:

5V / 5V0VCC (Shared power line for both the OLED display and Rotary Encoder)

GNDGND (Common ground for all components and mechanical keys)

D0 (Internal GPIO 2)Key 1

D1 (Internal GPIO 3)Key 2

D2 (Internal GPIO 4)Key 3

D3 (Internal GPIO 5)Key 4

D4 (Internal GPIO 6)OLED SDA (Data line)

D5 (Internal GPIO 7)OLED SCL (Clock line)

D6 (Internal GPIO 21)Encoder CLK (Rotation Pin A)

D7 (Internal GPIO 20)Encoder DT (Rotation Pin B)

D8 (Internal GPIO 8)Encoder SW (Encoder Built-in Button)

D9 (Internal GPIO 9)Key 5

D10 (Internal GPIO 10)Key 6

For initial testing, you can connect everything using Dupont connectors, like I did. However, space inside the chassis is tight, and these connectors are not secure enough for daily use. To ensure 100% reliability and a clean fit, I highly recommend soldering everything in place.

Software Configuration (ESPHome)

Now that everything is wired up, it is (vibe)coding time 😎! Connecting the ESP32 to a PC or Mac is easy. Just plug it into your computer using a USB-C cable, download the necessary tools, and upload the code to the board via terminal. On Mac, you can use Python and its ESPHome package, installed using the Python package manager pip via the command pip3 install esphome.

Once the installation is complete, create two YAML files: keyboard.yaml and secrets.yaml. The keyboard.yaml contains all the code for the ESP32, and the secrets.yaml safely stores your Wi-Fi credentials. You will also need a font file. In this project, I’ve used the Nunito font from the Google Fonts library. You can find it here: https://fonts.google.com/specimen/Nunito. Download and extract the file, place the regular weight (Nunito-Regular.ttf) font in the same folder as the other files, and rename it to font.ttf.

Note: This code is written specifically for the Seeed Studio XIAO ESP32-C3. If you are using a different ESP32 board, you will need to adjust the board: parameter and the GPIO pin mappings to match your specific hardware. If you’re not sure how to do it, do not hesitate to ask your favorite AI assistant to help you out! 🙂

Here is the complete keyboard.yaml file. Don’t be intimidated by the long sections marked as lambda:. These handle the background logic, such as converting raw brightness data into clean percentages (%) and formatting the text on the OLED screen.


esphome:
  name: keyboard
  on_boot:
    priority: -100
    then:
      - sensor.rotary_encoder.set_value:
          id: rotary_knob
          value: 10
      - sensor.template.publish:
          id: ha_knob
          state: 10
      - binary_sensor.template.publish:
          id: ha_service_mode
          state: false

esp32:
  board: seeed_xiao_esp32c3

wifi:
  ssid: !secret wifi_ssid
  password: !secret wifi_password

captive_portal:

api:
  on_client_connected:
    - lambda: |-
        id(ha_service_mode).publish_state(id(service_mode));
        id(ha_active_key_sensor).publish_state(id(active_text));
  services:
    - service: set_display_text
      variables:
        new_text: string
      then:
        - lambda: |-
            id(active_text) = new_text;
            id(ha_active_key_sensor).publish_state(new_text);
            id(oled_display).update();

logger:
  baud_rate: 115200

i2c:
  sda: 6
  scl: 7

globals:
  - id: active_text
    type: std::string
    initial_value: '"OFF"'
  - id: service_mode
    type: bool
    initial_value: 'false'
  - id: saved_light_steps
    type: int
    initial_value: '10'
  - id: display_steps
    type: int
    initial_value: '10'

script:
  - id: block_feedback_script
    mode: restart
    then:
      - delay: 800ms

font:
  - file: "font.ttf"
    id: font_large
    size: 14
  - file: "font.ttf"
    id: font_sub
    size: 10

text_sensor:
  - platform: template
    name: "Active Key"
    id: ha_active_key_sensor

sensor:
  - platform: template
    name: "Rotary Knob"
    id: ha_knob

  - platform: rotary_encoder
    id: rotary_knob
    pin_a: 21
    pin_b: 20
    min_value: 0
    max_value: 10
    resolution: 2
    on_value:
      then:
        - lambda: |-
            if (isnan(x)) return;
            if (id(service_mode)) {
              if (x == 0) id(rotary_knob).set_value(1);
              id(oled_display).set_contrast(id(rotary_knob).state / 10.0);
            } else {
              if (x == 0 and id(ha_light_state).state) id(rotary_knob).set_value(1);
              id(block_feedback_script).execute();
              id(ha_knob).publish_state(id(rotary_knob).state);
            }
        - component.update: oled_display

  - platform: homeassistant
    id: ha_light_brightness
    entity_id: light.office
    attribute: brightness
    on_value:
      then:
        - lambda: |-
            if (not id(service_mode) and not id(block_feedback_script).is_running() and not isnan(x)) {
              int steps = round((x / 255.0) * 10.0);
              id(rotary_knob).set_value(steps);
              id(ha_knob).publish_state(steps);
            }
        - component.update: oled_display

binary_sensor:
  - platform: template
    name: "Service Mode"
    id: ha_service_mode

  - platform: status
    id: ha_connection_status

  - platform: homeassistant
    id: ha_light_state
    entity_id: light.office
    filters:
      - delayed_off: 500ms
    on_state:
      then:
        - lambda: |-
            if (not x) {
              if (not id(service_mode)) {
                id(rotary_knob).set_value(0);
                id(ha_knob).publish_state(0);
              } else {
                id(saved_light_steps) = 0;
              }
              id(active_text) = "OFF";
              id(ha_active_key_sensor).publish_state("OFF");
              id(oled_display).update();
            }

  - platform: gpio
    name: "Encoder Press"
    pin: {number: 8, mode: INPUT_PULLUP, inverted: true}
    on_press:
      then:
        - lambda: |-
            float current_state = isnan(id(rotary_knob).state) ? 10.0 : id(rotary_knob).state;
            if (not id(service_mode)) {
              id(saved_light_steps) = (int)current_state;
              id(service_mode) = true;
              id(rotary_knob).set_value(id(display_steps));
              id(ha_service_mode).publish_state(true);
            } else {
              id(display_steps) = (int)current_state;
              id(service_mode) = false;
              id(rotary_knob).set_value(id(saved_light_steps));
              id(ha_knob).publish_state(id(saved_light_steps));
              id(ha_service_mode).publish_state(false);
            }
        - component.update: oled_display

  - platform: gpio
    name: "Key 1"
    pin: {number: 2, mode: INPUT_PULLUP, inverted: true}
  - platform: gpio
    name: "Key 2"
    pin: {number: 3, mode: INPUT_PULLUP, inverted: true}
  - platform: gpio
    name: "Key 3"
    pin: {number: 4, mode: INPUT_PULLUP, inverted: true}
  - platform: gpio
    name: "Key 4"
    pin: {number: 5, mode: INPUT_PULLUP, inverted: true}
  - platform: gpio
    name: "Key 5"
    pin: {number: 9, mode: INPUT_PULLUP, inverted: true}
  - platform: gpio
    name: "Key 6"
    pin: {number: 10, mode: INPUT_PULLUP, inverted: true}

display:
  - platform: ssd1306_i2c
    id: oled_display
    model: "SSD1306 128x32"
    address: 0x3C
    rotation: 180
    lambda: |-
      if (id(service_mode)) {
        it.print(0, 0, id(font_large), "DISPLAY");
      } else {
        it.print(0, 0, id(font_large), id(active_text).c_str());
      }
      
      if (id(ha_connection_status).state) {
        it.print(82, 0, id(font_sub), "HA: OK");
      } else {
        it.print(82, 0, id(font_sub), "HA: --");
      }
      
      it.printf(0, 19, id(font_sub), "Int: %.0f%%", isnan(id(rotary_knob).state) ? 0.0 : id(rotary_knob).state * 10.0);
      
      if (wifi::global_wifi_component->is_connected()) {
        it.print(75, 19, id(font_sub), "Wi-Fi: OK");
      } else {
        it.print(75, 19, id(font_sub), "Wi-Fi: --");
      }

The password for the Wi-Fi is located in a separate secrets.yaml file. Create it and edit it with your own credentials:

wifi_ssid: "my_wi-fi"
wifi_password: "my_secret_password"

Now, you can compile and upload the code to your ESP32 with the command python3 -m esphome run keyboard.yaml. Don’t forget that you’ll need to run this command from inside the folder where your YAML file is located. When prompted in the terminal, type the number corresponding to your connected USB port and press Enter. If everything was uploaded successfully, you should see the communication log between your computer and the ESP32 in the terminal, and the display should turn on.

Among other things, the display also shows information about the Wi-Fi connection status, which should after a few seconds show “OK”. You will also see an “HA” line on the display, which represents the connection status to Home Assistant. You don’t need to worry about this status right now. As soon as we configure everything in HA, it should also show OK. If you see the logs and the display shows the info, you’ve successfully flashed the ESP32, and you can proceed to the Home Assistant configuration. You can leave the ESP32 connected to a USB port on your computer or use any standard 5V USB wall adapter to power the macropad.

Home Assistant Setup

1. Adding the ESP32 to Home Assistant

Thanks to the native ESPHome integration, Home Assistant should recognize your new macropad automatically.

  • Navigate to Settings > Devices & Services in your Home Assistant dashboard.
  • Look at the top of the Integrations tab. You should see a newly discovered ESPHome device named keyboard.
  • Click Configure and follow the on-screen prompts to add it to your setup.
  • Note: If it doesn’t appear automatically, click Add Integration in the bottom right corner, search for ESPHome, and enter the local IP address of your ESP32.

Once it’s added, Home Assistant automatically loads all your mechanical keys, the rotary encoder, and the service mode switch.

2. Creating the Light Group Helper

To keep this project elegant and universal, we want to avoid hardcoding specific physical smart bulbs directly into the code. Instead, create a Light Group helper. This way, if you replace your light bulbs, you’ll just update this group, and your automation remains completely untouched.

  • Go to Settings > Devices & Services and click on the Helpers tab at the top.
  • Click + Create Helper in the bottom right corner.
  • Scroll down, select Group, and then choose Light Group.
  • Name the helper Office (this will automatically generate the required entity ID: light.office).
  • Under Members, select the actual physical smart lights that you want the macropad to control.
  • Click Submit / Create.

With the macropad connected and our light group ready, we can now tie everything together with our master automation script.

  • Go to Settings > Automations & Scenes and click Create Automation.
  • Select Create new automation, then click the three dots in the top right corner and choose Edit in YAML.
  • Clear any default code, paste the script below, and hit Save.

For this guide, I mapped the six mechanical keys to activate simple color scenes (Blue, Green, Red, etc.), but you can customize these to trigger any scene, simple or advanced.


alias: Light Office - Master Control
description: Master Control for Macropad
triggers:
  - entity_id: binary_sensor.keyboard_key_1
    to: "on"
    id: press_k1
    trigger: state
  - entity_id: binary_sensor.keyboard_key_2
    to: "on"
    id: press_k2
    trigger: state
  - entity_id: binary_sensor.keyboard_key_3
    to: "on"
    id: press_k3
    trigger: state
  - entity_id: binary_sensor.keyboard_key_4
    to: "on"
    id: press_k4
    trigger: state
  - entity_id: binary_sensor.keyboard_key_5
    to: "on"
    id: press_k5
    trigger: state
  - entity_id: binary_sensor.keyboard_key_6
    to: "on"
    id: press_k6
    trigger: state
  - entity_id: sensor.keyboard_rotary_knob
    id: knob_rotation
    trigger: state
conditions:
  - condition: state
    entity_id: binary_sensor.keyboard_service_mode
    state: "off"
actions:
  - choose:
      - conditions:
          - condition: trigger
            id: knob_rotation
        sequence:
          - action: light.turn_on
            metadata: {}
            target:
              entity_id: light.office
            data:
              brightness_pct: "{{ (trigger.to_state.state | int(10)) * 10 }}"
      - conditions:
          - condition: trigger
            id: press_k1
        sequence:
          - choose:
              - conditions:
                  - condition: state
                    entity_id: sensor.keyboard_active_key
                    state: Blue
                sequence:
                  - action: light.turn_off
                    target:
                      entity_id: light.office
            default:
              - action: light.turn_on
                target:
                  entity_id: light.office
                data:
                  brightness_pct: 100
                  rgb_color:
                    - 4
                    - 51
                    - 255
              - action: esphome.keyboard_set_display_text
                data:
                  new_text: Blue
      - conditions:
          - condition: trigger
            id: press_k2
        sequence:
          - choose:
              - conditions:
                  - condition: state
                    entity_id: sensor.keyboard_active_key
                    state: Green
                sequence:
                  - action: light.turn_off
                    target:
                      entity_id: light.office
            default:
              - action: light.turn_on
                target:
                  entity_id: light.office
                data:
                  brightness_pct: 100
                  rgb_color:
                    - 0
                    - 249
                    - 0
              - action: esphome.keyboard_set_display_text
                data:
                  new_text: Green
      - conditions:
          - condition: trigger
            id: press_k3
        sequence:
          - choose:
              - conditions:
                  - condition: state
                    entity_id: sensor.keyboard_active_key
                    state: Red
                sequence:
                  - action: light.turn_off
                    target:
                      entity_id: light.office
            default:
              - action: light.turn_on
                target:
                  entity_id: light.office
                data:
                  brightness_pct: 100
                  rgb_color:
                    - 255
                    - 0
                    - 0
              - action: esphome.keyboard_set_display_text
                data:
                  new_text: Red
      - conditions:
          - condition: trigger
            id: press_k4
        sequence:
          - choose:
              - conditions:
                  - condition: state
                    entity_id: sensor.keyboard_active_key
                    state: Yellow
                sequence:
                  - action: light.turn_off
                    target:
                      entity_id: light.office
            default:
              - action: light.turn_on
                target:
                  entity_id: light.office
                data:
                  brightness_pct: 100
                  rgb_color:
                    - 255
                    - 255
                    - 0
              - action: esphome.keyboard_set_display_text
                data:
                  new_text: Yellow
      - conditions:
          - condition: trigger
            id: press_k5
        sequence:
          - choose:
              - conditions:
                  - condition: state
                    entity_id: sensor.keyboard_active_key
                    state: Purple
                sequence:
                  - action: light.turn_off
                    target:
                      entity_id: light.office
            default:
              - action: light.turn_on
                target:
                  entity_id: light.office
                data:
                  brightness_pct: 100
                  rgb_color:
                    - 255
                    - 0
                    - 255
              - action: esphome.keyboard_set_display_text
                data:
                  new_text: Purple
      - conditions:
          - condition: trigger
            id: press_k6
        sequence:
          - choose:
              - conditions:
                  - condition: state
                    entity_id: sensor.keyboard_active_key
                    state: White
                sequence:
                  - action: light.turn_off
                    target:
                      entity_id: light.office
            default:
              - action: light.turn_on
                target:
                  entity_id: light.office
                data:
                  brightness_pct: 100
                  rgb_color:
                    - 255
                    - 255
                    - 255
              - action: esphome.keyboard_set_display_text
                data:
                  new_text: White
mode: restart

 

The YAML editor in Home Assistant looks like this:

Testing and Wrap-up

With everything uploaded, it’s time to test the macropad. Press a key, and the lighting scene should immediately switch. Turn the rotary encoder, and the light intensity will change. The OLED display should also correctly show the currently selected scene.

If everything works perfectly, all that’s left is to print the bottom cover and snap it into place. Thanks to a friction-fit design, it holds securely without any glue or screws. Because I kept my test version wired up with bulky Dupont connectors, I needed a bit more clearance inside, so I’ve made a slightly taller bottom piece. On the bright side, I took advantage of this by blending different Prusament Woodfill materials again to create a nice transition stripe. If you decide to solder your components, you can slim down the bottom cover file to significantly reduce the macropad height.

Although I use this macropad just for lighting control, you can map it to any smart home functionality. With Home Assistant handling the logic, a keypress or encoder rotation can adjust window blinds, start a robot vacuum, or trigger a Spotify automation to Rickroll your guests. The sky is the limit! And once you build your first project like this, it is hard not to look around the house for other places where a 3D printer and some electronics can help.

Keep in mind that working with Home Assistant and ESPHome involves handling sensitive network data. While this setup works in my environment, you will need to adapt and tweak the code to match your own specific smart home layout. The configuration is shared “as-is” and without any warranties. Be sure to double-check everything and always use ESPHome’s secrets feature to keep your private credentials secure.

Happy printing!