WebSocket/Socket.ioを使用することでリアルタイムなGPIO制御を目指します。
FlaskでWEBサーバを稼働させ、ブラウザ上のJavascriptからGPIOを制御していたのですが、HTTPS通信のオーバーヘッドが気になるレベルで発生しています。ブラウザ⇄Flask(Python)間の往復で概ね150~200ms程度の遅延が発生しています。ラジコンエデンのシステム的にはもっとリアルタイムに制御したいです。Ping は 10ms 程度、Pythonの処理自体は4~5ms程度です。目標としてはPingと同等+αということで20ms程度の遅延に抑えたいですね。現状の10分の1です。そこで、従来のHTTPのPOSTからWebSocketに切り替えることでどのくらい短縮されるのか確認してみます。
純粋なWebSocketではなく、Flask側の実装に合わせてSocket.ioを使用します。
まずはRaspberry Pi側でFlask-SocketIOをインストールします。
$ sudo pip install flask-socketio simple-websocket
当初、simple-websocketを入れずに試したのですが、その場合は遅延が300~400msに増えてしまいました。また、毎回POSTなりGETなりが走っていました。推測ですが本来のWebSocketではなくエミュレーションしている感じなのではないかなと。
サーバ側のプログラムはこんな感じです。
FlaskのSSL化はこちらの記事を参考にしてください。
#!/usr/bin/env python3
from flask import Flask, render_template, request
from flask_socketio import SocketIO, send, emit
import pigpio
import time
pig = pigpio.pi()
app = Flask(__name__)
# SocketIO
socketio = SocketIO(app, cors_allowed_origins='*')
@app.route('/', methods=['GET', 'POST'])
def index():
time_sta = time.perf_counter()
if request.method == 'POST':
# 以下は旧コード
target = request.form['GPIO18']
pig.set_servo_pulsewidth(18, int(target))
return str(time.perf_counter()- time_sta)
else:
return render_template('index.html')
# GPIOの更新要求が来たときに実行
@socketio.on('update_gpio')
def update_gpio(json):
time_sta = time.perf_counter()
pig.set_servo_pulsewidth(18, int(json["GPIO18"]))
stime = json["stime"]
emit('update_complete', {'time': str(time.perf_counter()- time_sta),'stime':stime}, broadcast=True, include_self=True)
if __name__ == '__main__':
socketio.run(app,debug=True, port=443, ssl_context=('./certs/server.crt', './certs/server.key'), host='0.0.0.0')
元々はPOSTで受け取ったフォームの部品からGPIOの処理をしていましたが、気まぐれでJSONに変えてみました。時間のやり取りをしたかったからというのもありますが。
クライアントのコードはこんな感じです。
const gamepadID = "Xbox 360 Controller (XInput STANDARD GAMEPAD)";
const FPS = 10; // 何フレームの平均値をとるか
//(HTTPのレイテンシーを考慮すると実効フレームレートは20FPS程度)
const SPF = 5; // (Send Per Frame) 何フレーム毎に送信するか
// gamepad index
const STEERING = 0 // 左ジョイスティック 左右(ステアリング)
const THROTTLE = 1; // 左ジョイスティック 上下(スロットル)
const PAN = 2; // 右ジョイスティック 左右(カメラ左右)
const TILT = 3; // 右ジョイスティック 上下(カメラ上下)
const BOOST = 6; // LT
let FrameCount = 0;
let ave = {
STEERING: 0,
THROTTLE: 0,
PAN: 0,
TILT: 0,
BOOST: 0,
}
let elm ={
STEERING: 0,
THROTTLE: 0,
PAN: 0,
TILT: 0,
BOOST: 0,
}
let val = {
STEERING: 0,
THROTTLE: 0,
PAN: 0,
TILT: 0,
BOOST: 0,
}
let axes = {
STEERING: Array(FPS).fill(0),
THROTTLE: Array(FPS).fill(0),
PAN: Array(FPS).fill(0),
TILT: Array(FPS).fill(0),
};
let GPIO = {
STEERING: "GPIO18",
THROTTLE: "GPIO17",
BOOST: "GPIO16",
PAN: "GPIO15",
TILT: "GPIO14",
RIVERS: "GPIO13",
}
let button = {
BOOST: Array(FPS).fill(0),
}; // 今のところ1つだけ
var haveEvents = 'ongamepadconnected' in window;
var controllers = {};
// Socket.io
var socket = io();
function average(a){
let sum = 0;
for(let i = 0; i < a.length; i++) {
sum += a[i];
}
return sum / a.length;
}
//main
function loop() {
let sendFLG = false;
FrameCount++;
if(FrameCount > FPS) FrameCount = 0;
const gamepads = navigator.getGamepads
? navigator.getGamepads()
: navigator.webkitGetGamepads
? navigator.webkitGetGamepads
: [];
if (!gamepads) {
return;
}
for(let i=0; i < gamepads.length; i++ ){
if(gamepads[i]) gamepad = gamepads[i];
}
//if(gamepad.buttons[6].pressed) console.log(gamepad.buttons[6].value);
// ステアリング: 左ジョイスティック 左右 左-1、中立0、右1
axes.STEERING[FrameCount] = gamepad.axes[STEERING];
axes.THROTTLE[FrameCount] = gamepad.axes[THROTTLE];
axes.PAN[FrameCount] = gamepad.axes[PAN];
axes.TILT[FrameCount] = gamepad.axes[TILT];
if(gamepad.buttons[BOOST].pressed && gamepad.buttons[BOOST].value > 0.3) {
button.BOOST[FrameCount] = gamepad.buttons[BOOST].value;
}else{
button.BOOST[FrameCount] = 0.3;
}
// 送信該当フレームの場合
if(FrameCount % SPF){
// ステアリング
ave.STEERING = average(axes.STEERING);
//console.log('axes[STEERING]='+ave);
//let tmp = Math.trunc(((2400 - 500 ) / total) * (ave - min) + 500);
//val.STEERING = Math.trunc(950 * (ave.STEERING + 1 ) + 500);
val.STEERING = Math.trunc(1000 * (ave.STEERING + 1 ) + 500);
document.getElementById( GPIO.STEERING ).value = val.STEERING;
document.getElementById("axes"+GPIO.STEERING).value = val.STEERING;
//if( val.STEERING < 1435 || 1464 < val.STEERING ) { // 1450 ±1%
if( val.STEERING < 1495 || 1505 < val.STEERING ) { // 1500 ±1%
sendFLG = true;
//console.log('val.STEERING='+val.STEERING);
}
// ブースト
ave.BOOST = average(button.BOOST);
val.BOOST = Math.round(ave.BOOST * 100) / 100 ;
document.getElementById( GPIO.BOOST ).value = val.BOOST;
document.getElementById("button"+GPIO.BOOST).value = val.BOOST;
//if( 0.29 < val.BOOST && val.BOOST < 0.31 ){
if( val.BOOST < 0.297 || 0.303 < val.BOOST ){ // 0.3 ±1%
sendFLG = true;
//console.log('val.BOOST='+val.BOOST);
}
// スロットル
ave.THROTTLE = average(axes.THROTTLE);
//console.log('axes[STEERING]='+ave);
//let tmp = Math.trunc(((2400 - 500 ) / total) * (ave - min) + 500);
if(ave.THROTTLE < -0.01){ // レバーを完全に上にすると-1
val.THROTTLE = Math.trunc(1900 * ( -ave.THROTTLE * ave.BOOST ) + 500);
document.getElementById( GPIO.RIVERS ).value = "0";
document.getElementById("axes"+GPIO.THROTTLE).value = val.THROTTLE;
}else if(ave.THROTTLE > 0.01){
// 後進の処理
val.THROTTLE = Math.trunc(1900 * ( ave.THROTTLE * ave.BOOST ) + 500);
document.getElementById( GPIO.RIVERS ).value = "1";
val.THROTTLE = -val.THROTTLE;
document.getElementById("axes"+GPIO.THROTTLE).value = val.THROTTLE;
}else{
val.THROTTLE = 0;
document.getElementById( GPIO.RIVERS ).value = "0";
document.getElementById("axes"+GPIO.THROTTLE).value = val.THROTTLE;
}
document.getElementById( GPIO.THROTTLE ).value = val.THROTTLE;
if( val.THROTTLE < -505 || 505 < val.THROTTLE ){ // 最小値500±1%
sendFLG = true;
//console.log('val.THROTTLE='+val.THROTTLE);
}
// カメラ パン(左右)
ave.PAN = average(axes.PAN);
//console.log('axes[STEERING]='+ave);
//let tmp = Math.trunc(((2400 - 500 ) / total) * (ave - min) + 500);
val.PAN = Math.trunc(1000 * (ave.PAN + 1 ) + 500);
document.getElementById( GPIO.PAN ).value = val.PAN;
document.getElementById("axes"+GPIO.PAN).value = val.PAN;
if( val.PAN < 1495 || 1505 < val.PAN ) { // 1450 ±1%
sendFLG = true;
//console.log('val.PAN='+val.PAN);
}
// カメラ チルト(上下)
ave.TILT = average(axes.TILT);
//console.log('axes[STEERING]='+ave);
//let tmp = Math.trunc(((2400 - 500 ) / total) * (ave - min) + 500);
val.TILT = Math.trunc(1000 * (ave.TILT + 1 ) + 500);
document.getElementById( GPIO.TILT ).value = val.TILT;
document.getElementById("axes"+GPIO.TILT).value = val.TILT;
if( val.TILT < 1495 || 1505 < val.TILT ) { // 1450 ±1%
sendFLG = true;
//console.log('val.TILT='+val.TILT);
}
// 送信はまとめて行うように変更予定
if(sendFLG){
const xhr = new XMLHttpRequest();
const fd = new FormData();
// (1) 送信先を指定
xhr.open('post', '/',false); // 非同期にせずに同期にしたほうが安定する?
//xhr.open('post', '/'); // 非同期にせずに同期にしたほうが安定する?
// (2) FormDataオブジェクトにデータをセット
fd.append('GPIO18', GPIO18.value);
// (3) FormDataオブジェクトと一緒にリクエスト(要求)を送信
const startTime = performance.now()
//xhr.send(fd);
socket.emit('update_gpio', {GPIO18: GPIO18.value,stime: startTime});
const endTime = performance.now();
if (xhr.status == 200){
let data = xhr.responseText;
console.log('PythonProc='+ data);
console.log('Latency='+ parseFloat(endTime - startTime - data));
}
}
}
// axes[1]: 左ジョイスティック、上-1、中立0、下1
//document.getElementById("axes1").value = gamepad.axes[1]//xx + "px";
// axes[2]: 右ジョイスティック、左-1、中立0、右1
//document.getElementById("axes2").value = gamepad.axes[2]//xx + "px";
// axes[3]: 右ジョイスティック、上-1、中立0、下1
//document.getElementById("axes3").value = gamepad.axes[3]//xx + "px";
// 四捨五入
requestAnimationFrame(loop);
return;
}
socket.on('update_complete', function(msg) {
//console.log('msg.stime='+msg.stime);
console.log('Latency='+ parseFloat(performance.now() - parseFloat(msg.stime) - parseFloat(msg.time)));
});
function disconnecthandler(e) {
removegamepad(e.gamepad);
}
function removegamepad(gamepad) {
var d = document.getElementById("controller" + gamepad.index);
document.body.removeChild(d);
delete controllers[gamepad.index];
}
//window.addEventListener("gamepadconnected", connecthandler);
window.addEventListener("gamepadconnected", loop);
window.addEventListener("gamepaddisconnected", disconnecthandler);
自分で作っておいていうのもなんですが、まぁ見にくいこと・・・。
ポイントは
socket.emit('update_gpio', {GPIO18: GPIO18.value,stime: startTime});
ここのemitでサーバに送信するということと、サーバの戻りは同期処理ではないので
socket.on('update_complete', function(msg) {
//console.log('msg.stime='+msg.stime);
console.log('Latency='+ parseFloat(performance.now() - parseFloat(msg.stime) - parseFloat(msg.time)));
});
ここで受け取るためのイベントを用意しておくことですね。
結果的にWebSocketにすることで、GPSや電子コンパスなどのテレメトリー系の情報を非同期にイベント駆動で受け取れる準備が整った気がします。
気になるお値段は遅延時間は290回程度のテスト結果の平均値で21.9msでした。目標には1.9ms届かなかったですが、VPNサーバ経由の環境だということと、Pingの純粋なネットワーク遅延が10msであることを考えると、11.9秒のオーバーヘッドであれば、まぁまぁ許容範囲でしょう。今日もお疲れ様でした。
モーター届かないなぁ、SIMが届かないなぁ、次は何をやろうかなぁ・・・。