Write Code to Control the Raspberry Pi Cat Laser Pointer Toy

Write Code to Control the Raspberry Pi Cat Laser Pointer Toy

At this point you’ve covered part one and part two (nice job!), or you didn’t and just happen to stumble upon part three of this guide (it’s good to have you). Here in part three we’ll be focusing primarily on writing the necessary python code to make our super evil laser contraption fully functional at the push of a button. This could technically be your last part of the series (it’s sad, but it had to end sometime). If you’re hankering for more like I was, part four will cover the bonus feature of triggering the cat laser pointer toy remotely and on a schedule.

With that said, let’s dig in!

Create the Scripts to Automate the Cat Laser Pointer Toy

We’re going to create two scripts:

  • Laser.py, which will contain the code responsible for configuring our laser and servo motors, and moving the laser from one position to another.
  • LaserWrapper.py, which as the name suggests will wrap the Laser.py functionality, and will primarily be responsible for deciding when the cat laser pointer toy should be enabled as well as which settings it should use during runtime.

Create a new file called Laser.py with the following code:

#!/usr/bin/env python

import time
import RPi.GPIO as GPIO
import random

DEFAULT_RUN_TIME = 90
DEFAULT_MIN_MOVEMENT = 10
DEFAULT_X_MIN_POSITION = 40
DEFAULT_X_MAX_POSITION = 120
DEFAULT_Y_MIN_POSITION = 20
DEFAULT_Y_MAX_POSITION = 60

# define which GPIO pins to use for the servos and laser
GPIO_X_SERVO = 4
GPIO_Y_SERVO = 17
GPIO_LASER = 27

class Laser:
    def __init__(self):
        GPIO.setmode(GPIO.BCM)
        
        self.x_servo = None
        self.y_servo = None

        GPIO.setup(GPIO_X_SERVO, GPIO.OUT)
        GPIO.setup(GPIO_Y_SERVO, GPIO.OUT)
        GPIO.setup(GPIO_LASER, GPIO.OUT)

    def calibrate_laser(self, min_movement, x_min, x_max, y_min, y_max):
        # set config variables, using the defaults if one wasn't provided
        self.min_movement = DEFAULT_MIN_MOVEMENT if min_movement is None else min_movement
        self.x_min = DEFAULT_X_MIN_POSITION if x_min is None else x_min
        self.x_max = DEFAULT_X_MAX_POSITION if x_max is None else x_max
        self.y_min = DEFAULT_Y_MIN_POSITION if y_min is None else y_min
        self.y_max = DEFAULT_Y_MAX_POSITION if y_max is None else y_max
        
        # start at the center of our square/ rectangle.
        self.x_position = x_min + (x_max - x_min) / 2
        self.y_position = y_min + (y_max - y_min) / 2
        
        # turn on the laser and configure the servos
        GPIO.output(GPIO_LASER, 1)
        self.x_servo = GPIO.PWM(GPIO_X_SERVO, 50)
        self.y_servo = GPIO.PWM(GPIO_Y_SERVO, 50)
        
        # start the servo which initializes it, and positions them center on the cartesian plane
        self.x_servo.start(self.__get_position(self.x_position))
        self.y_servo.start(self.__get_position(self.y_position))

        # give the servo a chance to position itself
        time.sleep(1)

    def fire(self):
        self.movement_time = self.__get_movement_time()
        print "Movement time: {0}".format(self.movement_time)
        print "Current position: X: {0}, Y: {1}".format(self.x_position, self.y_position)

        # how many steps (how long) should we take to get from old to new position
        self.x_incrementer = self.__get_position_incrementer(self.x_position, self.x_min, self.x_max)
        self.y_incrementer = self.__get_position_incrementer(self.y_position, self.y_min, self.y_max)

        for index in range(self.movement_time):
            print "In For, X Position: {0}, Y Position: {1}".format(self.x_position, self.y_position)
            self.x_position += self.x_incrementer
            self.y_position += self.y_incrementer

            self.__set_servo_position(self.x_servo, self.x_position)
            self.__set_servo_position(self.y_servo, self.y_position)

            time.sleep(0.02)

        # leave the laser still so the cat has a chance to catch up
        time.sleep(self.__get_movement_delay())

    def stop(self):
        # always cleanup after ourselves
        print ("\nTidying up")
        if(self.x_servo is not None):
            self.x_servo.stop()
        
        if(self.y_servo is not None):
            self.y_servo.stop()
            
        GPIO.output(GPIO_LASER, 0)
        
    def __set_servo_position(self, servo, position):
        servo.ChangeDutyCycle(self.__get_position(position))

    def __get_position(self, angle):
        return (angle / 18.0) + 2.5

    def __get_position_incrementer(self, position, min, max):
        # randomly pick new position, leaving a buffer +- the min values for adjustment later
        newPosition = random.randint(min + self.min_movement, max - self.min_movement)
        print "New position: {0}".format(newPosition)

        # bump up the new position if we didn't move more than our minimum requirement
        if((newPosition > position) and (abs(newPosition - position) < self.min_movement)):
            newPosition += self.min_movement
        elif((newPosition < position) and (abs(newPosition - position) < self.min_movement)):
            newPosition -= self.min_movement

        # return the number of steps, or incrementer, we should take to get to the new position
        # this is a convenient way to slow the movement down, rather than seeing very rapid movements
        # from point A to point B
        return float((newPosition - position) / self.movement_time)

    def __get_movement_delay(self):
        return random.uniform(0, 1)

    def __get_movement_time(self):
        return random.randint(10, 40)

