2-Axis Laser Controller

So, it’s been a pretty hectic time since I last posted – moving house (again) and also beginning my Masters and figuring out how to balance it with work while remaining sane (I have no advice sorry!)

I thought I’d do a short post on the first iteration of a fun little personal project born out of necessity – how to keep your cat happy when you’re away or hard at work?

Even for small projects, it’s really important to have your goal and functional requirements relatively finalised. I was able to knock this project out in 2 sessions of hands on work, I think mostly because I’d been thinking of exactly what I wanted to achieve for a while. 

With that in mind, my overarching goal was to create a two axis, programmatically controlled laser pointer, that can follow a defined path, on some sort of schedule.

Now, I’m sure there’s products or modules that achieve this pretty much off the shelf (honestly, I didn’t even look), but we all know they’re no fun, so let’s build something up!

Equipment/Tools:

1 x Raspberry pi (I’m using a 3B)
1 x Servo Controller (Adafruit 16-Channel 12-bit PWM/Servo HAT)
2 x Hobby Servos
1 x 5v laser module (Duinotech XC4490 Laser Diode Module)
1 x 2N7000 N-Channel Enhancement Mode Field Effect Transistor

Assorted wire, enclosures, heat-shrink (depending on how pretty you want to make it look), and the usual workbench tools like soldering iron, cutters/strippers, hot glue etc.

Hardware:

The hardware build is relatively simple with the servo hat, which is pretty much plug and play. 

To achieve the 2 axis range of motion, I literally hot glued the laser module to the armature of one servo, then hot glued that to the armature of the other. It’s not exactly pretty, but it does the trick. 

The only other hardware hurdle was powering and controlling the laser module. I knew the laser module was 5V, but it turns out I didn’t read the hat documentation well enough, thinking that you could PWM 5V AND/OR 3.3V. As it turns out, 5V pins are just steady Vcc, and it’s the 3.3V that is PWM’ed – great for servo’s and simple LED circuits, less so for any generalised control.

In comes the 2N7000. We can use in a switching circuit to control the 5V Vcc with the 3.3V PWM signal. Mostly we’ll just be toggling state between High/Low to turn the laser on and off, but there’s also opportunity to PWM the laser to reduce the output power. See below for the switching circuit schematic – noting that this “direct” connection to the transistor is only possible because:

  1. The 3.3V signal from the PWM controller has an internal series resistor, and is current limited.
  2. The laser module also has an onboard series resistor.
Switching circuit to PWM 5V Vcc with 3.3V control.

The pretty straight forward block connections (and some actual photo’s) are shown in the diagram below (some liberty has been taken with the components used in the diagram due to the limitations of Fritzing’s hardware library).

Software:

Software was written in Python, see here for instructions on getting your hat connected and some basic usage. I will also share the repo link for full source code at the end, but let’s have a look at some basic code structure. Forgive the lack of comments!

Hardware Controllers:

The harware control is implemented in the module hardware.py, as below.

 DEBUG = False
import numpy as np

# Set up
if not DEBUG:
    import board
    import busio
    import adafruit_pca9685
    from adafruit_servokit import ServoKit

    i2c = busio.I2C(board.SCL, board.SDA)
    hat = adafruit_pca9685.PCA9685(i2c)
    hat.frequency = 60

    kit = ServoKit(channels=16)


class Laser(object):
    def __init__(self, channel, power=0.05):
        if not DEBUG:
            self._channel = hat.channels[channel]

            self.power = power

            self.off()

    def on(self):
        self._channel.duty_cycle = self.hpower

    def off(self):
        self._channel.duty_cycle = 0

    @property
    def power(self):
        return self._power

    @power.setter
    def power(self, p):
        self._power = max(0.0, min(1.0, p))

        self.on()

    @property
    def hpower(self):
        return int(self.power * (2 ** 16 - 1))


class TwoAxis(object):
    _speed_scale = 500

    def __init__(self, pan_channel, tilt_channel):
        if not DEBUG:
            self._pan = kit.servo[pan_channel]
            self._tilt = kit.servo[tilt_channel]
        self._pan_bounds = (50, 105)
        self._tilt_bounds = (78, 110)

        self._centre_angles = ((self._pan_bounds[0] + self._pan_bounds[1]) / 2.0, \
                               (self._tilt_bounds[0] + self._tilt_bounds[1]) / 2.0)
        if not DEBUG:
            self._centre()

    def _centre(self):
        self.pan_angle = self._centre_angles[0]
        self.tilt_angle = self._centre_angles[1]

    @property
    def pan_angle(self):
        return self._pan.angle

    @pan_angle.setter
    def pan_angle(self, theta):
        self._pan.angle = max(self._pan_bounds[0], min(self._pan_bounds[1], theta))

    @property
    def tilt_angle(self):
        return self._tilt.angle

    @tilt_angle.setter
    def tilt_angle(self, theta):
        self._tilt.angle = max(self._tilt_bounds[0], min(self._tilt_bounds[1], theta))

    def go_to(self, state={'pan': 90.0, 'tilt': 90.0, 'speed': 100.0, 'method': 'simultaneous'}):

        steps = int(self._speed_scale * 100.0 / state['speed']) + 1
        pans = np.linspace(self.pan_angle, state['pan'], steps)[1:]
        tilts = np.linspace(self.tilt_angle, state['tilt'], steps)[1:]

        if state['method'] == 'simultaneous':
            for p, t in zip(pans, tilts):
                self.pan_angle = p
                self.tilt_angle = t

        elif state['method'] == 'sequential':
            for p in pans:
                self.pan_angle = p
            for t in tilts:
                self.tilt_angle = t


