ESP32の非同期WebサーバーとWebSocket:リアルタイムの操作パネル
ESP32の非同期WebサーバーとWebSocketで、ページを再読み込みせずにリレーを操作し、センサーの値をリアルタイムで確認します。セットアップ、PROGMEMとLittleFS、esp32.local、パスワード、よくあるエラーの対処法を紹介します。
ESPAsyncWebServerライブラリで作るESP32の非同期Webサーバーは、ESP32を、複数のスマホやパソコンに同時に応答できる高速なWebサーバーに変えます。ライブラリに含まれるWebSocketの機能を使うと、ブラウザとボードの間に常に開いた回線ができます。ページのボタンを押すとすぐにリレーがカチッと動き、温度が変わるとページの数字が再読み込みなしで自動的に更新されます。同じパネルを開いているほかの端末にも、変更は同じ1秒のうちに反映されます。つまりこの組み合わせは、クラウドサービスを使わずに、家のネットワークの中で自分だけのスマートホームのパネルを作る、最も実用的な方法です。
このガイドでは、まず非同期サーバーが従来のWebServerライブラリとどう違うのか、WebSocketがふつうのHTTPリクエストより速いのはなぜかを説明します。続いて必要なライブラリをインストールし、いちばんシンプルなサーバーを動かしてから、リレーとDHT22センサーを使ったリアルタイムの操作パネルを作ります。そのあと、HTMLをPROGMEMやLittleFSに置く方法、IPアドレスの代わりにesp32.localを使う方法、複数のクライアントの扱い方、パネルに簡単なパスワードをかける方法、よくあるコンパイルエラーや接続エラーの対処法を学びます。コードは、Arduino IDEのESP32コアの2.xと3.xのどちらでも動くように書いています。

