LaserCat 9000
Overview
Do you ever have a crazy project idea in the middle of the night? Well that is where the idea for making a remote controlled laser cat toy came from..! I built this in a day in the middle of the COVID lockdowns in 2020. 4 years later and I was about to scavenge it for parts, but thought I might as well document it beforehand. If you want to see what a sugar fueled afternoon of python, solder, and javascript gets you, then read on!
How it works
The hardware is very simple: a mains power supply provides 5V which powers two servos, a Raspberry Pi Zero W, a Pi ZeroCam, and a laser. The servos are connected directly to the two hardware PWM pins on the Pi Zero and laser is connected to a GPIO on the Pi via a MOSFET. The mechanical parts were salvaged from a cheap robotic arm like this one (but I only paid about £10 on aliexpress)!
On the software side of things a python backend streams the video footage, hosts the frontend's static elements, and provides a simple API to allow the front end to send servo position updates to the backend. At the frontend the video feed is displayed (via motion JPEG) and an HTML canvas is used to render a touchpad. On a touchscreen device the user simply moves their finger around the touchpad and the laser will follow their movements. Source code is available at the bottom of this article.
The python backend is set to run on boot-up and with the magic of mDNS you can just go to lasercat9000.local
to access the frontend. To connect to the system from an external network I just used SSH port forwarding.
The Result
Here's what the webpage looks like:
And here is what the system looks like in action. Much more suitable for scaring cats than playing with them!
Source Code
Backend
Python
Hopefully it goes without saying, but don't expose this server to the internet, it is comically insecure.
import io
import picamera
import logging
import socketserver
from threading import Condition
from http import server
from os import curdir, sep
from urllib.parse import urlparse, parse_qs
from rpi_hardware_pwm import HardwarePWM
import RPi.GPIO as GPIO
connected_clients = 0
pwm0 = HardwarePWM(pwm_channel=0, hz=50)
pwm1 = HardwarePWM(pwm_channel=1, hz=50)
def is_float(element):
try:
float(element)
return True
except ValueError:
return False
class StreamingOutput(object):
def __init__(self):
self.frame = None
self.buffer = io.BytesIO()
self.condition = Condition()
def write(self, buf):
if buf.startswith(b'\xff\xd8'):
# New frame, copy the existing buffer's content and notify all
# clients it's available
self.buffer.truncate()
with self.condition:
self.frame = self.buffer.getvalue()
self.condition.notify_all()
self.buffer.seek(0)
return self.buffer.write(buf)
class StreamingHandler(server.BaseHTTPRequestHandler):
def do_GET(self):
global connected_clients
global pwm0
global pwm1
if self.path.startswith("/api"):
value = parse_qs(urlparse(self.path).query)
if "x" not in value or "y" not in value:
self.send_response(400)
self.send_header('Content-Type', 'application/json')
self.end_headers()
json_str = '{"success":false}'
self.wfile.write(json_str.encode(encoding='utf_8'))
return
if type(value["x"]) is not list or type(value["y"]) is not list:
self.send_response(400)
self.send_header('Content-Type', 'application/json')
self.end_headers()
json_str = '{"success":false}'
self.wfile.write(json_str.encode(encoding='utf_8'))
return
x = value["x"][0]
y = value["y"][0]
if not is_float(x) or not is_float(y):
self.send_response(400)
self.send_header('Content-Type', 'application/json')
self.end_headers()
json_str = '{"success":false}'
self.wfile.write(json_str.encode(encoding='utf_8'))
return
x_val = float(x)
y_val = float(y)
if x_val < 1000.0 or x_val > 2200.0:
self.send_response(400)
self.send_header('Content-Type', 'application/json')
self.end_headers()
json_str = '{"success":false}'
self.wfile.write(json_str.encode(encoding='utf_8'))
return
if y_val < 1000.0 or y_val > 2400.0:
self.send_response(400)
self.send_header('Content-Type', 'application/json')
self.end_headers()
json_str = '{"success":false}'
self.wfile.write(json_str.encode(encoding='utf_8'))
return
pwm0.change_duty_cycle((y_val / 20000.0) * 100.0)
pwm1.change_duty_cycle((x_val / 20000.0) * 100.0)
self.send_response(200)
self.send_header('Content-Type', 'application/json')
self.end_headers()
json_str = '{"success":true}'
self.wfile.write(json_str.encode(encoding='utf_8'))
elif self.path == '/':
self.send_response(301)
self.send_header('Location', '/index.html')
self.end_headers()
elif self.path in ["/index.html", "/main.js"]:
f = open(curdir + sep + "www" + sep + self.path, "rb")
self.send_response(200)
if self.path.endswith(".html"):
self.send_header('Content-type', 'text/html')
elif self.path.endswith(".js"):
self.send_header('Content-type', 'text/javascript')
elif self.path.endswith(".css"):
self.send_header('Content-type', 'text/css')
self.end_headers()
self.wfile.write(f.read())
f.close()
elif self.path == '/stream.mjpg':
self.send_response(200)
self.send_header('Age', 0)
self.send_header('Cache-Control', 'no-cache, private')
self.send_header('Pragma', 'no-cache')
self.send_header('Content-Type', 'multipart/x-mixed-replace; boundary=FRAME')
self.end_headers()
try:
connected_clients += 1
GPIO.output(6, 1)
while True:
with output.condition:
output.condition.wait()
frame = output.frame
self.wfile.write(b'--FRAME\r\n')
self.send_header('Content-Type', 'image/jpeg')
self.send_header('Content-Length', len(frame))
self.end_headers()
self.wfile.write(frame)
self.wfile.write(b'\r\n')
except Exception as e:
connected_clients -= 1
if connected_clients == 0:
GPIO.output(6, 0)
else:
self.send_error(404)
self.end_headers()
def log_message(self, format, *args):
pass
class StreamingServer(socketserver.ThreadingMixIn, server.HTTPServer):
allow_reuse_address = True
daemon_threads = True
def angleToPer(angle):
upper = 2400
lower = 600
pulse_width = ((angle / 180) * (upper-lower)) + lower
percent = (pulse_width / 20000) * 100
return percent
GPIO.setwarnings(False)
GPIO.setmode(GPIO.BCM)
GPIO.setup(6, GPIO.OUT)
GPIO.output(6, 0)
pwm0.start((1700 / 20000) * 100)
pwm1.start((1600 / 20000) * 100)
with picamera.PiCamera(resolution='640x480', framerate=24) as camera:
output = StreamingOutput()
camera.start_recording(output, format='mjpeg')
try:
address = ('', 80)
server = StreamingServer(address, StreamingHandler)
server.serve_forever()
finally:
camera.stop_recording()
pwm0.stop()
pwm1.stop()
GPIO.cleanup()
Frontend
HTML
<!DOCTYPE html>
<html>
<head>
<title>Laser Cat 9000</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<script src="https://code.jquery.com/jquery-3.6.0.min.js"
integrity="sha256-/xUj+3OJU5yExlq6GSYGSHk7tPXikynS7ogEvDej/m4="
crossorigin="anonymous">
</script>
<script src="main.js"></script>
<link rel="stylesheet" href="https://unpkg.com/@picocss/pico@latest/css/pico.min.css">
<style>
.stream {
width: 100%;
max-width: 640px;
height: auto
}
</style>
</head>
<body>
<script>0</script>
<main class="container">
<h1>Laser Cat 9000</h1>
<div class="grid">
<div>
<img src="stream.mjpg" width="640" height="480" class="stream"/>
</div>
<div id="canvas_div">
<canvas id="sketchpad" width="640" height="480" style="border:1px solid #000000;"></canvas>
</div>
</div>
</main>
</body>
</html>
Javascript
$( document ).ready(function() {
console.log( "ready!" );
// Get the specific canvas element from the HTML document
canvas = document.getElementById('sketchpad');
// If the browser supports the canvas tag, get the 2d drawing context for this canvas
if (canvas.getContext)
ctx = canvas.getContext('2d');
// Check that we have a valid context to draw on/with before adding event handlers
if (ctx) {
parent = document.getElementById('canvas_div');
canvas.width = parent.clientWidth;
canvas.height = canvas.width * 0.75;
// React to mouse events on the canvas, and mouseup on the entire document
canvas.addEventListener('mousedown', sketchpad_mouseDown, false);
canvas.addEventListener('mousemove', sketchpad_mouseMove, false);
window.addEventListener('mouseup', sketchpad_mouseUp, false);
// React to touch events on the canvas
canvas.addEventListener('touchstart', sketchpad_touchStart, false);
canvas.addEventListener('touchmove', sketchpad_touchMove, false);
window.onresize = updateCanvas;
updateCanvas();
}
});
function updateCanvas() {
parent = document.getElementById('canvas_div');
canvas.width = parent.clientWidth;
canvas.height = canvas.width * 0.75;
ctx.strokeStyle = "rgba(255,0,0,255)";
ctx.fillStyle = "rgba(255,0,0,255)";
ctx.beginPath();
ctx.setLineDash([5, 5]);
ctx.moveTo(canvas.width/2, 0);
ctx.lineTo(canvas.width/2, canvas.height);
ctx.stroke();
ctx.beginPath();
ctx.setLineDash([5, 5]);
ctx.moveTo(0,canvas.height/2);
ctx.lineTo(canvas.width, canvas.height/2);
ctx.stroke();
ctx.beginPath();
ctx.arc(canvas.width/2, canvas.height/2, 3, 0, Math.PI*2, true);
ctx.closePath();
ctx.fill();
}
function sendPosition(x,y){
$.get({
method: 'GET',
url: 'api',
headers: {
'Content-Type': 'application/json',
},
data: {
x: x,
y: y
}
});
}
let last_time = 0
// Variables for referencing the canvas and 2dcanvas context
var canvas,ctx;
// Variables to keep track of the mouse position and left-button status
var mouseX,mouseY,mouseDown=0;
// Variables to keep track of the touch position
var touchX,touchY;
function translateX(value) {
const servo_min = 1000;
const servo_max = 2200;
if (value < 0) value = 0;
if (value > canvas.width) value = canvas.width;
// Invert axis
value = canvas.width - value
const canvasSpan = canvas.width;
const servoSpan = servo_max - servo_min;
let valueScaled = value / canvasSpan;
let pulse_width = servo_min + (valueScaled * servoSpan);
return pulse_width;
}
function translateY(value) {
const servo_min = 1000;
const servo_max = 2400;
if (value < 0) value = 0;
if (value > canvas.height) value = canvas.height;
// Invert axis
value = canvas.height - value
const canvasSpan = canvas.height;
const servoSpan = servo_max - servo_min;
let valueScaled = value / canvasSpan;
let pulse_width = servo_min + (valueScaled * servoSpan);
return pulse_width;
}
// Draws a dot at a specific position on the supplied canvas name
// Parameters are: A canvas context, the x position, the y position, the size of the dot
function drawDot(ctx,x,y,size) {
const d = new Date();
const current_time = d.getTime();
if ((current_time - 100) > last_time) {
sendPosition(translateX(x),translateY(y));
//console.log(xabs,yabs);
last_time = current_time;
}
// Select a fill style
ctx.fillStyle = "rgba(0,0,0,255)";
// Draw a filled circle
ctx.beginPath();
ctx.arc(x, y, size, 0, Math.PI*2, true);
ctx.closePath();
ctx.fill();
}
// Clear the canvas context using the canvas width and height
function clearCanvas(canvas,ctx) {
ctx.clearRect(0, 0, canvas.width, canvas.height);
}
// Keep track of the mouse button being pressed and draw a dot at current location
function sketchpad_mouseDown() {
mouseDown=1;
drawDot(ctx,mouseX,mouseY,2);
}
// Keep track of the mouse button being released
function sketchpad_mouseUp() {
mouseDown=0;
}
// Keep track of the mouse position and draw a dot if mouse button is currently pressed
function sketchpad_mouseMove(e) {
// Update the mouse co-ordinates when moved
getMousePos(e);
// Draw a dot if the mouse button is currently being pressed
if (mouseDown==1) {
drawDot(ctx,mouseX,mouseY,2);
}
}
// Get the current mouse position relative to the top-left of the canvas
function getMousePos(e) {
if (!e)
var e = event;
if (e.offsetX) {
mouseX = e.offsetX;
mouseY = e.offsetY;
}
else if (e.layerX) {
mouseX = e.layerX;
mouseY = e.layerY;
}
}
// Draw something when a touch start is detected
function sketchpad_touchStart() {
// Update the touch co-ordinates
getTouchPos();
drawDot(ctx,touchX,touchY,2);
// Prevents an additional mousedown event being triggered
event.preventDefault();
}
// Draw something and prevent the default scrolling when touch movement is detected
function sketchpad_touchMove(e) {
// Update the touch co-ordinates
getTouchPos(e);
// During a touchmove event, unlike a mousemove event, we don't need to check if the
// touch is engaged, since there will always be contact with the screen by definition.
drawDot(ctx,touchX,touchY,2);
// Prevent a scrolling action as a result of this touchmove triggering.
event.preventDefault();
}
// Get the touch position relative to the top-left of the canvas
// When we get the raw values of pageX and pageY below, they take into account the scrolling on the page
// but not the position relative to our target div. We'll adjust them using "target.offsetLeft" and
// "target.offsetTop" to get the correct values in relation to the top left of the canvas.
function getTouchPos(e) {
if (!e)
var e = event;
if(e.touches) {
if (e.touches.length == 1) { // Only deal with one finger
var touch = e.touches[0]; // Get the information for finger #1
touchX=touch.pageX-touch.target.offsetLeft;
touchY=touch.pageY-touch.target.offsetTop;
}
}
}