class LaserController(Laser, TwoAxis):
    def __init__(self, laser_channel, pan_channel, tilt_channel):
        Laser.__init__(self, laser_channel)
        TwoAxis.__init__(self, pan_channel, tilt_channel)

    def go_to(self, state={'pan': 90.0, 'tilt': 90.0, 'speed': 100.0, 'method': 'simultaneous', 'laser': False}):

        super().go_to(state)

        if state['laser']:
            self.on()
        else:
            self.off()

    def follow_path(self, path):

        for state in path:
            self.go_to(state)

        self.go_to()
 

There is a Laser class, initiated with the channel the laser breakout is connected to on the hat. The class is simple, giving the ability to turn the laser on and off (x% and 0% PWM respectively, where x is defined by the power setting). The TwoAxis class implements control for the two servos, providing pan and tilt control (with max/min bounds to keep the laser in a specified area), and a TwoAxis.go_to() method, controlling the logic from moving from one position (state) to another.

The go_to() method provides a very basic control over speed of movement, essentially creating an array of intermediate positions to move from one state to another, with higher speeds having less intermediate steps. The class also provides a TwoAxis._speed_scale variable, providing a single point of control for tweaking the overall speed, as the state variable nominally defines speeds in the range of 1-100 (this was something that proved super useful – when coding, I had little idea of how the servos would actually react, so building this parameter in from the start was very handy).

The LaserController class is a superclass of both the Laser and TwoAxis classes, since it will combine control over all aspects of the “Laser Module”. It is largely convenience at this time, but it does overload the go_to() method, by splitting the state into hardware components and passing parameters to the relevant subclass methods. It also provides a follow_path() method, for iterating over an array of states.

Paths:

You should hopefully be able to see by now how paths are being handled: an iterable of states (where states define pan/tilt position, laser on/off as well as type and speed of movement to the next state).

import json
import random


class PathManager(object):
    def __init__(self, file):
        if file:
            with open(file) as f:
                self._paths = json.load(f)

        else:
            self._paths = {}

    @staticmethod
    def generate_random(num_points, pan_bounds=(0, 180), tilt_bounds=(0, 180), speed_bounds=(30, 100)):

        path = []

        for i in range(0, num_points):
            pan = random.randint(*pan_bounds)
            tilt = random.randint(*tilt_bounds)
            speed = random.randint(*speed_bounds)

            path.append({'pan': pan, 'tilt': tilt, 'speed': speed, 'method': 'simultaneous', 'laser': True})

        return path

    def __getitem__(self, key):
        return self._paths[key]

The path_manager.py module, well, manages the paths. It currently has one class, the PathManager, which reads a json file containing any pre-defined paths, and a method for creating a random path.

Run:

Now that we have our software components, we can run some routines. Here’s what I had for an approximately 7-8 minute routine.

 
import hardware
import path_manager

if __name__ == '__main__':
    lc = hardware.LaserController(0, 1, 2)

    pm = path_manager.PathManager('/home/pi/projects/laser_module/paths.json')

    repeats = 3

    for i in range(0, repeats):
        lc.follow_path(pm['zigzag'])
        lc.follow_path(pm['zigzag'])
        lc.follow_path(pm['zagzig'])
        lc.follow_path(pm['zagzig'])
        lc.follow_path(pm['zigzag'])
        lc.follow_path(pm['zigzag'])
        lc.follow_path(pm.generate_random(50, (50, 105), (78, 110), (30, 100)))

    lc.follow_path(pm['blink'])

Scheduling:

Scheduling is simple, implemented using crontab on the pi, executing a bash script to run the above python process at defined times throughout the day. Don’t forget to make sure theres a blank line at the end of your crontab file, and that the bash script has the requisite executable permissions.

Finished:

Here’s our cat Knuckles running some unit tests. You can see a switch from the “zigzag” path, to a random path. I’m particularly happy with the motion generated from the random generation – it looks very chaseable.

And here’s the repo if you want to build your own.

Improvements/Known Issues:

When I have the time, I plan to:

  • Implement proper Async control for simultaneous control of the two servos.
  • Investigate a better speed control – the array size method works, but is a bit clunky. It will probably tie in to the above.
  • Investigate smoother servo movement. I’m pretty sure my float positional arrays are being truncated to int, and the movement is quantised and jerky because of it. I’ll have to investigate the source of the limitation.
  • General commenting and minor improvements.
  • I have an RPI camera module, so there is a bunch of cool stuff that can happen with that.

Comment if you have any other ideas!

About: Jonathan South

I'm a professional acoustician, acoustic engineer/scientist/consultant chasing the carrot of an interesting and niche career in the world of sound, audio and acoustics.