This code is pretty self contained. The only reason you’d need to adjust anything above is if you decided to use different GPIO pins than I described in the previous sections. If so, you’ll need to set them accordingly (lines 15 – 17).

One script down, that was easy! Create another file called LaserWrapper.py with the following code:

#!/usr/bin/env python

from Laser import Laser
import json
import datetime
import RPi.GPIO as GPIO
import time

GPIO_BUTTON = 26

laser = Laser()
start_time = datetime.datetime.now()
run_time = 0
engage = False

default_configuration = '{"run_time": 30, "min_movement": 12, "x_min": 0, "x_max": 90, "y_min": 0, "y_max": 22}'

def initiateLaserSequence():
    global engage
    # setup the push button GPIO pins
    GPIO.setmode(GPIO.BCM)
    GPIO.setup(GPIO_BUTTON, GPIO.IN, pull_up_down=GPIO.PUD_UP)

    run = True
    
    # wire up a button press event handler to avoid missing a button click while the loop below
    # is busy processing something else.
    GPIO.add_event_detect(GPIO_BUTTON, GPIO.FALLING, callback=__button_handler, bouncetime=200)
    
    print "running, press CTRL+C to exit..."
    try:
        while run:
            try:
                if(engage):
                    if(__run_time_elapsed()):
                        # we ran out of time for this run, shutdown the laser
                        engage = False
                        laser.stop()
                    else:
                        laser.fire()
                else:
                    # sleep here to lessen the CPU impact of our infinite loop
                    # while we're not busy shooting the laser. Without this, the CPU would
                    # be eaten up and potentially lock the Pi.
                    time.sleep(1)
                        
            except Exception, e:
                # swallowing exceptions isn't cool, but here we provide an opportunity to
                # print the exception to an output log, should crontab be configured this way
                # for debugging.
                print 'Unhandled exception: {0}'.format(str(e))
            except KeyboardInterrupt:
                run = False
                print 'KeyboardInterrupt: user quit the script.'
                break
    finally:
        print 'Exiting program'
        laser.stop()
        GPIO.cleanup()

def __button_handler(channel):
    global engage
    
    print 'Button pressed! '.format(str(channel))
    
    if(engage):
        print 'Already firing the laser, button press ignored'
    else:
        print 'Initiating Firing Sequence!'
        # only start a new firing sequence if we're not already in the middle of one.
        engage = True
        __calibrate_laser(None)

def __run_time_elapsed():
    # figure out if the laser has ran its course, and should be stopped.
    now = datetime.datetime.now()
    end_time = (start_time + datetime.timedelta(seconds=run_time)).time()
    
    if(now.time() > end_time):
        return True
    
    return False

