気の向くままに辿るIT/ICT/IoT
IoT・電子工作

Raspberry Pi/Flask/WebSocket/RPI.GPIO/28BYJ-48でパンチルト

ホーム前へ次へ
Raspberry Piって?

Raspberry Pi/Flask/WebSocket/RPI.GPIO/28BYJ-48でパンチルト

Raspberry Pi/Flask/WebSocket/RPI.GPIO/28BYJ-48でパンチルト

2022/05/04

 見守り・防犯カメラ自作の一環としてRaspberry Piとステッピングモータ28BYJ-48 5V、ステッピングモータードライバULN2003、Python WebサーバフレームワークFlask、WebSocket、ラズパイGPIOピン操作アプリRPi.GPIO(raspberry-gpio-python)でUSBカメラの遠隔パン調整機能を作ってみた話。

 一貫してPython 3系を使えるように、Python 2系依存で開発を終えているWebIOPi+RPi.GPIOならそのままで希望通りな挙動をしてくれるWebiopi with steppermotorのdanjperronさんのプログラム一式(pythonスクリプト+css/javascript込みのhtml)によるRaspberry Pi/WebIOPi/RPI.GPIO/28BYJ-48のプログラムFlaskとwebsocketを使った簡易的なチャットを開発するのプログラムをミックスさせてもらってFlask+RPi.GPIO版にしてみた次第。

 気づけば、ボタンが逆になっている...HTMLをちょこっと編集するだけですけど。

Flask/Flask-Sockets/gunicorn/gevent/gevent-websocketのインストール

USER@raspberrypi:~ $ pip install Flask Flask-Sockets gunicorn gevent gevent-websocket
USER@raspberrypi:~ $

 FlaskやPython WSGI HTTPサーバ gunicorn、WebSocketサーバ関連のインストールについては、前述、後者のリンク先の通りで、こんな感じ。

 gunicornでwebsocketといえば、gevent-websocketらしく、今やgevent-websocketからの派生gunicorn-websocket(ver 0.03)もあるようですが、初期も初期なのでgevent-websocketを。

Pythonスクリプト

USER@raspberrypi:~ $ cat script.py
from flask import Flask, render_template, request, url_for, Response
 
from gevent.pywsgi import WSGIServer
from geventwebsocket.handler import WebSocketHandler
 
import threading
import json
import time
import RPi.GPIO as GPIO
 
GPIO.setmode(GPIO.BCM)
GPIO.setwarnings(False)
 
class Stepper:
 
  GPIO= None
 
  def __init__(self, Id=0,IO=[17, 27, 22, 10], PulseDelay = 0.03):
    # クラス変数設定
    self.StepperId=Id
    self.StepperIO= IO
    self.PulseDelay = PulseDelay
    self.CurrentPosition = 0
    self.TargetPosition = 0
    self.ThreadRunning = False
    self.StopThread= True
    self.StepperTable= [[1,0,0,1] , [1,1,0,0] , [0,1,1,0] , [0,0,1,1]]
 
  def init(self):
    pass
 
 
  def begin(self):
    # GPIO出力設定
    for i in self.StepperIO:
      GPIO.setup(i,GPIO.OUT)
      GPIO.output(i, GPIO.LOW)
    #スレッド開始
    self.StopThread=False
    t = threading.Thread(target=self.stepperThread)
    t.start()
 
  def setCoil(self):
      StepperIdx = self.CurrentPosition & 3
      #print("StepperIdx : " + str(StepperIdx))
      for i in range(4):
        GPIO.output(self.StepperIO[i], self.StepperTable[i][StepperIdx])
        #print("self.StepperIO[i]:self.StepperTable[i][StepperIdx] / " + str(self.StepperIO[i]) + " : " + str(self.StepperTable[i][StepperIdx]))
 
  def stepperThread(self):
    self.ThreadRunning=True
    while not self.StopThread:
      if self.CurrentPosition != self.TargetPosition:
        if self.CurrentPosition > self.TargetPosition:
          self.CurrentPosition -= 1
        else:
          self.CurrentPosition += 1
        #print("self.CurrentPosition" + str(self.CurrentPosition))
        self.setCoil()
        time.sleep(self.PulseDelay)
      else:
        time.sleep(0.01)
    self.ThreadRunning=False
    self.StopThread=True
    self.cleanup()
 
  def moveTo(self, Target):
    self.TargetPosition = Target
 
  def move(self,StepCount):
    Target= self.TargetPosition + StepCount
    self.moveTo(Target)
 
  def stop(self):
    TargetPosition= CurrentPosition
 
  def cleanup(self):
    for i in self.StepperIO:
      GPIO.setup(i,GPIO.OUT)
      GPIO.output(i, GPIO.LOW)
    GPIO.cleanup()
 
