はじめに
ESP32は、安価なWiFi/Bluetoothマイコンです。
低価格ながら、各種I/O、十分な性能、充実したライブラリを持ち、おうちハックには欠かせない存在といえるでしょう。
今回は、赤外線送受信のハードウェア支援機構とHTTPパーサライブラリを使って、Web APIで操作できる赤外線学習リモコンを実装します。
やること
- ESP32 上で動く HTTP サーバの実装
- HW 制御による赤外線送受信
- 学習リモコン制作
- Google Home と連動した制御 (別途記事を投稿予定です)
部品
すべて秋月で入手できます。
- ESP32 DevKitC http://akizukidenshi.com/catalog/g/gM-11819/
- 赤外線リモコン受信モジュール OSRB38C9AA http://akizukidenshi.com/catalog/g/gI-04659/
- 5mm赤外線LED OSI5FU5111C-40 http://akizukidenshi.com/catalog/g/gI-03261/
- LED光拡散キャップ 5mm http://akizukidenshi.com/catalog/g/gI-00641/
- 100Ω抵抗 http://akizukidenshi.com/catalog/g/gR-25101/
- ブレッドボード http://akizukidenshi.com/catalog/g/gP-05294/
- ジャンパ http://akizukidenshi.com/catalog/g/gP-00288/
配線
赤外線LEDはIO33, 赤外線受信モジュールの信号ピンはIO35に接続します。1
LEDへの電流を調整するために100Ωの抵抗を入れます。2
ESP32 WiFi のテスト
まずはじめに環境構築ですが、 今回はArduino IDEを用いることとします。ESP32にはArduino環境が移植されているため、Arduino IDEで開発・書き込みができます。
具体的な手順はOS毎に異なるので、 https://github.com/espressif/arduino-esp32#installation-instructions を参照してください。
ESP32のライブラリは日々更新されているため、古いバージョンではコンパイルが通らないことがあります。今回のコードは記事執筆日(2017/12/16)時点の最新版で動作確認しています。
さて、まずは WiFi への接続を試してみましょう。
#include <WiFi.h>
// 注意: ESP32は802.11/bgのみ対応
#define SSID "<おうちWiFiのSSID>"
#define PASSWORD "<おうちWiFiのパスワード>"
void setup() {
Serial.begin(115200);
Serial.print("start WiFi");
WiFi.begin(SSID, PASSWORD);
while (WiFi.status() != WL_CONNECTED) {
Serial.print(".");
delay(500);
}
Serial.println("");
Serial.println("connected");
Serial.print("IP: ");
Serial.println(WiFi.localIP());
}
void loop() {
}
上記コードを書き込んでください。Arduino IDE のシリアルモニタで IP アドレスが表示されれば、WiFi へ接続されたことが確認できます。
ESP32 はそれなりの電力を消費するため、質の悪い USB ケーブルでは動作しないことがあります。"start WiFi"の後で止まってしまう場合は、ケーブルを変えて試してみてください。
HTTP リクエストの処理
次に、HTTPリクエストの処理を実装しましょう。
ESP32のライブラリには、nodejsで使われているhttp_parserが同梱されています。簡単な使い方としては、以下をおさえておけば十分でしょう。
- http_parser 型のパーサと、http_parser_settings 型のコールバックを設定
- http_parser_execute に、クライアントから受け取ったリクエストを順次渡す
- parser.http_errno にエラーがないことを確認
より詳しい使い方を知りたい方は、ソースコードを確認してください。
- https://github.com/nodejs/http-parser
- https://github.com/nodejs/http-parser/blob/master/http_parser.h
それでは http_parser.hをインクルードして HTTP の処理を実装してみましょう。
#include <WiFi.h>
#include "http_parser.h"
#define SSID "<おうちのSSID>"
#define PASSWORD "<おうちのパスワード>"
// 後に使うコールバックの宣言
char url[128];
int on_url(http_parser *http_parser, const char *buf, size_t len);
char body[4096];
size_t bodylen = 0;
int on_body(http_parser *http_parser, const char *buf, size_t len);
bool request_end = false;
int on_message_complete(http_parser *http_parser);
int on_chunk_complete(http_parser *http_parser);
// 80番ポートで待ち受け
WiFiServer server(80);
void setup() {
Serial.begin(115200);
WiFi.begin(SSID, PASSWORD);
while (WiFi.status() != WL_CONNECTED) {
Serial.print(".");
delay(500);
}
Serial.println("");
Serial.print("IP: ");
Serial.println(WiFi.localIP());
// 80番ポートの待受を開始
server.begin();
}
void loop() {
char buf[1024];
bool error = false;
http_parser parser;
http_parser_settings settings;
// パーサとコールバックを初期化
http_parser_init(&parser, HTTP_REQUEST);
http_parser_settings_init(&settings);
// コールバックを設定(定義は後述)
settings.on_url = on_url;
settings.on_body = on_body;
settings.on_message_complete = on_message_complete;
settings.on_chunk_complete = on_chunk_complete;
WiFiClient client = server.available();
if (!client) { return; }
while (client.connected()) {
if (client.available()) {
// バッファに入る分だけのリクエストを受け取り
size_t nread = client.readBytes(buf, sizeof(buf));
// パーサに渡す
size_t nparsed = http_parser_execute(&parser, &settings, buf, nread);
// エラーがあれば中断
if (nread != nparsed || parser.http_errno != HPE_OK) {
error = true;
break;
}
// リクエストが完了したら中断 (コールバック内で true を設定する)
if (request_end) {
break;
}
}
}
// リクエストの処理が完了。URLを判定する
if (!request_end || error) {
// リクエストの処理に失敗したので、400 Bad Requestを返す
client.print("HTTP/1.1 400 Bad Request\r\nContent-Length: 0\r\n\r\n");
} else if (strcmp("/hello", url) == 0) {
// Hello World を返す
char *body = "hello, world";
char response[128];
sprintf(response, "HTTP/1.1 200 OK\r\nContent-Length: %d\r\n\r\n%s", strlen(body), body);
client.print(response);
} else {
// 404 Not Found
client.print("HTTP/1.1 404 Not Found\r\nContent-Length: 0\r\n\r\n");
}
}
// 以下コールバックの定義
//
// http_parser_execute に渡すバッファ長が有限のため、URLやbodyは細切れになる事がある
// 例 url が /hello の場合
// * on_url 1回目: buf: "/hel", len: 4
// * on_url 2回目: buf: "lo", len: 2
// そのため、同じコールバックが複数回呼ばれた場合は順次つなげていく。
//
// また、コールバックで 0 以外の値を返すことで http_parser_execute をエラー終了にできる。
// 今回は url や body を保存する固定長のバッファが溢れたら 1 を返すようにする
int on_url(http_parser *http_parser, const char *buf, size_t len) {
if (sizeof(url) <= strlen(url) + len) {
Serial.println("URL too long");
return 1;
}
strncat(url, buf, len);
return 0;
}
int on_body(http_parser *http_parser, const char *buf, size_t len) {
if (sizeof(body) < bodylen + len) {
Serial.println("Body too long");
return 1;
}
memcpy(body + bodylen, buf, len);
bodylen += len;
return 0;
}
// リクエストが最後まで届いたか確認
int on_message_complete(http_parser *http_parser) {
request_end = true;
}
int on_chunk_complete(http_parser *http_parser) {
request_end = true;
}
シリアルモニタで ESP32 に割り当てられた IP を確認して、手元のブラウザで http://<確認したIP>/hello にアクセスします。
hello, world の文字が表示されれば成功です。
赤外線送受信
最後に、赤外線学習機能を組み込みましょう。ESP32 の赤外線送受信機能は driver/rmt.h に定義されています。
赤外線の送受信は時間がかかるため、非同期に実行します。今回は以下の点を把握しておけば大丈夫です。
- xTaskCreate で非同期タスクを開始
- 非同期タスクは void task(void *arg) の型を持つ
- 非同期タスク終了時は vTaskDelete を呼ぶ
- 送受信中に更に送受信リクエストが来たときにエラーを返すため、変数 ir_use に実行状況を保存しておく
赤外線を操作するため、以下の Web API を用意します
- /recv 赤外線の読み取りを非同期で開始します
- /dump 読み取った赤外線信号を返します
- /send 送られてきた赤外線信号を再生します
長くなってきたので、赤外線の送受信に関わるところだけ記載します。
// 非同期呼び出しにするため FreeRTOS のヘッダも読み込む
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "driver/rmt.h"
// 全8チャンネルある赤外線送信HWのうち、受信には 0 ~ 3、送信には 4 ~ 7 を使う
#define RMT_RX_CHANNEL RMT_CHANNEL_0
#define RMT_TX_CHANNEL RMT_CHANNEL_4
// 赤外線受信センサを接続する端子は IO35, 送信用 LED は IO33
#define RMT_RX_GPIO_NUM GPIO_NUM_35
#define RMT_TX_GPIO_NUM GPIO_NUM_33
// 赤外線信号を保存するバッファ
// 信号データは、例えば "0 が 100us、1 が 200us" のような、信号レベルと持続時間のペアになります。
// 具体的には、https://github.com/espressif/esp-idf/blob/master/components/soc/esp32/include/soc/rmt_struct.h で以下のように定義されています。
/*
typedef struct {
union {
struct {
uint32_t duration0 :15;
uint32_t level0 :1;
uint32_t duration1 :15;
uint32_t level1 :1;
};
uint32_t val;
};
} rmt_item32_t;
*/
#define MAX_SIGNAL_LEN 1024
rmt_item32_t signals[MAX_SIGNAL_LEN];
#define RMT_CLK_DIV 100
#define RMT_TICK_10_US (240000000/RMT_CLK_DIV/100000)
#define rmt_item32_TIMEOUT_US 10000
// 送受信ハードウェアの初期化 setup() 内で呼ぶ
void init_rx() {
rmt_config_t rmt_rx;
rmt_rx.rmt_mode = RMT_MODE_RX;
rmt_rx.channel = RMT_RX_CHANNEL;
rmt_rx.clk_div = RMT_CLK_DIV;
rmt_rx.gpio_num = RMT_RX_GPIO_NUM;
rmt_rx.mem_block_num = 4;
rmt_rx.rx_config.filter_en = true;
rmt_rx.rx_config.filter_ticks_thresh = 100;
rmt_rx.rx_config.idle_threshold = rmt_item32_TIMEOUT_US / 10 * (RMT_TICK_10_US);
rmt_config(&rmt_rx);
rmt_driver_install(rmt_rx.channel, 1000, 0);
}
void init_tx() {
rmt_config_t rmt_tx;
rmt_tx.rmt_mode = RMT_MODE_TX;
rmt_tx.channel = RMT_TX_CHANNEL;
rmt_tx.gpio_num = RMT_TX_GPIO_NUM;
rmt_tx.mem_block_num = 4;
rmt_tx.clk_div = RMT_CLK_DIV;
rmt_tx.tx_config.loop_en = false;
rmt_tx.tx_config.carrier_duty_percent = 50;
rmt_tx.tx_config.carrier_freq_hz = 38000;
rmt_tx.tx_config.carrier_level = RMT_CARRIER_LEVEL_HIGH;
rmt_tx.tx_config.carrier_en = 1;
rmt_tx.tx_config.idle_level = RMT_IDLE_LEVEL_LOW;
rmt_tx.tx_config.idle_output_en = true;
rmt_config(&rmt_tx);
rmt_driver_install(rmt_tx.channel, 0, 0);
}
// 赤外線送受信機能を使用中かどうか記録しておく
bool ir_use = false;
// 受信した信号の長さ
size_t received = 0;
void rmt_tx_task(void *) {
Serial.println("send...");
// 送信バッファに書き込んで
rmt_write_items(RMT_TX_CHANNEL, signals, received, true);
// 送信完了まで待つ
rmt_wait_tx_done(RMT_TX_CHANNEL, portMAX_DELAY);
Serial.println("send done");
ir_use = false;
vTaskDelete(NULL);
}
void rmt_rx_task(void *) {
// 受信チャンネルに紐付いているバッファを取得
// ここに受信した赤外線信号のデータが溜まる
RingbufHandle_t rb = NULL;
rmt_get_ringbuf_handle(RMT_RX_CHANNEL, &rb);
// 受信開始
rmt_rx_start(RMT_RX_CHANNEL, 1);
size_t rx_size = 0;
Serial.println("wait ir signal...");
// 3000ms 受信を待つ
rmt_item32_t *item = (rmt_item32_t*)xRingbufferReceive(rb, &rx_size, 3000);
rmt_rx_stop(RMT_RX_CHANNEL);
if(!item) {
// 3000ms 待ってもデータがなければ終了
Serial.println("no data received");
ir_use = false;
vTaskDelete(NULL);
return;
}
Serial.print("received items: ");
Serial.println(rx_size);
// バッファに結果をコピー
memcpy(signals, item, sizeof(rmt_item32_t) * rx_size);
// 今回使っているセンサは Active HIGH なので、送信に備えて信号レベルを反転させておく
for (int i = 0; i < rx_size; ++i) {
signals[i].level0 = ~signals[i].level0;
signals[i].level1 = ~signals[i].level1;
}
received = rx_size;
vRingbufferReturnItem(rb, (void*)item);
Serial.println("recv done");
rmt_rx_stop(RMT_RX_CHANNEL);
ir_use = false;
vTaskDelete(NULL);
}
// よく使うレスポンスを定義
void send_202(WiFiClient &client) {
client.print("HTTP/1.1 202 Accepted\r\n");
client.print("Connection: close\r\n");
client.print("Content-Length: 0\r\n\r\n");
}
void send_400(WiFiClient &client) {
client.print("HTTP/1.1 400 Bad Request\r\n");
client.print("Connection: close\r\n");
client.print("Content-Length: 0\r\n\r\n");
}
void send_404(WiFiClient &client) {
client.print("HTTP/1.1 404 Not Found\r\n");
client.print("Connection: close\r\n");
client.print("Content-Length: 0\r\n\r\n");
}
void send_409(WiFiClient &client) {
client.print("HTTP/1.1 409 Conflict\r\n");
client.print("Connection: close\r\n");
client.print("Content-Length: 0\r\n\r\n");
}
void setup() {
init_rx();
init_tx();
}
void loop() {
/* 前回までの Web サーバのコードは省略。urlの判定部分から記載 */
if (!request_end || error) {
send_400(client);
} else if (strcmp(url, "/dump") == 0) {
// /dump にアクセスが来たら、受信した赤外線信号をそのまま返却
if (ir_use) {
send_409();
} else {
client.print("HTTP/1.1 200 OK\r\nContent-Type: octet-stream\r\nContent-Length: ");
client.print(sizeof(rmt_item32_t) * received);
client.print("\r\n\r\n");
client.write((uint8_t*)signals, sizeof(rmt_item32_t) * received);
}
} else if (strcmp(url, "/send") == 0) {
// /send にアクセスが来たら、body を赤外線バッファにセットして送信
if (ir_use) {
send_409(client);
} else {
received = bodylen / sizeof(rmt_item32_t);
memcpy(signals, body, bodylen);
ir_use = true;
xTaskCreate(rmt_tx_task, "rmt_tx_task", 2048, NULL, 10, NULL);
send_202(client);
}
} else if (strcmp(url, "/recv") == 0) {
// recv にアクセスが来たら、赤外線受信を実行
if (ir_use) {
send_409(client);
} else {
ir_use = true;
xTaskCreate(rmt_rx_task, "rmt_rx_task", 2048, NULL, 10, NULL);
send_202(client);
}
} else {
send_404(client);
}
}
赤外線制御の API をより詳しく知りたい方は https://esp-idf.readthedocs.io/en/latest/api-reference/peripherals/rmt.html を参照してください。
これでようやく赤外線送受信の実装が完了しました。
ソースコード(最終形)
最後に、全ソースコードを掲載しておきます。繰り返しになりますが、使う際は SSID と PASSWORD を忘れずに修正してください。
#include <WiFi.h>
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "driver/rmt.h"
#include "http_parser.h"
#define SSID "<SSID>"
#define PASSWORD "<PASSWORD>"
#define RMT_TX_CHANNEL RMT_CHANNEL_4
#define RMT_TX_GPIO_NUM GPIO_NUM_33
#define RMT_RX_CHANNEL RMT_CHANNEL_0
#define RMT_RX_GPIO_NUM GPIO_NUM_35
#define RMT_CLK_DIV 100
#define RMT_TICK_10_US (80000000/RMT_CLK_DIV/100000)
#define rmt_item32_TIMEOUT_US 10000
#define MAX_SIGNAL_LEN 1024
void process();
WiFiServer server(80);
void setup() {
Serial.begin(115200);
WiFi.begin(SSID, PASSWORD);
while (WiFi.status() != WL_CONNECTED) {
Serial.print(".");
delay(500);
}
Serial.println("");
Serial.print("IP: ");
Serial.println(WiFi.localIP());
server.begin();
init_tx();
init_rx();
}
void loop(){
process();
}
/* ir */
bool ir_use = false;
size_t received = 0;
rmt_item32_t signals[MAX_SIGNAL_LEN];
void init_tx() {
rmt_config_t rmt_tx;
rmt_tx.rmt_mode = RMT_MODE_TX;
rmt_tx.channel = RMT_TX_CHANNEL;
rmt_tx.gpio_num = RMT_TX_GPIO_NUM;
rmt_tx.mem_block_num = 4;
rmt_tx.clk_div = RMT_CLK_DIV;
rmt_tx.tx_config.loop_en = false;
rmt_tx.tx_config.carrier_duty_percent = 50;
rmt_tx.tx_config.carrier_freq_hz = 38000;
rmt_tx.tx_config.carrier_level = RMT_CARRIER_LEVEL_HIGH;
rmt_tx.tx_config.carrier_en = 1;
rmt_tx.tx_config.idle_level = RMT_IDLE_LEVEL_LOW;
rmt_tx.tx_config.idle_output_en = true;
rmt_config(&rmt_tx);
rmt_driver_install(rmt_tx.channel, 0, 0);
}
void init_rx() {
rmt_config_t rmt_rx;
rmt_rx.rmt_mode = RMT_MODE_RX;
rmt_rx.channel = RMT_RX_CHANNEL;
rmt_rx.clk_div = RMT_CLK_DIV;
rmt_rx.gpio_num = RMT_RX_GPIO_NUM;
rmt_rx.mem_block_num = 4;
rmt_rx.rx_config.filter_en = true;
rmt_rx.rx_config.filter_ticks_thresh = 100;
rmt_rx.rx_config.idle_threshold = rmt_item32_TIMEOUT_US / 10 * (RMT_TICK_10_US);
rmt_config(&rmt_rx);
rmt_driver_install(rmt_rx.channel, 1000, 0);
}
void rmt_tx_task(void *) {
Serial.println("send...");
rmt_write_items(RMT_TX_CHANNEL, signals, received, true);
rmt_wait_tx_done(RMT_TX_CHANNEL, portMAX_DELAY);
Serial.println("send done");
ir_use = false;
vTaskDelete(NULL);
}
void rmt_rx_task(void *) {
RingbufHandle_t rb = NULL;
rmt_get_ringbuf_handle(RMT_RX_CHANNEL, &rb);
rmt_rx_start(RMT_RX_CHANNEL, 1);
size_t rx_size = 0;
Serial.println("wait ir signal...");
rmt_item32_t *item = (rmt_item32_t*)xRingbufferReceive(rb, &rx_size, 3000);
rmt_rx_stop(RMT_RX_CHANNEL);
if(!item) {
Serial.println("no data received");
ir_use = false;
vTaskDelete(NULL);
return;
}
Serial.print("received items: ");
Serial.println(rx_size);
memcpy(signals, item, sizeof(rmt_item32_t) * rx_size);
for (int i = 0; i < rx_size; ++i) {
signals[i].level0 = ~signals[i].level0;
signals[i].level1 = ~signals[i].level1;
}
received = rx_size;
vRingbufferReturnItem(rb, (void*)item);
Serial.println("recv done");
rmt_rx_stop(RMT_RX_CHANNEL);
ir_use = false;
vTaskDelete(NULL);
}
/* http */
void send_202(WiFiClient &client) {
client.print("HTTP/1.1 202 Accepted\r\n");
client.print("Connection: close\r\n");
client.print("Content-Length: 0\r\n\r\n");
}
void send_400(WiFiClient &client) {
client.print("HTTP/1.1 400 Bad Request\r\n");
client.print("Connection: close\r\n");
client.print("Content-Length: 0\r\n\r\n");
}
void send_404(WiFiClient &client) {
client.print("HTTP/1.1 404 Not Found\r\n");
client.print("Connection: close\r\n");
client.print("Content-Length: 0\r\n\r\n");
}
void send_409(WiFiClient &client) {
client.print("HTTP/1.1 409 Conflict\r\n");
client.print("Connection: close\r\n");
client.print("Content-Length: 0\r\n\r\n");
}
char url[128];
int on_url(http_parser *http_parser, const char *buf, size_t len) {
if (sizeof(url) <= strlen(url) + len) {
Serial.println("URL too long");
return 1;
}
strncat(url, buf, len);
return 0;
}
char body[sizeof(signals)];
size_t bodylen = 0;
int on_body(http_parser *http_parser, const char *buf, size_t len) {
if (sizeof(body) < bodylen + len) {
Serial.println("Body too long");
return 1;
}
memcpy(body + bodylen, buf, len);
bodylen += len;
return 0;
}
bool request_end = false;
int on_message_complete(http_parser *http_parser) {
request_end = true;
}
int on_chunk_complete(http_parser *http_parser) {
request_end = true;
}
void process() {
char buf[1024];
http_parser parser;
http_parser_settings settings;
WiFiClient client = server.available();
bool error = false;
memset(url, 0, sizeof(url));
request_end = false;
bodylen = 0;
http_parser_init(&parser, HTTP_REQUEST);
http_parser_settings_init(&settings);
settings.on_url = on_url;
settings.on_body = on_body;
settings.on_message_complete = on_message_complete;
settings.on_chunk_complete = on_chunk_complete;
if (!client) { return; }
while (client.connected()) {
if (client.available()) {
size_t nread = client.readBytes(buf, sizeof(buf));
size_t nparsed = http_parser_execute(&parser, &settings, buf, nread);
if (nread != nparsed || parser.http_errno != HPE_OK) {
error = true;
break;
}
if (request_end) {
break;
}
}
}
if (!request_end || error) {
send_400(client);
} else if (strcmp(url, "/dump") == 0) {
client.print("HTTP/1.1 200 OK\r\nContent-Type: octet-stream\r\nContent-Length: ");
client.print(sizeof(rmt_item32_t) * received);
client.print("\r\n\r\n");
client.write((uint8_t*)signals, sizeof(rmt_item32_t) * received);
} else if (strcmp(url, "/send") == 0) {
received = bodylen / sizeof(rmt_item32_t);
memcpy(signals, body, bodylen);
if (ir_use) {
send_409(client);
} else {
ir_use = true;
xTaskCreate(rmt_tx_task, "rmt_tx_task", 2048, NULL, 10, NULL);
send_202(client);
}
} else if (strcmp(url, "/recv") == 0) {
if (ir_use) {
send_409(client);
} else {
ir_use = true;
xTaskCreate(rmt_rx_task, "rmt_rx_task", 2048, NULL, 10, NULL);
send_202(client);
}
} else {
send_404(client);
}
client.flush();
client.stop();
}
改めて使い方を再掲しておくと
- /recv で受信モードに入る(3000msの間受信を待つ)
- /dump で受信した信号を取得
- /send に信号を送ることで送信
以下のようなコマンドで送受信できます。
// 受信開始
curl http://<ESP32のIP>/recv
// データ取得
curl http://<ESP32のIP/dump
// データ送信
curl http://<ESP32のIP/send --data-binary @dump
動作検証
一般に、信号が長く、学習リモコンでは対応できない場合があるとされるエアコンで検証してみました。
東芝、シャープのエアコンは各操作問題なく動くことを確認しました。ダイキン製エアコンは、信号の学習こと上手くいったように見えますが、データを送信しても反応しませんでした。
また、テレビ(東芝REGZA)は問題なく操作することができました。
まとめ
- ESP32 を使って、赤外線学習リモコンを実装しました
- nodejs 由来の http パーサを導入して Web API を実装しました
- 赤外線送受信を非同期で実装しました
- 学習結果
- テレビ(東芝製)の信号は学習できました
- エアコンは、東芝、シャープの機種は操作できました。ダイソン製は反応しませんでした
次回記事では、この赤外線学習リモコンを Google Home と連動させて、おうちの家電を音声操作できるようにします。