def __calibrate_laser(configuration):
    global start_time
    global run_time
    
    if(configuration is None):
        # no user defined config, so we'll go with the defaults
        configuration = json.loads(default_configuration)
    
    print "starting laser with config: {0}".format(configuration)
    
    start_time = datetime.datetime.now()
    
    run_time = configuration.get('run_time')
    min_movement = configuration.get('min_movement')
    x_max = configuration.get('x_max')
    x_min = configuration.get('x_min')
    y_max = configuration.get('y_max')
    y_min = configuration.get('y_min')
 
    laser.calibrate_laser(min_movement, x_min, x_max, y_min, y_max)

if __name__ == '__main__':
    initiateLaserSequence()

As mentioned previously, this script defines a few settings which tell the Laser.py script information about how it should move. This script is also responsible for triggering the movement, as it’s continuously looking for the press of that physical button we wired up. 

The default settings are defined as a JSON string on line 16, and are stored in the default_configuration variable. I chose to use JSON instead of individual variables to prepare the script for future changes introduced in part four, where we trigger the laser remotely. 

Here’s a bit of documentation around what exactly these configuration variables mean:

PropertyDescription
run_timeHow long, in seconds, the cat laser pointer toy should operate each time the button is pressed.
min_movementThe minimum amount of movement that needs to occur when the laser moves from point A to point B on both the x and y axis.
x_minThe minimum position, in degrees, the laser is allowed to move to along the x axis.
Must be greater than or equal to 0 degrees.
x_maxThe maximum position, in degrees, the laser is allowed to move to along the x axis.
Must be less than or equal to 180 degrees. 
y_minThe minimum position, in degrees, the laser is allowed to move to along the y axis
Must be greater than or equal to 0 degrees.
y_maxThe maximum position, in degrees, the laser is allowed to move to along the y axis.
Must be less than or equal to 180 degrees. 

 

To really drive home the point, let’s dissect the default_configuration variable in the above script:

default_configuration = '{"run_time": 30, "min_movement": 12, "x_min": 0, "x_max": 90, "y_min": 0, "y_max": 22}'

With these settings, the cat laser pointer toy will:

  • Run for 30 seconds when the physical button is pressed, stopping after that time has elapsed.
  • Move at least 12 degrees along both the x axis (left/ right) and y axis (up/ down).
  • Move within the bounds of 0 degrees (minimum) and 90 degrees (maximum) along the x axis. Remember, our servo’s can move between 0 and 180 degrees if we wanted. Here we can restrict that range to our needs.
  • Move within the bounds of 0 degrees (minimum) and 22 degrees (maximum) along the y axis.

The x and y min/ max variables are nothing more than a way for us to define a square or rectangular shape for the laser to move within. I adjusted these settings until they fit the space where I have the cat laser pointer toy set up. You’ll likely need to tailor them to your own play space.

Now that we have all the code in place we can finally test this puppy out, hell yeah!

Begin Laser Ignition!

Frau Farbissina said it best, it’s time we begin laser ignition. Open up the terminal and navigate to the directory where you created the Laser.py and LaserWrapper.py scripts, then type the following commands:

chmod +x Laser.py
chmod +x LaserWrapper.py

Our scripts are now executable. The LaserWrapper.py script is the one we want to kick off, as it’s responsible for configuring and running Laser.py internally. Run the script by typing ./LaserWrapper.py. If all went well you should see an error-free terminal, which means the script is running and awaiting your command.

Press the button already!

If everything’s wired up correctly and the code is in place, the laser should have started moving. Congratulations, you’ve built a fully functional world dominating cat laser pointer toy!

If nothing happened or you had errors while executing the script: take a step back and double-check all your GPIO connections from the previous section. Did you use the same GPIO’s as me? If you did, great, that’s probably not the problem, but if you didn’t then you’ll need to adjust the GPIO variables in the scripts to point to the ones you chose. If the pins are in place and still no luck, don’t be discouraged! Reach out in the comments or contact me directly and we can take a crack at it together.

Using Cron to Automatically Run our Script

Our cat laser pointer toy has a push button to trigger the movement of the laser, but that button is no good to us if our LaserWrapper.py script isn’t constantly running. We need this script to run on a regular basis, which means starting it when the Raspberry Pi boots up. For that, we can use cron.

Open a terminal and type crontab -e to open the cron job editor in nano. Add the following line to the end of this file:

