Trigger the Automatic Cat Laser Pointer Toy Remotely

Trigger the Automatic Cat Laser Pointer Toy Remotely

You made it here to part four because pushing that button isn’t enough. No, you want to trigger this cat laser pointer toy remotely, or even on a schedule. Well you’re in luck, because I too wanted more ways to do the same thing.

Part one got us on our way, part two was all about building the laser contraption, and part three brought it life. That means you have a fully functional cat laser pointer toy capable of being triggered at the push of a button.

What are we going to be doing here exactly?

  • Set up your Gmail account with 2-step authentication for a secure way to interact with it through code.
  • Introduce a modified version of the GmailWrapper.py script from my automatic cat feeder series to scan for an email with a given subject and, when found, trigger the cat laser pointer toy.
  • Use IFTTT to provide support for remote triggering and scheduling.

Enough said. It’s go time.

Preparing Your Gmail Account with 2-step Authentication

We’re going to be using email as the trigger to engage the cat laser pointer toy. Before that’s possible we’ll need a way to securely log into our Gmail account from code. You could store your credentials in plain text within the code we write, but I strongly discourage that. Instead, make use of Gmail’s two-step authentication and app passwords

I created a new Gmail account for this purpose.

  1. Log into your Gmail account
  2. Navigate to the Sign-in and security page
  3. Under the Signing in to Google section, click the 2-Step Verification menu, then follow the instructions to enable 2-Step Verification
  4. Back in the Sign-in and security page right under the 2-Step Verification button you’ll see App passwords
  5. Generate a password
    1. Note: this password will be 16 digits with spaces separating every 4 digits (e.g. xxxx xxxx xxxx xxxx). Please be sure to include the spaces!
  6. You can only view a generated password once, so copy it to the side for now.

The password you generated can be used to log into your Gmail account. We don’t need it right this second, but we’ll be using it to scan for an email that demands we initiate the laser sequence!

Writing Code to Read A Gmail Account

Now that we have a Gmail account ready to rock, let’s write some code to interrogate it. We’re going to be using the IMAPClient python library for this purpose, and we’ll wrap the calls to this client in our own Python class.

Install IMAPClient now from the terminal: sudo pip install imapclient

Create the GmailWrapper.py Script

Now let’s create our Gmail wrapper class: create a new file called GmailWrapper.py with the following code:

#!/usr/bin/env python
 
from imapclient import IMAPClient, SEEN
 
SEEN_FLAG = 'SEEN'
UNSEEN_FLAG = 'UNSEEN'
 
class GmailWrapper:
    def __init__(self, host, userName, password):
        #   force the user to pass along username and password to log in as 
        self.host = host
        self.userName = userName
        self.password = password
        
        self.login()
 
    def login(self):
        print('Logging in as ' + self.userName)
        server = IMAPClient(self.host, use_uid=True, ssl=True)
        server.login(self.userName, self.password)
        self.server = server
 
    #   The IMAPClient search returns a list of Id's that match the given criteria.
    #   An Id in this case identifies a specific email
    def getIdsBySubject(self, subject, unreadOnly=True, folder='INBOX'):
        #   search within the specified folder, e.g. Inbox
        self.setFolder(folder)  
 
        #   build the search criteria (e.g. unread emails with the given subject)
        self.searchCriteria = [UNSEEN_FLAG, 'SUBJECT', subject]
 
        if(unreadOnly == False):
            #   force the search to include "read" emails too
            self.searchCriteria.append(SEEN_FLAG)
 
        #   conduct the search and return the resulting Ids
        return self.server.search(self.searchCriteria)

    def getIdsByGmailSearch(self, search, folder='INBOX'):
        # powerful search enabled by Gmail. Examples: `in:unread, subject: <subject>`
        self.setFolder(folder)
        return self.server.gmail_search(search)
    
    def getFirstSubject(self, mailIds, folder='INBOX'):
        self.setFolder(folder)
        
        data = self.server.fetch(mailIds, ['ENVELOPE'])
        for msgId, data in data.items():
            envelope = data[b'ENVELOPE']
            return envelope.subject
            
        return None
 
    def markAsRead(self, mailIds, folder='INBOX'):
        self.setFolder(folder)
        self.server.set_flags(mailIds, [SEEN])
 
    def setFolder(self, folder):
        self.server.select_folder(folder)

Verifying the GmailWrapper.py Class Works

Let’s do a test: our script is going to log into the Gmail account, search for email with a specific subject, retrieve that subject, then mark the email as read. Before running the code, send yourself an email with the subject begin laser ignition (the search is case-insensitive, by the way).

We’re going to use the Python interpreter to run our tests. In your terminal make sure you’re in the same directory as the GmailWrapper.py script we just created, then:

# press enter after each line for the interpreter to engage
 
# invoke the interpreter
python
 
# import our wrapper class for reference
from GmailWrapper import GmailWrapper
 