stepper0 = Stepper(Id=0,IO=[17, 27, 22, 10])
stepper1 = Stepper(Id=1,IO=[23, 24, 25, 4])
 
stepper0.begin()
 
#stepper1 is not activate right now
#remove the rem on the next line to activate it
#stepper1.begin()
 
def StepperMove(_StepperId,_Step):
  StepperId= int(_StepperId)
  Step = int(_Step)
  if StepperId == 0 :
    stepper0.move(Step)
  else:
    stepper1.move(Step)
 
def StepperMoveTo(_StepperId,_Step):
  StepperId= int(_StepperId)
  Step = int(_Step)
  if StepperId == 0 :
    stepper0.moveTo(Step)
  else:
    stepper1.moveTo(Step)
 
def StepperStop(_StepperId):
  StepperId= int(_StepperId)
  if StepperId == 0:
    stepper0.stop()
  else:
    stepper1.stop()
 
def GetStepperPosition(_StepperId):
  StepperId= int(_StepperId)
  if StepperId == 0:
    response = str(stepper0.CurrentPosition)
  else:
    response = str(stepper1.CurrentPosition)
  return response
 
def GetStepperTarget(_StepperId):
  StepperId= int(_StepperId)
  if StepperId == 0:
    response = str(stepper0.TargetPosition)
  else:
    response = str(stepper1.TargetPosition)
  return response
 
app = Flask(__name__, static_folder='./static', template_folder='./templates')
 
@app.route('/')
def entry():
   return 'Hello'
 
@app.route('/templates')
def index():
  return render_template("index.html")  
 
@app.route('/ws')
def wsrec():
  if request.environ.get('wsgi.websocket'):
    ws = request.environ['wsgi.websocket']
    while True:
      #time.sleep(1)
      message = ws.receive()
      msg = json.loads(message)
      if msg is None:
        break
      msg = json.loads(message)
      #print(type(msg))
      #print(msg)
      func = msg['func']
      args = [msg['args1'], msg['args2']]
    
      if func == "GetStepperPosition":
        GetStepperPosition(args[0])
      if func == 'StepperMoveTo':
        StepperMoveTo(args[0] , args[1])
      if func == "GetStepperTarget":
        GetStepperTarget(args[0])
      if func == "StepperMove":
        StepperMove(args[0], args[1])
      #stepper0.cleanup()
  return
 
if __name__ == "__main__":
  app.debug = True
 
  #host = 'xyz.local'
  host = '192.168.0.22'
  port = 1234
 
  host_port = (host, port)
  server = WSGIServer(
    host_port,
    app,
    handler_class=WebSocketHandler
  )
  server.serve_forever()
 
  GPIO.cleanup()
USER@raspberrypi:~ $

 script.pyは、こんな感じ。

 基本、前掲リンク2件のがっちゃんこでほぼ完成ですが、WebIOPiからFlaskへの移植に伴い、相応に編集しました。

 どうやらthreadの時代は過ぎようとしているっぽいですが、元プログラムで使っていたまま、踏襲しました。

 コメントアウトの一部は、値の確認などに使えるかと。

JavaScriptソース

USER@raspberrypi:~ $ cat camera.js
//var URL = "xyz.local";
var URL = "192.168.0.22";
var PORT = ":1234";
var WEBSOCKET_PATH = "/ws";
var HOST = URL + PORT + WEBSOCKET_PATH;
var ws;
 
if(window.location.protocol == "http:"){
  var ws = new WebSocket("ws://" + HOST);
}
else if(window.location.protocol == "https:"){
  var ws = new WebSocket("wss://" + HOST);
}
 