@reboot sudo python /path/to/your/script/LaserWrapper.py

It may seem obvious, but make sure you replace /path/to/your/script above with the actual path to your script. Move your cursor to the end of the newly added line (after .../LaserWrapper.py) and press Enter to create a new line under it. A known quirk with cron is that it requires the command to be followed by a new line, else it won’t run our script.

Since we’re in nano, save and exit by pressing CTRL+X, then Y, then Enter. This script will now be executed anytime the Pi reboots. Give it a go: reboot your Pi and give it time to load up. Once loaded, try triggering the cat laser pointer toy without manually running the script first.  

Conclusion and Next Steps

You did it! We now have the awesome power of an automatic cat laser pointer toy. I hope you have a cat to use it on (I won’t judge if you don’t).

Part one got us started, while part two really made the project interesting once we had a fully assembled contraption. Here in part three we were able to bring life to our laser with a little help from our friend python. 

Not satisfied with stopping here? Too lazy to physically push the button on the laser? Me too. If you haven’t had enough yet I’ll be over in part four over-engineering this project with some remote capabilities.

I want to reiterate that if you’re stuck, that’s ok! Don’t be discouraged. This project caught me up a few times, it’s all part of the process. Just reach out!

If you won’t be joining me for part four, then I want to thank you for taking the time to read through this series; I hope you found it most excellent. If you have any feedback, good or bad, I implore you to reach out in the comments.

