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:
Wiring and Assembling
The following is my wiring, very similar to the one from wtadler, adapted for an ESP8266:
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).
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).
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.
[!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.
The overall setup ended up looking like this:
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.