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

無線電動ロールスクリーンを自作 ESP8266・ESP32/WebSocket

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

無線電動ロールスクリーンを自作 ESP8266・ESP32/WebSocket

無線電動ロールスクリーンを自作 ESP8266・ESP32/WebSocket

2020/02/12

 Wi-Fi(wifi)モジュールESP8266の内、NodeMCU開発ボードを使ってWiFi越しに動く電動ロールスクリーンを作ってみるページ。

 以前、作ったArduino+ステッピングモータ28BYJ-48製電動ロールカーテンを無線化したもの。

 無線版としては、MQTT版ロールスクリーンと併行して、今回は、操作にあたり、HTML5実装のWebSocketを介すことにしました。

 MQTT同様、非同期通信できますが、MQTTと違ってブローカーといったものは、存在しないため、環境としてのPCやRaspberry Piなどのハードウェアは不要、操作は端末ではなく、ブラウザから手軽にできるのでPCでもスマホやタブレットでも操作可能です。

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

前置き

 完成後は不要かもしれませんが、モノがモノ、場所が場所だけにAruduinoOTAを使ってOTA(Over The Air/無線)アップデートできるようにしました。

 そうそう要らないでしょ...と思っていたのですが、今回のケースでは、超絶便利でした。

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

使ったもの

 ほとんどは、Arduino版やMQTT版に書いた通りですが、材料については、個々の環境に合わせて適宜用意していただければ。

前提

 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の環境設定で追加する方法の場合、カンマ区切りで複数指定可能。

回路

 回路については、ほぼ、Arduino版のArduinoが、ESPボードに変わっただけです。

 実のところ、今回は、逆流防止のダイオードは入れなかったのですが、少なくともスクリーンの上限下限の位置決めが終わるまでは、シャフトを手動で回すことがあったりし、発電機のような状態になるので各モーターピン用にいるかもなと思った次第です。

 また、操作は、物理スイッチではなく、後述の通り、同じネットワーク上にいるPCやタブレット、スマホなどのブラウザを介し、遠隔操作するだけです。

電源

 電源は、十分な電流を供給できるACアダプタなどを降圧して使えば、5V程度(今回は、ブレッドボード用電源)でもESPボードのVIN/GNDとモーター用5V/GNDを共用しても、もちろん、モーター用と分けても十分機能します。

 尚、共有する場合の電源が、モバイルバッテリーだと(機能時、電流を要するため、自動OFFはしないものの、)特に巻き上げ時、トルク不足と勘違いするような状況で稀にしかうまくいきません。

 また、モーターを別電源とし、ESPボードへの電源供給専用としてならモバイルバッテリーでも十分ですが、別電源によってスクリーン動作中であってもESPボードには供給(充電)不要と判断され、自動OFFしてしまいます。

 よって強力な電源で共用するか、ESPボードは、電源タップに挿したAC充電アダプタから供給するかの何れかになるでしょう。

AruduinoOTAサンプルスケッチBasicOTAのアップロード

 いろいろやってみた中で、これをしなくてもできたのですが...。

 一からやってみたら、これをしないとできなかったので基本に忠実にWiFi SSIDとパスフレーズのみ環境に合わせたBasicOTAをNodeMCUボードにアップロード。

 AruduinoOTAを使うスケッチを無線アップデートできているESPボードであれば、この必要はありません。

独自スケッチのアップロード

 初めてArduinoOTAを使うボードの場合、BasicOTAの、既に他スケッチで機能していた場合、OTA用の内容を網羅したオリジナルスケッチをNodeMCUボードにアップロード。

 こうすることでOTAアップデートできるようになります。

スケッチ

 今回のスケッチは、こんな感じ。

#include <ESP8266WiFi.h>
#include <ArduinoOTA.h>
#include <ESP8266WebServer.h>
#include <ESP8266mDNS.h>
#include <FS.h>
#include <WebSocketsServer.h>
#include <Stepper.h>
 
ESP8266WebServer 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
#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] = "websockscreen";
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
 
const int stepsPerRevolution = 200;
int motorSpeed = 1200;
int lookup[9] = {B01000, B01100, B00100, B00110, B00010, B00011, B00001, B01001, B00000};
 
// pins_arduino.h参照
const int Motor1 = D1;
const int Motor2 = D2;
const int Motor3 = D5;
const int Motor4 = D6;
 
Stepper myStepper(stepsPerRevolution, Motor1, Motor2, Motor3, Motor4);
 
int FullRotationNum = 5000;
 
/*__________________________________________________________SETUP__________________________________________________________*/
 
