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

WiFi/サーボでWebカメラのパン/チルト ESP8266・ESP32/WebSocket

ウェブ造ホーム前へ次へ
サイト内検索
カスタム検索
ESP8266って?

WiFi/サーボでWebカメラのパン/チルト ESP8266・ESP32/WebSocket

WiFi/サーボでWebカメラのパン/チルト ESP8266・ESP32/WebSocket

Webカメラパンチルト台試作
2020/03/05

 Wi-Fi(wifi)モジュールESP8266の姉妹チップESP32の内、DEVKIT1開発ボード1つとサーボモータ2つを使ってWiFi越しにWebカメラ(USBカメラ)をパン/チルトするガジェットを作ってみるページ。

 以前、Arduinoとジョイスティックでサーボモータ2軸制御でやっつけで作ったボール紙製マウンタが雑である点は、無視するものとします。

 Webカメラ含め、カメラのパンチルト台はよくある題材であり、今更感はありありですが、ESP32での無線化、WebSocketによる非同期通信、ブラウザにおいてHTML5/Canvas上のクリック位置でアングル(角度)調整...という点は、割とレアかも?

 できれば手持ちの、DCモータ、ギヤモータ、ステッピングモータ、サーボモータを使って何か実用品を作りたい!シリーズ、Arduinoとステッピングモータでロールカーテン、これを無線操作できるようにしたESP8266・ESP32/MQTTでロールカーテン/ESP8266・ESP32/WebSocketでロールカーテン、物理スイッチ操作となるESP8266・ESP32/WebSocket/サーボで壁面照明スイッチON/OFFに続く、今回のパンチルト台。

 つい先日、手持ちのモータではトルク不足で棚上げしたものの、普通のカーテンの電動化もやってみました。

 このパンチルト台も結果、PC/スマホ/タブレットなどのブラウザからの操作だけでなく、スクリプト化し、デスクトップ版操作パネルからブラウザ操作パネルを表示したり、これを自作スマートスピーカー用のデスクトップGUI操作パネルから呼ぶこともできます。

 WebSocketクライアント(ws://、SSL対応はwss://で始まるドメインやアドレスへのアクセス)には、JavaScriptを使いました。

前置き

 最近、その便利さにすっかりハマったAruduinoOTAを使ってOTA(Over The Air/無線)アップデートできるようにしました。

 3通りほどあるらしき、実装方法の内、Arduino IDEを使う前提のmDNS機能を必要とするものを選びました。

使ったもの

 Amazon(Prime対応品)だと4500円前後、ESP32開発ボード、WebカメラやACアダプタの価格差が特に大きくなるかとは思いますが、Aliexpressだと、これの4〜5割くらいで2500円前後かと。

 材料については、個々の環境に合わせて適宜用意。

前提

 mDNS機能を持つパッケージアプリケーションとしてLinuxならAvahi、Mac/WindowsならBonjourがインストール済みであること(macOSはBonjourはプリインストール済みのはず)。

 Arduino IDEが利用できることは、もちろん、ESP8266やESP32をArduino IDEで使えるようにしておくこと。

 ESP-01やESP-02〜ESP14などのESP8266チップなら、Arduino IDEの[ツール] => [ボード]から[Generic ESP8266 Module]を選択、ESPモジュールにスケッチをアップロードできる状態であること。

 ESP32なら、[espressif/arduino-esp32]の要領でESPモジュールにスケッチをアップロードできる状態であること。

 ちなみにこれらArduino IDEの環境設定で追加する方法の場合、カンマ区切りで複数指定可能。

回路

ESP8266 NodeMCUサーボ別電源備考
GPIO12信号線()-サーボ1用
GPIO13信号線()-サーボ2用
-プラス()5V-
GNDマイナス()マイナス

 ブレッドボード用電源を介した場合、サーボを2つ使ったからか、12V/2A ACアダプタを供給源にステッピングモータではできた一方、サーボ2つではできなかったため、今回は、ESP8266 NodeMCUの電源については、USB接続を前提にしています。

 が、別電源の条件によっては、ESP8266 NodeMCUもVINからの供給も可能と思われます。

 ちなみに今回の電源は、5V/2A ACアダプタでDCプラグが5.5x2.1mmではなかったため、切断し、端子台出しのプラグとジャックを接続、そこから(5Vなので)そのままジャンパワイヤでブレッドボードに供給しました。

 停止時にビビリ音と共にサーボが振動(何れもわずか)することがありますが、適切な電圧が9V以上のACアダプタと降圧コンバータを使うなど電源を強化すれば解消するのではと思っています。

AruduinoOTAアップデートがうまくいかない場合

 もし、OTA(On The Air/無線)アップデートがうまくいかない場合、サンプルスケッチBasicOTAにおいてWiFi SSIDとパスフレーズのみ環境に合わせ、NodeMCUボードにアップロードしてから、目的のスケッチをアップロードしてみるとよいかもしれません。

スケッチ

 今回のWebsocketサーバとなるESPボード側のスケッチは、こんな感じ。

#include <WiFi.h>
#include <WiFiClient.h>
#include <ArduinoOTA.h>
#include <ESP32WebServer.h>
#include <ESPmDNS.h>
#include <SPIFFS.h>
#include <FS.h>
#include <WebSocketsServer.h>
#include <Servo.h>
 
ESP32WebServer server(80);    // create a web server on port 80
WebSocketsServer webSocket(81);  // create a websocket server on port 81
 
File fsUploadFile;                  // a File variable to temporarily store the received file
 
const char* path_root  = "/index.html";
#define BUFFER_SIZE 16384
uint8_t buf[BUFFER_SIZE];
 
const char *ssid = "SSID"; // Wifi_STA network
const char *password = "PASSPHRASE"; // Wifi_STA passphrase
 
const char common_name[40] = "esp32pantilt";
const char *OTAName = common_name;      // A name and a password for the OTA service
const char *mdnsName = common_name; // Domain name for the mDNS responder
 
#define INTERVAL_VS_BASE 500
 
const int x_axis = 12;
const int y_axis = 13;
 
Servo x_axisServo;
Servo y_axisServo;
 
const int base = 0;
int x = 0;
int y = 0;
 
/*__________________________________________________________SETUP__________________________________________________________*/
 
void setup() {
 delay(1000);
 Serial.begin(115200);
 delay(10);
 Serial.println("\r\n");
 
 pinMode(x_axis, OUTPUT);
 pinMode(y_axis, OUTPUT);
 
 x_axisServo.attach(x_axis);
 y_axisServo.attach(y_axis);
 
 startWiFi();
 startOTA();
 startSPIFFS();
 startWebSocket();
 startMDNS();
 startServer();
}
 
/*__________________________________________________________LOOP__________________________________________________________*/
 
void loop() {
 webSocket.loop();              // constantly check for websocket events
 server.handleClient();           // run the server
 ArduinoOTA.handle();            // listen for OTA events
}
 
/*__________________________________________________________SETUP_FUNCTIONS__________________________________________________________*/
 
void startWiFi() { // Start a Wi-Fi access point, and try to connect to some given access points. Then wait for either an AP or STA connection
 // WiFi.softAP(ssid, password);       // Start the access point
 WiFi.mode(WIFI_STA);       // Start the access point
 WiFi.begin(ssid, password);       // Start the access point
 while ( WiFi.status() != WL_CONNECTED) {
  delay(500);
  Serial.print(".");
 }
 Serial.println("");
 Serial.print("SSID \"");
 Serial.print(ssid);
 Serial.println("\" started\r\n");
 
 Serial.print("Connected to ");
 Serial.println(ssid);
 
 Serial.print("IP address: ");
 Serial.println(WiFi.localIP());
 
 Serial.print("hostname : ");
 // Serial.println(WiFi.hostname());
 Serial.println("");
}
 
void startOTA() { // Start the OTA service
 ArduinoOTA.setHostname(OTAName);
 // ArduinoOTA.setPassword(OTAPassword);
 
 ArduinoOTA.onStart([]() {
  Serial.println("Start");
  // turn off the LEDs
  for (int i = 0; i < 6; i++) {
   //   digitalWrite(LED_BUILTIN, HIGH);
   //   digitalWrite(LED_BUILTIN, LOW);
  }
 });
 ArduinoOTA.onEnd([]() {
  Serial.println("\r\nEnd");
 });
 ArduinoOTA.onProgress([](unsigned int progress, unsigned int total) {
  Serial.printf("Progress: %u%%\r", (progress / (total / 100)));
 });
 ArduinoOTA.onError([](ota_error_t error) {
  Serial.printf("Error[%u]: ", error);
  if (error == OTA_AUTH_ERROR) Serial.println("Auth Failed");
  else if (error == OTA_BEGIN_ERROR) Serial.println("Begin Failed");
  else if (error == OTA_CONNECT_ERROR) Serial.println("Connect Failed");
  else if (error == OTA_RECEIVE_ERROR) Serial.println("Receive Failed");
  else if (error == OTA_END_ERROR) Serial.println("End Failed");
 });
 ArduinoOTA.begin();
 Serial.println("OTA ready\r\n");
}
 
//
// https://github.com/zhouhan0126/WebServer-esp32/blob/master/examples/FSBrowser/FSBrowser.ino
//
void listDir(fs::FS &fs, const char * dirname, uint8_t levels) {
 // Serial.printf("Listing directory: %s\n", dirname);
 
 File root = fs.open(dirname);
 if (!root) {
  Serial.println("Failed to open directory");
  return;
 }
 if (!root.isDirectory()) {
  Serial.println("Not a directory");
  return;
 }
 
 File file = root.openNextFile();
 while (file) {
  if (file.isDirectory()) {
   Serial.print(" DIR : ");
   Serial.println(file.name());
   if (levels) {
    listDir(fs, file.name(), levels - 1);
   }
  } else {
   Serial.print(" FILE: ");
   Serial.print(file.name());
   Serial.print(" SIZE: ");
   Serial.println(file.size());
  }
  file = root.openNextFile();
 }
}
 
void startSPIFFS() { // Start the SPIFFS and list all contents
 SPIFFS.begin();               // Start the SPI Flash File System (SPIFFS)
 Serial.println("SPIFFS started. Contents:");
 {
  listDir(SPIFFS, "/", 0);
 }
 Serial.printf("\n");
/*
  Serial.println("SPIFFS started. Contents:");
  {
   Dir dir = SPIFFS.openDir("/");
   while (dir.next()) {           // List the file system contents
    String fileName = dir.fileName();
    size_t fileSize = dir.fileSize();
    Serial.printf("\tFS File: %s, size: %s\r\n", fileName.c_str(), formatBytes(fileSize).c_str());
   }
   Serial.printf("\n");
  }
*/
}
 
void startWebSocket() { // Start a WebSocket server
 webSocket.begin();             // start the websocket server
 webSocket.onEvent(webSocketEvent);     // if there's an incomming websocket message, go to function 'webSocketEvent'
 Serial.println("WebSocket server started.");
}
 
void startMDNS() { // Start the mDNS responder
 MDNS.begin(mdnsName);            // start the multicast domain name server
 Serial.print("mDNS responder started: http://");
 Serial.print(mdnsName);
 Serial.println(".local");
}
 
void startServer() { // Start a HTTP server with a file read handler and an upload handler
 server.on("/", handleRoot);
 server.on("/edit.html", HTTP_POST, []() { // If a POST request is sent to the /edit.html address,
  server.send(200, "text/plain", "");
 }, handleFileUpload);            // go to 'handleFileUpload'
 server.onNotFound(handleNotFound);     // if someone requests any other file or page, go to function 'handleNotFound'
 // and check if the file exists
 
 server.begin();               // start the HTTP server
 Serial.println("HTTP server started.");
}
 
/*__________________________________________________________Motor_FUNCTIONS__________________________________________________________*/
 
void adjust_base() {
 delay(INTERVAL_VS_BASE);
 x_axisServo.write(base);
 delay(INTERVAL_VS_BASE);
 y_axisServo.write(base);
}
 
/*__________________________________________________________HELPER_FUNCTIONS__________________________________________________________*/
 
String formatBytes(size_t bytes) { // convert sizes in bytes to KB and MB
 if (bytes < 1024) {
  return String(bytes) + "B";
 } else if (bytes < (1024 * 1024)) {
  return String(bytes / 1024.0) + "KB";
 } else if (bytes < (1024 * 1024 * 1024)) {
  return String(bytes / 1024.0 / 1024.0) + "MB";
 }
}
 
String getContentType(String filename) { // determine the filetype of a given filename, based on the extension
 if (filename.endsWith(".html")) return "text/html";
 else if (filename.endsWith(".css")) return "text/css";
 else if (filename.endsWith(".json")) return "text/css";
 else if (filename.endsWith(".js")) return "application/javascript";
 else if (filename.endsWith(".ico")) return "image/x-icon";
 else if (filename.endsWith(".png")) return "image/x-icon";
 else if (filename.endsWith(".gz")) return "application/x-gzip";
 return "text/plain";
}
/*__________________________________________________________SERVER_HANDLERS__________________________________________________________*/
 
void handleNotFound() { // if the requested file or page doesn't exist, return a 404 not found error
 if (!handleFileRead(server.uri())) {    // check if the file exists in the flash memory (SPIFFS), if so, send it
  server.send(404, "text/plain", "404: File Not Found");
 }
}
 
boolean readHTML() {
 File htmlFile = SPIFFS.open(path_root, "r");
 if (!htmlFile) {
  Serial.println("Failed to open index.html");
  return false;
 }
 size_t size = htmlFile.size();
 if (size >= BUFFER_SIZE) {
  Serial.print("File Size Error:");
  Serial.println((int)size);
 } else {
  Serial.print("File Size OK:");
  Serial.println((int)size);
 }
 // htmlFile.read(buf, size);
 htmlFile.close();
 return true;
}
 
bool handleFileRead(String path) { // send the right file to the client (if it exists)
 Serial.println("handleFileRead: " + path);
 if (path.endsWith("/")) path += "index.html";     // If a folder is requested, send the index file
 String contentType = getContentType(path);       // Get the MIME type
 String pathWithGz = path + ".gz";
 if (SPIFFS.exists(pathWithGz) || SPIFFS.exists(path)) { // If the file exists, either as a compressed archive, or normal
  if (SPIFFS.exists(pathWithGz))             // If there's a compressed version available
   path += ".gz";                     // Use the compressed verion
  File file = SPIFFS.open(path, "r");          // Open the file
  size_t sent = server.streamFile(file, contentType);  // Send it to the client
  file.close();                     // Close the file again
  Serial.println(String("\tSent file: ") + path);
  return true;
 }
 Serial.println(String("\tFile Not Found: ") + path);  // If the file doesn't exist, return false
 return false;
}
 
void handleRoot() {
 Serial.println("Access");
 char message[20];
 String(server.arg(0)).toCharArray(message, 20);
 server.send(200, "text/html", (char *)buf);
}
 
void handleFileUpload() { // upload a new file to the SPIFFS
 HTTPUpload& upload = server.upload();
 String path;
 if (upload.status == UPLOAD_FILE_START) {
  path = upload.filename;
  if (!path.startsWith("/")) path = "/" + path;
  if (!path.endsWith(".gz")) {             // The file server always prefers a compressed version of a file
   String pathWithGz = path + ".gz";         // So if an uploaded file is not compressed, the existing compressed
   if (SPIFFS.exists(pathWithGz))           // version of that file must be deleted (if it exists)
    SPIFFS.remove(pathWithGz);
  }
  Serial.print("handleFileUpload Name: "); Serial.println(path);
  fsUploadFile = SPIFFS.open(path, "w");      // Open the file for writing in SPIFFS (create if it doesn't exist)
  path = String();
 } else if (upload.status == UPLOAD_FILE_WRITE) {
  if (fsUploadFile)
   fsUploadFile.write(upload.buf, upload.currentSize); // Write the received bytes to the file
 } else if (upload.status == UPLOAD_FILE_END) {
  if (fsUploadFile) {                  // If the file was successfully created
   fsUploadFile.close();                // Close the file again
   Serial.print("handleFileUpload Size: "); Serial.println(upload.totalSize);
   server.sendHeader("Location", "/success.html");   // Redirect the client to the success page
   server.send(303);
  } else {
   server.send(500, "text/plain", "500: couldn't create file");
  }
 }
}
 
void webSocketEvent(uint8_t num, WStype_t type, uint8_t * payload, size_t lenght) { // When a WebSocket message is received
 switch (type) {
  case WStype_DISCONNECTED:       // if the websocket is disconnected
   Serial.printf("[%u] Disconnected!\n", num);
   break;
  case WStype_CONNECTED: {       // if a new websocket connection is established
    IPAddress ip = webSocket.remoteIP(num);
    Serial.printf("[%u] Connected from %d.%d.%d.%d url: %s\n", num, ip[0], ip[1], ip[2], ip[3], payload);
    //    rainbow = false;         // Turn rainbow off when a new connection is established
   }
   break;
  case WStype_TEXT:           // if new text data is received
   Serial.printf("[%u] get Text: %s\n", num, payload);
   Serial.print("payload[0] : ");
   Serial.println(payload[0]);
 
   String payloadtxt = String((char *) &payload[0]);
   Serial.print("payloadtxt : ");
   Serial.println(payloadtxt);
 
   int colonpos = payloadtxt.indexOf(":");
   x = payloadtxt.substring(0, colonpos).toInt();
   y = payloadtxt.substring(colonpos + 1).toInt();
   Serial.print("x : y = ");
   Serial.print(x);
   Serial.print(" : ");
   Serial.println(y);
 
   int xp = map(x, 0, 320, 0, 255);
   int yp = map(y, 0, 320, 0, 255);
   Serial.print("xp : yp = ");
   Serial.print(xp);
   Serial.print(" : ");
   Serial.println(yp);
 
   delay(INTERVAL_VS_BASE);
   x_axisServo.write(xp);
   y_axisServo.write(yp);
   delay(INTERVAL_VS_BASE);
   break;
 }
}

 ESP32ボードを使うにあたり、以前、そんなことした記憶はないのですが、ESP32WebServer.hをincludeすべく、 Pedroalbuquerque / ESP32WebServerをArduino libraliesフォルダにダウンロードさせていただきました。

 また、コード内に参照URLがありますが、ESP32においては、startSPIFFS()内のコメントアウト部は、listDirlistDir(fs::FS &fs, const char * dirname, uint8_t levels){}関数を追記の上、listDir(SPIFFS, "/", 0);としないと機能しないようです。

 未使用のadjust_base()関数がありますが、サーボホーンの向きによっては、初期値を設定するためにsetup内で指定した方がよいかもしれません。

 便利なもので後述のように(今回は、JavaScriptから)WebSocketのパスが呼ばれるとESP8266/ESP32にアップロードしたスケッチのwebSocketEvent関数がコールされ、payload引数にその値が入ってきます。

 そのpayloadについては、ブラウザからX座標:Y座標の恰好でコロンを挟んだものが送信されてくるようにしたため、分割、intへのキャスト、map関数でブラウザ上のキャンバスサイズ320:320をなんとなく255:255へ変換を行っています。

 WebScokets.hのライブラリや、ベースとさせて頂いたスケッチにおける基本的な修正点などについては、ESP8266・ESP32/WebSocket自作無線電動ロールスクリーン同様です。

 ESP8266 NodeMCUを使う場合には、include行においてSPIFFS.h、WiFiClient.hは不要、WiFi.h、ESP32WebServer.h、ESPmDNS.hは、それぞれ、ESP8266WiFi.h、ESP8266WebServer.h、ESP8266mDNS.hに、これに伴い、ESP32WebServer server(80);行は、ESP8266WebServer server(80);に変更、startSPIFFS()内のlistDir(SPIFFS, "/", 0);は、コメントアウト部と入れ替えます。

HTML/JavaScript

<!DOCTYPE html>
<html>
<head>
<meta http-equiv="Content-Type" content="text/html; charset=utf-8"/>
<title>Webカメラパンチルト</title>
<meta charset="utf-8" />
<script>
var connection = new WebSocket('ws://'+location.hostname+':81/', ['arduino']);
connection.onopen = function () {
 connection.send('Connect ' + new Date());
};
connection.onerror = function (error) {
 console.log('WebSocket Error ', error);
};
connection.onmessage = function (e) {
 console.log('Server: ', e.data);
};
connection.onclose = function(){
 console.log('WebSocket connection closed');
};
 
function PrintPosition(canvas, message) {
 var context = canvas.getContext('2d');
 context.clearRect(0, 0, canvas.width, canvas.height);
 context.font = '12pt "MSゴシック"';
 context.fillStyle = 'black';
 context.fillText(message, 32, 48);
}
 
function getMousePosition(canvas, evt) {
 var rect = canvas.getBoundingClientRect();
 return {
  x: evt.clientX - rect.left,
  y: evt.clientY - rect.top
 };
}
 
function draw() {
 var canvas = document.getElementById('SimpleCanvas');
 var context = canvas.getContext('2d');
 
 canvas.addEventListener('click', function (evt) {
  var mousePos = getMousePosition(canvas, evt);
  var message = 'Mouse position X:' + mousePos.x + ', Y:' + mousePos.y;
  var data = mousePos.x + ':' + mousePos.y;
  document.getElementById("coodinates").value = data
  PrintPosition(canvas, message);
  context.fillStyle = "rgb(255, 255, 0)"; // 黄
  context.strokeStyle = "rgb(255, 255, 0)"; // 黄
 
  context.lineWidth = 1;
  context.beginPath();
  context.arc(mousePos.x, mousePos.y, 5, 0, Math.PI*2, true);
  context.fill();
  context.stroke();
  connection.send(data);
 }, false);
}
 
</script>
</head>
<body onload="draw()" style="background-color:#D0D0D0;">
<center>
<canvas id="SimpleCanvas" width="320" height="320" style="background-color:blue ;color:yellow ;"></canvas>
<div>カメラアングル指定</div>
<div>X座標:Y座標 <input id="coodinates" type="text"></div>
<p>実際の座標は、320:320から255:255に調整されます。</p>
</center>
</body>
</html>

 テキストボックス(input type=text)は、クリック時の送信データ確認用ですが、実用的とは言い難いものの、ここに表示した値を送信するようにすれば、入力した座標でも操作できるでしょう。

 今回、WebsocketクライアントとなるJavaScriptからESP32に実装されたWebSocketサーバへのデータは、X座標:Y座標としてコロンを介して送ることにしました。

操作方法

ESP32+サーボWebカメラパンチルト台用ブラウザ版操作パネル

 ブラウザにmDNS名でxxx.local/index.htmlか、IPアドレスでxxx.xxx.xxx.xxx/index.htmlにアクセスすると、ダイニング用ライト、キッチン用ライトそれぞれのオン・オフ操作パネルが表示されます。

 青い領域内をクリックすると領域内に黄色い丸が描かれ、領域内及び下部テキストボックスにX座標、Y座標が表示され、実際には、領域サイズ320x320を255x255に変換した角度でパンチルト台が動きます。

 これには、HTML5のCanvasとJavaScriptを使っています。

 これなら、別途、ESP8266/ESP32で作って運用中の集中操作パネルとしているブラウザ版スマートホーム操作パネルに簡単に統合できます。

スマートスピーカー用操作パネルから呼ぶこともできる

自作スマートスピーカー用Qtデスクトップ操作パネル

 また、必要か否かは別としてブラウザ版操作パネルをデスクトップ版操作パネルから呼ぶこともできます。

ウェブ造ホーム前へ次へ