M5Stack CoreS3でTailscale接続を試してみた
目次
以前作ったCoreS3の音声チャットは、外部に借りているサーバーのVPSへ録音音声を送り、PHPのプログラムで自宅の音声サーバーへ中継していた。
VPSと自宅PCは、離れた端末をプライベートネットワークでつなぐTailscaleを使っている。CoreS3自身もTailscaleへ参加させて、VPSの中継処理を外せないか試してみた。
ciniml/serial_wifi_loggerはUSBシリアルのログをネットワーク経由で取得するソフトで、ESP32-S3向けのTailscaleクライアントが入っていた。
手元のCoreS3にもESP32-S3が載っているので、この実装で自宅サーバーのWAV音声ファイルを1本取得するプログラムを作ってみた。
音声チャット全体を組み込む前に通信だけを確かめるため、以前作成した待ち時間用の音声ファイルを選んだ。
音声認識・返答生成・音声合成のAPIや、画面表示・スピーカー再生の処理は入れていない。
検証環境
| 項目 | 使用した環境 |
|---|---|
| 実機 | M5Stack CoreS3、ESP32-S3、16MiBフラッシュ |
| 作業用PC | Windows、Tailscale参加済み |
| USB接続 | CoreS3本体のUSB-C、USB Serial/JTAG、COM3 |
| ビルド | ESP-IDF 6.0、Dockerイメージ espressif/idf:v6.0 |
| 書き込み・読み出し | esptool 5.3.0 |
| 接続先 | 自宅音声サーバーのTailscale IPv4、HTTP、TCPポート8357 |
ESP32向けのTailscale実装
serial_wifi_logger の参照コミットを c537f4b8b64306bc2510641045160ff19fcec5d2 に固定し、components/tailscale と components/wireguard を取り込んだ。
開発にはEspressifのESP32向けフレームワーク、ESP-IDFを使った。Tailscale側のヘッダーファイルにESP-IDF 6.0向けと書かれていたため、以前の音声チャットに使ったArduino用コードとは別のプロジェクトにした。
経路設定のコードでは、TailscaleのIPアドレス帯 100.64.0.0/10 宛ての通信を、VPN通信を暗号化するWireGuardへ流している。
端末間を直接接続する処理に加え、暗号化した通信を中継サーバーへ送るDERPの処理も含まれていた。
PCからWAVを取得する
手元のWindows PCはTailscaleへ参加済みなので、先に同じ音声サーバーへ接続した。
/health は正常に応答し、/fillers から待ち時間用音声の一覧を取得できた。一覧にあった /filler/filler_0.wav をダウンロードした。
| 確認項目 | Windows PCでの結果 |
|---|---|
| 接続先 | 自宅サーバーのTailscale IPv4、ポート8357 |
| 音声ファイル | /filler/filler_0.wav |
| HTTPステータス | 200 |
| 取得サイズ | 433,964バイト |
| 音声形式 | 48,000 Hz、16 bit、モノラル |
| 長さ | 216,960フレーム、4.52秒。モノラルなので1フレームは1サンプル |
| PCでの取得時間 | 約0.154秒、1回の測定 |
ESP-IDFでビルド
作業用PCにはESP-IDFのビルド環境がなかったので、EspressifのDockerイメージを使い、コンパイラーとSDKを含む環境をコンテナー内で実行した。
接続処理とWAV取得のプログラム、ビルド設定、USBシリアルのログを保存するスクリプトは、今回の実験ソースに置いた。
Wi-Fiの設定はGit管理外の config.h に入れ、Tailscaleの認証キーは空にした。USBシリアルへ出るURLをブラウザで開き、端末を承認する方式にした。
公開用のフォルダーでは、設定例をコピーして接続先を編集してからビルドする。
Copy-Item config.example.h config.h
.\build.ps1
アプリ用の領域を3MiBに設定し、最初のビルドでは956,176バイトのバイナリを生成できた。
書き込み前のバックアップ
CoreS3をPCへUSB接続すると、esptoolがESP32-S3と16MiBのフラッシュを認識した。フラッシュにはプログラムや設定が保存されているため、書き換える前に全体をバックアップした。
esptool 5.3.0の通常の読み出しは4KiB付近で停止した。
通常は読み出し用の補助プログラムをRAMへ転送して実行するが、今回は --no-stub を付けてチップ内蔵ROMの処理を使った。なぜ通常の読み出しが止まったかは分かっていない。
読み出しには約479秒かかった。64KiBごとに内容を照合し、未使用部分も含めた16,777,216バイト全体も確認した。データから計算する照合値のMD5は実機と一致していた。
その後、起動処理を行うブートローダー、保存領域の区切りを指定するパーティションテーブル、アプリを実機へ書き込んだ。アプリ本体の転送は約12.4秒で完了し、ハッシュも一致した。
CoreS3の端末登録
起動後、Wi-Fiへの接続と時刻同期が完了し、Tailscaleの制御サーバーから端末承認用のURLが返ってきた。
元の実装は、承認前にネットワーク情報を取得しようとして node not found を返され、登録をやり直すたびに別のURLを発行していた。
Tailscaleの登録リクエストの定義には、発行済みのURLを指定して承認を待つ Followup フィールドがある。これを再登録時に送るよう tailscale_control.c を変更した。承認待ちの間はネットワーク情報を要求しない処理も tailscale_esp32.c へ追加した。
956,256バイトの修正版を実機へ書き込むと、同じURLで承認を待つ状態になった。USBシリアルを保存するPythonスクリプトも、Windowsの既定文字コードではログ中のダッシュ記号を出力できなかったため、標準出力をUTF-8へ変更した。
ブラウザで承認するとCoreS3にTailscaleのIPアドレスが割り当てられ、接続先の端末情報も取得できた。ただ、WAVを取得しようとするとHTTP接続がタイムアウトし、再試行中には再起動した。
***ERROR*** A stack overflow in task main has been detected.
ログはメイン処理のスタック不足を示していた。スタックは関数内の変数や呼び出し情報を置く領域で、取得処理では4KiBの受信バッファも使っていた。
メイン処理の割り当てを8KiBから16KiBへ増やすと、次の試行では再起動せずに処理が続いた。
HTTPの接続はまだタイムアウトしていたので、通信経路を調べた。
DERPの接続先を変更
取り込んだ実装は、端末間の直接通信に使うUDP接続候補のうち、最初に通知されたものを選んでいた。DERP経由に限定する設定も定義されていたが、接続先の選択処理には反映されていなかった。
この設定を反映する処理を追加し、中継サーバー経由へ切り替えた。
元のコードは、中継サーバーの地域を示すリージョン番号が最小のものを選び、CoreS3からはニューヨークへ接続していた。音声サーバー側は東京を使っていたので、公式のDERP一覧で東京の番号が7と確認し、これを優先する設定を追加した。
CONFIG_TAILSCALE_DERP_ONLY=y
CONFIG_TAILSCALE_PREFERRED_DERP_REGION=7
flowchart LR
A[CoreS3] <-->|暗号化通信| R[東京のDERP中継]
R <-->|暗号化通信| B[自宅の音声サーバー]
CoreS3とDERPサーバーの間では、通信を暗号化するTLSを使う。音声サーバーへのWAV取得は、その中を流れるHTTP通信になる。
DERPの設定変更でHTTP 200が返るようになったが、受信中にTLSの送信処理が NULL 参照で異常終了した。
TLSの異常終了は2回起き、その後の再起動では433,964バイトを取得できた。この状態では安定して取得できないので、tailscale_derp.c の送受信処理とTLSの設定を変更した。
TLSの動的バッファを無効にし、送受信が同じ接続を同時に操作しないよう排他制御を入れた。受信待ちで送信まで止まらないよう、データがなければ待たずに戻る非ブロッキングI/Oも使った。
これらはまとめて変更しており、この試行だけでは、どの変更が異常終了の解消に必要だったかは判別できない。
CoreS3からWAVを受信
修正後のプログラムは951,824バイトになり、実機でHTTP 200とWAVの受信完了を確認できた。
HTTP status=200 length=433964
PASS: WAV bytes=433964 time_ms=9025 rate_KiB_s=47.0
WAVを4KiBずつ受信し、先頭に RIFF と WAVE の識別子があることと、ヘッダーから計算したファイルサイズがHTTPの受信サイズと一致することを確認した。受信済みのデータは破棄しているため、WAV全体をメモリーへ保存してはいない。
同じプログラムのまま再起動し、保存済みの端末情報で再接続すると、ブラウザでの再承認なしでもう一度取得できた。
どちらの起動でも最初の接続はタイムアウトし、5秒待ってからの再試行で成功した。受信中の異常終了は2回とも起きなかった。表の取得時間には、起動・端末登録・最初の失敗を含めていない。
| 確認項目 | CoreS3の1回目の起動 | CoreS3の再起動後 |
|---|---|---|
| HTTPステータス | 200 | 200 |
| 取得サイズ | 433,964バイト | 433,964バイト |
| 成功した試行 | 2回目 | 2回目 |
| 成功リクエストの取得時間 | 9.025秒 | 8.816秒 |
| 取得後の内部RAM空き | 89,780バイト | 89,784バイト |
| 処理中の内部RAM空きの最小値 | 70,108バイト | 69,576バイト |
今回は音声サーバーと同じ東京のDERPへ接続した。端末間の直接UDP接続や他の地域への自動切り替えは試していない。