void setup() {
 pinMode(LED_BUILTIN, OUTPUT);
 Serial.begin(115200);      // Start the Serial communication to send messages to the computer
 delay(10);
 Serial.println("\r\n");
 
 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
 startOTA();             // Start the OTA service
 startSPIFFS();           // Start the SPIFFS and list all contents
 startWebSocket();        // Start a WebSocket server
 startMDNS();             // Start the mDNS responder
 startServer();           // Start a HTTP server with a file read handler and an upload handler
}
 
/*__________________________________________________________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);         // ステーションモード設定
 WiFi.begin(ssid, password);         // WiFi接続
 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");
}
 
void startSPIFFS() { // Start the SPIFFS and list all contents
 SPIFFS.begin();                     // Start the SPI Flash File System (SPIFFS)
 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.");
}
 
/*__________________________________________________________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");
 }
}
 
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);
    }
    break;
  case WStype_TEXT:               // if new text data is received
    Serial.printf("[%u] get Text: %s\n", num, payload);
    // 0:stop 1:up 2:down
    if (payload[0] == '1')
    {
      Serial.print(" clockwise loop: " );
      for (int stepperLoop = 0; stepperLoop < FullRotationNum; stepperLoop++) {
       clockwise();
       delayMicroseconds(motorSpeed);
       Serial.print(stepperLoop);
       Serial.print(" ");
       delay(1);
      }
      Serial.print("]");
      setOutput(9);
    }
    else if (payload[0] == '2')
    {
      Serial.print(" counterclockwise loop: " );
      for (int stepperLoop = 0; stepperLoop < FullRotationNum; stepperLoop++) {
       counterclockwise();
       delayMicroseconds(motorSpeed);
       Serial.print(stepperLoop);
       Serial.print(" ");
       delay(1);
      }
      Serial.print("]");
      setOutput(9);
    }
    else if (payload[0] == '0')
    {
      setOutput(9);
    }
    break;
 }
}
 
/*__________________________________________________________Motor_FUNCTIONS__________________________________________________________*/
 
void counterclockwise() //反時計回り
{
 for (int i = 0; i < 8; i++)
 {
  setOutput(i);
  delayMicroseconds(motorSpeed);
 }
}
 
void clockwise() //時計回り
{
 for (int i = 7; i >= 0; i--)
 {
  setOutput(i);
  delayMicroseconds(motorSpeed);
 }
}
 
