『キャプティブポータル』の説明は省略しますが、
スマホでWifiに接続すると、特定のURLをアクセスして『インターネットへの接続』を確認するそうです。
このメカニズムを利用して、キャプティブポータル で遊ぼうと思います。
- ESP32-C3をAPモードでWifi接続します
- 次に、スマホのWifi画面からSSID『ESP32-C3_AP』に接続します
- すると、スマホはバックグラウンドで前記URLにアクセスします
- ESP32はDNSサーバーを偽装し、すべてに『自身』のアドレスを応答します
- スマホからのHTTP-GETに対し、用意していおいたHTMLを応答します
- 少し待っていると、用意していおいたHTMLが表示されます
WiFiに接続すると、待っているだけで キャプティブポータル が勝手に表示されます。
CircuitPythonだと、次のコードで実現できます。
CircuitPythonには、DNSライブラリが用意されいないため、少しコードが長くなってしまいます。
import socketpool
import wifi
from adafruit_httpserver import Server, Response, GET
# 1. APモード(アクセスポイント)の起動
SSID = "ESP32-C3_AP"
print("Starting Access Point...")
wifi.radio.start_ap(ssid=SSID)
print(f"AP Started! IP Address: {wifi.radio.ipv4_address_ap}")
pool = socketpool.SocketPool(wifi.radio)
server = Server(pool)
# スマホに表示させるHTML画面
HTML_PAGE = """<!DOCTYPE html><html><head><meta charset='UTF-8'>
<meta name='viewport' content='width=device-width, initial-scale=1.0'>
<title>ESP32-C3 Notification</title>
<style>body{font-family:sans-serif; text-align:center; padding:50px; background:#f0f2f5;}
h1{color:#ff5722;} p{font-size:18px; color:#333;}</style></head>
<body>
<h1>🔔 キャプティブポータルで遊ぶ (CircuitPython)</h1>
<p>ESP32-C3への接続が完了しました。</p>
<p>最新のライブラリ環境でキャプティブポータルが動作しています。</p>
</body></html>"""
# メイン画面のルーティング
@server.route("/", GET)
def base(request):
return Response(request, content_type="text/html", body=HTML_PAGE)
# 接続検知ルーティング
# すべてのアクセスを強制フックするルーティング
@server.route("/...", GET)
def apple_captive(request):
return Response(request, content_type="text/html", body=HTML_PAGE)
# サーバーの開始
server.start(host=str(wifi.radio.ipv4_address_ap), port=80)
# 簡易DNSサーバー(UDP 53番ポート)の設定
dns_socket = pool.socket(pool.AF_INET, pool.SOCK_DGRAM)
dns_socket.bind((str(wifi.radio.ipv4_address_ap), 53))
dns_socket.setblocking(False)
print("Captive Portal is ready.")
ip_bytes = bytes(int(x) for x in str(wifi.radio.ipv4_address_ap).split("."))
dns_buffer = bytearray(512)
while True:
# 簡易DNSサーバー処理
try:
bytes_received, addr = dns_socket.recvfrom_into(dns_buffer)
if bytes_received > 0:
data = dns_buffer[:bytes_received]
# DNSの標準的な応答パケットを生成
packet = data[:2] + b"\x81\x80\x00\x01\x00\x01\x00\x00\x00\x00"
idx = 12
while data[idx] != 0:
idx += data[idx] + 1
idx += 5
packet += data[12:idx]
# 回答レコード(TTL: 60秒、データ長: 4バイト、ESP32のIPアドレス)
packet += b"\xc0\x0c\x00\x01\x00\x01\x00\x00\x00\x3c\x00\x04" + ip_bytes
dns_socket.sendto(packet, addr)
except OSError:
pass
# Webサーバー処理
try:
server.poll()
except OSError:
pass
上記コードにて、Androidスマホに ↓このようなHTMLが表示されます。
もう少し遊ぶため、NeoPixel を 光らせてみたいと思います。
fetchを使いますが、CircuitPythonだとレスポンスが追いつきませんので、Arduinoで実装します。
環境は次の通り;
(必要によりダウンロードして、Arduino-IDE に ZIPインストールしてください)
- Arduino ESP32 Core 3.3.8
- Async TCP 3.3.2
- ESP Async WebServer 3.6.0
- Adafruit NeoPixel 1.15.5
コードは以下の通り。
#include <Arduino.h>
#include <WiFi.h>
#include <DNSServer.h>
#include <ESPAsyncWebServer.h>
#include <Adafruit_NeoPixel.h>
#define PIN_NEOPIXEL 4
#define NUM_PIXELS 1
const char* ssid = "ESP32_NeoPixel_AP";
Adafruit_NeoPixel pixels(NUM_PIXELS, PIN_NEOPIXEL, NEO_GRB + NEO_KHZ800);
AsyncWebServer server(80);
DNSServer dnsServer;
// グローバル変数で現在の色を記憶(明るさ変更時に元の色を保つため)
uint8_t currentR = 0;
uint8_t currentG = 0;
uint8_t currentB = 0;
// HTML画面 (ダークモード)
const char HTML_PAGE[] PROGMEM = R"rawliteral(
<!DOCTYPE html><html><head><meta charset='UTF-8'>
<meta name='viewport' content='width=device-width, initial-scale=1.0'>
<title>NeoPixel Controller</title>
<style>
body { font-family: -apple-system, sans-serif; text-align: center; padding: 20px; background: #1c1c1e; color: #ffffff; }
h1 { color: #ffffff; font-size: 24px; margin-bottom: 20px; font-weight: 600; }
.card { background: #2c2c2e; padding: 25px; border-radius: 20px; display: inline-block; box-shadow: 0 4px 16px rgba(0,0,0,0.4); width: 85%; max-width: 320px; margin-bottom: 20px; }
input[type="color"] { -webkit-appearance: none; border: none; width: 130px; height: 130px; border-radius: 50%; cursor: pointer; box-shadow: 0 4px 12px rgba(0,0,0,0.5); margin: 10px 0; background: none; }
input[type="color"]::-webkit-color-swatch-wrapper { padding: 0; }
input[type="color"]::-webkit-color-swatch { border: 2px solid #3a3a3c; border-radius: 50%; }
.slider-container { text-align: left; margin-top: 20px; }
.slider-label { font-size: 14px; color: #aeaeActive; color: #aeaeb2; margin-bottom: 8px; font-weight: 500; display: flex; justify-content: space-between; }
input[type="range"] { -webkit-appearance: none; width: 100%; height: 8px; border-radius: 4px; background: #3a3a3c; outline: none; }
input[type="range"]::-webkit-slider-thumb { -webkit-appearance: none; width: 28px; height: 28px; border-radius: 50%; background: #ffffff; box-shadow: 0 2px 6px rgba(0,0,0,0.5); cursor: pointer; }
</style></head>
<body>
<h1>🌈 フルカラーで遊ぶ</h1>
<div class="card">
<p style="margin:0 0 10px 0; color:#f2f2f7; font-weight:600;">カラー選択</p>
<input type="color" id="colorPicker" value="#000000">
<div class="slider-container">
<div class="slider-label">
<span>明るさ</span>
<span id="brightVal">50%</span>
</div>
<input type="range" id="brightSlider" min="0" max="100" value="50">
</div>
</div>
<script>
const picker = document.getElementById('colorPicker');
const slider = document.getElementById('brightSlider');
const brightVal = document.getElementById('brightVal');
picker.addEventListener('input', (e) => {
const hex = e.target.value;
const r = parseInt(hex.substring(1, 3), 16);
const g = parseInt(hex.substring(3, 5), 16);
const b = parseInt(hex.substring(5, 7), 16);
fetch(`/set_color?r=${r}&g=${g}&b=${b}`).catch(err => {});
});
slider.addEventListener('input', (e) => {
const val = e.target.value;
brightVal.innerText = val + '%';
fetch(`/set_brightness?b=${val}`).catch(err => {});
});
</script>
</body></html>
)rawliteral";
void setup() {
Serial.begin(115200);
delay(10);
pixels.begin();
pixels.setBrightness(128); // 💡 デフォルト50% (255の約半分)
pixels.setPixelColor(0, pixels.Color(0, 0, 0));
pixels.show();
WiFi.mode(WIFI_AP);
WiFi.softAP(ssid);
IPAddress apIP = WiFi.softAPIP();
Serial.print("AP IP Address: ");
Serial.println(apIP);
dnsServer.start(53, "*", apIP);
// カラーデータ受信エンドポイント
server.on("/set_color", HTTP_GET, [](AsyncWebServerRequest *request){
if (request->hasParam("r") && request->hasParam("g") && request->hasParam("b")) {
currentR = request->getParam("r")->value().toInt();
currentG = request->getParam("g")->value().toInt();
currentB = request->getParam("b")->value().toInt();
pixels.setPixelColor(0, pixels.Color(currentR, currentG, currentB));
pixels.show();
}
request->send(200, "text/plain", "OK");
});
// 明るさデータ受信エンドポイント
server.on("/set_brightness", HTTP_GET, [](AsyncWebServerRequest *request){
if (request->hasParam("b")) {
int brightPercent = request->getParam("b")->value().toInt();
// 0-100% の値を 0-255 の輝度値に変換
uint8_t brightDuty = (brightPercent * 255) / 100;
pixels.setBrightness(brightDuty);
// 明るさ変更を即座に反映させるため、現在の色で再描画
pixels.setPixelColor(0, pixels.Color(currentR, currentG, currentB));
pixels.show();
}
request->send(200, "text/plain", "OK");
});
// ワイルドカードによる全アクセス強制キャッチ
server.on("*", HTTP_GET, [](AsyncWebServerRequest *request){
request->send(200, "text/html", HTML_PAGE);
});
server.onNotFound([](AsyncWebServerRequest *request){
request->send(200, "text/html", HTML_PAGE);
});
server.begin();
Serial.println("System Ready.");
}
void loop() {
dnsServer.processNextRequest();
delay(5);
}
iPhoneでは、下記動画のようにリアルタイムで色が変わって遊べます。

↑(スロー動画ではありません)動きが鈍いときは画像をクリック
OS標準のカラーピッカーが使われますが、自分の古いAndroidスマホだと、リアルタイムで色を切り替えられませんでした。
キャプティブポータルを作れると、いつか何かで役に立つ場面があるように思います。
以上