11 Replies to “Write Code to Control the Raspberry Pi Cat Laser Pointer Toy”

  1. Hey, Sam! Loving this project so far- thanks for writing it up! Just running into an issue I can’t seem to track down. All goes well until I’m ready to run LaserWrapper.py. Getting some errors related to x_position not being an attribute. I’m just skimmed the code to try to figure it out but I’m still a bit stuck. Anything you can suggest from the following output?

    ./LaserWrapper.py
    running, press CTRL+C to exit…
    Button pressed!
    Initiating Firing Sequence!
    starting laser with config: {u’y_min’: 0, u’y_max’: 22, u’y_min_movement’: 3, u’x_min_movement’: 20, u’run_time’: 30, u’x’: 90, u’x_min’: 0}
    Traceback (most recent call last):
    File “./LaserWrapper.py”, line 72, in __button_handler
    __calibrate_laser(None)
    File “./LaserWrapper.py”, line 104, in __calibrate_laser
    laser.calibrate_laser(x_min_movement, y_min_movement, x_min, x_max, y_min, y_max)
    TypeError: calibrate_laser() takes exactly 6 arguments (7 given)
    Movement time: 16
    Unhandled exception: Laser instance has no attribute ‘x_position’
    Movement time: 36
    Unhandled exception: Laser instance has no attribute ‘x_position’
    Movement time: 32

    The Unhandled exception errors continue until I ctrl+c out of there.

    Probably something simple I’m just overlooking, but I appreciate any advice you have on tracking it down.

    Thanks!

    1. Hey Josh, thanks for the feedback! I’m glad you’re enjoying the project. The error is showing the “config” used as:

      {u’y_min’: 0, u’y_max’: 22, u’y_min_movement’: 3, u’x_min_movement’: 20, u’run_time’: 30, u’x’: 90, u’x_min’: 0}

      If you look closely, one of those properties has a typo.

      “u’x’: 90” should actually be “u’x_max’: 90”

      Give that a go and let me know how it works out.

  2. I’m getting the same error as Josh did last year, even though I’m using the version that has fixed the u’x’: 90 to u’x_max’: 90 typo. Specifically it seems like the script is looking for six variables, but it’s seeing seven.

    Error messages:

    Button pressed!
    Initiating Firing Sequence!
    starting laser with config: {u’y_min’: 0, u’y_max’: 22,
    u’y_min_movement’: 3, u’x_min_movement’: 20, u’run_time’: 30,
    u’x_max’: 90, u’x_min’: 0}
    Traceback (most recent call last):
    File “./LaserWrapper.py”, line 72, in __button_handler
    __calibrate_laser(None)
    File “./LaserWrapper.py”, line 103, in __calibrate_laser
    laser.calibrate_laser(x_min_movement, y_min_movement, x_min,
    x_max, y_min, y_max)
    TypeError: calibrate_laser() takes exactly 6 arguments (7 given)
    Movement time: 28
    Unhandled exception: Laser instance has no attribute ‘x_position’
    Movement time: 21
    Unhandled exception: Laser instance has no attribute ‘x_position’
    Movement time: 18
    (exception continues until ctrl-C)

    1. Hey bicoid – thanks for reporting you’re also having difficulties. It turns out the fault was mine; there was a bug in the code I posted. I must have mixed things up while I was originally testing this out and posted the wrong version. Sorry about that!

      I’ve updated the blog post with the latest code which should resolve this issue, give that a go. Please let me know if this works out for you!

      If you’re curious, the exception was being thrown because I was passing “x_min_movement” and “y_min_movement” as separate variables to the “laser.calibrate_laser(…)” function (line 103 in the LaserWrapper.py code above). However, within the “Laser.py” script we’re only expecting a single variable to define “min_movement”, rather than a variable per axis. In other words, these two separate variables for “x” and “y” were merged into a single variable called “min_movement”.

      1. Okay – those changes worked great! The laser now fires and moves erratically, although I have some limited movement from one of the servos that I have to look at later. On the screen, I’m getting a some minor error messages after I click the physical button, and apparently right after a new position is generated. I’ve pasted a few below in case they make any sense to you:

        Current position: X: 45, Y: 11
        New position: 25
        Unhandled exception: empty range for randrange() (12,11, -1)
        Movement time: 40
        Current position: X: 45, Y: 11
        New position: 21
        Unhandled exception: empty range for randrange() (12,11, -1)
        Movement time: 18
        Current position: X: 45, Y: 11
        New position: 32
        Unhandled exception: empty range for randrange() (12,11, -1)
        Movement time: 39
        Current position: X: 45, Y: 11

        If it helps you figure out where this is going on, when I break, I get this:

        ^CExiting program

        Tidying up
        Traceback (most recent call last):
        File “./LaserWrapper.py”, line 105, in
        initiateLaserSequence()
        File “./LaserWrapper.py”, line 51, in initiateLaserSequence
        print ‘Unhandled exception: {0}’.format(str(e))
        KeyboardInterrupt

        1. I’m glad that worked out for you! That error looks to be related to line 95 in the Laser.py script, which is responsible for generating the next position using the `random.randint(min, max)` function.

          The range must be valid, where the left parameter (min) is less than the right (max). There’s some simple math going on in that script that adjusts the min and max ranges. I can see that causing the range to become invalid if the calculation results in the left param (min) being greater than the right (max). For example, if you have the configuration set where “x_min” + “min_movement” comes out to be equal to or greater than “x_max” – “min_movement”, that error will likely be seen.

          In other words, the combination you’ve set for your x/y min/max values, as well as your min_movement value, is causing the issue.

          I apologize for not handling that scenario gracefully, I didn’t realize it existed! Hopefully this points you in the right direction though.

  3. Hi Sam! My son is building this for an 8th grade science project and when he presses the button, he gets this

    unhandled exception type object ‘datetime.datetime’ has no attribute ‘nom’

    I am no help when it comes to coding!

    1. Hi Kelly! That’s so cool, excellent science project.

      It sounds like there’s possibly a typo or other related error in the file. If you can email me the code ([email protected]) I can take a peek. Please temporarily change the file extension to “txt” instead of “py” before emailing to avoid the email service from treating it as a potential security threat.

      In addition to the code, I’d really appreciate a screenshot of the actual error on screen if possible, or any additional text associated with it. That text is very helpful in pointing a finger at where the problem may have started.

  4. Thank you so much! We decided to utilize the maker’s space at our local library, and the makers are helping him learn how to find the errors in the code. It’s a good learning opportunity for him 🙂 I think we’re good, so far it’s just been typos, but I will follow up with you if he runs into anything he can’t fix. Thank you so much for such a great project!

  5. Hi, Thanks for this tutorial. Has been very enjoyable working on it. I have got the code running but I notice that the x axis servo kind of stops working halfway through a run. I get a good amount of x axis movement but halfway through a run it stops and the only movement is the y axis servo (up and down). Any reason for this? I get no error codes,

I'd love to hear your thoughts