ESP32 ArduinoでWebサーバー サーボでダイヤル式アンプ音量を制御!
いやー、なんとも便利な世の中になったものでして。今日のネタはその利便性を最大限利用した話をします。
さて、私の自宅のオーディオ機器はめちゃくちゃ古い機器なのです。Cyrusというメーカーの製品で、音量はダイヤル式。入力はアナログ・オンリー。とてもシンプル且つ、昭和的な代物なのであります。これをTANNOYというメーカーのスピーカにつないで、大げさなオーディをシステムを構築しています。
このオーディオは、オーディオマニアな義理の父からの30年ほど前の頂き物ですが、音質もそこそこ良くて、AI全盛の現在もレトロな音質を楽しむ機器として快適に使わせてもらっています。
さて、今日の話題はこんなアナログな機器を、今どきのスマートホーム機器につないで、もっと便利にしちゃおうと言うストーリーになります。どんなに「スマート」にするかと言いますと、音量をリモコン制御にしようというお話。
しかも、やることは結構なアナログな感じで、サーボモーターでCyrusアンプのダイヤル式ボリュームを機械的に回しちゃおう(動画あり)ということです。操作するためのユーザーインターフェースは、お手製のWebアプリにします。
それでは、行ってみましょう!
完成形イメージ
まずは、完成形イメージ。
見た目は以下の動画のような感じ。スマホの画面上で音量ボタン(Low, Mid1, Mid2, High)を押すとそれに応じて、サーボモーターが回転して音量ダイヤルを回す、という仕組み。実にシンプルで機械的ではありませんか。
サーボモーターは、SG90というもの。制御はM5 Stack ATOM S3 Lite(ESP32搭載)を使っています。ESP32にPlatformIO上のArduinoプラットフォームでC++でソフト開発しました。
サーボと音量ダイヤルはクリップの針金を伸ばして引っ掛けています。また、音量ダイヤルにつけた「羽」やサーボの固定は3Mの強力両面テープという雑な作り(笑)です。一応、強度的には間に合っている様子。
M5 ATOM S3 Liteのシステム構成
全体のシステムは以下のようなイメージです。
M5 ATOM S3 Lite (ESP32搭載)の内部のプログラムを自作しました。
このプログラムは、Arduino互換のWiFi + Webサーバーの仕組みでサーバーとして動作します。
サーバーにはファイル数個程度のWeb アプリを搭載していています。つまり、M5 ATOM S3 Liteが、ブラウザからの要求に応じてWebアプリを提供する役割を持つわけです。
そして、家の中でスマホとM5 ATOM S3 Liteを同じWiFiアクセスポイント(宅内LAN)に接続して使います。その際、スマホからURLを開きやすいように、M5 ATOM S3 Lite側では、mDNSによりvolume.localという名前を名乗らせています。これにより、ブラウザ側は http://volume.localという名前でこのWebアプリにアクセスできるという仕組みです。
Arduino Webサーバー(Webアプリ)構築の課題
ネットで少し探すと、ArduinoのWiFiServer/WiFiClientのモジュールを使ったサーバープログラムをネットで公開されている方が結構いらっしゃいます。
しかし大抵やっていることは、HTTPのリクエストヘッダを直にパースし、クライアントに返すペイロードもWiFiClient::println()でゴリゴリとプログラム中に書かくようなもの。したがって、ちょっとまともなWebアプリを作ろうとすると、デバッグ含めて手間がかかり、かなり面倒なことになります。
考えてもみて下さい。
一般的に言って"ちゃんと動く"Webアプリを作ろうとすると、サーバーからブラウザに対して、いろんなファイルを転送する必要がありますよね。
例えば、Faviconやmanifest.jsonなど関連ファイルをブラウザに送らないと、スマホのホーム画面にアイコン置くときにデフォルトのアイコンが当たったりしてイマイチ。そこまでは必要はないとしても、それらしいUIを表示したいと思えば、JPEGやSVGのアイコンくらいはブラウザに送りたい。そういうのをゴリゴリとWiFiClient::println()で書きますか?それはちょっと、無謀というものです。
一方、ESP32のフラッシュにファイルを置いてそれを返してくれるようなESP32用のWebサーバーライブラリも存在します。筆者も使ったこともあるのですが、なぜかまともに動かなかったり、プログラムが複雑になったり、フットプリントが無駄に大きくなってしまったりするわけです。
Arduino Webサーバー(ファイル転送版)の設計
そこで今回は、ファイルは直接ESP32のフラッシュに書き込まず、C++のPROGMEMキーワード付きの変数にバイナリーデータ入れてWebサーバーのプログラムで読み出すようにしてみました。
ArduinoはPROGMEMとキーワードをつけた変数はフラッシュに置いてくれるそう。バイナリデータなど、大きなデータはこのようにやるのが良いみたいです。
変数にいれるのはクライアントに送りたいファイルをバイナリ化したものです。ブラウザからの要求に応じて、このバイナリデータをWiFiClient::write()で返すと言う実装にしてみました。
バイナリ化するための簡単なツールも自作しました。以下では少しコードを見ながら解説していきます。
AdruinoのWebサーバーから「ファイル」を送る
ソースコードはGithubの以下のリポジトリにあります。
WebアプリをM5 ATOM S3 Liteに埋め込むにはファイルをブラウザに転送する仕組みが必要になります。その仕組みはこうです。
まず、ソースコード・ディレクトリ中にWebアプリのすべてのファイルをまとめて置いておきます(ここでは、src/wwwというディレクトリ)。以下のような感じ。
この中には、HTMLのほか、ブラウザのヘッダに表示するfaviconや、UIで使うビットマップ(pngファイル)などを保存しておきます。
で、プロジェクトのトップ・ディレクトリにあるbuild-contents.jsというJavaScriptファイルを実行します。
node build-contents.js
これにより、このファイルの情報をBinFiles.cppとBinFiles.hというファイルが書き出されます。
BinFiles.hヘッダには、src/wwwディレクトリ内部の全ファイルのバイナリデータが一つ一つ別々の変数(PROGMEM付き)に保存されたコードが生成されています。
#ifndef __BIN_FILE_H__
#define __BIN_FILE_H__
boolean dispatchBinFiles(const HeaderInfo& header, WiFiClient& client);
const uint8_t binFile_favicon_ico[] PROGMEM = {0x0, 0x0, 0x1, ...
const uint8_t binFile_index_html[] PROGMEM = {0x3c, 0x21, 0x44, ...
const uint8_t binFile_manifest_json[] PROGMEM = {0x7b, 0xa, 0x20, ...
...
一方、BinFiles.cppには、上の変数のバイナリデータをHTTPリクエストに応じてクライアントに返すべく、URLのパスと変数のマッピングを示すコードが出力されています。
boolean dispatchBinFiles(const HeaderInfo& header, WiFiClient& client) {
if (0) {}
WEB_ROUTE_BIN_FILE_DEF("/favicon.ico", binFile_favicon_ico, "image/x-icon")
WEB_ROUTE_BIN_FILE_DEF("/index.html", binFile_index_html, "text/html")
WEB_ROUTE_BIN_FILE_DEF("/manifest.json", binFile_manifest_json, "application/json")
WEB_ROUTE_BIN_FILE_DEF("/speaker144.png", binFile_speaker144_png, "image/png")
WEB_ROUTE_BIN_FILE_DEF("/speaker16.png", binFile_speaker16_png, "image/png")
WEB_ROUTE_BIN_FILE_DEF("/speaker180.png", binFile_speaker180_png, "image/png")
WEB_ROUTE_BIN_FILE_DEF("/speaker48.png", binFile_speaker48_png, "image/png")
WEB_ROUTE_BIN_FILE_DEF("/switch-off.png", binFile_switch_off_png, "image/png")
WEB_ROUTE_BIN_FILE_DEF("/switch-on.png", binFile_switch_on_png, "image/png")
return false;
}
バリバリのCプリプロセッサ定義を使っているので、やりたいことはかなりわかりやすくなっていますね。
WEB_ROUTE_BIN_FILE_DEF()の第一パラメータは、HTTPで要求されるパス。第二パラメータは、上記のヘッダで定義されているファイルのバイナリデータを格納した変数の名前、第3パラメータは、そのファイルのContent-Type(拡張子に紐づけられる)です。
繰り返しになりますが、上記のコードは、build-contents.jsで自動生成されたものになりますです、はい。
サーバーロジック(動的制御)の仕組みについて
ところで、実際には静的なファイルだけだとサーバーロジックがないのでサーボモーターを制御できませんね。
そこで、サーバー側のダイナミック処理(ロジック)は別途HTTPパスでWeb API”もどき”として実装しています。
つまり簡単に言うと、HTMLの中にJavaScriptのコードが埋め込み、ユーザ操作に従ってJavaScriptのfetch関数でサーボモーターを動かすためのAPIを呼び出す、と言う仕組みを実現しようというものです。
図で示すと以下のようになります。
HTMLの中身は超シンプル(かなり抜粋)。
<script>
function changeVol(index) {
fetch("/level" + index);
}
</script>
...
<div class="container">
<button class="button color1" onclick="changeVol(0)">Low</button>
<button class="button color2" onclick="changeVol(1)">Mid 1</button>
<button class="button color3" onclick="changeVol(2)">Mid 2</button>
<button class="button color4" onclick="changeVol(3)">High</button>
</div>
...
各ボタンのハンドラ(onclick)で呼ばれるchangeVol()と言う関数でJavaScriptのfetch()、つまり、HTTPのリクエストを実行するコードを書いておくわけです。
fetch()を介したアプリの設計のいいところは、ユーザ操作に応じて画面の書き換えが発生しない、ということです。偉そうに言っていますが、まあ、Webアプリ界隈ではあたり前のことなのです…。組み込み(C++)でやろうとすると色々とハードルがあるわけです。
サーバー側の実装も見ていきましょう。
fetch()経由で呼び出されるURLパスの要求を処理するサーバー側のコードはServoWebRouter.cppというファイルにあります。この中で、以下のように要求のディスパッチしています。
boolean ServoWebServer::dispatch(HeaderInfo& header, WiFiClient& client)
{
overridePath(header);
if (header.command == "GET") {
/** api or custom routers */
WEB_ROUTE_MACRO_BEGIN()
WEB_ROUTE_EXECUTE_2("/level0", respondWithChangeLevel, 0, m_pServo)
WEB_ROUTE_EXECUTE_2("/level1", respondWithChangeLevel, 1, m_pServo)
WEB_ROUTE_EXECUTE_2("/level2", respondWithChangeLevel, 2, m_pServo)
WEB_ROUTE_EXECUTE_2("/level3", respondWithChangeLevel, 3, m_pServo)
WEB_ROUTE_MACRO_END()
/** static files (implemented in 'BinFiles.cpp' which was generated by "build-contents.js") */
if (!dispatchBinFiles(header, client)) {
return respondNoResource(header, client);
}
return true;
} else {
return respondGenericError(header, client);
}
}
このコードでは、先程BinFiles.hで定義していたdispatchBinFiles()を呼び出していますが、その前に/level1、/level2、…といったAPIもどきを割り当てる処理を書いている(WEB_ROUTER_EXECUTE_2()というやつ)いるのでこちらが優先的に実行されるわけです。
例えば、/level1を呼び出したらサーボモーターを特定の角度に設定したいわけですが、そのコードは以下のようになっています。
boolean respondWithChangeLevel(const HeaderInfo& header, WiFiClient& client, int level, Servo* pServo)
{
if (pServo && (0 <= level) && (level < sizeof(LevelToServoDegs) / sizeof(LevelToServoDegs[0]))) {
pServo->write(LevelToServoDegs[level]);
}
client.println("HTTP/1.1 200 OK");
client.println("Content-Type: application/json");
client.println("Connection: close");
client.println();
client.println("{code: \"success\"}");
return true;
}
つまり、levelの値に応じて、Servo::write()で角度を書き込む処理ですね。そしてクライアントには、ご丁寧(?!)にcode: successのJSONを返しているわけです。
今日のところは、以上です。
要点だけの説明で、ソースコードすべてが網羅できていませんでしたが、詳しくはGithubのソースをご確認下さい。
ちなみに、manifest.jsonもちゃんと搭載しているので、スマホのホーム画面に置くとWebアプリのアイコンがきちんと表示されますよー。
このWebサーバーの仕組みは今後コードを整理してライブラリ化したいと考えています。お楽しみに!
ディスカッション
コメント一覧
まだ、コメントがありません