ws.onopen = function(evt) {
  $("#ws-status").html("Connected");
  var title = document.getElementById('cam_title');
  title.innerHTML = 'カメラ調整';
  setInterval(ReadPosition,500);
  //initialize();
  ReadTarget(0);
  //ReadTarget(1);
  };
 
ws.onmessage = function(evt) {
  };
 
ws.onclose = function(evt) {
  $("#ws-status").html("Disconnected");
  };
 
function initialize()
{
}
 
function ReadPosition()
{
  var pos = "0";
  var data = JSON.stringify({func: "GetStepperPosition", args1: pos, args2: ""});
  //document.getElementById("return0").value = data.args1;
  ws.send(data);
}
 
function SetTarget(StepperId,value)
{
  var nStep = parseInt(value);
  var data = JSON.stringify({func: "StepperMoveTo", args1: StepperId , args2: nStep});
  ws.send(data);
}
 
function ReadTarget(StepperId)
{
  var data = JSON.stringify({func: "GetStepperTarget", args1: StepperId, args2: ""});
  ws.send(data);
}
 
function Move(StepperId , StepCount) {
  var data = JSON.stringify({func: "StepperMove", args1: StepperId, args2: StepCount});
  ws.send(data);
  var TargetP = "TargetP" + StepperId.toString();
  var CurrentP = "CurrentP" + StepperId.toString();
  var newTarget = parseInt(document.getElementById(TargetP).value);
  newTarget = newTarget + parseInt(StepCount);
  document.getElementById(TargetP).value = newTarget.toString();
  var cp = document.getElementById(CurrentP);
  if (Math.sign(StepCount) == 1) {
    for (i=0;i<StepCount;i++) {
      cp.value = parseInt(cp.value)+1;
    }
  } else {
    for (i=StepCount;i<0;i++) {
      cp.value = parseInt(cp.value)-1;
    }
  }
}
 
function Forward(StepperId) {
  var nStep = "nStep" + StepperId.toString();
  var StepValue = parseInt(document.getElementById(nStep).value);
  Move(StepperId,StepValue);
}
 
function Backward(StepperId) {
  var nStep = "nStep"+StepperId.toString();
  var StepValue = parseInt(document.getElementById(nStep).value);
  Move(StepperId,-StepValue);
}
USER@raspberrypi:~ $

 camera.jsは、こんな感じ。

 WebIOPiからFlaskへの移植に伴い、それなりに編集しました。

 オリジナルソースだとid=Current0のinput type=textタグの内容は、美しくもボタン押下ごとにカウントダウン/カウントアップします。

 が、そこ端折った、このロジックだとid=Current0のinput type=textタグは、id=Tartget0のそれと同じなので不要ですが残しました。

 一部の関数や#ws-status行など使っていない部分、オリジナルにはないHTMLファイルに残したid=return0のinput type=textタグ用DOMなどコメントアウトしつつ、場合によっては使うかなという部分も。

CSSソース