ESP32の非同期Webサーバーとは?その仕組みは?
ESP32コアに付属するWebServer.hライブラリは同期型です。届いたリクエストは、loop()の中でserver.handleClient()が呼ばれたときに、1つずつ順番に処理されるだけです。loop()に2秒のdelay()があればページも2秒待たされ、2人が同時に接続すると、片方はもう片方の処理が終わるまで待ちます。一方、ESPAsyncWebServerは、AsyncTCPライブラリが作る別のFreeRTOSタスク(async_tcp)で動きます。リクエストが届くとバックグラウンドでヘッダーが解析され、リクエストの準備ができた時点で自分で書いた関数(コールバック)が呼ばれ、レスポンスもバックグラウンドで送られます。その間もloop()は自分の処理を続けます。そのため複数の接続を同時に開いておくことができ、WebSocketやServer-Sent Events(SSE)のような常時接続も使えるようになります。ESP32の2つのコアとタスクについて知りたい場合は、「ESP32のFreeRTOS」の記事をご覧ください。
| 特徴 | WebServer.h(同期型) | ESPAsyncWebServer(非同期型) |
|---|---|---|
| インストール | ESP32コアに付属 | ライブラリマネージャから2つのライブラリ |
| リクエストの処理 | loop()の中のhandleClient()で1つずつ | バックグラウンドのasync_tcpタスクで、複数を同時に |
| loop()の中のdelay() | ページの応答が遅れる | サーバーには影響しない |
| WebSocket | なし。別のライブラリが必要(例:Markus Sattler氏のWebSocketsライブラリ) | 内蔵(AsyncWebSocket) |
| Server-Sent Events(SSE) | なし | 内蔵(AsyncEventSource) |
| 静的ファイルの配信 | serveStaticで配信 | serveStatic、.gzの自動対応、キャッシュの制御 |
| 注意点 | いちばん覚えやすい | コールバックの中でdelay()や時間のかかる処理をしない |
WebSocketとは?HTTPリクエストとの違いは?
ふつうのHTTPリクエストでは、ブラウザが接続を開き、「このページをください」と頼み、レスポンスを受け取ったところでやり取りが終わります。サーバーのほうから自発的にブラウザに何かを伝えることはできず、新しい値を見るにはブラウザがもう一度尋ねる必要があります。WebSocket(RFC 6455)は、HTTPとして始まるハンドシェイクです。ブラウザがリクエストにUpgrade: websocketヘッダーを付け、ESP32が101 Switching Protocolsのレスポンスで受け入れると、同じTCP接続が開いたままになります。そこから先は、どちらの側も好きなときにメッセージを送れます(全二重)。メッセージ1つあたりの付加データはわずか2〜14バイトですが、HTTPリクエストは毎回数百バイトのヘッダーを運びます。そのためWebSocketのメッセージは数ミリ秒で届き、1秒に何回も更新を送るパネルには最も効率のよい方法です。ページをセンサーの値で最新に保つ4つの方法を比べると、次のようになります。
| 方法 | 仕組み | 遅延と負荷 | 向いている場面 |
|---|---|---|---|
| ページの再読み込み(meta refresh) | ブラウザが数秒ごとにページ全体をダウンロードし直す | 大きい。毎回ページがちらつく | 避けたほうがよい |
| fetchによるポーリング | JavaScriptが1〜2秒ごとに小さなJSONを要求する | 中程度。リクエストのたびに新しいヘッダーが付く | めったに変わらない1つの値 |
| Server-Sent Events(SSE) | サーバーが開いた接続で一方向のイベントを送る | 小さい。サーバーからブラウザへの一方向のみ | 監視専用のダッシュボード |
| WebSocket | 開いた接続での双方向のメッセージのやり取り | 最も小さい。数バイトのフレーム | ボタン、スライダー、リアルタイムのデータを組み合わせる場合 |
必要なライブラリ:ESPAsyncWebServerとAsyncTCPのインストール
- Arduino IDEにESP32のボードパッケージ(Espressif Systems)がインストールされていることを確認します。手順は「ESP32とは?」の記事にあります。
- ライブラリマネージャ(Ctrl+Shift+I)を開いてESPAsyncWebServerを検索します。作者がESP32AsyncのESP Async WebServerライブラリをインストールします。
- このライブラリは依存ライブラリをライブラリマネージャに登録していないので、IDEは追加のライブラリを提案しません。同じくESP32Asyncが公開しているAsync TCPライブラリを別にインストールしてください。ESP8266の場合は、代わりにESP32AsyncのESP Async TCPライブラリ(ESPAsyncTCP.h)が必要です。
- パネルの例のために、AdafruitのDHT sensor libraryと、それが求める依存ライブラリAdafruit Unified Sensorをインストールします。ライブラリのインストールが初めての場合は、「Arduinoにライブラリを追加する方法」のガイドをご覧ください。
- ドキュメント/Arduino/librariesフォルダに、以前ZIPで追加したESPAsyncWebServer-master、AsyncTCP-master、ESPAsyncWebSrvのフォルダがあれば削除します。同じライブラリが2つあると、コンパイラが間違ったほうを選んでしまいます。
最初のステップ:AsyncWebServerでいちばんシンプルなサーバー
パネルに進む前に、小さな例でライブラリの仕組みを見てみましょう。下のコードはESP32を家のネットワークにつなぎ、ホームページにあいさつを表示し、/led?durum=1と/led?durum=0のアドレスで内蔵LEDを点灯・消灯します(durumはトルコ語で「状態」)。書き込んだらシリアルモニタを115200ボーで開き、表示されたアドレスを同じWi-Fiにつないだスマホのブラウザに入力します。ボードがネットワークにつながらない場合は、「ESP32がWi-Fiにつながらないとき」のガイドにある診断用のコードを使ってください。
1#include <WiFi.h>
2#include <AsyncTCP.h>
3#include <ESPAsyncWebServer.h>
4
5const char* ssid = "AgAdi"; // Wi-Fi adın / your Wi-Fi name
6const char* password = "Sifre123"; // Wi-Fi şifren / your Wi-Fi password
7const int LED_PIN = 2; // çoğu DevKit'te dahili LED / onboard LED on most DevKits
8
9AsyncWebServer server(80); // 80 = standart HTTP portu / standard HTTP port
10bool ledAcik = false;
11
12void setup() {
13 Serial.begin(115200);
14 pinMode(LED_PIN, OUTPUT);
15
16 WiFi.mode(WIFI_STA);
17 WiFi.begin(ssid, password);
18 while (WiFi.status() != WL_CONNECTED) {
19 delay(500);
20 Serial.print(".");
21 }
22 Serial.println();
23 Serial.print("Adres / address: http://");
24 Serial.println(WiFi.localIP());
25
26 // Ana sayfa / home page
27 server.on("/", HTTP_GET, [](AsyncWebServerRequest *request) {
28 request->send(200, "text/plain", "Merhaba, ben ESP32 / Hello from ESP32");
29 });
30
31 // /led?durum=1 yakar, /led?durum=0 söndürür / turns the LED on or off
32 server.on("/led", HTTP_GET, [](AsyncWebServerRequest *request) {
33 if (request->hasParam("durum")) {
34 ledAcik = request->getParam("durum")->value() == "1";
35 digitalWrite(LED_PIN, ledAcik ? HIGH : LOW);
36 }
37 request->send(200, "text/plain", ledAcik ? "LED: ON" : "LED: OFF");
38 });
39
40 // Tanımsız adresler / unknown addresses
41 server.onNotFound([](AsyncWebServerRequest *request) {
42 request->send(404, "text/plain", "Sayfa yok / Not found");
43 });
44
45 server.begin();
46}
47
48void loop() {
49 // Sunucu arka planda çalışır, loop() boş kalabilir / the server runs in the background
50}| 命令 | 働き |
|---|---|
| AsyncWebServer server(80)(サーバーの作成) | HTTPの80番ポートで待ち受けるサーバーを作る |
| server.on("/led", HTTP_GET, ...)(ルートの登録) | アドレスとリクエストの種類ごとに実行する関数(コールバック)を登録する |
| request->hasParam("durum")(パラメーターの確認) | アドレスに?durum=...のパラメーターがあるかを調べる |
| request->getParam("durum")->value()(値の取得) | パラメーターの値をStringとして返す |
| request->send(コード, 種類, 内容) | レスポンスを送る。200は成功、404は見つからない。種類はtext/plain、text/html、application/jsonなど |
| server.onNotFound(...)(未定義のアドレス) | 定義されていないすべてのアドレスに対して実行される |
| server.begin()(サーバーの開始) | サーバーを開始する。すべてのserver.on()の行のあとで呼ぶ |
操作パネルの部品と配線
パネルの例では、リレーモジュールで電灯やファンをオン・オフし、DHT22で温度と湿度を測ります。必要なのは、ESP32 DevKit、1チャンネルの5 Vリレーモジュール、DHT22(またはDHT11)のセンサーモジュール、ブレッドボード、ジャンパーワイヤーです。リレーは、フォトカプラ付きのモジュールを選びましょう。DHT22は−40〜80 °Cを±0.5 °Cの精度で測れ、読み取りは2秒に1回までです。DHT11は安価ですが、精度は0〜50 °Cの範囲で±2 °Cです。センサーの詳細は「DHT11温湿度センサー」の記事を、ボードのピン配置は「ピン配置」ツールをご覧ください。
| モジュールのピン | ESP32 DevKit | ESP8266 NodeMCU | メモ |
|---|---|---|---|
| リレーのVCC | VIN(5 V) | VINまたはVU(5 V) | リレーのコイルには5 Vが必要。USBから給電 |
| リレーのGND | GND | GND | 共通GND |
| リレーのIN | GPIO 26 | D1(GPIO 5) | ほとんどのモジュールはLOWの信号でオンになる |
| DHT22のVCC(+) | 3V3 | 3V3 | 3.3 Vで給電すると、データ線も3.3 Vになる |
| DHT22のDATA(OUT) | GPIO 4 | D2(GPIO 4) | センサー単体の場合は、DATAとVCCの間に10 kΩの抵抗 |
| DHT22のGND(−) | GND | GND | 共通GND |
リアルタイムの操作パネル:リレー、DHT22、WebSocket
いよいよメインのプロジェクトです。下のコードは1つのファイルで、HTML、CSS、JavaScriptはPROGMEMの配列としてコードの中に入っています。ページが開くと、ブラウザは/wsへのWebSocket接続を開きます。ESP32は2秒ごとにDHT22を読み取り、温度、湿度、リレーの状態、接続中の端末の数を1つのJSONメッセージにまとめて、すべてのブラウザに送ります。ボタンを押すとブラウザがtoggleというメッセージを送り、ESP32はリレーを切り替えて、新しい状態をまた全員に知らせます。ネットワーク名とパスワードを書き換えて書き込み、ブラウザでhttp://esp32.localかシリアルモニタに表示されたIPアドレスを開きます。
1#include <WiFi.h>
2#include <AsyncTCP.h>
3#include <ESPAsyncWebServer.h>
4#include <ESPmDNS.h>
5#include <DHT.h>
6
7const char* ssid = "AgAdi"; // Wi-Fi adın / your Wi-Fi name
8const char* password = "Sifre123"; // Wi-Fi şifren / your Wi-Fi password
9
10const int ROLE_PIN = 26; // röle modülü IN / relay module IN
11const bool ROLE_AKTIF_LOW = true; // çoğu modül LOW ile çeker / most modules switch on LOW
12const int DHT_PIN = 4; // DHT22 veri pini / DHT22 data pin
13
14DHT dht(DHT_PIN, DHT22); // DHT11 için DHT11 yaz / write DHT11 for a DHT11
15AsyncWebServer server(80);
16AsyncWebSocket ws("/ws"); // WebSocket adresi / WebSocket path
17
18bool roleAcik = false;
19float sicaklik = NAN;
20float nem = NAN;
21volatile bool gonder = false; // loop() yayın yapsın mı? / should loop() broadcast?
22unsigned long sonOlcum = 0;
23unsigned long sonTemizlik = 0;
24
25// Tarayıcıya giden sayfa / the page sent to the browser
26const char index_html[] PROGMEM = R"rawliteral(
27<!DOCTYPE html>
28<html lang="tr">
29<head>
30<meta charset="utf-8">
31<meta name="viewport" content="width=device-width, initial-scale=1">
32<title>ESP32 Panel</title>
33<style>
34body{font-family:sans-serif;background:#0f172a;color:#e2e8f0;text-align:center;margin:0;padding:16px}
35.kart{background:#1e293b;border-radius:12px;padding:16px;margin:12px auto;max-width:320px}
36.deger{font-size:2.4em;font-weight:bold}
37button{font-size:1.2em;padding:14px 32px;border:0;border-radius:8px;color:#fff;width:100%}
38.acik{background:#16a34a}.kapali{background:#475569}
39</style>
40</head>
41<body>
42<h2>ESP32 Panel</h2>
43<p id="baglanti">Bağlanıyor / Connecting...</p>
44<div class="kart">Sıcaklık / Temperature<div class="deger"><span id="sicaklik">--</span> °C</div></div>
45<div class="kart">Nem / Humidity<div class="deger"><span id="nem">--</span> %</div></div>
46<div class="kart">Röle / Relay<p><button id="dugme" class="kapali" onclick="degistir()">KAPALI / OFF</button></p></div>
47<p>Bağlı cihaz / Clients: <span id="istemci">0</span></p>
48<script>
49let ws;
50const $ = (id) => document.getElementById(id);
51function baglan() {
52 ws = new WebSocket('ws://' + location.host + '/ws');
53 ws.onopen = () => { $('baglanti').textContent = 'Bağlı / Connected'; };
54 ws.onclose = () => {
55 $('baglanti').textContent = 'Koptu, yeniden deneniyor / Reconnecting...';
56 setTimeout(baglan, 2000);
57 };
58 ws.onmessage = (e) => {
59 const d = JSON.parse(e.data);
60 $('sicaklik').textContent = d.sicaklik === null ? '--' : d.sicaklik.toFixed(1);
61 $('nem').textContent = d.nem === null ? '--' : d.nem.toFixed(0);
62 $('istemci').textContent = d.istemci;
63 $('dugme').textContent = d.role ? 'AÇIK / ON' : 'KAPALI / OFF';
64 $('dugme').className = d.role ? 'acik' : 'kapali';
65 };
66}
67function degistir() {
68 if (ws.readyState === WebSocket.OPEN) ws.send('toggle');
69}
70baglan();
71</script>
72</body>
73</html>
74)rawliteral";
75
76void roleAyarla(bool acik) {
77 roleAcik = acik;
78 digitalWrite(ROLE_PIN, ROLE_AKTIF_LOW ? !acik : acik);
79}
80
81// Durumu JSON metnine çevir / build the state as JSON text
82String durumJson() {
83 String s = "{\"role\":";
84 s += roleAcik ? "true" : "false";
85 s += ",\"sicaklik\":";
86 s += isnan(sicaklik) ? String("null") : String(sicaklik, 1);
87 s += ",\"nem\":";
88 s += isnan(nem) ? String("null") : String(nem, 0);
89 s += ",\"istemci\":";
90 s += (unsigned long)ws.count();
91 s += "}";
92 return s;
93}
94
95// WebSocket olayları (async_tcp görevinde çalışır) / WebSocket events (run in the async_tcp task)
96void wsOlay(AsyncWebSocket *sunucu, AsyncWebSocketClient *client, AwsEventType type,
97 void *arg, uint8_t *data, size_t len) {
98 switch (type) {
99 case WS_EVT_CONNECT:
100 Serial.printf("Istemci #%u baglandi / connected: %s\n",
101 (unsigned)client->id(), client->remoteIP().toString().c_str());
102 gonder = true; // yeni gelen güncel durumu alsın / newcomer gets the state
103 break;
104 case WS_EVT_DISCONNECT:
105 Serial.printf("Istemci #%u ayrildi / disconnected\n", (unsigned)client->id());
106 gonder = true;
107 break;
108 case WS_EVT_DATA: {
109 AwsFrameInfo *info = (AwsFrameInfo *)arg;
110 // Tek parça, tam bir metin mesajı mı? / a complete single-frame text message?
111 if (info->final && info->index == 0 && info->len == len && info->opcode == WS_TEXT) {
112 if (len == 6 && memcmp(data, "toggle", 6) == 0) {
113 roleAyarla(!roleAcik);
114 gonder = true; // tüm ekranlar eşitlensin / sync every screen
115 }
116 }
117 break;
118 }
119 default: // PONG, ERROR vb. / PONG, ERROR etc.
120 break;
121 }
122}
123
124void setup() {
125 Serial.begin(115200);
126 pinMode(ROLE_PIN, OUTPUT);
127 roleAyarla(false); // açılışta röle kapalı / relay off at start
128 dht.begin();
129
130 WiFi.mode(WIFI_STA);
131 WiFi.begin(ssid, password);
132 Serial.print("Baglaniyor / connecting");
133 while (WiFi.status() != WL_CONNECTED) {
134 delay(500);
135 Serial.print(".");
136 }
137 WiFi.setSleep(false); // daha hızlı tepki / lower latency
138 Serial.println();
139 Serial.print("http://");
140 Serial.println(WiFi.localIP());
141
142 if (MDNS.begin("esp32")) { // http://esp32.local
143 MDNS.addService("http", "tcp", 80);
144 Serial.println("http://esp32.local");
145 }
146
147 ws.onEvent(wsOlay);
148 server.addHandler(&ws);
149
150 server.on("/", HTTP_GET, [](AsyncWebServerRequest *request) {
151 request->send(200, "text/html", index_html);
152 });
153 server.onNotFound([](AsyncWebServerRequest *request) {
154 request->send(404, "text/plain", "Sayfa yok / Not found");
155 });
156 server.begin();
157}
158
159void loop() {
160 if (millis() - sonOlcum >= 2000) { // DHT22 en fazla 2 sn'de bir / DHT22: once per 2 s
161 sonOlcum = millis();
162 float t = dht.readTemperature();
163 float h = dht.readHumidity();
164 if (!isnan(t) && !isnan(h)) { // hatalı okumayı atla / skip failed reads
165 sicaklik = t;
166 nem = h;
167 }
168 gonder = true;
169 }
170
171 if (gonder) {
172 gonder = false;
173 ws.textAll(durumJson()); // tüm tarayıcılara yolla / send to every browser
174 }
175
176 if (millis() - sonTemizlik >= 1000) {
177 sonTemizlik = millis();
178 ws.cleanupClients(); // kopuk istemcileri temizle / drop stale clients
179 }
180}コードの仕組み
- AsyncWebSocket ws("/ws")でWebSocketのエンドポイントを作り、server.addHandler(&ws)でサーバーに登録します。JavaScriptのアドレスは、このパスと完全に一致している必要があります。
- wsOlay()関数(olayはトルコ語で「イベント」)は、接続、切断、メッセージの受信のたびに呼ばれます。WS_EVT_CONNECTは新しいクライアント、WS_EVT_DISCONNECTはクライアントの離脱、WS_EVT_DATAはメッセージの受信を表します。
- AwsFrameInfoは、受信したフレームの情報を持っています。final、index == 0、lenを確認すると、メッセージが1つのフレームで欠けずに届いたことが、WS_TEXTを確認するとテキストであることがわかります。短い命令は必ず1つのフレームに収まります。
- 受信したデータはmemcmp()で長さも含めて比較するので、データの最後にヌル文字があるかどうかに左右されません。
- コールバックはasync_tcpタスクで動くので、リレーを切り替えてgonderフラグ(gonderはトルコ語で「送信」)を立てるだけにしています。実際の送信はloop()の中のws.textAll()で行うので、すべてのメッセージが1か所から順番に送られます。
- ws.cleanupClients()は1秒に1回呼びます。切れた接続のメモリを解放し、クライアントの数が上限を超えたときにいちばん古い接続を閉じます。
- JavaScript側では、oncloseイベントで2秒後に再接続します。ESP32が再起動したり、Wi-Fiが一瞬切れたりしても、ページは自動的に復帰します。アドレスをlocation.hostから組み立てているので、ページはIPアドレスでもesp32.localでも動きます。
- WiFi.setSleep(false)は、Wi-Fiの省電力機能をオフにします。標準のモデムスリープでは、応答に最大で数百ミリ秒の遅れが出ることがあります。コンセントから給電するパネルでこれをオフにすると、ボタンがすぐに反応するようになります。
メッセージの形式にJSONを選ぶと、処理が簡単になります。ブラウザはJSON.parse()を使って、受信したテキストを1行でオブジェクトに変換できます。メッセージの例は{"role":true,"sicaklik":23.4,"nem":48,"istemci":2}のようになります(トルコ語のキーで、roleは「リレー」、sicaklikは「温度」、nemは「湿度」、istemciは「クライアント」)。センサーが読み取れないときは値をnullとして送り、ページには「--」と表示されます。読み取りの失敗がNaNとして送られると、JSONが無効になってページがまったく更新されなくなります。メッセージ1つあたりのデータは約60バイトなので、1秒に何回か更新してもネットワークの負担になりません。もっと複雑なメッセージにはArduinoJsonライブラリに切り替えることもできますが、この規模のパネルならStringをつなげるだけで十分です。リレーの代わりにLEDやブザーをつないで、同じコードをそのまま試すこともできます。
HTMLはどこに置く?PROGMEMとLittleFS
パネルのコードでは、ページはR"rawliteral( ... )rawliteral"という形の生文字列リテラルに入っています。この書き方なら、エスケープ文字を使わずに引用符や改行を書けます。AVRベースのArduinoでは、テキストをRAMではなくフラッシュに置くためにPROGMEMが必要です。ESP32では定数の配列はもともとフラッシュから読まれるので、PROGMEMは互換性のために書いているだけで、害はありません。ページが大きくなると、コードの中でHTMLを編集するのが大変になります。そうなったら、ファイルをフラッシュ内のLittleFSファイルシステムに移すほうがすっきりします。
| 比較の観点 | PROGMEM(コードの中) | LittleFS(ファイルシステム) |
|---|---|---|
| 準備 | 追加の手順なし、ファイルは1つ | dataフォルダと書き込み用のプラグインが必要 |
| 編集 | HTMLはC++の配列の中 | index.html、style.css、app.jsを別々のファイルにできる |
| 更新 | 変更のたびにスケッチを書き込み直す | ファイルシステムだけを書き込み直す |
| サイズ | 数KBのページに最適 | 標準のパーティション構成で約1.5 MBの領域 |
| 画像とフォント | 実用的ではない | アイコン、画像、.gzファイルを配信できる |
| 使う場面 | 最初のテストや小さなパネル | 複数ページの、または大きくなっていくUI |
LittleFSでHTML、CSS、JavaScriptのファイルを配信する
- スケッチのフォルダ(.inoファイルがあるフォルダ)の中にdataという名前のフォルダを作り、パネルのコードのR"rawliteral(と)rawliteral"の間にあるHTMLをdata/index.htmlとして保存します。必要なら、CSSとJavaScriptを別のファイルに分けてもかまいません。
- Arduino IDE 2は、以前の「ESP32 Sketch Data Upload」メニューに対応していません。代わりにarduino-littlefs-uploadプラグインの.vsixファイルをダウンロードし、WindowsならC:\Users\(ユーザー名)\.arduinoIDE\plugins、macOSとLinuxなら~/.arduinoIDE/pluginsに置き(フォルダがなければ作成)、IDEを再起動します。このプラグインにはIDE 2.2.1以降が必要です。
- シリアルモニタが開いていれば閉じ、Ctrl+Shift+P(macOSではCmd+Shift+P)でコマンドパレットを開いて、Upload LittleFS to Pico/ESP8266/ESP32(LittleFSを書き込む)を選びます。ファイルがフラッシュのファイルシステム用パーティションに書き込まれます。
- 「ツール」>「Partition Scheme」で、ファイルシステム用のパーティションがある構成(たとえば「Default 4MB with spiffs」)が選ばれていることを確認します。LittleFSは、spiffsという名前のこのパーティションを使います。
- 下のコードをいつもどおり書き込みます。「ツール」メニューの「Erase All Flash」がオフになっていれば、スケッチを書き込んでもファイルシステムは消えません。ファイルを変更したときは、ステップ3だけを繰り返せば済みます。
1#include <WiFi.h>
2#include <AsyncTCP.h>
3#include <ESPAsyncWebServer.h>
4#include <LittleFS.h>
5
6const char* ssid = "AgAdi"; // Wi-Fi adın / your Wi-Fi name
7const char* password = "Sifre123"; // Wi-Fi şifren / your Wi-Fi password
8
9AsyncWebServer server(80);
10
11void setup() {
12 Serial.begin(115200);
13
14 if (!LittleFS.begin(true)) { // true: bağlanamazsa biçimlendir / format if mount fails
15 Serial.println("LittleFS hatasi / LittleFS error");
16 return;
17 }
18
19 WiFi.mode(WIFI_STA);
20 WiFi.begin(ssid, password);
21 while (WiFi.status() != WL_CONNECTED) {
22 delay(500);
23 }
24 Serial.println(WiFi.localIP());
25
26 // data/ klasörünün içeriğini sun / serve the contents of the data/ folder
27 server.serveStatic("/", LittleFS, "/")
28 .setDefaultFile("index.html") // varsayılan index.htm'dir / default is index.htm
29 .setCacheControl("max-age=600"); // tarayıcı 10 dk önbelleğe alsın / cache for 10 min
30
31 server.onNotFound([](AsyncWebServerRequest *request) {
32 request->send(404, "text/plain", "Sayfa yok / Not found");
33 });
34 server.begin();
35}
36
37void loop() {
38}mDNS:IPアドレスの代わりにesp32.local
ルーターは起動のたびにESP32に違うIPアドレスを割り当てることがあり、192.168.1.47のような数字は覚えにくいものです。mDNS(マルチキャストDNS)を使うと、端末がローカルネットワーク上で自分の名前を知らせることができます。パネルのコードにあるMDNS.begin("esp32")の行のおかげで、同じネットワークの端末からhttp://esp32.localでページを開けます。また、MDNS.addService("http", "tcp", 80)の行は、ネットワークをブラウズするアプリに、ここにWebサーバーがあることを知らせます。名前に「.local」を付けず、使うのは小文字の英字、数字、ハイフンだけにしてください。ESP32を何台も使う場合は、それぞれに違う名前(kitchen、living-roomなど)を付けましょう。同じ名前の端末が2台あると互いに干渉します。mDNSの対応状況はシステムによって異なります。
| システム | .localへの対応 | 動かない場合 |
|---|---|---|
| iPhone、iPad、macOS | 標準で対応(Bonjour) | 同じWi-Fiネットワークにつながっているか確認する |
| Windows 10と11 | 最新の版なら通常は動く | Bonjourサービスをインストールするか、IPアドレスを使う |
| Linux | avahi-daemonがインストールされていれば動く | avahi-daemonとnss-mdnsのパッケージをインストールする |
| Android | バージョンやブラウザによって異なる | IPアドレスを使うか、ルーターでDHCPの予約を設定する |
複数のクライアント:スマホ、タブレット、パソコンから同時に
WebSocketのいちばん喜ばれる点は、パネルを開いた全員が同じ状態を見られることです。スマホでリレーをオンにすると、ESP32はtextAll()で新しい状態をすべての接続に送り、パソコンのタブも同じ瞬間に緑に変わります。タブは1つ1つが別のクライアントで、同じブラウザでタブを3つ開くとカウンターは3を示します。ライブラリが標準で許可するWebSocketクライアントの同時接続数は、ESP32で8、ESP8266で4です(DEFAULT_MAX_WS_CLIENTS)。この上限は自動的には適用されず、cleanupClients()を呼んだときに上限を超えた分のいちばん古い接続が閉じられます。ほかにも次の点に注意してください。
- スマホがスリープしたとき:画面が消えたスマホは、接続を正しく閉じないことがあります。こうした中途半端に残った接続は、cleanupClients()と定期的なデータ送信によって、時間がたてば片付きます。
- 送信の頻度:キューで待てるメッセージは、クライアント1つにつき最大32個(ESP8266では8個)です。loop()の周回のたびにtextAll()を呼ぶとキューがいっぱいになり、新しいメッセージは何も知らせずに捨てられます。「ツール」>「Core Debug Level」をWarn以上の詳しいレベルにすると、シリアルモニタにToo many messages queued(キューのメッセージが多すぎる)という警告が表示されます。値は変わったときだけ、または1秒に数回だけ送りましょう。
- 新しく接続したクライアント:接続したページにはすぐに現在の状態を送りましょう。そうしないと、最初の測定まで空のパネルを見続けることになります。パネルのコードでは、WS_EVT_CONNECTの中で立てるフラグがこれを行っています。
- 1つのクライアントだけに返信:全員ではなく、尋ねてきたブラウザだけに答えるにはclient->text("...")を使います。クライアントの番号はclient->id()で取得できます。
- メモリ:開いている接続は、それぞれTCPソケットとバッファ用のメモリを使います。ESP32の520 KBのRAMは数台のクライアントには十分すぎるほどですが、ESP.getFreeHeap()でときどき空きメモリを表示しておくと、メモリリークを早く見つけられます。
簡単なパスワード保護
家のネットワークにいる人なら、パネルのアドレスを知っていれば誰でもリレーを操作できてしまいます。ライブラリのrequest->authenticate()とrequest->requestAuthentication()関数を使うと、ページにユーザー名とパスワードを設定できます。ブラウザは専用のログイン画面を開き、セッションの間は入力した情報を覚えています。標準の方式はDigest認証なので、パスワードが平文のままネットワークを流れることはありません。ただし、ページを守るだけでは不十分です。WebSocketのアドレスは別の入口なので、ws://ip/wsに直接つながれるおそれがあります。下の例ではこの入口もふさぎます。ESP32は起動のたびにランダムなキーを作り、ログインしたブラウザにだけ/anahtar(anahtarはトルコ語で「鍵」)のアドレスでそのキーを渡し、setFilter()で正しいキーを持たないWebSocket接続を拒否します。
1#include <WiFi.h>
2#include <AsyncTCP.h>
3#include <ESPAsyncWebServer.h>
4
5const char* ssid = "AgAdi"; // Wi-Fi adın / your Wi-Fi name
6const char* password = "Sifre123"; // Wi-Fi şifren / your Wi-Fi password
7const char* KULLANICI = "admin"; // panel kullanıcı adı / panel username
8const char* PAROLA = "Gizli-2026"; // panel parolası / panel password
9const int LED_PIN = 2;
10
11AsyncWebServer server(80);
12AsyncWebSocket ws("/ws");
13String wsAnahtar; // her açılışta yeni anahtar / a new key on every boot
14bool ledAcik = false;
15
16const char index_html[] PROGMEM = R"rawliteral(
17<!DOCTYPE html>
18<html><head><meta charset="utf-8">
19<meta name="viewport" content="width=device-width, initial-scale=1"></head>
20<body style="font-family:sans-serif;text-align:center">
21<h2>Korumali panel / Protected panel</h2>
22<button style="font-size:1.4em;padding:12px 32px" onclick="ws && ws.send('toggle')">LED</button>
23<p id="durum">...</p>
24<script>
25let ws;
26fetch('/anahtar').then(r => r.text()).then(k => {
27 ws = new WebSocket('ws://' + location.host + '/ws?k=' + k);
28 ws.onmessage = (e) => { document.getElementById('durum').textContent = e.data; };
29});
30</script>
31</body></html>
32)rawliteral";
33
34bool girisVar(AsyncWebServerRequest *request) {
35 return request->authenticate(KULLANICI, PAROLA);
36}
37
38void setup() {
39 Serial.begin(115200);
40 pinMode(LED_PIN, OUTPUT);
41
42 WiFi.mode(WIFI_STA);
43 WiFi.begin(ssid, password);
44 while (WiFi.status() != WL_CONNECTED) {
45 delay(500);
46 }
47 Serial.println(WiFi.localIP());
48
49 // Wi-Fi açıkken random() donanım üretecini kullanır / random() uses the hardware RNG
50 wsAnahtar = String(random(0x7FFFFFFF), HEX) + String(random(0x7FFFFFFF), HEX);
51
52 // Sayfa: tarayıcı kullanıcı adı ve parola sorar / page: the browser asks for login
53 server.on("/", HTTP_GET, [](AsyncWebServerRequest *request) {
54 if (!girisVar(request)) return request->requestAuthentication();
55 request->send(200, "text/html", index_html);
56 });
57
58 // Anahtar yalnız giriş yapana verilir / the key is given only after login
59 server.on("/anahtar", HTTP_GET, [](AsyncWebServerRequest *request) {
60 if (!girisVar(request)) return request->requestAuthentication();
61 request->send(200, "text/plain", wsAnahtar);
62 });
63
64 // Doğru anahtarı getirmeyen bağlantıyı reddet / reject sockets without the right key
65 ws.setFilter([](AsyncWebServerRequest *request) {
66 return request->hasParam("k") && request->getParam("k")->value() == wsAnahtar;
67 });
68
69 ws.onEvent([](AsyncWebSocket *sunucu, AsyncWebSocketClient *client, AwsEventType type,
70 void *arg, uint8_t *data, size_t len) {
71 if (type == WS_EVT_DATA && len == 6 && memcmp(data, "toggle", 6) == 0) {
72 ledAcik = !ledAcik;
73 digitalWrite(LED_PIN, ledAcik ? HIGH : LOW);
74 sunucu->textAll(ledAcik ? "LED: ON" : "LED: OFF");
75 }
76 });
77
78 server.addHandler(&ws);
79 server.begin();
80}
81
82void loop() {
83 ws.cleanupClients(); // saniyede bir yeterli / once per second is enough
84 delay(1000);
85}ESP8266とRaspberry Pi Pico W向けのメモ
同じライブラリはESP8266(NodeMCU、Wemos D1 mini)でも動きます。変更点は次のとおりです。Async TCPの代わりにESP32AsyncのESP Async TCPライブラリをインストールし、WiFi.hの代わりにESP8266WiFi.h、AsyncTCP.hの代わりにESPAsyncTCP.h、ESPmDNS.hの代わりにESP8266mDNS.hをインクルードして、loop()の中にMDNS.update()の行を加えます。WiFi.setSleep(false)はWiFi.setSleepMode(WIFI_NONE_SLEEP)に置き換え、ピンは配線表のNodeMCUの列に合わせて設定します。ESP8266で使えるRAMは約80 KBしかないので、ページは小さく保ち、クライアントの上限が4であることを忘れないでください。Raspberry Pi Pico Wを使う場合は、MicroPythonでWebサーバーを作る方法を「Raspberry Pi Pico WのWi-Fi」のガイドで紹介しています。ESPAsyncWebServerの新しい版は、Arduino側でRP2040とRP2350のチップにも対応しています。
ESP32のWebサーバーを使ったプロジェクトのアイデア
- マルチチャンネルのスマートコンセント:4チャンネルのリレーボードを使い、電灯、ファン、コンセント用に別々のボタンを作ります。命令は「r1」「r2」のような短いテキストで区別します。リレーの選び方は「リレーモジュール」の記事をご覧ください。
- 植物の水やりパネル:土壌水分センサーの値をリアルタイムで表示し、水やりのしきい値をページのスライダーで設定し、必要なら手動でポンプを動かします。
- 気象ステーション:DHT22の代わりにBME280を使って気圧も加え、直近60回の測定値をページに小さな折れ線グラフで表示します。
- カラーピッカーのライト:ページのカラーピッカーの値をWebSocketで送り、WS2812B NeoPixelのテープを即座にその色に染めます。
- スライダーで動くサーボ:スライダーを動かすたびにその値を送り、サーボモーターを回します。カメラの向きの調整やドアのかんぬきに最適です。
- タイムスタンプ付きの記録:各測定値にNTPで取得した時刻を付け、コードは毎回USBで書き込む代わりにOTAで無線で更新します。
よくあるエラーと対処法
| 症状 | 原因 | 対処法 |
|---|---|---|
| エラー:'mbedtls_md5_starts_ret' was not declared in this scope(宣言されていない) | 古いme-no-devのライブラリとESP32コア3.xの組み合わせ | ESP32AsyncのESP Async WebServerとAsync TCPをインストールし、古いフォルダを削除する |
| エラー:Multiple libraries were found for "AsyncTCP.h"(ライブラリが複数見つかった) | 同じライブラリが2つある | librariesフォルダにある古い-masterのコピーを削除する |
| send_Pに非推奨(deprecated)の警告が出る | 新しい版ではsend_Pが廃止される予定 | request->send(200, "text/html", index_html)を使う |
| ページがまったく開かない | スマホがゲストネットワークやモバイルデータ通信につながっている、またはIPアドレスが変わった | 同じWi-Fiにつなぎ、シリアルモニタに表示された現在のIPアドレスを使う |
| ページは開くが、値が「--」のまま | WebSocketが接続できていない。パスが違う、またはaddHandlerを忘れている | F12でコンソールを開き、ws("/ws")とJavaScriptのアドレスを一致させる |
| async_tcpでTask watchdog got triggered(ウォッチドッグの発動) | コールバックの中のdelay()、長いループ、HTTPリクエスト | コールバックは短くし、処理はフラグでloop()に移す |
| 警告:Too many messages queued(キューのメッセージが多すぎる) | textAll()を呼ぶ回数が多すぎる | 変わったときだけ、または1秒に数回だけ送る |
| LittleFSのページが404になる | dataフォルダが書き込まれていない、またはsetDefaultFileがない | プラグインでファイルを書き込み、setDefaultFile("index.html")を追加する |
| シリアルモニタに「does not exist, no permits for creation」(ファイルが存在しない) | 要求されたファイルがLittleFSにない | ファイル名と大文字・小文字を確認する。.gzを含む行は問題ない |
| リレーがいつまでもオフにならない | 5 Vのモジュールが3.3 VのHIGHを認識しない | 3.3 V対応のモジュール、JD-VCC、またはトランジスタを使う |
| リレーが動くとESP32がリセットされる | コイルの電流で電源電圧が下がる(ブラウンアウト) | しっかりした5 VのACアダプターと470 µFのコンデンサを使う |
| esp32.localが開かない | 端末がmDNSに対応していない、または名前に.localを付けて書いた | MDNS.begin("esp32")と書き、必要ならIPアドレスを使う |
| ボタンの反応が遅い | Wi-Fiのモデムスリープがオンになっている | 接続後にWiFi.setSleep(false)を呼ぶ |
ESP32の非同期WebサーバーとWebSocketの組み合わせは、クラウドサービスもスマホアプリの開発も必要なく、複数の端末で共有できる高速な操作パネルを作るいちばんの近道です。まずは1ファイルのPROGMEMのパネルから始め、UIが大きくなったらLittleFSに移り、ネットワークに新しい端末を加えるたびにそれぞれ独自のmDNS名を付けましょう。ESP32が初めての場合は、基本を「ESP32とは?」の記事で紹介しています。BlueGraysアプリでは、非同期Webサーバー、WebSocketカウンター、mDNSでのアクセス、NTP時計、OTAアップデートなどのESP32のプロジェクトを、回路図とすぐに使えるコードつきでステップごとに試せます。
よくある質問
ESP32のESPAsyncWebServerとWebServerの違いは何ですか?
WebServerライブラリは、loop()の中のhandleClient()の呼び出しでリクエストを1つずつ処理するので、loop()が遅くなるとページも遅くなります。ESPAsyncWebServerは、バックグラウンドの別タスクでリクエストを処理し、複数の接続を同時にさばけて、WebSocketとSSEの機能も最初から備えています。リアルタイムのパネルには、非同期版のほうが向いています。
ESP32のWebサーバーには何台まで同時に接続できますか?
ESPAsyncWebServerは標準で、ESP32では8台までのWebSocketクライアントを許可し、cleanupClients()が呼ばれたときに超えた分を閉じます。ESP8266では上限は4台です。通常のページのリクエストは短時間で終わるので、もっと多くの端末が順番にページを開けます。実際には、家庭用のパネルなら3〜5台の同時接続で問題なく動きます。
ESP32のWebサーバーにインターネットからアクセスできますか?
ルーターでポート転送を設定すれば技術的には可能ですが、おすすめしません。ライブラリはESP32でHTTPSに対応しておらず、端末がインターネットからの攻撃にさらされたままになるからです。外から操作したいなら、Telegramボット、MQTTを使ったシステム、またはルーターのVPN機能のほうがずっと安全です。
esp32.localが使えないのはなぜですか?
最もよくある理由は、スマホやパソコンがmDNSの名前を解決できないことで、特に一部のAndroidスマホや古いWindowsで起こります。また、コードにMDNS.begin("esp32.local")と書くのも間違いです。名前は.localを付けずに指定します。それでも名前が解決できない場合は、シリアルモニタに表示されたIPアドレスを使うか、ルーターでESP32に固定IPを予約しましょう。
ESP32のWebサーバーはルーターなしで動きますか?
はい。WiFi.softAP("ESP32-Panel", "12345678")を使うと、ESP32が自分のWi-Fiネットワークを作ります。スマホをそのネットワークにつなぎ、ブラウザで192.168.4.1を開きます。このアクセスポイントモードではインターネットは使えませんが、パネルとWebSocketはまったく同じように動きます。ネットワークのパスワードは8文字以上にする必要があります。
ESPAsyncWebServerはどのライブラリをインストールすればいいですか?
Arduinoのライブラリマネージャで、作者がESP32AsyncのESP Async WebServerとAsync TCPのライブラリをインストールします。me-no-devアカウントにある元の版はもうメンテナンスされておらず、ESP32コア3.xではコンパイルできません。ZIPで追加した古いコピーがあれば、librariesフォルダから削除してください。
この記事を読んだ後に作れるプロジェクト
全プロジェクトを見る
ESP32非同期Webサーバー(LCDにIPアドレスを表示)
このプロジェクトでは、ESP32を同じWi-Fiネットワーク上のスマートフォンやパソコンから操作できる非同期Webサーバーにします。ESPAsyncWebServerライブラリを使うと、従来のWebServer.hのように1つのリクエストで処理がブロックされず、複数のリクエストを同時に処理できます。ブラウザーでESP32のIPアドレスを開き、ON/OFFボタン(/onと/offのルート)を押すと、GPIO 5に接続したLEDを点灯・消灯できます。

ESP32 WebSocketカウンター(2つのボタン+LCD+Webリアルタイム表示)
このプロジェクトでは、ESP32に接続した2つのボタンでカウンターの値を変更します(GPIO 5で増加、GPIO 4で減少)。値は16x2 I2C LCDに即座に反映され、ESPAsyncWebServerのWebSocket機能により、接続中のすべてのブラウザーでもページを再読み込みせずに更新されます。HTTPポーリングの代わりに1つの接続を維持し、ESP32がws.textAll()で新しい値を全員に送信します。

ESP32 mDNS:bluegrays-esp.localでデバイスにアクセス(LCD付き)
このプロジェクトでは、ESP32にmDNS(Multicast DNS)サービスを追加し、IPアドレスの代わりにbluegrays-esp.localのような覚えやすいホスト名でアクセスできるようにします。DHCPで割り当てられるIPアドレスが変わっても、同じ名前でデバイスにアクセスできます。小さなWebサーバーも動かし、ブラウザーでhttp://bluegrays-esp.local/を開いてLEDを操作します。I2C LCDディスプレイにはホスト名、IPアドレス、LEDの状態をトルコ語と英語で表示します。iOS、macOS、LinuxはmDNSに標準対応しています。

ESP32 Wi-Fi STA接続(LCDディスプレイ付き)
このプロジェクトでは、ESP32をステーション(STA)モードで家庭のWi-Fiネットワークに接続し、安全で障害に強い接続を実現します。SSIDとパスワードを別のsecrets.hファイルに保存してGitへの流出を防ぎ、自動再接続の仕組みを実装します。RSSIによる信号強度とIPアドレスはI2C LCDディスプレイに表示します。内蔵LEDは状態表示に使います。実運用を想定した接続管理の仕組みです。

ESP32 DHT22温湿度計(LCDディスプレイ付き)
DHT22センサーで周囲の温度と湿度を測定し、16x2 I2C LCDにリアルタイムで表示するプロジェクトです。Adafruit DHTライブラリを使い、1本の信号線を使うプロトコルでセンサーからデータを読み取ります。IoT気象観測装置やスマートホームの自動化、温室の環境監視システムの基礎となります。ESP32のGPIO 4ピンを介してDHT22と通信します。

ESP32スマート照明(PIR + LDR + リレー + OLED)
ESP32で廊下やトイレ、食品庫の照明をスマート化します!PIRモーションセンサー、LDR光センサー、リレーモジュールを使い、「暗い場所で動きを検知すると点灯する」定番の仕組みを作ります。周囲が明るければ動きを検知しても点灯しないため、日中の無駄な電力消費を抑えられます。照明は30秒間点灯し、動きが続くとタイマーがリセットされて点灯時間が延びます。SSD1306 OLEDには大きな文字で状態(ON / DAYTIME / WAITING)とカウントダウンを表示します。

ESP32 OTA更新:無線でファームウェアを書き込み(LCD付き)
このプロジェクトでは、USBケーブルを使わず、Wi-Fi経由でESP32に新しいファームウェアを書き込む方法を学びます。ArduinoOTAライブラリを使うと、ESP32がネットワーク上で検出され、Arduino IDEの「ネットワークポート」タブにbluegrays-otaとして自動表示されます。初回はUSBで書き込み、その後の更新はすべて無線で行えます。設置済みのデバイスにとって非常に重要な機能です。I2C LCDディスプレイには、Wi-Fiの状態、IPアドレス、ホスト名、OTA書き込みの進捗率、エラーメッセージをトルコ語と英語で表示します。

ESP32とNTPでインターネットから正確な時刻を取得
ESP32をWi-Fiに接続し、NTP(Network Time Protocol)サーバーからミリ秒精度の現在時刻を取得します。configTzTime()関数でタイムゾーンを自動適用し、夏時間を採用する国ではPOSIX TZ文字列で夏時間と標準時間の切り替えも自動管理します。時刻はシリアルモニタと、必要に応じて接続するI2C LCDに表示します。configTzTime()がSNTPサービスを開始し、ネットワークにアクセスできる間は定期的に再同期します。
ほかの記事
BlueGraysアプリ
この回路をスマホでステップごとに作る
回路図、コード、部品リストを1つの画面で確認できます。Arduino、ESP32、Picoのプロジェクトを200件以上用意しています。