# create an instance of the class, which will also log us in
# the <password> should be the 2-step auth App Password, or your regular password
gmailWrapper = GmailWrapper('imap.gmail.com', '<your gmail username>', '<password>')
 
# search for any unread emails with the subject 'begin laser ignition', and return their Ids
ids = gmailWrapper.getIdsByGmailSearch('begin laser ignition')
 
# have the interpreter print the ids variable so you know you've got something
ids

# grab the full subject of the first id returned
subject = gmailWrapper.getFirstSubject(ids)

# have the interpreter print the subject variable to see what you've got
subject
 
# we successfully found and read our email subject, now lets mark the email as read
gmailWrapper.markAsRead(ids)
 
# exit the interpreter
quit()

If everything went as planned your email should now be marked as read. Pretty neat! 

LaserWrapper, Meet GmailWrapper: Putting the Two Together

As we know by now, the LaserWrapper.py script is constantly watching the physical button and waiting for it to be pressed. We’re going to modify it a bit so it also watches the Gmail account and waits for an email with the correct subject to come through. In the event it finds this email we want it to trigger the cat laser pointer toy.

Alright, time for some modifications. Open your LaserWrapper.py script and replace its contents with the following:

#!/usr/bin/env python
 
from Laser import Laser
from GmailWatcher import GmailWrapper
import json
import datetime
import RPi.GPIO as GPIO
import time
from imaplib import IMAP4

HOSTNAME = 'imap.gmail.com'
USERNAME = 'your username'
PASSWORD = 'your password'
 
# seconds to wait before searching Gmail
CHECK_DELAY = 30
 
# seconds to wait before logging into gmail. if we don't wait, we run the risk of trying to 
# log in before the Pi had a chance to connect to wifi.
GMAIL_CONNECT_DELAY = 20

# minutes to wait before reconnecting our Gmail instance.
GMAIL_RECONNECT_DELAY = 60
GPIO_BUTTON = 26
 
FIRE_LASER_SUBJECT = 'begin laser ignition'
DISMANTLE_LASER_SUBJECT = 'dismantle laser'
 
engage = False
gmailWrapper = None
laser = Laser()
 
start_time = datetime.datetime.now()
run_time = 0
last_gmail_check_time = datetime.datetime.now()
last_gmail_connect_attempt = datetime.datetime.now()
last_reconnect_attempt = datetime.datetime.now()
 
default_configuration = '{"run_time": 30, "min_movement": 12, "x_min": 0, "x_max": 90, "y_min": 0, "y_max": 22}'
 
def initiateLaserSequence():
    global gmailWrapper
    global engage
    
    GPIO.setmode(GPIO.BCM)
    GPIO.setup(GPIO_BUTTON, GPIO.IN, pull_up_down=GPIO.PUD_UP)
 
    stop = False
    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)
    
    last_gmail_check = datetime.datetime.now().time()
    print 'running, press CTRL+C to exit...'
    try:
        while run:
            try:
                __check_gmail_connection()
                
                # avoid pinging gmail too frequently so they don't lock us out.
                if(__should_check_gmail(CHECK_DELAY)):
                    print 'Checking Gmail!'
                    stop = __should_stop_firing()
                    
                    ids = __get_email_ids_with_subject(FIRE_LASER_SUBJECT)
                    if(len(ids) > 0):
                        print 'Email found, initiating laser sequence!'
                        engage = True
            
                        laser.stop()
            
                        # grab any config options from the email and begin
                        __calibrate_laser(__get_configuration(ids))
                        gmailWrapper.markAsRead(ids)
                
                if(stop):
                    engage = False
                    stop = False
                    laser.stop()
                
                if(engage):
                    if(__run_time_elapsed()):
                        stop = True
                    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 IMAP4.abort, e:
                # Gmail will expire your session after a while, so when that happens we
                # need to reconect. Setting None here will trigger reconnect on the
                # next loop.
                gmailWrapper = None
                print 'IMAP4.abort exception: {0}'.format(str(e))
            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 __check_gmail_connection():
    if(gmailWrapper is None):
        __connect_gmail()
    
def __connect_gmail():
    global gmailWrapper
    global last_gmail_connect_attempt
    
    now = datetime.datetime.now()
    next_connect_time = (last_gmail_connect_attempt + datetime.timedelta(seconds=GMAIL_CONNECT_DELAY)).time()
    if(now.time() > next_connect_time):
        print '__connect_gmail: Attempting to login to Gmail'
        try:
            last_gmail_connect_attempt = now
            gmailWrapper = GmailWrapper(HOSTNAME, USERNAME, PASSWORD)
        except Exception, e:
            print '__connect_gmail: Gmail failed during login, will retry automatically.'.format(str(e))

def __should_check_gmail(delay):
    global last_gmail_check_time
    
    if(gmailWrapper is None):
        # we haven't yet successfully connected to Gmail, so exit
        return
    
    now = datetime.datetime.now()
    next_check_time = (last_gmail_check_time + datetime.timedelta(seconds=delay)).time()
    
    if(now.time() > next_check_time):
        last_gmail_check_time = now
        return True
    
    return False
 
