DIY Kids Room Light Remote Control using ESP32 with ESPHome and Home Assistant

Or: How ESPHome and Home Assistant Made Me the Coolest Dad Ever

Smart homes can be pretty dumb. Automation is the goal but so often it devolves into fancy remote control. This is fine for adults with a smartphone but fails for children. This is my kids home too, so one goal is to make the house accessible for them. This is where purpose built remotes really shine. I’ve done this before for my daughter.

Now that my son is old enough to have preferences for his room, we’re starting to decorate it for his tastes. Color changeable lights can be fun for an accent or nightlight. He asked me for a robot, I think we can make that happen.

Finished Project:

Electronics:

If you break the electronics up, it’s not as complicated. The buttons are all wired directly to a GPIO pin and GND using esp32’s input pullup. The DFPlayer Mini is wired to +3.3v, GND, and the pins for UART2. The WS2812 is wired to +5v, GND, and a GPIO pin.

Robot Build:

A box with generic pushbuttons and marker labels is just as effective… but my hobby is collecting tools and I need to justify having them. A full materials list is included at the end of this post.

The hexagon buttons on the front use a mechanical keyboard switch. A 3d printed button sits on that. The colored vinyl is stuck to the button. A laser-cut clear acrylic window sits on that. Another 3d printed piece fits on that, and it’s all stuffed in the hexagon grid. Repeat for all switches. The switch back piece gets a center screw to hold it in together while you finish assembly.

Start by attaching the head shell to the main body. The WS2812 8×8 neopixel matrix is sandwiched between a laser-cut white diffuser and a 3d printed standoff. This is all placed inside the head shell and head back plate is screwed on.

The arms are attached to the main body with a countersunk bolt into a captive nut on the inside of the main body.

The main body has an internal standoff for the ESP32 board, mounting posts for the speaker, panel holes for the USB micro extender. There are 2 laser-cut clear acrylic front panels. I printed and cut a design to sandwich between the acrylic panels. The hex buttons insert through the acrylic front panels, and are secured by 3d printed mounting brackets. The arcade buttons are just inserted from the front.

Once all of the electrical connections are completed, place the acrylic front panels into the recess in the main body. The front plate goes over the acrylic panels and screws secure it all in place.

Programming:

UX Requirements:

  • Button press must trigger Home Assistant to change light color.
  • Robot must be interactive; must provide visual feedback, confirm the color.
  • Robot must only draw attention when interacting with it; must not interrupt sleep.
  • Robot must have some personality.

My first attempt at building a light remote just exposed binary sensors to home assistant and triggered automations based off that. Since then I’ve discovered that homeassistant.event exists and keeps my entities list much tidier.

To provide visual feedback and confirm the color, the robot face is drawn in the color of the button that was just pressed. To be fun, it plays a random robot noise from the dfplayer mini and makes a couple different faces. It blinks with a 10% possibility every second while the face is shown.

To avoid drawing attention when it’s not in use, the robot face blanks 30 seconds after the last button press. It only makes noises when a button is pressed.

Giving an inanimate object some personality isn’t all that hard, all we need is a little license to use our imagination. A handful of face sprites and a bit of random delay can bring the robot to life.

Sounds are the other big part of giving the robot some personality. Get as many sound effects as you want, just name them with sequential numbers on the SD card and update the number of sounds in the rand_sound script. I found all of the sounds I used on the following sites.

owen-robot.yaml – ESPHome

esphome:
  name: owen-robot
  friendly_name: owen-robot

esp32:
  board: esp32dev
  framework:
    type: arduino

# Enable logging
logger:

# Enable Home Assistant API
api:
  encryption:
    key: !secret encryption_key

ota:
  - platform: esphome
    password: !secret ota_password

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

  # Enable fallback hotspot (captive portal) in case wifi connection fails
  ap:
    ssid: "Owen-Robot"
    password: !secret fallback_password

web_server:
  port: 80

globals:
  - id: last_active
    # stores time of last interaction
    # used to turn off screen and manage button lockout
    type: int
    restore_value: no
    initial_value: '0'

  - id: last_color
    # stores color value associated with last button press
    # makes code less complicated than passing color variables
    type: Color
    restore_value: no
    initial_value: 'white'

  - id: screen_active
    # is screen supposed to be displaying an image?
    # used to keep random blink interval from turning on screen
    type: bool
    restore_value: no
    initial_value: 'false'

  - id: button_lockout
    # if true buttons should NOT respond to press
    # my kids push the buttons too quickly and Home Assistant 
    # can't execute automations fast enough to keep up
    type: bool
    restore_value: no
    initial_value: 'false'

