自律走行の計画・判断を担う上位コンピュータ(Raspberry Pi 5)と、実際にモーターを駆動する低レベル制御(ESP32)は、役割を明確に分離している。上位側が高度な処理でクラッシュしたり通信が途絶えたりしても、モーター駆動の最終防衛ラインだけは独立して動き続けるようにするためである。

ESP32開発ボードの一般的な例

ESP32開発ボードの一例(実機ではなく一般的な参考写真)。newbotではFreenove製のESP32開発ボードを使用している。

画像: Edwiyanto, Wikimedia Commons(CC BY-SA 4.0)

役割分担: 高度な判断とリアルタイム制御の分離

Raspberry Pi 5は自己位置推定・地図生成・経路計画といった計算量の多い処理を担う。ESP32はエンコーダの読み取りとモーターのPWM制御という、遅延の少なさが要求されるリアルタイム性の高い処理に専念する。両者はUSBシリアル経由で、115200bpsのボーレート・改行区切りのテキストプロトコルで通信する。

シリアルプロトコル: 40ms周期のコマンド

Pi5からESP32へは、40ms周期で以下のコマンドを送り続ける。

速度指令より前進上限を先に送ることで、同じ周期内で新しい速度指令が古い上限値のまま通過してしまう抜け道を防いでいる。

ESP32からPi5へは、同じく40ms周期で車輪の回転量・経過時間・現在の動作モード・バッテリー電圧・コントローラー接続状態を返す。

二重のwatchdog

通信が途絶えた場合の安全側フォールバックを、ESP32側とPi5側の両方に持たせている。ESP32側は自律モード時300ms、手動モード時500msでコマンド未受信を検知すると自律的に安全側へ倒れる。Pi5側も500msで相手の応答途絶を検知する。どちらか一方だけでなく双方に持たせているのは、単一の監視機構だけに安全性を委ねないための設計である。

コントローラーのペアリング

手動操作用のコントローラー(Nintendo Switch Proコントローラー、Bluepad32経由)のペアリングは、ESP32ファーム側の役割として実装している。未接続の間は自動的にスキャン待機する既定動作のため、Pi5の起動→通信確立という順序だけで「起動すればペアリング待機状態になる」という単純な運用にできている。

BP32.setup()だけでは不十分だった実機ペアリングのつまずき

Bluepad32のサンプルコードではBP32.setup()を呼ぶだけでコントローラーが接続できるように見えるが、実機では新規ペアリングが成立しなかった。原因はBP32.setup()が新規Bluetooth接続の受け入れを既定で行わない点にあり、明示的にBP32.enableNewBluetoothConnections(true)を呼ぶ必要があった。加えて、一度ペアリングに失敗したコントローラーは古いBluetooth鍵をProコントローラー側が保持し続け、再試行しても弾かれることがあったため、シリアル経由でPコマンドを送るとforgetBluetoothKeys()を呼んで鍵を消去し、ペアリングをやり直せるようにした(前節のコマンド一覧にあるPはこの役割)。ダッシュボード側にも「ペアリングやり直し」ボタンを設け、/procon_pairing_resetサービスからこのコマンドをESP32へ送れるようにしている。

Bluetoothのハードウェア要件にも制約がある。Nintendo Switch ProコントローラーはBluetooth Classic (BR/EDR) で通信しており、BLE(Bluetooth Low Energy)専用のチップでは接続できない。そのため使用するESP32モジュールは、BR/EDRを含む無印のESP32(WROOM-32等)に限られ、BLEのみをサポートするESP32-S3やESP32-C3では動作しない。

実際のペアリング手順は次の通りである。

  1. ESP32を起動し、シリアルログでBP32 setup complete, waiting for controller...を確認する
  2. Proコントローラーの同期ボタン(上部のマイナスボタン脇の小さなボタン)を長押しし、LEDを点滅させてペアリングモードに入れる
  3. シリアルログにCONTROLLER CONNECTED, Model: ...のような接続完了メッセージが出れば成功
  4. 接続に失敗する場合はPコマンドで鍵を消去してから再試行する

ファームウェア書き込み

esptool.pyを使い、ボーレート921600・フラッシュモードdioでビルド済みバイナリを書き込む。開発中は書き込み後にシリアルモニタでログを確認し、上記のペアリング完了メッセージと、40ms周期のテレメトリ送信が安定して出ているかを目視で確認する運用にしている。

CLAUDE.mdに記録した実運用の罠

実機投入までの過程で、ファームウェアの挙動に関連して次のような問題を踏み、対処してきた。

障害物ガードのクランプが「宣言だけされて一度も効いていなかった」事故

