Edited at

Slack から Windows, ESP-WROOM-32 を経由して自宅のエアコンをつける

More than 1 year has passed since last update.

本記事は U-TOKYO AP Advent Calendar 2017 の 6 日目です.

こんにちは,東京大学工学部 物理工学科@cexen です.

本記事を書くにあたり ラズパイ を自宅配備しようと思ったのですが お金がなくて買えませんでした.(参考: Black Friday に関する記事

そこで,自宅の Windows 機と無線モジュール ESP-WROOM-32 で お安く IoT することにします.

最近寒いですね.帰宅しても出迎えてくれる人がいないので, エアコンの遠隔操作 で温かい部屋に出迎えてもらいます.


やること


  • Wi-Fi + BLE モジュール ESP-WROOM-32 (ESPr® Developer 32) で赤外線リモコン信号を送受信する.( Arduino プラットフォームと IRremote ライブラリを使用.)

  • ESP-WROOM-32 と Windows 10 機上の Node.js サーバーとを BLE (Bluetooth Low Energy) で接続する.( ESP32 BLE for Arduino ライブラリ と noble ライブラリを使用.)

  • Node.js サーバーで Slack とメッセージを送受信する.( node-slack-sdk ライブラリを使用.)

巷の優秀なソフトウェア・ライブラリ・ツール群を駆使します.物理工学科らしい数式や高度な内容は含みません.


使ったもの

スイッチサイエンス さんで以下を注文しました.

計 3,422 円です.

生の ESP-WROOM-32 で開発段階からやるのはさすがに大変なので,今回は開発ボード ESPr® Developer 32 を使用します.以降,単に ESP32 と呼びます.

なお, BLE 不要で Wi-Fi だけでよいならば代わりに ESP-WROOM-02 (開発ボードは ESPr® Developer または ESPr® Developer(ピンソケット実装済)) を使って同様のことができます.巷の情報は 02 のほうが多い印象です.

加えて,どこの家にもある以下のアイテムを使用しました.


  • はんだ,はんだごて,はんだごてホルダー


    • ESP32 へのピンソケット実装のため.



  • 紙やすり(#1000とか#400とか)


    • ピンソケットの端を削るため.



  • R とか V とか計れるテスター


    • はまったときのため.順調にいけば要らない.



  • MOSFET の 2SK4019 と単三充電池×3 ,単三電池3本用ホルダー


    • 赤外線 LED の信号を少し強くするため.必須ではない.



  • ブレッドボード,適当な数のジャンプワイヤ

  • USB A - microB ケーブル

  • Windows 10 ノート PC


    • 実は OS は重要ではない.



  • USB Bluetooth アダプター USB-BT40LE


環境の準備

最近は Mongoose OS という魅力的な解決もあるようですが,ここではオーソドックスに Arduino プラットフォームを使用します.

まず Windows 機に Arduino IDE をインストールします. choco install arduino でもできるようですが私は公式インストーラーで入れてしまいました.

さらにプラグインとして Arduino core for ESP32 WiFi chip を導入します.手順は公式の arduino-esp32/windows.md を参考にします.

私の環境では以下のようになります.なお Git がない場合は予め ChocolateyMSYS2Git for Windows など好きな方法で入れてください.

cd C:\Users\*****\OneDrive\Documents\Arduino

mkdir -p hardware\espressif\esp32
cd hardware\espressif\esp32
git clone https://github.com/espressif/arduino-esp32.git .
cd tools
./get.exe

Arduino IDE を起動し,上メニューの ツール からボードを ESP32 Dev Module にするなど適当な設定を行ってください.

細かいところがよくわからない場合は esp8266 (ESP-WROOM-02, ESPr® Developer) のほうのライブラリでググるといいかもしれません.(導入手順はよく似ています.)


ESP32 で L チカ

まずは動作確認として LED をチカチカさせます(いわゆる L チカ).

ESP32 に足の長いピンソケットをはんだ付けします.

スイッチサイエンスさんには 20 ピンのソケットがなかったので, 10 ピンのやつを 2 つ用意してそれぞれ一方の端を紙やすりで少し削りました.

秋月電子さんなどに行けば 20 ピンのものがあると思います.

次に,ブレッドボード上で ESP32 と LED を接続します.

ここで赤外線 LED を使うと常人には目視できないので,たとえば「抵抗コンデンサLED詰め合わせパック」内の緑色 LED を使います.

ESP32 の 3V3 端子から +3.3 V が, GND 端子から 0 V がそれぞれ出ます.まずはこれを LED につなぎます.

LED の長いほう (アノード) に高電位 (3V3) ,短いほう (カソード) に低電位 (GND) をつなぎます.ただし LED にたくさん電流を流すと壊れるので,適当な抵抗として 100 Ω を介します.

ESP32 と PC を接続するか,もしくは GND - VIN 間に 3.7~6.0 V を入力すると LED が CW で光ります.

続いて,LED の高電位側を 3V3 の代わりに 26 につなぎ,以下のコードを Arduino IDE で書き込むと LED が 1 秒周期でチカチカします.

int pinLED = 26;

void setup() {
pinMode(pinLED, OUTPUT); // モードを出力に
}
void loop() {
digitalWrite(pinLED, HIGH); // つける
delay(500);
digitalWrite(pinLED, LOW); // 消す
delay(500);
}


赤外線リモコンの受信と送信

自宅の既存リモコンの信号を知るために受信が必要です.

得られたものを再現して送信します.

赤外線リモコン通信の基礎知識は 赤外線リモコンについて がまとまっています.

さて,赤外線通信の細かいことは IRremote Arduino Library に任せます.

執筆時点だと ESP32 は receive only とのことなので自前実装を覚悟していたのですが,送信にも対応するプルリクエスト ( ESP32 Support by SensorsIot · Pull Request #540 · z3t0/Arduino-IRremote ) が最近生まれたようなので,これを使ってみます.

cd C:\Users\*****\OneDrive\Documents\Arduino

mkdir -p libraries
cd libraries
git clone https://github.com/SensorsIot/Arduino-IRremote.git IRremote

Arduino IDE を再起動すると上メニューの ファイル>スケッチ例 に IRremote が出現します.


受信

ESPr® Developer 32 の VOUT ピンは,ボードに供給した電源電圧と同じ電圧が出ます. USB から電源供給すると 5 V なので +5 V が出ます.

RPM6938リモコン受光モジュールは動作電圧 5±0.5 V ですから この VOUT ピンを使用します.

RPM6938 と ESP32 を接続するために, データシート の p.9 をじっと見ます.

RPM6938 の端子は,丸い出っ張りがある側から見て,左から ROUT(1), GND(2), VCC(3) です.

ROUT とたとえば 25 番ピン, GND と GND , VCC と VOUT をそれぞれ接続します.

ただしデータシートの p.7 に「供給電源の安定化のために 47 μF 以上と 47 Ω をつなげ」と書いてあるので,「抵抗コンデンサLED詰め合わせパック」の 470 μF を GND-VCC 間につけ, 47 Ω を VCC の前に介しました.

(※容量が大きいため電解コンデンサを用います.大抵の電解コンデンサは極性と耐圧を持ちます.足の短いほうが低電位 (GND) です.間違えると最悪の場合,沸騰して爆発するそうなので気をつけてください.)

Arduino IDE 上で IRremote スケッチ例の IRrecvDemo を開き, RECV_PIN を 25 に, Serial.begin(9600)Serial.begin(115200)に変更しボードに書き込みます.

シリアルモニタを開き,レートを 115200 bps に変更してから ESP32 上の RESET ボタンを押せばいくつかの情報と「Enabling IRin」「Enabled IRin」が表示されます.

RPM6938 に例えば適当なリモコンで SONY の TV 電源 ON を照射すると A90 が複数個表示されます.

さらに IRrecvDemo の代わりに IRrecvDumpV2 で同じことをすると,より詳細な解析結果が得られます.

なお,使うピンは自由ですが,ピンによっては別の仕組みで既に使われていることがあります.

使用状況は ESP32Dev Board PINMAP を参考にするとよいでしょう.ただしこのピンマップは違う開発ボード (Espressif ESP32 Development Board) のものですから位置は正しくありません.

くれぐれも,ライブラリのサンプルプログラムが 11 番ピンだからといって 11 番ピンに接続して「受信してくれない……」とはまらないようにしてください.(私がやりました.)

ちなみに私はテスターを使ってみて電位がおかしい(受信素子をつながなくても 3.3 V 出ている)ことから気付きました.

一方,上記ピンマップで ADC と書いてあるピンは analogRead できる(出典: 【ESP32】analogReadする方法 - ソースに絡まるエスカルゴ )そうなのでそれを使って動作確認してみるのもよいかもしれません.


送信

今度は 26 番ピンに LED をつないで信号を送信してみます.

セットアップは L チカのときと全く同じです.

IRremote のスケッチ例から IRsendDemo を開きます.

これは Sony の TV 電源 ON (A90) を送信するものです.

IRsend irsend;IRsend irsend(26); に変更してボードに書き込むと,緑色 LED が 5 秒ごとに 3 回光ります.

緑色 LED を赤外線 LED に置き換えるとそのまま(見えませんが)光ります.

SONY のテレビのお持ちの方であれば点くかもしれません.

有名な話ですが,スマホのカメラなどで赤外線 LED の点灯を観察することができます.

私のスマホでは,真上からじっと構えると,わずかに紫色に写ります.

もしくは特殊な訓練を積んだ人なら“視える”かもしれません.( 950 nm ですのでさすがに肉眼では厳しいと思います.)

ここで使用した ESP32 Support by SensorsIot · Pull Request #540 · z3t0/Arduino-IRremote では, ESP32 だけ ledc 系関数を使うように,またオブジェクトの宣言時に ledc 系での使用ピンを選択できるように拡張されています.

なお,私はコードを読む際に, ledc 系関数については 【ESP32】PWMでモーターを制御する方法 - ソースに絡まるエスカルゴ を参考にしました.


エアコンの ON, OFF

我が家のエアコン(三菱製)のリモコンから IRrecvDumpV2 によって ON (22℃), OFF に対応する rawData を得ました.

(その際,バッファを増やせと言われたので言われたとおりに増やしました.)

フォーマットは UNKNOWN で,おそらくリーダー信号なし・データ信号のみです.

フォーマットがないので sendRaw() を使って rawData を送信します.

とりあえず変調は 38 kHz としてみたら動作しました.( 赤外線リモコンについて によれば大抵のリモコンは 38 kHz です.)

今回は簡単のため,リモコンの信号をハードコーディングしました.

利便性のために, IRsendRaw と IRrecvDumpV2 をくっつけたようなコードを作成しました.

コード(抜粋):

int recvPin = 25;

int sendPin = 26;
IRrecv irrecv(recvPin);
IRsend irsend(sendPin);
// ON (22 ℃)
unsigned int ON[] = {3200,1600, 400,400, 400,1200, 400,350, 450,350, 450,1150, 450,350, 400,1200, 400,350, 450,350, 450,1150, 450,1150, 400,1200, 400,350, 450,1150, 450,350, 450,1150, 450,1150, 400,1150, 450,350, 450,350, 450,350, 450,350, 400,1200, 400,1150, 450,350, 450,1150, 450,1150, 400,400, 400,350, 450,1150, 450,350, 450,350, 450,1150, 400,350, 450,400, 400,1150, 450,1150, 450,350, 400,1200, 400,1150, 450,1150, 450,1150, 450,1100, 450,1150, 450,1150, 450,1150, 450,1100, 450,1150, 450,350, 450,350, 450,350, 450,300, 450,350, 450,350, 450,350, 450,350, 450,1150, 450,1150, 400,1200, 400,1200, 400,1150, 450,1150, 400,1150, 450,1150, 450,350, 450,350, 450,350, 400,400, 400,350, 450,350, 450,350, 450,350, 450,1150, 450,1150, 400,350, 450,400, 400,350, 450,1150, 450,300, 500,1150, 400,400, 400,350, 450,1150, 450,1150, 450,1100, 450,350, 450,1150, 450,350, 450}; // UNKNOWN F288C8C8
// OFF
unsigned int OFF[] = {3200,1650, 400,350, 450,1150, 450,350, 400,400, 400,1200, 400,350, 450,1150, 450,350, 400,400, 450,1150, 450,1100, 450,1150, 450,350, 450,1150, 400,400, 400,1150, 450,1150, 400,1200, 450,350, 400,350, 450,350, 450,350, 450,1150, 450,1100, 500,350, 400,1150, 450,1150, 450,350, 450,350, 450,1150, 400,400, 400,400, 400,1150, 450,350, 400,400, 450,1150, 400,1150, 450,350, 450,1150, 450,1150, 450,1100, 450,1150, 450,1150, 450,1100, 500,1100, 450,1150, 450,1150, 450,1150, 450,350, 400,350, 450,350, 450,350, 450,350, 450,350, 450,350, 450,350, 400,1150, 450,1150, 450,1150, 450,1100, 500,1150, 400,1150, 450,1150, 450,1150, 450,300, 450,350, 450,350, 450,350, 450,350, 400,400, 400,400, 450,350, 400,1200, 400,1150, 450,350, 400,1200, 400,400, 450,1100, 450,350, 450,1150, 450,350, 450,350, 400,1150, 450,350, 450,1150, 450,350, 400,1200, 400,400, 450}; // UNKNOWN 51E87A6C
int khz = 38;

void sendOn() {
irsend.sendRaw(ON, sizeof(ON)/sizeof(ON[0]), khz);
Serial.print("ON!\n");
}

void sendOff() {
irsend.sendRaw(OFF, sizeof(OFF)/sizeof(OFF[0]), khz);
Serial.print("OFF!\n");
}


光の強化

参考までに.私の場合,ちょっと効きが悪いと思ったので少し強くしました.

たまたま家に MOSFET の 2SK4019 があったので, 2SK4019 と 単三充電池 3 本(直列実測 3.9 V )を使って LED への電源供給を ESP32 から分けました.このセットアップはありあわせのものであり,必然性はありません.

2SK4019 の駆動電圧は 4 V なのですが, 3.3 V でもギリギリ使えないこともなかったです.赤外線 LED の絶対最大定格順電流 (100 mA) と相談しつつ,最終的に抵抗は 10 Ω にしました.

なお,抵抗ゼロでしばらく動かしていたら,赤外線 LED がいつの間にか死にました(光らなくなりました)…….下の写真の赤外線 LED は 2 代目(予備)です. 私が死んでも代わりはいるもの. 単三電池3本をつなぐなら何かしら抵抗はつけたほうがいいです(あたりまえ).


Windows PC との接続


BLE (Blootooth Low Energy)


ESP32 側

BLE のライブラリは esp32 プラグインと紐付いてはいますが,別のリポジトリで管理されていて esp32 のほうを clone しても付いてきません.

Arduino IDEでESP32 BLEライブラリを導入 - Qiita に基づき, arduino-esp32/libraries at master · espressif/arduino-esp32 のリンク先から追加で clone します.

cd C:\Users\***\OneDrive\Documents\Arduino\hardware\espressif\esp32\libraries\BLE

git clone https://github.com/nkolban/ESP32_BLE_Arduino.git .

サンプルコードや実装とにらめっこしながらコードを書きます.

characteristic として on または off を持ち,これを Windows 側から write されたときに赤外線を発するようにします.

ESP32 BLE Arduino のサンプルコードをもとにしつつ, SERVICE_UUIDCHARACTERISTIC_UUID をネット上で適当にランダム生成して決めます.

ESP32 側の最終的なコードは以下のようになります(中盤は IRrecvDumpV2 のコードです).

#include <BLEDevice.h>

#include <BLEUtils.h>
#include <BLEServer.h>
#include <IRremote.h>

// See the following for generating UUIDs:
// https://www.uuidgenerator.net/

#define SERVICE_UUID "cbdad163-7a4a-4908-a257-219b571bb663"
#define CHARACTERISTIC_UUID "cfea1bbb-7cb3-4c95-a52a-2276b74839e5"

int recvPin = 25;
int sendPin = 26;
IRrecv irrecv(recvPin);
IRsend irsend(sendPin);
// ON (22 ℃)
unsigned int ON[] = {3200,1600, 400,400, 400,1200, 400,350, 450,350, 450,1150, 450,350, 400,1200, 400,350, 450,350, 450,1150, 450,1150, 400,1200, 400,350, 450,1150, 450,350, 450,1150, 450,1150, 400,1150, 450,350, 450,350, 450,350, 450,350, 400,1200, 400,1150, 450,350, 450,1150, 450,1150, 400,400, 400,350, 450,1150, 450,350, 450,350, 450,1150, 400,350, 450,400, 400,1150, 450,1150, 450,350, 400,1200, 400,1150, 450,1150, 450,1150, 450,1100, 450,1150, 450,1150, 450,1150, 450,1100, 450,1150, 450,350, 450,350, 450,350, 450,300, 450,350, 450,350, 450,350, 450,350, 450,1150, 450,1150, 400,1200, 400,1200, 400,1150, 450,1150, 400,1150, 450,1150, 450,350, 450,350, 450,350, 400,400, 400,350, 450,350, 450,350, 450,350, 450,1150, 450,1150, 400,350, 450,400, 400,350, 450,1150, 450,300, 500,1150, 400,400, 400,350, 450,1150, 450,1150, 450,1100, 450,350, 450,1150, 450,350, 450}; // UNKNOWN F288C8C8
// OFF
unsigned int OFF[] = {3200,1650, 400,350, 450,1150, 450,350, 400,400, 400,1200, 400,350, 450,1150, 450,350, 400,400, 450,1150, 450,1100, 450,1150, 450,350, 450,1150, 400,400, 400,1150, 450,1150, 400,1200, 450,350, 400,350, 450,350, 450,350, 450,1150, 450,1100, 500,350, 400,1150, 450,1150, 450,350, 450,350, 450,1150, 400,400, 400,400, 400,1150, 450,350, 400,400, 450,1150, 400,1150, 450,350, 450,1150, 450,1150, 450,1100, 450,1150, 450,1150, 450,1100, 500,1100, 450,1150, 450,1150, 450,1150, 450,350, 400,350, 450,350, 450,350, 450,350, 450,350, 450,350, 450,350, 400,1150, 450,1150, 450,1150, 450,1100, 500,1150, 400,1150, 450,1150, 450,1150, 450,300, 450,350, 450,350, 450,350, 450,350, 400,400, 400,400, 450,350, 400,1200, 400,1150, 450,350, 400,1200, 400,400, 450,1100, 450,350, 450,1150, 450,350, 450,350, 400,1150, 450,350, 450,1150, 450,350, 400,1200, 400,400, 450}; // UNKNOWN 51E87A6C
int khz = 38;

bool willSendOn = false;
bool willSendOff = false;

void sendOn() {
irsend.sendRaw(ON, sizeof(ON)/sizeof(ON[0]), khz);
Serial.print("ON!\n");
}

void sendOff() {
irsend.sendRaw(OFF, sizeof(OFF)/sizeof(OFF[0]), khz);
Serial.print("OFF!\n");
}

class MyCallbacks: public BLECharacteristicCallbacks {
void onWrite(BLECharacteristic *pCharacteristic) {
std::string value = pCharacteristic->getValue();

if (value.length() > 0) {
if (value == "off") willSendOff = true;
else if (value == "on") willSendOn = true;
else {
Serial.println("*********");
Serial.print("New value: ");
for (int i=0; i<value.length(); i++) Serial.print(value[i]);
Serial.println();
Serial.println("*********");
}
}
}
};

void setup()
{
Serial.begin(115200); // Status message will be sent to PC at 9600 baud

BLEDevice::init("ESP32");
BLEServer *pServer = BLEDevice::createServer();
BLEService *pService = pServer->createService(SERVICE_UUID);
BLECharacteristic *pCharacteristic = pService->createCharacteristic(
CHARACTERISTIC_UUID,
BLECharacteristic::PROPERTY_READ |
BLECharacteristic::PROPERTY_WRITE
);
pCharacteristic->setCallbacks(new MyCallbacks());
pCharacteristic->setValue("off");
pService->start();
BLEAdvertising *pAdvertising = pServer->getAdvertising();
pAdvertising->addServiceUUID(SERVICE_UUID);
pAdvertising->start();

irrecv.enableIRIn(); // Start the receiver
}

//+=============================================================================
// Display IR code
//
void ircode (decode_results *results)
{
// Panasonic has an Address
if (results->decode_type == PANASONIC) {
Serial.print(results->address, HEX);
Serial.print(":");
}

// Print Code
Serial.print(results->value, HEX);
}

//+=============================================================================
// Display encoding type
//
void encoding (decode_results *results)
{
switch (results->decode_type) {
default:
case UNKNOWN: Serial.print("UNKNOWN"); break ;
case NEC: Serial.print("NEC"); break ;
case SONY: Serial.print("SONY"); break ;
case RC5: Serial.print("RC5"); break ;
case RC6: Serial.print("RC6"); break ;
case DISH: Serial.print("DISH"); break ;
case SHARP: Serial.print("SHARP"); break ;
case JVC: Serial.print("JVC"); break ;
case SANYO: Serial.print("SANYO"); break ;
case MITSUBISHI: Serial.print("MITSUBISHI"); break ;
case SAMSUNG: Serial.print("SAMSUNG"); break ;
case LG: Serial.print("LG"); break ;
case WHYNTER: Serial.print("WHYNTER"); break ;
case AIWA_RC_T501: Serial.print("AIWA_RC_T501"); break ;
case PANASONIC: Serial.print("PANASONIC"); break ;
case DENON: Serial.print("Denon"); break ;
}
}

//+=============================================================================
// Dump out the decode_results structure.
//
void dumpInfo(decode_results *results)
{
// Check if the buffer overflowed
if (results->overflow) {
Serial.println("IR code too long. Edit IRremoteInt.h and increase RAWBUF");
return;
}

// Show Encoding standard
Serial.print("Encoding : ");
encoding(results);
Serial.println("");

// Show Code & length
Serial.print("Code : ");
ircode(results);
Serial.print(" (");
Serial.print(results->bits, DEC);
Serial.println(" bits)");
}

//+=============================================================================
// Dump out the decode_results structure.
//
void dumpRaw(decode_results *results)
{
// Print Raw data
Serial.print("Timing[");
Serial.print(results->rawlen-1, DEC);
Serial.println("]: ");

for (int i = 1; i < results->rawlen; i++) {
unsigned long x = results->rawbuf[i] * USECPERTICK;
if (!(i & 1)) { // even
Serial.print("-");
if (x < 1000) Serial.print(" ") ;
if (x < 100) Serial.print(" ") ;
Serial.print(x, DEC);
} else { // odd
Serial.print(" ");
Serial.print("+");
if (x < 1000) Serial.print(" ") ;
if (x < 100) Serial.print(" ") ;
Serial.print(x, DEC);
if (i < results->rawlen-1) Serial.print(", "); //',' not needed for last one
}
if (!(i % 8)) Serial.println("");
}
Serial.println(""); // Newline
}

//+=============================================================================
// Dump out the decode_results structure.
//
void dumpCode(decode_results *results)
{
// Start declaration
Serial.print("unsigned int "); // variable type
Serial.print("rawData["); // array name
Serial.print(results->rawlen - 1, DEC); // array size
Serial.print("] = {"); // Start declaration

// Dump data
for (int i = 1; i < results->rawlen; i++) {
Serial.print(results->rawbuf[i] * USECPERTICK, DEC);
if ( i < results->rawlen-1 ) Serial.print(","); // ',' not needed on last one
if (!(i & 1)) Serial.print(" ");
}

// End declaration
Serial.print("};"); //

// Comment
Serial.print(" // ");
encoding(results);
Serial.print(" ");
ircode(results);

// Newline
Serial.println("");

// Now dump "known" codes
if (results->decode_type != UNKNOWN) {

// Some protocols have an address
if (results->decode_type == PANASONIC) {
Serial.print("unsigned int addr = 0x");
Serial.print(results->address, HEX);
Serial.println(";");
}

// All protocols have data
Serial.print("unsigned int data = 0x");
Serial.print(results->value, HEX);
Serial.println(";");
}
}

//+=============================================================================
// The repeating section of the code
//
void loop()
{
if (willSendOff) { sendOff(); willSendOff = false; }
if (willSendOn) { sendOn(); willSendOn = false; }

decode_results results; // Somewhere to store the results
if (irrecv.decode(&results)) { // Grab an IR code
dumpInfo(&results); // Output the results
dumpRaw(&results); // Output the results in RAW format
dumpCode(&results); // Output the results as source code
Serial.println(""); // Blank line between entries
irrecv.resume(); // Prepare for the next value
}
}

なお,上記コード中の sendOn(), sendOff() は,指示着信時点では一旦フラグを立てるだけにして, loop() 内で呼びます.

指示着信時点ですぐに呼ぼうとすると, LED は光りますが なぜか 有効な信号として効きません.

(この手の「込み入った処理はloop()に回さないと動かない」事象は esp を触っていると時々あるのですが,これらの原因を私は深く理解していません.)


Windows 側

Node.js サーバーを立てて noble: A Node.js BLE (Bluetooth Low Energy) central module を用います.

ちょっとした js スクリプトをこれから書いていきます.

node がない場合は choco install node なり公式インストーラーなりで入れてください.

noble の Readme の Windows の項の説明に沿って windows-build-tools と node-bluetooth-hci-socket の prerequisites をセットアップした後, noble をインストールします.

説明に書いてある通り, Windows の仕組みをバイパスするために Zadig tool を使って BLE ドライバーを WinUSB に置き換えるので, Windows 側からはその Blootooth アダプターを(ドライバーを元に戻すまで)使えなくなります.

今回私の使う USB-BT40LE は(案の定)説明書きの Compatible USB Adapters には入っていません.その不安を紛らわすために Windows 7 のnoble でBluetooth LE を使う - Qiita の記事の画面を参照して Driver が同じ BTHUSB であることを一応の勇気の材料にしました.

結果としては,動きました.

私の環境での Zadig の画面は以下のとおりでした.

以下, yarn がない方は代わりに npm を使ってもいいですし choco install yarn してもいいです.

mkdir (適当な新しい作業フォルダ)

cd (その作業フォルダ)
yarn init
yarn add noble

なお, node-bluetooth-hci-socket のインストールが gyp ERR! stack Error: Can't find Python executable "C:\tools\Anaconda3\python.EXE", you can set the PYTHON env variable. といわれて失敗したので, python 2.7 を追加したら通りました.

choco install python2

cd C:\Python27
mv python.exe python2.exe
mv pythonw.exe python2w.exe
yarn config set python C:\Python27\python2
cd (その作業フォルダ)
yarn add node-bluetooth-hci-socket

yarn init した作業フォルダ内にたとえば以下のような index.js を配置して node index.js すると, 5 秒ごとにエアコンが on 信号を受信することになります.

const noble = require('noble');

const SERVICE = "cbdad1637a4a4908a257219b571bb663";
const CHARACTERISTIC = "cfea1bbb7cb34c95a52a2276b74839e5";

noble.on('stateChange', state => {
if (state === 'poweredOn') {
noble.startScanning([SERVICE]);
} else {
noble.stopScanning();
}
});

noble.on('discover', peripheral => {
peripheral.on('disconnect', () => process.exit(0));
peripheral.connect(error => {
console.log("Connected.");
peripheral.discoverServices([SERVICE], (error, services) => {
services[0].discoverCharacteristics([CHARACTERISTIC], (error, characteristics) => {
const characteristic = characteristics[0];
setInterval(() => characteristic.write(Buffer.from("on"), true), 5000);
});
});
});
});

process.on('uncaughtException', err => {
if (err == "Error: LIBUSB_TRANSFER_STALL") console.log("LIBUSB_TRANSFER_STALL");
else throw err;
});

ここで最後の 4 行ですが,謎のエラー LIBUSB_TRANSFER_STALL が頻繁に発生し,これの原因がどうしてもわからなかったので

黒魔術・キャッチして握り潰し を行っています.(この黒魔術,噂には聞いていましたが初めて使いました…….)


Slack との通信

あまり ESP32 上で込み入ったことをしたくないので, Slack との通信は Windows に担当させることにします(ふつうは Raspberry Pi でやるところかと思います).

Slack 公式から node-slack-sdk が出ているので,これを使用します.

これまでと同じ作業フォルダ上で yarn add @slack/client します.

カジュアルに rtm.start() したいので Legacy token を入手します.

https://slack.com/intl/ja-jp/signin

で所望の Workspace に適当にログインした後,

https://api.slack.com/custom-integrations/legacy-tokens

で所望の Workspace に対応する Legacy token を issue します.(取り扱い注意です.)

形式は xoxp- で始まるはずです.

Windows 側 (index.js) の最終的なコードは以下のとおりです(汚くてごめんなさい). TOKEN は自分のものに書き換えてください.

// index.js for ir_remote_ble

// Written by cexen 2017-12-06

const noble = require('noble');
const RtmClient = require('@slack/client').RtmClient;
const CLIENT_EVENTS = require('@slack/client').CLIENT_EVENTS;
const RTM_EVENTS = require('@slack/client').RTM_EVENTS;

const SERVICE = "cbdad1637a4a4908a257219b571bb663";
const CHARACTERISTIC = "cfea1bbb7cb34c95a52a2276b74839e5";
const TOKEN = "xoxp-*****************";

const State = {
rtm: null,
characteristic: null,
channel: null,
};

// Slack
const rtm = new RtmClient(TOKEN);
rtm.on(CLIENT_EVENTS.RTM.AUTHENTICATED, rtmStartData => {
for (const c of rtmStartData.channels) {
if (c.is_member && c.name ==='random') State.channel = c.id;
}
console.log(`Logged in as ${rtmStartData.self.name} of team ${rtmStartData.team.name}, but not yet connected to a channel`);
});
rtm.on(CLIENT_EVENTS.RTM.RTM_CONNECTION_OPENED, function () {
Const.rtm = rtm;
rtm.sendMessage("BOSS-OF-ESP32 is on :slightly_smiling_face:", Const.channel);
});
rtm.on(RTM_EVENTS.MESSAGE, message => {
if (message.channel === Const.channel && Const.characteristic !== null) {
if (/^エアコン(?:つ|点)けて$/.test(message.text)) {
Const.characteristic.write(Buffer.from("on"), true);
rtm.sendMessage("つけました :+1:", Const.channel);
console.log("Sent on");
}
if (/^エアコン(?:け|消)して$/.test(message.text)) {
Const.characteristic.write(Buffer.from("off"), true);
rtm.sendMessage("消しました :+1:", Const.channel);
console.log("sent off");
}
}
console.log('Message: ', message);
});
rtm.start();

// BLE
noble.on('stateChange', state => {
if (state === 'poweredOn') {
noble.startScanning([SERVICE]);
} else {
noble.stopScanning();
}
});
noble.on('discover', peripheral => {
peripheral.on('disconnect', () => process.exit(0));
peripheral.connect(error => {
console.log("Connected.");
peripheral.discoverServices([SERVICE], (error, services) => {
services[0].discoverCharacteristics([CHARACTERISTIC], (error, characteristics) => {
Const.characteristic = characteristics[0];
});
});
});
});

// 黒魔術:例外の握り潰し
process.on('uncaughtException', err => {
if (err == "Error: LIBUSB_TRANSFER_STALL") console.log("LIBUSB_TRANSFER_STALL");
else throw err;
});

node index.js すればサーバーが立ち上がります.

Slack の #random チャンネルを監視して,


  • メッセージが /^エアコン(?:つ|点)けて$/ なら on を送信

  • メッセージが /^エアコン(?:け|消)して$/ なら off を送信

します.

Bot の仕組みを使わずに Legacy Token を使っているので,自問自答の様相になりますが,機能に問題はありません.

Bot をつかえばかわいい女の子に自宅のお世話をしてもらえるのでベターかもしれませんね.


まとめ

これで,帰宅時に温かい部屋に出迎えてもらうことができるようになりました.

残った課題としては,


  • Node サーバー上または ESP32 上でリモコン信号を動的に記録し書き換える(ハードコーディングをやめる)

  • 信号のフォーマットを理解して温度を制御する

  • 信号の送達確認としてエアコンのピ音をマイクで拾う

といったところでしょうか.


最後に

この記事を通して,狭義の Arduino や Raspberry Pi だけではない IoT 遊びの存在を知っていただければ幸いです.


なんで Windows なの?

物理工学科では Windows が主流だからです.(過言)

実際には Mac でも Linux でもほとんど同様の方針で同じことができます.

巷の記事は Unix 系前提で書かれていることが比較的多いと思うので,あえて Windows 前提で書いてみました.


U-TOKYO AP Advent Calendar 2017 について

東京大学工学部の応用物理系(計数工学科,物理工学科)および工学系等関連研究科の有志各位によるアドベントカレンダーです.

企画の @snowhork さんありがとうございます.

「おそらく理論寄りな話が多くなる」という記述に震えながら実装のことを書きました.許してください.