color:
  - id: red
    hex: 'FF0000'
  - id: orange
    hex: 'FFA500'
  - id: yellow
    hex: 'FFFF00'
  - id: green
    hex: '00FF00'
  - id: blue
    hex: '0000FF'
  - id: purple
    hex: '800080'
  - id: white
    hex: 'FFFFFF'
  - id: black
    hex: '000000'

dfplayer:

uart:
  # using pins for uart2 on esp32
  tx_pin: GPIO17
  rx_pin: GPIO16
  baud_rate: 9600

light:
  - platform: neopixelbus
    id: robot_face
    variant: WS2812
    pin: GPIO25
    num_leds: 64
    type: GRB
    # setting color correction to limit output brightness
    color_correct: [60%, 60%, 60%]
    # needs to be set to internal or will be exposed to home assistant
    internal: true

display:
  - platform: addressable_light
    id: led_matrix_display
    addressable_light_id: robot_face
    width: 8
    height: 8
    update_interval: 16ms

binary_sensor:
  - platform: gpio
    pin: 
      number: GPIO23
      mode: INPUT_PULLUP
      inverted: True
    filters:
      # using filter to debounce
      delayed_on_off: 100ms
    name: "robotbutton-red"
    
    # needs to be set to internal or will be exposed to home assistant
    internal: true
    on_press:
      if:
        condition:
          # button will only respond if button_lock is false
          - not:
            - lambda: return id(button_lockout);
        then:
          # play beeps and boops
          - script.execute: rand_sound
          
          # stop any running isntance of draw_face
          # animations start to overlap without this
          - script.stop: draw_face
          
          # set color for robot face animations
          - globals.set:
              id: last_color
              value: red
          
          # send event to Home Assistant
          - homeassistant.event:
              event: esphome.robot_button_pressed
              data: {button: "red"}
          
          # draw the face
          - script.execute: draw_face

  - platform: gpio
    pin: 
      number: GPIO22
      mode: INPUT_PULLUP
      inverted: True
    filters:
      delayed_on_off: 100ms
    name: "robotbutton-orange"
    internal: true
    on_press:
      if:
        condition:
          - not:
            - lambda: return id(button_lockout);
        then:
          - script.execute: rand_sound
          - script.stop: draw_face
          - globals.set:
              id: last_color
              value: orange
          - homeassistant.event:
              event: esphome.robot_button_pressed
              data: {button: "orange"}
          - script.execute: draw_face

  - platform: gpio
    pin: 
      number: GPIO21
      mode: INPUT_PULLUP
      inverted: True
    filters:
      delayed_on_off: 100ms
    name: "robotbutton-yellow"
    internal: true
    on_press:
      if:
        condition:
          - not:
            - lambda: return id(button_lockout);
        then:
          - script.execute: rand_sound
          - script.stop: draw_face
          - globals.set:
              id: last_color
              value: yellow
          - homeassistant.event:
              event: esphome.robot_button_pressed
              data: {button: "yellow"}
          - script.execute: draw_face

  - platform: gpio
    pin: 
      number: GPIO19
      mode: INPUT_PULLUP
      inverted: True
    filters:
      delayed_on_off: 100ms
    name: "robotbutton-green"
    internal: true
    on_press:
      if:
        condition:
          - not:
            - lambda: return id(button_lockout);
        then:
          - script.execute: rand_sound
          - script.stop: draw_face
          - globals.set:
              id: last_color
              value: green
          - homeassistant.event:
              event: esphome.robot_button_pressed
              data: {button: "green"}
          - script.execute: draw_face

  - platform: gpio
    pin: 
      number: GPIO18
      mode: INPUT_PULLUP
      inverted: True
    filters:
      delayed_on_off: 100ms
    name: "robotbutton-blue"
    internal: true
    on_press:
      if:
        condition:
          - not:
            - lambda: return id(button_lockout);
        then:
          - script.execute: rand_sound
          - script.stop: draw_face
          - globals.set:
              id: last_color
              value: blue
          - homeassistant.event:
              event: esphome.robot_button_pressed
              data: {button: "blue"}
          - script.execute: draw_face

  - platform: gpio
    pin: 
      number: GPIO05
      mode: INPUT_PULLUP
      inverted: True
    filters:
      delayed_on_off: 100ms
    name: "robotbutton-purple"
    internal: true
    on_press:
      if:
        condition:
          - not:
            - lambda: return id(button_lockout);
        then:
          - script.execute: rand_sound
          - script.stop: draw_face
          - globals.set:
              id: last_color
              value: purple
          - homeassistant.event:
              event: esphome.robot_button_pressed
              data: {button: "purple"}
          - script.execute: draw_face

  - platform: gpio
    pin: 
      number: GPIO04
      mode: INPUT_PULLUP
      inverted: True
    filters:
      delayed_on_off: 100ms
    name: "robotbutton-white"
    internal: true
    on_press:
      if:
        condition:
          - not:
            - lambda: return id(button_lockout);
        then:
          - script.execute: rand_sound
          - script.stop: draw_face
          - globals.set:
              id: last_color
              value: white
          - homeassistant.event:
              event: esphome.robot_button_pressed
              data: {button: "white"}
          - script.execute: draw_face

  - platform: gpio
    pin: 
      number: GPIO15
      mode: INPUT_PULLUP
      inverted: True
    filters:
      delayed_on_off: 100ms
    name: "robotbutton-off"
    internal: true
    on_press:
      if:
        condition:
          - not:
            - lambda: return id(button_lockout);
        then:
          - script.execute: rand_sound
          - script.stop: draw_face
          - globals.set:
              id: last_color
              value: black
          - script.execute: blank_screen
          - globals.set:
              id: screen_active
              value: 'false'
          - homeassistant.event:
              event: esphome.robot_button_pressed
              data: {button: "off"}


