This is a quick guide on how to make a 3D printed cat laser toy, integrated with Home Assistant via ESPHome.

I mainly followed the setup and wiring from wtadler on Thingiverse with some slight modifications.

Required material

  • 1x Mini 5v Laser Pointer Red Diode. Cheaply sourced from AliExpress (example) or Amazon.
  • 2x SG90 servo motors (example)
  • 1x ESP32 or ESP8266. Anything goes, I personally used an ESP8266 NodeMCU v3 I had around (example)
  • Jumper wires or normal wires
  • 5v power supply (i used an old USB-A cable)

3D printing the case

.stl files can be downloaded from wtadler on Thingiverse. I printed them with standard white PETG: targets

Wiring and Assembling

The following is my wiring, very similar to the one from wtadler, adapted for an ESP8266: targets

It’s important to use an external power supply (instead of the NodeMCU USB power) to not damage the chip over time, as the servos might draw much current over time.

The assembly of this toy is quite straightforward.

First of all, I inserted the laser pointer into its holder and fixed it with hotglue, soldered a quick connector and attached the servo motor attachment to the holder with some hot glue (tiny screws can also be used).

targets

Then, I installed the two servos on their slots, and fixed them with the screws that came with them. I also hotglued the remaining servo attachment to the 3d-printed “arm” holding the second servo motor. (In the following picture before hotgluing and screwing the servo motors).

targets

I proceeded to (badly) wire everything up using some jumper wires I had around. Ideally, after testing everything and making sure this is the final setup, I’ll solder everything with normal wires.

targets

[!IMPORTANT] Make sure to flash your chip with ESPHome before closing the cabling box (or you’ll have to reopen it). See the Programming section below.

After that, I pushed everything into the bottom enclosure and closed it with normal screws. The bigger NodeMCU v3 plus all the jumper cables was tiny fit but I made it fit by placing the board diagonally. An power supply attached to its standard micro-usb port would definitely not fit.

targets

The overall setup ended up looking like this:

targets

After that, I just better fixed the laser and servo wires to not hang out so much.

Programming

ESPHome

I flashed my ESP9266 with ESPHome at https://web.esphome.io/, including settin up the Wi-Fi credentials to connect it to my IoT network. After that, the node was quickly picked up by Home Assistant, where i adopted it.

I adapted the original project’s YAML to my NodeMCU platform, also enabling the web server for debugging purposes. I then installed the following yaml to the NodeMCU node via Home Assistant:

esphome:
  name: cat-laser
  friendly_name: Cat Laser
  min_version: 2025.5.0
  name_add_mac_suffix: false

esp8266:
  board: esp01_1m

# Enable logging
logger:

# Enable web server
web_server:

# Enable Home Assistant API
api:

# Allow Over-The-Air updates
ota:
- platform: esphome

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

# GPIO12 is D6, GPIO14 is D5
output:
  - platform: esp8266_pwm #used instead of 'ledc' of ESP32
    id: ledc_1
    pin: GPIO12
    frequency: 50 Hz
  - platform: esp8266_pwm #used instead of 'ledc' of ESP32
    id: ledc_2
    pin: GPIO14
    frequency: 50 Hz


servo:
  - id: servo_y
    output: ledc_1
    transition_length: "5s"
    auto_detach_time: 1s
  - id: servo_x
    output: ledc_2
    transition_length: "5s"
    auto_detach_time: 1s

number:
  - platform: template
    name: Servo X
    min_value: -100
    initial_value: 0
    max_value: 100
    step: 1
    optimistic: true
    set_action:
      then:
        - servo.write:
            id: servo_x
            level: !lambda 'return x / 100.0;'
    icon: "mdi:axis-x-arrow"
  - platform: template
    name: Servo Y
    min_value: -100
    initial_value: 0
    max_value: 100
    step: 1
    optimistic: true
    set_action:
      then:
        - servo.write:
            id: servo_y
            level: !lambda 'return x / 100.0;'
    icon: "mdi:axis-y-arrow"
  - platform: template
    name: Speed
    min_value: 0
    max_value: 100
    initial_value: 50
    step: 1
    optimistic: true
    restore_value: true
    unit_of_measurement: "%"
    set_action:
      then:
        - lambda: |-
            // Map the percent to ms range
            float min_ms = 250;
            float max_ms = 10000;

            // Inverse mapping
            float mapped_ms = min_ms + (1.0 - (x / 100.0)) * (max_ms - min_ms);

            // Apply the calculated value
            id(servo_x)->set_transition_length(mapped_ms);
            id(servo_y)->set_transition_length(mapped_ms);
    icon: "mdi:speedometer"