上位から送られる前進速度上限(Lコマンド)とは別に、旋回速度を制限するRコマンドを2026年8月13日に追加した。ところが実装したのは受信して変数に代入するところまでで、その値を実際にモーター出力へ適用する関数を書き忘れていた。つまりrotationLimitMmSという変数は宣言と代入だけがあり、コード上どこからも参照されない状態のまま本番のガードロジックとして扱われていた。

この状態のまま、Nav2の自動リカバリ動作(その場で回転して行き詰まりを脱するSpin)の最中に車体側面が障害物へ接触する事故が起きている。旋回ガードを実装したはずなのに旋回が野放しだったことが直接の原因だった。ログにはTurning 1.57 for spin behaviorという記録が12秒近く残っていたが、角速度成分は常に無制限のまま通過しており、車輪の応答としては旋回していなかったと考えられる。「configとして持っている」ことと「実際にモーターへ効いている」ことの間には、実装漏れという形で簡単に溝ができるという教訓を残した事故である。

修正後のapplyRotationLimit()は、前進成分vと旋回成分wを左右輪速度l,rから分解し、wだけをクランプしてから合成し直す。前進側のapplyForwardLimit()と対になる実装である。

void applyRotationLimit(float &l, float &r) {
  float limit = rotationLimitMmS;
  if (millis() - lastLimitMillis > LIMIT_TIMEOUT_MS) {
    limit = LIMIT_FAILSAFE_MM_S;   // 指令途絶時は低速フェイルセーフへ
  }
  if (limit < 0.0f) return;        // 無制限
  if (allAxesBlocked()) {
    limit = ROTATION_ESCAPE_CREEP_MM_S;  // 全軸拘束時は脱出用クリープを残す
  }
  float v = (l + r) / 2.0f;
  float w = (r - l) / 2.0f;
  if (w > limit) w = limit;
  else if (w < -limit) w = -limit;
  else return;
  l = v - w;
  r = v + w;
}

さらに2026年8月14日、4件目の接触事故として後方への衝突が発生している。前進と旋回のクランプは実装済みだったが、後退方向(v<0)には一切のクランプがかかっていなかったためで、Nav2のBackUpリカバリ動作が引き金になった。前進・旋回を厳しく制限すればするほど、Nav2は「進めない」と判断してBackUpを選びやすくなる、という皮肉な連鎖もここで確認している。対策として、applyReverseLimit()を新設し、後退成分にも同じ枠組みでクランプを掛けるようにした。

前進・旋回・後退の3方向すべてが同時に0(完全拘束)になると、機体はどの方向にも動けず固まる。実際に45秒以上動けなくなり、手で動かして救出した事例も発生している。この反省から、3方向すべてが塞がれた状態をallAxesBlocked()で検知し、その場合は後退方向にだけ低速のクリープ(脱出用の最低限の速度)を残すようにした。後で旋回方向にも同種のクリープ(ROTATION_ESCAPE_CREEP_MM_S)を追加している。これは、Nav2がSpinリカバリを選んだのに旋回上限が0のままで実際には一切回転できていなかった事象を確認したことによる追加対応である。旋回のクリープを後退よりやや控えめな値にしているのは、旋回は車体の角が振れる分、後退よりもクリアランスに余裕が要ると判断したためである。

L指令が途絶した場合のフェイルセーフ値

前進上限・旋回上限・後退上限のいずれも、上位からのL/R指令がLIMIT_TIMEOUT_MS(1000ms)を超えて途絶すると、無制限には戻さずLIMIT_FAILSAFE_MM_S(120mm/s)という固定の低速上限へ自律的にフォールバックする実装になっている。上位コンピュータの障害物監視が死んだ可能性を考慮しつつ、手動操作でロボットを退避させられる余地は残す、という折衷点である。

検討中の最終防衛ライン: 物理バンパー

上記のソフトウェア層をすべて信頼しても、衝突を完全にゼロにはできないという前提のもと、Pi5・ROS2・LiDARのいずれの状態にも依存しない独立層として、物理的なバンパースイッチの追加を構想している。設計案では、ノーマルクローズ(NC)接点のマイクロスイッチをESP32のGPIO割り込みに直結し、プルアップとハードウェアデバウンス(RC回路)を組み合わせる。NC接点を選ぶのは、配線が断線した場合にも「押された」側に倒れる(フェイルセーフになる)ようにするためである。ファーム側は割り込みでフラグをラッチし、以後は前進を0にクランプしたまま後退のクリープだけを許可する。解除条件は「後退して物理的に離れる」か明示コマンドのいずれかで、Pi・ROS2・LiDARの生存に一切依存しない点が、既存の「手動操作経路は上位コンピュータに依存しない」という設計思想と一致する。

参考リンク

#ESP32