script:
  - id: rand_sound
    # the SD card in dfplayer only has robot beeps and boops
    # I don't have to care which one plays so pick one at random
    # dfplayer files are named with a number
    # there are 11 files. add 1 because random starts at 0, dfplayer files start at 1 
    then:
      - dfplayer.play:
          file: !lambda 'return (random_uint32() % 11) + 1;'
          loop: false

  - id: reset_idle_timer
    # sets screen_active to true and updates the last_active with current time
    # used to turn off screen and manage button lockout
    then:
      - globals.set:
          id: screen_active
          value: 'true'
      - lambda: |-
          id(last_active) = millis() / 1000;

  - id: blank_screen
    # fills the screen with black pixels
    then:
      - lambda: |-
              Color off = Color(0x000000);
              id(led_matrix_display).filled_rectangle(0, 0, 8, 8, off);

  - id: draw_face
    # update the last_active variable
    # set button_lockout
    # draws face sprites in rapid succession to creation animation
    # includes a blink so the face blinks at least once
    then:
      - script.execute: reset_idle_timer
      - globals.set:
          id: button_lockout
          value: 'true'
      - script.execute: blank_screen
      - script.execute: draw_smile_face
      - delay: 250ms
      - script.execute: blank_screen
      - script.execute: draw_wow_face
      - delay: 300ms
      - script.execute: blank_screen
      - script.execute: draw_smile_face
      - delay: 250ms
      - script.execute: blank_screen
      - script.execute: draw_wow_face
      - delay: 500ms
      - script.execute: blank_screen
      - script.execute: draw_smile_face
      - delay: 500ms
      - delay: !lambda 'return (random_uint32() % 5000) + 1;'
      - script.execute: blank_screen
      - script.execute: draw_blink_face
      - delay: 400ms
      - script.execute: blank_screen
      - script.execute: draw_smile_face


  - id: draw_smile_face
    # draw smile face using last color
    #  |        |
    #  |###  ###|
    #  |# #  # #|
    #  |###  ###|
    #  |        |
    #  |#      #|
    #  | ###### |
    #  |        |
    then:
      - lambda: |-
              Color face_color = id(last_color);
              id(led_matrix_display).rectangle(0, 1, 3, 3, face_color);
              id(led_matrix_display).rectangle(5, 1, 3, 3, face_color);
              id(led_matrix_display).line(0, 5, 1, 6, face_color);
              id(led_matrix_display).line(7, 5, 6, 6, face_color);
              id(led_matrix_display).line(1, 6, 6, 6, face_color);


  - id: draw_blink_face
    # draw blink face using last color
    #  |        |
    #  |        |
    #  |###  ###|
    #  |        |
    #  |        |
    #  |#      #|
    #  | ###### |
    #  |        |
    then:
      - lambda: |-
              Color face_color = id(last_color);
              id(led_matrix_display).line(0, 2, 2, 2, face_color);
              id(led_matrix_display).line(5, 2, 7, 2, face_color);
              id(led_matrix_display).line(0, 5, 1, 6, face_color);
              id(led_matrix_display).line(7, 5, 6, 6, face_color);
              id(led_matrix_display).line(1, 6, 6, 6, face_color);


  - id: draw_wink_face
    # draw wink face using last color
    #  |        |
    #  |###     |
    #  |# #  ###|
    #  |###     |
    #  |        |
    #  |#      #|
    #  | ###### |
    #  |        |
    then:
      - lambda: |-
              Color face_color = id(last_color);
              id(led_matrix_display).rectangle(0, 1, 3, 3, face_color);
              id(led_matrix_display).line(5, 2, 7, 2, face_color);
              id(led_matrix_display).line(0, 5, 1, 6, face_color);
              id(led_matrix_display).line(7, 5, 6, 6, face_color);
              id(led_matrix_display).line(1, 6, 6, 6, face_color);


  - id: draw_wow_face
    # draw wow face using last color
    #  |        |
    #  |###  ###|
    #  |# #  # #|
    #  |###  ###|
    #  |        |
    #  | ###### |
    #  | #    # |
    #  | ###### |
    then:
      - lambda: |-
              Color face_color = id(last_color);
              id(led_matrix_display).rectangle(0, 1, 3, 3, face_color);
              id(led_matrix_display).rectangle(5, 1, 3, 3, face_color);
              id(led_matrix_display).rectangle(1, 5, 6, 3, face_color);


  - id: draw_neutral_face
    # draw neutral face using last color
    #  |        |
    #  |###  ###|
    #  |# #  # #|
    #  |###  ###|
    #  |        |
    #  |        |
    #  | ###### |
    #  |        |
    then:
      - lambda: |-
              Color face_color = id(last_color);
              id(led_matrix_display).rectangle(0, 1, 3, 3, face_color);
              id(led_matrix_display).rectangle(5, 1, 3, 3, face_color);
              id(led_matrix_display).line(1, 6, 6, 6, face_color);


  - id: draw_sad_face
    # draw sad face using last color
    #  |        |
    #  |###  ###|
    #  |# #  # #|
    #  |###  ###|
    #  |        |
    #  |        |
    #  | ###### |
    #  |#      #|
    then:
      - lambda: |-
              Color face_color = id(last_color);
              id(led_matrix_display).rectangle(0, 1, 3, 3, face_color);
              id(led_matrix_display).rectangle(5, 1, 3, 3, face_color);
              id(led_matrix_display).line(1, 7, 2, 6, face_color);
              id(led_matrix_display).line(6, 7, 5, 6, face_color);
              id(led_matrix_display).line(2, 6, 5, 6, face_color);


  - id: draw_ghost
    # draw ghost using last color
    #  | ###### |
    #  |########|
    #  |#  ##  #|
    #  |#  ##  #|
    #  |########|
    #  |########|
    #  |########|
    #  |## ## ##|
    then:
      - lambda: |-
              Color ghost_color = id(last_color);
              id(led_matrix_display).line(1, 0, 6, 0, ghost_color);
              id(led_matrix_display).line(1, 1, 7, 1, ghost_color);
              id(led_matrix_display).line(0, 2, 0, 3, ghost_color);
              id(led_matrix_display).filled_rectangle(3, 2, 2, 2, ghost_color);
              id(led_matrix_display).line(7, 2, 7, 3, ghost_color);
              id(led_matrix_display).filled_rectangle(0, 4, 8, 3, ghost_color);
              id(led_matrix_display).line(0, 7, 1, 7, ghost_color);
              id(led_matrix_display).line(3, 7, 4, 7, ghost_color);
              id(led_matrix_display).line(6, 7, 7, 7, ghost_color);


  - id: draw_skull
    # draw skull using last color
    #  | ###### |
    #  |########|
    #  |#  ##  #|
    #  |#  ##  #|
    #  |########|
    #  |  ####  |
    #  |  ####  |
    #  |  ####  |
    then:
      - lambda: |-
              Color skull_color = id(last_color);
              id(led_matrix_display).line(1, 0, 6, 0, skull_color);
              id(led_matrix_display).line(1, 1, 7, 1, skull_color);
              id(led_matrix_display).line(0, 2, 0, 3, skull_color);
              id(led_matrix_display).filled_rectangle(3, 2, 2, 2, skull_color);
              id(led_matrix_display).line(0, 4, 7, 4, skull_color);
              id(led_matrix_display).filled_rectangle(2, 5, 4, 3, skull_color);


  - id: draw_star
    # draw star using last color
    #  |   ##   |
    #  |   ##   |
    #  |  ####  |
    #  |########|
    #  | ###### |
    #  |  #  #  |
    #  | ##  ## |
    #  | #    # |
    then:
      - lambda: |-
              Color star_color = id(last_color);
              id(led_matrix_display).line(3, 0, 4, 0, star_color);
              id(led_matrix_display).line(3, 1, 4, 1, star_color);
              id(led_matrix_display).line(2, 2, 5, 2, star_color);
              id(led_matrix_display).line(0, 3, 7, 3, star_color);
              id(led_matrix_display).line(1, 4, 6, 4, star_color);


  - id: draw_heart_big
    # draw heart big using last color
    #  | ##  ## |
    #  |########|
    #  |########|
    #  |########|
    #  |########|
    #  | ###### |
    #  |  ####  |
    #  |   ##   |
    then:
      - lambda: |-
              Color heart_color = id(last_color);
              id(led_matrix_display).line(1, 0, 2, 0, heart_color);
              id(led_matrix_display).line(5, 0, 6, 0, heart_color);
              id(led_matrix_display).filled_rectangle(0, 1, 8, 4, heart_color);
              id(led_matrix_display).line(1, 5, 6, 5, heart_color);
              id(led_matrix_display).line(2, 6, 5, 6, heart_color);
              id(led_matrix_display).line(3, 7, 4, 7, heart_color);


  - id: draw_heart_small
    # draw heart big using last color
    #  |        |
    #  |  #  #  |
    #  | ###### |
    #  | ###### |
    #  | ###### |
    #  |  ####  |
    #  |   ##   |
    #  |        |
    then:
      - lambda: |-
              Color heart_color = id(last_color);
              id(led_matrix_display).draw_pixel_at(2, 1, heart_color);
              id(led_matrix_display).draw_pixel_at(5, 1, heart_color);
              id(led_matrix_display).filled_rectangle(1, 2, 6, 3, heart_color);
              id(led_matrix_display).line(2, 5, 5, 5, heart_color);
              id(led_matrix_display).line(3, 6, 4, 6, heart_color);


