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

WiFi/サーボで壁面照明スイッチをON/OFF ESP8266・ESP32/WebSocket

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

WiFi/サーボで壁面照明スイッチをON/OFF ESP8266・ESP32/WebSocket

WiFi/サーボで壁面照明スイッチをON/OFF ESP8266・ESP32/WebSocket

2020/03/01

 Wi-Fi(wifi)モジュールESP8266の内、NodeMCU開発ボード1つとサーボモータ2つを使ってWiFi越しに縦並びの物理スイッチ(壁埋め込みの照明・電気スイッチ)2つをON/OFFするガジェットを作ってみるページ。

 以前、やってみた電源タップでのArduino+サーボによるON/OFFデモを無線化し、実用的にしたもの。

壁面照明スイッチ切り替えサーボモータ

 取り付け方法にあぐねていたこともあり、必要性の点からも後回しにしてきました。

 が、DCモータ、ギヤモータ、ステッピングモータ、サーボモータを使って何か実用品を作りたい!と思いつつ、なかなか思いつかない時は、やっぱりこれだよね、ということで今やることにした次第。

 電動化後も、もちろん、手動での操作もできます。

 無線操作するにあたり、ソフト的には、ESP8266・ESP32/WebSocketによる自作無線電動ロールスクリーンのステッピングモータをサーボモータに代えたに過ぎません。

 結果、PC/スマホ/タブレットなどのブラウザからの操作だけでなく、スクリプト化し、デスクトップ版操作パネルからブラウザ操作パネルを表示したり、端末(≒ターミナル)からはもちろん、自作スマートスピーカーからの音声操作もできます。

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

前置き

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

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

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

取付場所・取付方法

壁面スイッチ切り替えサーボモータ構造体背面

 茶棚側面が10cmほどの距離で面した壁面スイッチにおいて茶棚側に画像の構造物を設置。

 茶棚天板にL字ステー、これにストレートステーをつなげ、立ちおろし、ここに画像の構造物をボルト締め。

壁面スイッチ切り替えサーボモータ構造体背面

 2つのL字ステー及び土台のストレートステーを共締めしたボルトの突起と、このL字ステーと先端のサーボ固定用小型L字ステーの大穴、長穴がスイッチまでの調整代(しろ)。

 と言いつつ、これらのボルトは調整しろにするには、わずかに短く足りませんでした。

 結局、微調整用のボルトナットとゴム板を駆使、その間、数回、茶棚自体も数mm?微妙に動かしたりもしました。

 下のスイッチは完璧も逆さに付けたからか、個体の問題か、上のサーボがたまにびびることがあり、スイッチをONにした瞬間にOFFにしてしまったりで、それまで驚くほど思い描いた通りで順調だった中、最終的なスイッチまでの位置決めに最も時間がかかりました。

 もう少し、スマートに位置決めできないものかと今尚、考えているほど。

壁面スイッチ切り替えサーボモータ構造体

 設置する向きは、こんな感じです。

 尚、今回対象となる照明用スイッチは、大きめである上、ON/OFFで左右ともに押さなければいけないもので試す前は、ホーンに何か抱かせた方が...とも思ったものの、サーボSG90の付属ホーンでいけました。

使ったもの

 Amazon(Prime対応品)だと4000円前後、Aliexpressだと、これの4〜5割くらいで1500〜2000円あたりかと。

 ステーは、300mmのストレートステー1つは100均(ダイソー)、それ以外のステーは、ホームセンターで使った分は600円前後、ステー固定用ネジやステー間固定用ボルト・ワッシャー・ナットも100均でセットで300円前後、単品なら100円前後かと。

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

前提

 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サーボ別電源備考
D4/GPIO02信号線()-サーボ1用
D8/GPIO15信号線()-サーボ2用
-プラス()5V-
GNDマイナス()マイナス

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

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

 また、よく調べていませんが、ESP8266 NodeMCUボードにおいては、2つのサーボを操作するにあたり、何通りか試したところ、できないポートもあるようでサーボの信号線につなぐポートは、D4/GPIO02とD8/GPIO15ならいけました。

 ブレッドボード電源に降圧を任せた今回、電源投入直後、たまに基準位置合わせに失敗することがあったり、停止時にビビリ音と共にサーボが振動(何れもわずか)することがありますが、適切な降圧コンバータを使うなど電源を強化すれば解消するのではと思っています。

 今回は、壁面スイッチの真下下方にコンセントがあるため、ここに集中スイッチ付き2口電源タップを、そこに別電源とした12V/2A ACアダプタとUSB充電ACアダプタを挿すことにしましたが、電源をVINからとれるなら、もちろん2口電源タップやUSB充電ACアダプタ、USBケーブルは不要です。

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

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

スケッチ

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

#include <ESP8266WiFi.h>
#include <ArduinoOTA.h>
#include <ESP8266WebServer.h>
#include <ESP8266mDNS.h>
#include <FS.h>
#include <WebSocketsServer.h>
#include <Servo.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] = "espdk_light";
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 dining_light_sw = D4;
const int kitchen_light_sw = D8;
 