switch:
  - platform: gpio
    pin: GPIO13 #D7
    name: "Laser"
    restore_mode: ALWAYS_OFF
    icon: "mdi:laser-pointer"
    id: laser

button:
  - platform: restart
    name: "Cat Laser Restart"

Home Assistant

To set up an automated “play mode” where the laser runs randomly for a given amount of time, I made my own version of a Home Assistant script. This also lets you configure the speed and the min and max values for the servo motors, in order to set up a “play area”. To define it, I put the laser turret on its place and commanded the servos until I knew the values of X and Y that were defining the boundaries of my area. These values I have then hardcoded as “default” for the servos in the following YAML.

To add it to Home Assistant, navigate to Settings > Automations & Scenes > Scripts tab. Click on “create new” and then edit it as YAML to paste the code. After that, click on the 3 dots icon on the right of the script and select “Run”.

alias: Cat Laser - Playtime
description: Playtime for the cat, with configurable area, speed, and duration.
mode: restart
fields:
  cat_laser_session_length_minutes:
    name: Session Length (Minutes)
    description: How long the laser play session should last.
    selector:
      number:
        min: 1
        max: 60
        step: 1
    default: 10
  servo_x_min:
    name: Servo X Min
    description: Minimum X position for laser.
    selector:
      number:
        min: -100
        max: 100
        step: 1
    default: -94
  servo_x_max:
    name: Servo X Max
    description: Maximum X position for laser.
    selector:
      number:
        min: -100
        max: 100
        step: 1
    default: -22
  servo_y_min:
    name: Servo Y Min
    description: Minimum Y position for laser.
    selector:
      number:
        min: -100
        max: 100
        step: 1
    default: 31
  servo_y_max:
    name: Servo Y Max
    description: Maximum Y position for laser.
    selector:
      number:
        min: -100
        max: 100
        step: 1
    default: 70
  move_delay_min_ms:
    name: Movement Delay Min (ms)
    description: Minimum delay between movements.
    selector:
      number:
        min: 100
        max: 2000
        step: 100
    default: 300
  move_delay_max_ms:
    name: Movement Delay Max (ms)
    description: Maximum delay between movements.
    selector:
      number:
        min: 100
        max: 2000
        step: 100
    default: 1000
sequence:
  - alias: Safety reset before play
    sequence:
      - target:
          entity_id: switch.cat_laser_laser
        action: switch.turn_off
        data: {}
      - target:
          entity_id: number.cat_laser_servo_x
        data:
          value: 0
        action: number.set_value
      - target:
          entity_id: number.cat_laser_servo_y
        data:
          value: 0
        action: number.set_value
  - variables:
      end_time: >
        {{ (now() + timedelta(minutes=(cat_laser_session_length_minutes |
        int))).timestamp() }}
  - target:
      entity_id: switch.cat_laser_laser
    action: switch.turn_on
    data: {}
  - repeat:
      while:
        - condition: template
          value_template: |
            {{ now().timestamp() < end_time }}
      sequence:
        - target:
            entity_id: number.cat_laser_servo_x
          data:
            value: |
              {{ range(servo_x_min | int, servo_x_max | int) | random }}
          action: number.set_value
        - target:
            entity_id: number.cat_laser_servo_y
          data:
            value: |
              {{ range(servo_y_min | int, servo_y_max | int) | random }}
          action: number.set_value
        - delay:
            milliseconds: >
              {{ range(move_delay_min_ms | int, move_delay_max_ms | int) |
              random }}
  - sequence:
      - target:
          entity_id: switch.cat_laser_laser
        action: switch.turn_off
        data: {}
      - target:
          entity_id: number.cat_laser_servo_x
        data:
          value: 0
        action: number.set_value
      - target:
          entity_id: number.cat_laser_servo_y
        data:
          value: 0
        action: number.set_value

User Testing

The user was quite pleased with the setup and recommends it.

targets