interval:
  - interval: 1s
    then:
      - if:
          # If it has been more than 30 seconds since last interaction
          # blank the screen and set screen_active to false
          condition:
            lambda: return (millis() / 1000) - id(last_active) >= 30;
          then:
            - script.execute: blank_screen
            - globals.set:
                id: screen_active
                value: 'false'
      - if:
          # random blink
          # plays blink animation if the following conditions are met
          #   - screen_active is true
          #   - draw_face is NOT running
          #   - a random 10% chance
          condition:
            and:
              - lambda: return (random_uint32() % 100) <= 10;
              - lambda: return id(screen_active);
              - not:
                - script.is_running: draw_face
          then:
            - script.execute: blank_screen
            - script.execute: draw_blink_face
            - delay: 400ms
            - script.execute: blank_screen
            - script.execute: draw_smile_face
      - if:
          # If it has been more than 2 seconds since last interaction
          # set button_lockout to false to allow buttons to function again
          condition:
            lambda: return (millis() / 1000) - id(last_active) >= 2;
          then:
            - globals.set:
                id: button_lockout
                value: 'false'

RobotButton Example – Home Assistant Automation

The Home Assistant side of this is simple. Add the esphome device and lights to control. Once that’s set up, I have one automation that fires for each button. The trickiest part was getting the event and event data mapped correctly. In my case, I’m using esphome.robot_button_pressed and button: blue to identify which button was pressed.

Note: The action doesn’t have to be turning on a light and setting a color. It could be activating a scene, turn on/off fan, open/close blinds, start/stop a media player, or anything else you can imagine.

alias: RobotButton Blue
description: ""
triggers:
  - event_type: esphome.robot_button_pressed
    trigger: event
    event_data:
      button: blue
conditions: []
actions:
  - data:
      brightness_pct: 15
      rgb_color:
        - 0
        - 0
        - 255
    target:
      entity_id:
        - light.owen_lamp
    action: light.turn_on
mode: single

Materials Used:

Electronics:

Custom Hexagon Buttons:

Robot Case:

As an Amazon Associate I earn from qualifying purchases.

Leave a comment