Servo diningServo;
Servo kitchenServo;
 
const int forward = 135;  // 正転角度
const int base = 90;    // 基準角度
const int backward = 45;  // 逆転角度
 
/*__________________________________________________________SETUP__________________________________________________________*/
 
void setup() {
 delay(1000);
 Serial.begin(115200);
 delay(10);
 Serial.println("\r\n");
 
 diningServo.attach(dining_light_sw);
 kitchenServo.attach(kitchen_light_sw);
 
 diningServo.write(base);
 kitchenServo.write(base);
 
 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
 // ESP8266用Modem-sleepモード設定(自動復帰)
 wifi_set_sleep_type(LIGHT_SLEEP_T);
}
 
/*__________________________________________________________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");
}
 
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);
    //    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]);
 
   delay(INTERVAL_VS_BASE);
   adjust_base();
   if (payload[0] == '1') {
    delay(INTERVAL_VS_BASE);
    diningServo.write(forward);
    delay(INTERVAL_VS_BASE);
    diningServo.write(base);
    delay(INTERVAL_VS_BASE);
   } else if (payload[0] == '2') {
    delay(INTERVAL_VS_BASE);
    diningServo.write(backward);
    delay(INTERVAL_VS_BASE);
    diningServo.write(base);
    delay(INTERVAL_VS_BASE);
   } else if (payload[0] == '3') {
    delay(INTERVAL_VS_BASE);
    kitchenServo.write(backward);
    delay(INTERVAL_VS_BASE);
    kitchenServo.write(base);
    delay(INTERVAL_VS_BASE);
   } else if (payload[0] == '4') {
    delay(INTERVAL_VS_BASE);
    kitchenServo.write(forward);
    delay(INTERVAL_VS_BASE);
    kitchenServo.write(base);
    delay(INTERVAL_VS_BASE);
   }
   break;
 }
}
 
/*__________________________________________________________Motor_FUNCTIONS__________________________________________________________*/
 
void adjust_base() {
 delay(INTERVAL_VS_BASE);
 diningServo.write(base);
 delay(INTERVAL_VS_BASE);
 kitchenServo.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";
}

 WebSocket communicationプロジェクトのスケッチをベースにさせて頂いたものです。

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

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

HTML/JavaScript

<!DOCTYPE html>
<html>
<head>
<title>ダイニング&キッチンライト</title>
<link href='main.css' rel='stylesheet' type='text/css' />
<meta charset="utf-8" />
<meta content='width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=0' name='viewport' />
<script src='WebSocket.js' type='text/javascript'></script>
<!--
<script src="WebSocket.js" type="text/javascript"></script>
-->
<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 sendCtrl(btn) {
document.getElementById("data").value = btn
console.log('Btn Data: ' + btn);
connection.send(btn);
}
</script>
</head>
<body>
<center>
<header>
<h1>ダイニング&キッチンライト</h1>
</header>
<div class="ctrlbtn"><input id="don" type="submit" onclick="sendCtrl('1');" value="ダイニングライトON" style="width:160px ;"></div>
<div class="ctrlbtn"><input id="doff" type="submit" onclick="sendCtrl('2');" value="ダイニングライトOFF" style="width:160px ;"></div>
<div style="clear:both ;"></div>
<div class="ctrlbtn"><input id="don" type="submit" onclick="sendCtrl('3');" value="キッチンライトON" style="width:160px ;"></div>
<div class="ctrlbtn"><input id="doff" type="submit" onclick="sendCtrl('4');" value="キッチンライトOFF" style="width:160px ;"></div>
<div style="clear:both ;"></div>
<div><input id="data" type="text"></div>
<div><input type="button" name="mainmenu" value="to Main Menu" onClick="http_req(location.href='http://esphamainsrv.local')"></div>
</center>
</body>
</html>

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

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

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

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

操作方法

ESP8266+サーボ自作ダイニング・キッチン各ライトスイッチ用ブラウザ版操作パネル

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

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

 押下ボタンがわかると言えばわかるのでテキストボックスである必要はないものの、確認用なのであってもなくても可。

スクリプトから操作

$ pip3 install websocket-client-py3
$ cat dk_light.py
#!/usr/bin/python3
# -*- coding: utf-8 -*-
 
from websocket import create_connection
import sys
 
ws = create_connection("ws://espdk_light:81/")
arg = sys.argv
 
ws.send(arg[1])
ws.close()
$ chmod +u dk_light.py
$ ./dk_light.py 1
$ ./dk_light.py 2
$ ./dk_light.py 3
$ ./dk_light.py 4

 例えば、Python(Python3)だと、まず、WebSocketクライアント(今回は、pipでwebsocket-client-py3)をインストール、スクリプトをこんな風に書けば、引数を渡して操作できます。

 このようにスクリプトから操作できると自作ラズパイスマートスピーカーでダイニングキッチンライトスイッチ切り替えサーボを音声操作するのも容易にできるようになります。

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

 また、先の照明スイッチ用含め、ブラウザ版操作パネルをデスクトップ版操作パネルから呼ぶこともできます。

ウェブ造ホーム前へ次へ