USER@raspberrypi:~ $ cat camera.css
.myButton {
  -moz-box-shadow:inset 0px 1px 0px 0px #ffffff;
  -webkit-box-shadow:inset 0px 1px 0px 0px #ffffff;
  box-shadow:inset 0px 1px 0px 0px #ffffff;
  background:-webkit-gradient(linear, left top, left bottom, color-stop(0.05, #ffffff), color-stop(1, #f6f6f6));
  background:-moz-linear-gradient(top, #ffffff 5%, #f6f6f6 100%);
  background:-webkit-linear-gradient(top, #ffffff 5%, #f6f6f6 100%);
  background:-o-linear-gradient(top, #ffffff 5%, #f6f6f6 100%);
  background:-ms-linear-gradient(top, #ffffff 5%, #f6f6f6 100%);
  background:linear-gradient(to bottom, #ffffff 5%, #f6f6f6 100%);
  background-color:#ffffff;
  -moz-border-radius:6px;
  -webkit-border-radius:6px;
  border-radius:6px;
  border:1px solid #dcdcdc;
  display:inline-block;
  cursor:pointer;
  color:#666666;
  font-family:Arial;
  font-size:15px;
  font-weight:bold;
  padding:6px 24px;
  text-decoration:none;
  text-shadow:0px 1px 0px #ffffff;
  width: 130px;
  height:30px;
  margin:1px;
}
.myButton:hover {
  background:-webkit-gradient(linear, left top, left bottom, color-stop(0.05, #f6f6f6), color-stop(1, #ffffff));
  background:-moz-linear-gradient(top, #f6f6f6 5%, #ffffff 100%);
  background:-webkit-linear-gradient(top, #f6f6f6 5%, #ffffff 100%);
  background:-o-linear-gradient(top, #f6f6f6 5%, #ffffff 100%);
  background:-ms-linear-gradient(top, #f6f6f6 5%, #ffffff 100%);
  background:linear-gradient(to bottom, #f6f6f6 5%, #ffffff 100%);
  background-color:#f6f6f6;
  padding:6px 24px;
}
.myButton:active {
  color:#ff0000;
  position:relative;
  top:1px;
  padding:6px 24px;
}
USER@raspberrypi:~ $

 camera.cssは、こんな感じ。

 CSSに関しては、外部ファイルとして分離とcamera.js編集に伴い不要となった#response-container部を削除しただけで参照元そのままです。

HTMLソース

USER@raspberrypi:~ $ cat index.html
<!doctype html>
<html>
<head>
<meta charset="utf-8">
<title>Stepper</title>
 
<link rel="stylesheet" href="{{ url_for('static', filename='camera.css') }}">
<meta http-equiv="X-UA-Compatible" content="IE=edge, chrome=1">
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=yes">
 
<style>
#refblock
{
display:none
/*
display:block
*/
}
</style>
</head>
<body>
<center>
<h1 id="cam_title" style="font-size:140% ;font-weight:normal ;"> My Stepper</h1>
<div style="margin-left:auto;margin-right:auto ;">
<!--
<div><iframe src="http://xyz.local:8080/stream"></iframe></div>
-->
<div><iframe src="http://192.168.0.22:8080/stream"></iframe></div>
<div><button id="Backward" class="myButton" type="button" onclick="Backward(0)">Backward</button>
<button id="Forward" class="myButton" type="button" onclick="Forward(0)">Forward</button></div>
</div>
<div id="refblock">
<div><input type="text" id="CurrentP0" name="CurrentP0" value="0" readonly></div>
<div><input type="text" id="TargetP0" name="TargetP0" value="0" onchange="SetTarget(0,this.value)"></div>
<div><input type="text" id="nStep0" name="nStep0" value="100"></div>
<div><input type="text" id="return0" value=""></div>
</div>
</center>
 
<script src="http://code.jquery.com/jquery-1.8.2.min.js"></script>
<script src="{{ url_for('static', filename='camera.js') }}"></script>
 
</body>
</html>
USER@raspberrypi:~ $

 index.htmlは、こんな感じ。

ほか、中身は少し編集しました。

 尚、せっかくなのでCSS読み込みのhref属性値、JavaScript読み込みのsrc属性値は、Flaskが依存するJinjaテンプレートに沿った記述としました。

操作パネル

 このままなら、sudo apt install -y ustreamer後、端末からustreamer --host=0.0.0.0 &、python script.py &(python2系と共存ならpython3)し、スマホなど同一ネットワーク上にあるリモートホストのブラウザで192.168.0.22:1234/templatesにアクセスするとカメラ映像と2つのボタンから成る操作パネルが表示されます。

 あえてHTMLソース内においたCSS設定でdisplay:blockを有効にすれば、値の確認もできます。

備考

 AvahiやBonjourを使える環境なら、mDNSでhostname.localでも良いですが、自身は、mDNSに対応していないAndroidスマホからの操作もする予定なのでIPアドレスを使用、これにあたり、DHCPで振り直されることがないようIPを固定する必要があります。

 既存の操作パネルも同様にブラウザ版スマートホームパネル操作のようにPCとAndroidデバイスでは、mDNSとIPを分けて運用しています。

 通常のZoneMinder管理画面のカメラ一覧や個別カメラ映像とは別に、カメラパン調整画面も、この操作パネルから、また、Julius/Open JTalkスマートスピーカーからIPカメラの映像を表示同様、音声操作でも表示できるようにする予定です。

 赤外線投光器の自作や通話機能はともかく、既に動作確認できているZoneMinderで複数カメラ増設対応、VPNで外からも操作・視聴可。

ホーム前へ次へ