void setOutput(int out)
{
 digitalWrite(Motor1, bitRead(lookup[out], 0));
 digitalWrite(Motor2, bitRead(lookup[out], 1));
 digitalWrite(Motor3, bitRead(lookup[out], 2));
 digitalWrite(Motor4, bitRead(lookup[out], 3));
}
/*__________________________________________________________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";
}

 WebSocket communicationプロジェクトのスケッチとMQTT版(Arduino版ロールスクリーンで使ったスケッチ、Stepper、ArduinoOTA、ESP8266mDNS各ライブラリ)の合作です。

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

 WebScokets.hは、複数のライブラリで利用可能なようですが、今回は、arduinoWebSocketsライブラリを使用しました。

 このスケッチ、ブラウザ上でのスケッチやSPIFFS内ファイルのアップデート機能までありますが、これらについては未検証で、ここでは、Arduino IDEからOTAアップデートしました。

 マルチアクセスポイント対応でアクセスポイントモードでしたが、ステーションモードに変更しました。

 なぜか、元のスケッチだと4つの内、一部は、モータードライバのLEDが点灯するものの、全点灯せず、モーターが回わらなかったため、既に動作確認がとれているスケッチからコードを取り込みました。

 モーターを回すためのループが完了した後もモータードライバ上のランプが点灯したままとなるため、これを消灯させるべく、停止を入れています。

 ESP32を使う場合、ESP8266と異なり、SPIFFS実装には、SPIFFS.hを明示的にinclude、ESP8266WiFi.h、ESP8266WebServer.h、ESP8266mDNS.hに代えて、それぞれ、WiFi.h、ESP32WebServer.h、ESPmDNS.hをinclude、これに伴い、ESP8266WebServer server ( 80 );は、ESP32WebServer server(80);に変更、モーター用出力ピンには、ESP32相応のピンを指定する必要があります。

 ちなみにそのままだと.gzファイルにも対応しているため、それを残したまま解凍・展開して編集しても反映されない!と慌てないよう要注意。

HTML/JavaScript

<!DOCTYPE html>
<html>
<head>
  <title>Auto Roll Screen</title>
  <link href='main.css' rel='stylesheet' type='text/css'>
  <meta content='width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=0' name='viewport'>
<script>
//var rainbowEnable = false;
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 sendCtrl(btn) {
document.getElementById("num").value = btn
  console.log('Btn Num: ' + btn);
  connection.send(btn);
}
</script>
</head>
 
<body>
  <center>
  <header>
  <h1>Auto Roll Screen</h1>
  </header>
  <div style="padding:6% ;width:20% ;float:left ;"><input id="up" type="submit" onclick="sendCtrl('1');" value="UP" style="width:80px ;"></div>
  <div style="padding:6% ;width:20% ;float:left ;"><input id="stop" type="submit" onclick="sendCtrl('0');" value="STOP" style="width:80px ;"></div>
  <div style="padding:6% ;width:20% ;float:left ;"><input id="down" type="submit" onclick="sendCtrl('2');" value="DOWN" style="width:80px ;"></div>
  <div><input id="num" type="text"></div>
 
  </center>
</body>
</html>

 なぜか、外部CSSは適用されるのに外部JavaScript(WebSocket.js)は適用されなかったのでJavaScript部分は、HTMLに埋め込みました。

(ん?CSSのHTML内のクオートがシングル、かたやダブルだけど、まさかそれ?)

 押下ボタンを判別、送信するメソッド・関数sendCtrl(btn){}以外は、参照させて頂いたプロジェクト同様です。

 テキスト(input type=text)は、外部スクリプトでうまくいかなかった際のボタン押下時のvalue値確認用です。

 また、なぜか、CSSにおいてSPIFFSでも日本語表示を確認できているfont-familyに変更してもコメントアウトしても日本語が文字化けしたのでtitleやヘッダ、ボタン名は、とりあえず、英文字としました(が、<head></head>内に<meta charset="utf-8">を入れておけば日本語も文字化けしないと当サイト別ページに...)。

 index.htmlのhead内は、必要な行のみ使い、外部CSSにも手は加えましたが、ちょっと雑さが目立ちますし、なくても機能確認には十分なので割愛します。

操作方法

ESP8266自作電動ロールスクリーン用ブラウザ版操作パネル

 ブラウザにmDNS名でxxx.local/index.htmlか、IPアドレスでxxx.xxx.xxx.xxx/index.htmlにアクセスすれば、UP、STOP、DOWNボタン付きの操作パネルが表示されます。

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

 流用させて頂いたスケッチについては、アクセス時index.htmlを省略する方法がいまいちわかりませんでした...。

 押下ボタンがわかると言えばわかるのでテキストボックスである必要はないものの、あってもよいですが、前述の通り、確認用なのでテキストボックスはなくても構いません。

スクリプトから操作

$ cat websocket_client.py
#!/usr/bin/python3
# -*- coding: utf-8 -*-
 
from websocket import create_connection
import sys
 
ws = create_connection("ws://websockscreen.local:81/")
arg = sys.argv
 
ws.send(arg[1])
ws.close()
$ chmod +u websocket_client.py
$ ./websocket_client.py 1
$ ./websocket_client.py 2

 例えば、Pythonだと、WebSocketクライアントをこんな風に書けば、引数を渡して操作できます。

 このようにスクリプトから操作できるということは、自作スマートスピーカーからでも操作できます。

2020/02/25

 というわけでブラウザからの操作に加え、自作ラズパイスマートスピーカーで自作電動ロールスクリーンを音声操作できるようにしてみました。

備考

 非同期通信なのでスクリーン動作中であってもHTTP TCP/IP同期通信のようにフリーズすることなく、ボタン操作自体は受け付けますが、今のところ、割り込み方法を思いつかず、途中での逆転や停止はしません(よってSTOPボタンは実質意味がありません)。

 ただ、端末で操作したMQTTと異なり、プログラム上UP/DOWN後に停止を入れているからか、なんだかカッコいい気もする一方、意味を見いだせるのか見い出せないのか、非同期の副産物としてUP後DOWNを、DOWN後UPを続けてクリック・タップすると全開→全閉、全閉→全開を自動で行ってくれます。

 ということは、間違えて続けて押してしまうと開けたつもりなのに閉まってる!とか、その逆もあり得るという話。

 が、UP/DOWNをそれぞれ続けてクリック・タップしても直後にメインメニューに戻る(ロールスクリーンのメニューを抜ける)と最初の動作だけ行って2つめ含む以降の動作はしないことがわかりました。

 ロールスクリーンのメニューも各操作もロールスクリーン用に割り当てたESPチップのフラッシュメモリ上に展開されたパスにアクセスすることで表示・操作できるようになっており、フラッシュメモリ内にアクセス中は、操作における値を保持、アクセスしなくなる(=ロールスクリーン用に割り当てたESPチップのスケッチを抜ける・スケッチの範囲外になる)と即、値も破棄されるということなのでしょう。

 同様に、ここで作ったロールスクリーンにとって良き値を検証し、FullOpenCloseNum = 5000としてループさせており、リミットスイッチがなくとも上限、下限に達すると自動で停止しますが、リミットスイッチがないため、UPやDOWNなど同じ操作ボタンを続けて2回以上押してしまうとスクリーン長とは無関係に巻き上げ・巻下げしてしまうので注意(運用でカバー)。

ウェブ造ホーム前へ次へ