Robot di sorveglianza parte 1
Questo articolo prevede la trasformazione da telecamera statica a robot teleguidato. In questa prima parte costruiremo il robot e lo renderemo utilizzabile via Web, nella seconda parte svilupperemo un'app tutta nostra che ci permetterà di comandarlo via tablet e/o smartphone.
Hardware necessario
- Un Raspberry Pi Zero possibilmente nella versione con i pin già saldati sulla scheda (per le risorse che ci servono un altro device sarebbe sprecato);
- Un controller che gestisca i motori che governano le ruote, io ho scelto un MotoZero Pi Hat in quanto ben documentato e molto economico, anche se in kit. Servirà quindi capacità di saldatura o un complice (grazie Daniele);
- Un modulo camera, dopo alcuni test di fissaggio ho optato per un modello con supporto in plexiglass ancorabile al case del robot;
- Un supporto per robot munito di ruote e motori. Personalmente ho scelto un modello con 4 ruote, la scelta è ampia assicuratevi solo che il numero delle ruote sia gestibile dal controller (il MotoZero PI Hat è in grado di gestirne al massimo 4);
- Un powerbank di media capacità per alimentare il Raspberry;
- 4 pile stilo per alimentare i motori passando dal controller (l'alloggiamento di solito è compreso nel case)
Opzionale
La Pi Camera può essere utilizzata sia con Raspberry Pi 1+ che con PI Zero, il bus del Pi Zero è più piccolo quindi potrebbe essere necessario l'acquisto di un cavo "a nastro" compatibile con Pi zero
Assemblare il robot
L'assemblaggio del robot è piuttosto semplice e ben documentato, raccomando solo di fare attenzione nel collegare i cavi + e - provenienti dai motori sul controller. Come vedremo più sotto il nostro script Python ama il riuso del codice e ha un metodo avanti (e uno indietro) utilizzabile per tutti i motori. Senza questa accortezza il nostro robot potrebbe muovere a caso le ruote (come nel mio primo test).
Operazioni preliminari
Per il nostro progetto possiamo tranquillamente appoggiarci alla versione Lite di Raspberry PI OS (fino a poco tempo fa noto come Raspbian Lite), utilizzando il nuovo Raspberry PI Imager possiamo caricarlo sulla scheda SD e grazie all'ausilio della documentazione ufficiale possiamo eseguire le prime configurazioni. Una volta installato il sistema operativo abbiamo bisogno dei pacchetti aggiuntivi: python3-picamera python3-rpi.gpio e python3-flask. Essi contengono librerie per gestire il modulo camera, i pin gpio e per creare una web app con flask. Per installarli usiamo i comandi:
sudo apt update
sudo apt install python3-picamera python3-rpi.gpio python3-flask
Potremmo dover installare solo l'ultimo. A questo punto non ci resta che abilitare il modulo camera utilizzando il comando:
sudo raspi-config
La scelta 5 ci permetterà di abilitare la nostra camera. Ci muoviamo con il tasto tab e con le frecce. Se non l'abbiamo già fatto ci conviene abilitare anche ssh.
Codice Python
Per una serie di motivi ho deciso di creare tre script: uno per la gestione della camera, uno per la gestione dei motori e l'ultimo per la gestione delle chiamate via web.
Primo script: server camera (nome file: camera_new.py)
Lo script è praticamente lo stesso del tutorial precedente, ho cambiato solo la porta del server Web e ho semplificato un po' la pagina visto che non ci serve (richiameremo direttamente il video).
#!/usr/bin/python3
import io
import picamera
import logging
import socketserver
from threading import Condition
from http import server
PAGE="""\
<html>
<head>
<title>PiCamera Telecamera di sorveglianza </title>
</head>
<body>
<img src="stream.mjpg" width="640" height="480" />
</body>
</html>
"""
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):
if self.path == '/':
self.send_response(301)
self.send_header('Location', '/index.html')
self.end_headers()
elif self.path == '/index.html':
content = PAGE.encode('utf-8')
self.send_response(200)
self.send_header('Content-Type', 'text/html')
self.send_header('Content-Length', len(content))
self.end_headers()
self.wfile.write(content)
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:
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:
logging.warning(
'Removed streaming client %s: %s',
self.client_address, str(e))
else:
self.send_error(404)
self.end_headers()
class StreamingServer(socketserver.ThreadingMixIn, server.HTTPServer):
allow_reuse_address = True
daemon_threads = True
with picamera.PiCamera(resolution='640x480', framerate=24) as camera:
output = StreamingOutput()
#Uncomment the next line to change your Pi's Camera rotation (in degrees)
#camera.rotation = 180
#camera.brightness = 70
camera.awb_mode = 'sunlight'
camera.start_recording(output, format='mjpeg')
try:
address = ('', 8001)
server = StreamingServer(address, StreamingHandler)
server.serve_forever()
finally:
camera.stop_recording()
Secondo script: gestione motori (nome file: macchina_new.py)
Questo script come è giusto che sia prende pesantemente spunto dalla documentazione ufficiale del MotoZero Pi Hat. Se abbiamo optato per un motore differente è necessario leggere la documentazione e adattarlo.
#!/usr/bin/python3
import RPi.GPIO as GPIO
from time import sleep
import os
class Macchina:
def __init__ (self):
GPIO.setwarnings(False)
GPIO.setmode(GPIO.BCM)
#Motore 1
self.Mot1A = 27
self.Mot1B = 24
self.Mot1E = 5
#Motore 2
self.Mot2A=22
self.Mot2B=6
self.Mot2E=17
#Motore 3
self.Mot3A = 16
self.Mot3B = 23
self.Mot3E = 12
#Motore 4
self.Mot4A = 18
self.Mot4B = 13
self.Mot4E = 25
#Spengo tutto
GPIO.setup(self.Mot1A,GPIO.OUT)
GPIO.setup(self.Mot1B,GPIO.OUT)
GPIO.setup(self.Mot1E,GPIO.OUT)
GPIO.setup(self.Mot2A,GPIO.OUT)
GPIO.setup(self.Mot2B,GPIO.OUT)
GPIO.setup(self.Mot2E,GPIO.OUT)
GPIO.setup(self.Mot3A,GPIO.OUT)
GPIO.setup(self.Mot3B,GPIO.OUT)
GPIO.setup(self.Mot3E,GPIO.OUT)
GPIO.setup(self.Mot4A,GPIO.OUT)
GPIO.setup(self.Mot4B,GPIO.OUT)
GPIO.setup(self.Mot4E,GPIO.OUT)
def motAvanti(self, motA, motB, motE):
GPIO.output (motA,GPIO.LOW)
GPIO.output (motB,GPIO.HIGH)
GPIO.output (motE,GPIO.HIGH)
def motIndietro(self,motA, motB, motE):
GPIO.output (motA,GPIO.HIGH)
GPIO.output (motB,GPIO.LOW)
GPIO.output (motE,GPIO.HIGH)
def tuttiAvanti(self, tempo):
self.motAvanti(self.Mot1A, self.Mot1B, self.Mot1E)
self.motAvanti(self.Mot2A, self.Mot2B, self.Mot2E)
self.motAvanti(self.Mot3A, self.Mot3B, self.Mot3E)
self.motAvanti(self.Mot4A, self.Mot4B, self.Mot4E)
#sleep(tempo)
#self.spegni()
def tuttiIndietro(self, tempo):
self.motIndietro(self.Mot1A, self.Mot1B, self.Mot1E)
self.motIndietro(self.Mot2A, self.Mot2B, self.Mot2E)
self.motIndietro(self.Mot3A, self.Mot3B, self.Mot3E)
self.motIndietro(self.Mot4A, self.Mot4B, self.Mot4E)
#sleep(tempo)
#self.spegni()
def giraDestra(self, tempo):
self.motAvanti(self.Mot1A, self.Mot1B, self.Mot1E)
self.motAvanti(self.Mot4A, self.Mot4B, self.Mot4E)
self.motIndietro(self.Mot2A, self.Mot2B, self.Mot2E)
self.motIndietro(self.Mot3A, self.Mot3B, self.Mot3E)
#sleep(tempo)
def giraSinistra(self, tempo):
self.motAvanti(self.Mot2A, self.Mot2B, self.Mot2E)
self.motAvanti(self.Mot3A, self.Mot3B, self.Mot3E)
self.motIndietro(self.Mot1A, self.Mot1B, self.Mot1E)
self.motIndietro(self.Mot4A, self.Mot4B, self.Mot4E)
#sleep(tempo)
def spegni(self):
GPIO.setmode(GPIO.BCM)
GPIO.output (self.Mot1E,GPIO.LOW)
GPIO.output (self.Mot2E,GPIO.LOW)
GPIO.output (self.Mot3E,GPIO.LOW)
GPIO.output (self.Mot4E,GPIO.LOW)
#GPIO.cleanup()
def halt(self):
os.system("halt")
def test (self, tempo):
self.tuttiAvanti(tempo)
self.tuttiIndietro(tempo)
#self.giraDestra(tempo)
#self.giraSinistra(tempo)
Partiamo dichiarando la classe Macchina, che useremo nel prossimo script, e il suo costruttore (def init). Il costruttore si occupa di dichiarare tutte le variabile associando per ogni motore i pin GPIO corrispondenti (come da documentazione ufficiale) per poi inizializzarli. Ogni motore usa 3 pin: positivo (B), negativo (A) e enable (E), dando corrente ad uno dei due pin +/- la ruota gira in avanti o indietro.
Creiamo quindi 2 metodi MotAvanti e MotIndietro che, inviando il giusto segnale, permettono alle ruote di fare il loro lavoro. Il metodo accetta come argomento le variabili A,B e E di un motore ed è richiamato 4 volte nei metodi specfici (da qui la necessità di armonizzazione dei cablaggi).
Adesso, grazie alle chiamate di MotAvanti e MotIndietro fatte dai nostri metodi generici (tuttiAvanti, tuttiIndietro, destra e sinistra) possiamo dare la direzione alla nostra macchina. Lo script contiene dei refusi utilizzabili per i primi test (l'argomento tempo e il metodo test). I metodi più interessanti sono destra e sinistra infatti, non essendo il robot dotato di volante ma di quattro ruote fisse, per far si che cambi direzione è necessario che le ruote dei due lati si muovano invertendo la direzione (alcuni tutorial consigliano di tenere ferme le ruote del lato che noi facciamo andare all'indietro, secondo me il motore della ruota ferma può rovinarsi così come la gomma della ruota stessa):
- il robot punta a destra se le ruote del lato sinistro (nel mio caso motori 1 e 4) ruotano in avanti mentre quelle del lato destro (motori 2 e 3) all'indietro
- il robot punta a sinistra se le ruote del lato sinistro (motori 1 e 4) ruotano in avanti e quelle del lato destro (motori 2 e 3) all'indietro
Questa è la parte più divertente di tutta l'operazione.
Serve anche un metodo spegni per fermare tutti i motori e un utilissimo halt che richiamando il metodo di sistema halt spegne la macchina da remoto.
Terzo Script: Server Web (nome file: server.py)
Questo script è il più complesso in quanto facendo ausilio delle librerie Flask collega Python, HTML e Javascript per creare una pagina di gestione e un server in ascolto, utilizzabile anche via App. E' un adattamento alla nostra situazione dello script reperibile qui
#!/usr/bin/python3
from flask import Flask
from flask import render_template, request
from macchina_new import Macchina
car = Macchina()
app = Flask(__name__)
@app.route('/index.html')
def index():
PAGE = """\
<html>
<head>
<script src="https://ajax.googleapis.com/ajax/libs/jquery/3.1.1/jquery.min.js"></script>
<title>PiCamera macchina di sorveglianza </title>
</head>
<body>
<center><h1>PiCamera macchina di sorveglianza</h1></center>
<iframe src="http://192.168.1.108:8001/stream.mjpg" style=" height:500px; width:650px;"></iframe>
<div style=" height:400px; width:300px; float:left;">
<center>
<h2><span style="color:#139442">Comand<span</h2>
</center>
<center>
<table border="0">
<tr>
<td colspan="3" align="center"><button id="up" style="font-size:150%;">Avanti</button><br><br> </td>
</tr>
<tr>
<td align="leftr"><button id="left" style="font-size:150%;">Sinistra</button></td>
<td> </td>
<td align="right"><button id="right" style="font-size:150%;">Destra</button></td>
</tr>
<tr>
<td colspan="3" align="center">
<br><br><button id="down" style="font-size:150%;">Indietro</button></td>
</tr>
<tr>
<td colspan="3" align="center">
<br><br><button id="halt" style="font-size:150%;">Spegni macchina</button></td>
</tr>
</table>
</center>
</div>
<script>
document.addEventListener("contextmenu", function(e){
e.preventDefault();
//alert('Tasto destro disabilitato');
}, false);
</script>
<script>
$( document ).ready(function(){
$("#halt").on("mousedown", function() {
$.get('/halt');
});//.on('mouseup', function() {
//$.get('/stop');
//});
$("#down").on("mousedown", function() {
$.get('/down_side');
}).on('mouseup', function() {
$.get('/stop');
});
$("#up").on("mousedown", function() {
$.get('/up_side');
}).on('mouseup', function() {
$.get('/stop');
});
$("#left").on("mousedown", function() {
$.get('/left_side');
}).on('mouseup', function() {
$.get('/stop');
});
$("#right").on("mousedown", function() {
$.get('/right_side');
}).on('mouseup', function() {
$.get('/stop');
});
});
</script>
</body>
</html>
"""
return (PAGE)
#return render_template('home.html')
@app.route('/halt')
def halt():
car.halt()
return 'true'
@app.route('/left_side')
def left_side():
car.giraSinistra(0.50)
return 'true'
@app.route('/right_side')
def right_side():
car.giraDestra(0.50)
return 'true'
@app.route('/up_side')
def up_side():
car.tuttiAvanti(1)
return 'true'
@app.route('/down_side')
def down_side():
car.tuttiIndietro(1)
return 'true'
@app.route('/stop')
def stop():
car.spegni()
return 'true'
if __name__ == "__main__":
print ("Start")
app.run(host='0.0.0.0',port=8000)
Abbiamo creato un secondo server web sulla porta 8000 (il primo gira sulla 8001). Se come nel mio caso il robot avesse indirizzo IP http://192.168.1.108 possiamo accedervi utilizzando l'URL [http://192.168.1.108/index.html](http://192.168.1.108(index.html)
Nello script mettiamo come import macchina_new che non è altro che lo script di gestione del paragrafo precedente. Per essere importato deve trovarsi nella stessa cartella.
Come si vede dallo screenshot seguente la pagine è divisa in due sezioni: quella di sinistra dei 5 pulsanti e un iframe che va recuperare dal server creato dal primo script l'input dalla telecamera.
I pulsanti
Alla pressione di ogni pulsante viene richiamato il corrispondete codice Javascript, vediamo quello richiamato alla pressione del pulsante avanti:
$("#up").on("mousedown", function() {
$.get('/up_side');
}).on('mouseup', function() {
$.get('/stop');
});
Il codice sembra complesso ma preso singolarmente è abbastanza intuitivo e traducibile in: finché si fa pressione con il mouse sul pulsante fai una chiamata GET sulla pagina up_side quando viene rilasciato falla sulla pagina stop. Fare una chiamata GET ad una pagina equivale a richiamarla dalla barra degli indirizzi del browser. Seguendo l'esempio precedente potremmo mandare avanti la macchina usando l'url http://192.168.1.108/up_side e fermarla l'url http://192.168.1.108/stop (cosa che faremo nella nostra mirabolante app). Rimanendo sul pulsante up vediamo il codice Python relativo:
@app.route('/up_side')
def up_side():
car.tuttiAvanti(1)
return 'true'
Come abbiamo visto precedentemente la variabile passata in argomento non è utilizzata.
La pagina web crea problemi sui dispositivi Touch. Come avete sicuramente notato su Android (e credo anche su iphone) il click prolungato equivale a "seleziona testo" o se non c'è un testo a "click tasto destro", il robot quindi o segue la direzione indicata fino alla morte o ci delizia con una favolosa falsa partenza. Consiglio quindi l'utilizzo della pagina Web solo via computer fisso e/o portatile utilizzando il mouse o il touchpad (anche il mio convertibile segue la stessa filosofia quando lavoro via touchscreen).
Siamo pronti
Possiamo adesso creare uno script bash (nome file: robot.sh) (si occupi di lanciare in sequenza i due server web
#!/bin/sh
echo "Avvio la camera"
/home/pi/camera_new.py &
echo "Faccio partire i motori e l'intefaccia Web"
/home/pi/server.py &
Possiamo richiamare lo script all'avvio inserendo il path assoluto prima della fine del file /etc/rc.local
Migliorie
Quando il robot è in movimento la visualizzazione delle immagini non è perfetta, la documentazione ufficiale consiglia l'aggiunta dell'istruzione seguente allo script camera_new.py (subito prima di start_recording):
camera.video_stabilization = True
Non ho ancora avuto modo di provarla.
Seconda parte
La seconda parte dell'articolo è disponibile qui.