def __run_time_elapsed():
    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)
        
def __get_email_ids_with_subject(subject):
    return gmailWrapper.getIdsByGmailSearch('in:unread subject:{0}'.format(subject))
    
def __get_configuration(emailIds):
    subject = gmailWrapper.getFirstSubject(emailIds)
    config_start_index = subject.find('{')
    
    # no config found in subject, so return nothing
    if(config_start_index == -1):
        return None
    
    # grab the substring from opening { to the end
    return json.loads(subject[config_start_index:None])
 
def __should_stop_firing():
    ids = __get_email_ids_with_subject(DISMANTLE_LASER_SUBJECT)
    return len(ids) > 0

if __name__ == '__main__':
    initiateLaserSequence()

The above script is almost ready for use, we just need to make a few tweaks:

  • Replace lines 12 and 13 with your Gmail username (that’s your email without @gmail), and the app password you generated.
  • Replace lines 26 and 27 with the email subjects that you want to use to start or stop the laser sequence. That’s right, the ability to remotely stop the laser is baked in as well, just in case it’s running a bit longer than desired.

Once these changes are in place, give it a test drive. Run the script and press the physical button to ensure we didn’t break anything there. If all looks good, send yourself an email with the subject you chose (I used begin laser ignition). After about 30 seconds the laser should have began firing.

Subject Matters: Override the Default Configuration

At this point we’re able to trigger the cat laser pointer toy remotely by sending an email. This is pretty powerful stuff. If you recall from the previous posts, we built in the ability to configure the cat laser pointer toy in the form of a JSON string. What if we want to set the run time, or the minimum amount of movement, remotely?

You’re in luck! The script we just wrote above is smart enough to do that. If you send an email with just the trigger key in the subject (e.g. begin laser ignition), then it will use the default_configuration variable on line 39 of the script. However, you can override that by adding your own configuration into the subject itself.

Here’s a sample email subject to showcase what I mean:

begin laser ignition {"run_time": 30, "min_movement": 12, "x_min": 0, "x_max": 90, "y_min": 0, "y_max": 22}

The script we wrote will parse this more complex subject and use the configuration settings you provide here over the default ones.

Scheduling an Email to be Sent Regularly

We have the code ready to rock. Sending an email on demand will cause the cat laser pointer toy to start firing, but what about firing it on a schedule? That’s where we’ll make use of IFTTT. IFTTT stands for if this then that and allows you to connect two disparate systems based on what they call a “recipe” (trigger and action). For our purpose, we need the clock to trigger an email to be sent (action).

Here’s what to do:

  1. Setup an account if you haven’t already (free, free, free).
  2. Use IFTTT website or download the app to your phone and log in.
  3. In the My Applets section, add a new applet.
  4. You’ll see a screen saying if +this then that: click the +this button, then select the Date & Time service.
  5. Select the Every day at trigger, and select the time you’d like the cat laser pointer toy to activate, then hit next
  6. Now you’ll see +that, click it and find the Gmail service. You’ll need to connect the service to your Gmail account. Once finished, set the action to Send yourself an email with the subject  Begin Laser Ignition.
    1. Don’t forget, you have the option of adding configuration to the subject, too.
  7. Hit next, then Finish

There you have it, every day at the time you specified an email will be sent to your account. If you had any issues setting up the IFTTT recipe, check out this post for a really nice and in-depth walk-through.

Having fun? Here’s Other Ways to Trigger

Alexa

Alexa (and the Echo Dot) integrates nicely with IFTTT. In the IFTTT app, create a new recipe with the trigger (+this) connecting to Alexa. You’ll need to connect the service like you did for Gmail. Once connected, select the option to Say a specific phrase and enter a phrase like cat laser. Once the Alexa side is setup, set the action (+that) to send an email, like we did in the previous section.

Hands free laser initiation at the ready, just say: Alexa, trigger the cat laser.

The DO Button App

Created by the IFTTT team, the DO Button app and accompanying widget provides a straightforward way to trigger the action. You! You’re the trigger. You setup a recipe, same as before, except you’ll notice there’s no +this. You are +this. You open the app and click the button, it then triggers an email which triggers the cat laser. This app can also be configured to show on your iPhone or Androids home screen, so triggering the laser is even easier.

Conclusion

I hope you enjoyed this project as much as I did. It was a blast seeing a shark with a laser attached to its head causing mayhem throughout my apartment. I think the cats enjoy it too, almost as much as I do.

As always, a little shoutout to the previous sections in case you need to get there:

  • Part one where we found out what we were building and what it would take.
  • Part two which helped guide us to creating a machine of chaos.
  • Part three where our inner mad scientist gave life to this machine in the form of code.
  • And of course, the bonus part, part four where we couldn’t live without a remote way to control our cat laser pointer toy.

I encourage you to leave feedback in the comments below. If you’re stuck, reach out! And of course, thank you for reading.

I'd love to hear your thoughts