ESP32活用 Volumio2向け リモコン

2021年9月4日土曜日

ESP32 Volumio

t f B! P L

ESP32モジュールを活用して、Volumio2の再生/停止、ボリューム調整などの最低限の機能に限定したリモコンを製作してみました。

ESP32活用 Volumio2向け リモコン(右)

製作の背景

最近、自宅にてRaspberry Pi 3 Model B+を利用してVolumio2を立ち上げてBGM用として使用し始めました。Volumio2はRaspberry Piで立ち上げておけばヘッドレスで、PCのブラウザやスマホアプリからすべての操作が行えるので、とても便利ですよね。一方で、しばらく使っていると一瞬音楽を止めたいとかボリュームを触りたいときに、いちいちブラウザやアプリを開いたりするのが手間に感じるようになりました。ハードウェア的にVolumio2を制御するにはRaspberry Pi本体にGPIOスイッチを設ける、IRリモコン機能を付けるという方法もありますが、当方の使用環境ではRaspberry Pi本体とアンプは目につかないところに置いてあることもあり、Volumio2のWebSocket APIを使ってWiFi経由でアクセスしてコントロールすることにしました。

できるだけシンプルに以下の機能のみをサポートすることとします。

  • 再生/停止
  • ボリューム調整
  • ランダムアルバム再生 (Randomizerプラグインを前提とする)

最後のランダムアルバム再生ですが、私はプレイリストは作成せずに基本的には音楽ライブラリにあるアルバム単位で聴くことが多く、1アルバムの再生が終了した際に手っ取り早く次のアルバムを選ぶ方法としてRandomizerプラグインのRandomAlbum機能を利用しているので、この機能をリモコンに割り当てることにしました。

Socket IOについて

Volumio2のWebSocket APIはSocket IOにより実現されており、Volumio側がSocket IOサーバー、リモコン側がSocket IOクライアントとなります。Socket IOではホスト、クライアント間で接続が一旦確立されれば接続を維持した状態でやり取りできる点がHTTPとは異なる特徴です。Volumio2にはネットワーク経由のAPIとして、WebSocket APIのほかにREST APIが準備されていますが、これはHTTPのGETアクセスのようなURLベースのやり取りであり、接続はクライアント側からのリクエストに始まり、ホストがレスポンスを返した時点で終了する方式となります。

HTTPとWebSocketの違いはこちらがわかりやすかったです。


今回製作してみてわかったのですが、Socket IOを用いて常に接続を確立させた状態でボタン操作に応じてコマンドを送出するようにすると常に最速時間で応答するため、リモコンとしての操作感が非常に優れています。その反面、常時ネットワークに接続しておくことを仮定するとバッテリー動作は困難になります。

構成要素

Volumio2の細かな操作はブラウザあるいはアプリから行うことを前提に、リモコンはごく限られた操作のみを割り当てることを考えたので、極力シンプルにESP32とプッシュボタンのみで構成します。

ESP32

電源はUSBから供給するとしても、ネットワークにはWiFiで接続したいので使用するマイコンボードはESPシリーズの一択となりました。今回は手元にあったNodeMCU ESP32 (ESP32-DevkitC)を使用しました。

ボタンの割り当て

ボタンは3つ配置して、メインのボタン(以降センターボタン)は複数クリック検出により複数の機能を割り当てて、残りの2つのボタンにそれぞれボリューム・アップとボリューム・ダウンを割り当てることとします。よりシンプルにプッシュスイッチ付きのロータリーエンコーダ 1個で全機能を割り当てることも考えましたが、小さな本体に回転つまみを付けると操作するために本体ごと回転しないように両手を使うことになってしまうので、シンプルにプッシュスイッチを使用することにしました。

センターボタンに対しては、1回押しで再生/停止(トグル)、3回押しでランダムアルバム再生を割り当てます。

ボタンの割り当て

回路図

接続回路はいたって簡単で3つのプッシュスイッチをGPIOとGND間に接続するのみです。プッシュスイッチを接続したGPIOはプログラムのほうでPull-upするように設定します。

回路図

プロジェクトのビルド

Arduinoを使用してプロジェクトをビルドします。使用したArduinoのバージョンは1.8.15です。

ESP32のプロジェクトをGitHubに置きましたのでgit cloneして、ArduinoでVolumioSimpleControlButtons.inoを開いてください。

https://github.com/elehobica/VolumioSimpleControlButtons

以下のライブラリをインストールしてからビルドしてください。

WiFi Manager

WiFiのSSIDとパスワードを決め打ちでソースコードに直接記入せずに、起動時にブラウザから設定するようにするためにWiFi Managerというライブラリを使用しました。Arduinoの「ツール」→「ライブラリを管理」からVersion 2.0.3-alphaをインストールしました。

WiFiManagerライブラリのインストール

ArduinoJson

Socket IOではデータを基本的にはJSON形式で扱いますのでArduinoJsonというライブラリを使用しました。Arduinoの「ツール」→「ライブラリを管理」からVersion 6.18.3をインストールしました。

ArduinoJsonライブラリのインストール

arduinoWebSockets

Socket IOを扱うライブラリを探しましたが、Web Socketを統合的に扱うarduinoWebSocketsというライブラリが自由度が高そうでしたので使用しました。このライブラリはArduinoの「ライブラリを管理」からは検索できませんので、arduinoWebSockets-2.3.4.zipをダウンロードしてから「スケッチ」→「ライブラリをインクルード」→「.ZIP形式のライブラリをインストール」からインストールします。当方の環境では最新版の2.3.5はビルドエラーが発生して使用できませんでした。

ソースコードの説明

WiFi Managerの起動

#include <WiFi.h>
#include <WebServer.h>
#include <DNSServer.h>
#include <WiFiManager.h>

void setup()
{
  ...
  // Launch WiFiManager
  WiFiManager wifiManager;
  wifiManager.autoConnect("OnDemandAP");

基本的には上記のみ記述すれば初回起動時にOnDemandAPというSSIDでWiFiアクセスポイントが立ち上がりますので、接続したいSSIDを選んでパスワードを入力すれば保存され、次回起動時からはそのSSIDに自動接続されます。この1行のみでも、SSIDに接続できなければまたOnDemandAPが立ち上がって再設定することが出来ますが、意図的に再設定したいときのために以下のコードを追加して、3つのボタン同時押しの状態で起動した場合はWiFi SSID設定がクリアされるようにしました。

  if (gpio_get_level(PIN_BUTTON_CENTER) == 0 && gpio_get_level(PIN_BUTTON_DOWN) == 0 && gpio_get_level(PIN_BUTTON_UP) == 0) {
    wifiManager.resetSettings();
  }

初回起動時に立ち上がるOnDemandAPというアクセスポイントにスマホを接続してWiFi設定を行う時のスクリーンショットを以下に載せておきます

WiFiManagerでのWiFi接続設定の様子

mDNSによる名前解決

volumioのデフォルト設定ではvolumio (volumio.localに同じ) の名前でLAN上に立ち上がっていると思います。したがって、IPアドレスの決め打ちをせずに、mDNS機能を使用してvolumioの名前解決によりIPアドレスを得るようにしました。今回の使用例ではこのリモコン自体にローカルのホスト名を設定する必要がないので、MDNS.begin("ホスト名")の代わりにmdns_init()を使用して初期化しています。MDNS.queryHost()の呼び出し後、volumioIpAddrが0.0.0.0でなければ名前解決ができています。

#include <ESPmDNS.h>

void setup()
{
  ...
  mdns_init();
  IPAddress volumioIpAddr = MDNS.queryHost(VolumioHost);
  if (volumioIpAddr == IPAddress(0, 0, 0, 0)) {
    Serial.println("Can't find Volumio");
    return;
  }

UI_Task (ボタン操作検出)

ボタン操作検出を行う部分をUI_Taskとして立ち上げます。50ms周期でupdate_button_action()を実行します。

void UI_Task(void *pvParameters)
{
  // Initialize
  for (int i = 0; i < NUM_BTN_HISTORY; i++) button_prv[i] = ButtonOpen;
  vTaskDelay(100 / portTICK_PERIOD_MS);
  while (true) {
    update_button_action();
    vTaskDelay(50 / portTICK_PERIOD_MS);
  }
}

void setup()
{
  ...
  // Queue Initialize
  ui_evt_queue = xQueueCreate(32, sizeof(ui_evt_t));
  // start UI task (button detection)
  xTaskCreatePinnedToCore(UI_Task, "UI_Task", 1024*4, NULL, 5, &th, 0);

update_button_action()の内部では、センターボタンに対してはクリック回数のカウントまたは長押し検出を行い、その他の2つのボタンに関しては1回押しまたは長押しの検出をします。以下の部分は機能割り当ての部分です。UI_Taskと実際にSocket IO送出を行うのは別タスクになりますので、Queueを介してイベントを通知します。

void update_button_action()
{
  ...
    if (button_prv[RELEASE_IGNORE_COUNT] == ButtonCenter) { // center release
      int center_clicks = count_center_clicks(); // must be called once per tick because button_prv[] status has changed
      switch (center_clicks) {
        case 1:
          trigger_ui_event(EVT_TOGGLE);
          break;
        case 2:
          //trigger_ui_event(EVT_NONE);
          break;
        case 3:
          trigger_ui_event(EVT_RANDOM_ALBUM);
          break;
        default:
          break;
      }
    }
  } else if (button_prv[0] == ButtonOpen) { // push
    if (button == ButtonUp) {
      trigger_ui_event(EVT_VOLUME_UP);
    } else if (button == ButtonDown) {
      trigger_ui_event(EVT_VOLUME_DOWN);
    }
  } else if (button_repeat_count == LONG_PUSH_COUNT) { // long push
    if (button == ButtonCenter) {
      //trigger_ui_event(EVT_NONE);
      button_repeat_count++; // only once and step to longer push event
    } else if (button == ButtonUp) {
      trigger_ui_event(EVT_VOLUME_UP);
    } else if (button == ButtonDown) {
      trigger_ui_event(EVT_VOLUME_DOWN);
    }
  ...
}

Socket IOサーバーへの接続

Socket IOサーバーへの接続はsocketIO.begin(サーバーのIPアドレス, ポート番号)で行いますが、接続開始直後のHTTPからSocket IOへの切り替えの際にsIOtype_CONNECT応答を返す必要があります。このプロジェクトはコマンド送出専用なので応答を返す必要があるのはこのSocket IO接続開始の部分のみです。

#include <WebSocketsClient.h>
#include <SocketIOclient.h>

// SocketIO Client
SocketIOclient socketIO;

void socketIOEvent(socketIOmessageType_t type, uint8_t * payload, size_t length)
{
  switch(type) {
    case sIOtype_DISCONNECT:
      Serial.printf("[IOc] Disconnected!\n");
      break;
    case sIOtype_CONNECT:
      Serial.printf("[IOc] Connected to url: %s\n", payload);
      // join default namespace (no auto join in Socket.IO V3)
      socketIO.send(sIOtype_CONNECT, "/");
      break;
    case sIOtype_EVENT:
      //Serial.printf("[IOc] get event: %s\n", payload);
      break;
    case sIOtype_ACK:
      Serial.printf("[IOc] get ack: %u\n", length);
      break;
    case sIOtype_ERROR:
      Serial.printf("[IOc] get error: %u\n", length);
      break;
    case sIOtype_BINARY_EVENT:
      Serial.printf("[IOc] get binary: %u\n", length);
      break;
    case sIOtype_BINARY_ACK:
      Serial.printf("[IOc] get binary ack: %u\n", length);
      break;
  } 
}

void setup()
{
  ...
  // connect to Volumio Socket IO Server (Port: VolumioPort)
  socketIO.begin(volumioIpAddr.toString(), VolumioPort);
  socketIO.onEvent(socketIOEvent);
  while (!socketIO.isConnected()) {
    socketIO.loop();
  }

Socket IO送出

以下がSocket IO送出部分です。通常のSocket IO Client専用ライブラリがemit()という上位概念のメンバ関数を持つのとは異なり、arduinoWebSocketsライブラりを使用する場合はemitを自前で実装する必要があります。具体的には、まずコマンド送出の際はsendEVENT()を使用してsIOtype_EVENTにて送出する必要があります。1つ目のデータがコマンド名となり、2つめのデータがコマンドパラメータとなるのですが、コマンドパラメータをPlainTextで送るか、JSON形式で送るかをコマンドに応じて使い分ける必要があります。逆に言えば、arduinoWebSocketsライブラリを使えばこのようなプリミティブな部分の細かな制御が可能です。

#include <ArduinoJson.h>
#include <WebSocketsClient.h>
#include <SocketIOclient.h>

...

void emit(String string)
{
  // Send event
  socketIO.sendEVENT(string);
  Serial.print("SocketIO emit: ");
  Serial.println(string);
}

void emitText(const char *event, const char *text)
{
  DynamicJsonDocument doc(256);
  JsonArray array = doc.to<JsonArray>();
  array.add(event);
  array.add(text);

  String output;
  serializeJson(doc, output);

  emit(output);
}

void emitJSON(const char *event, const char *json)
{
  DynamicJsonDocument doc(64 + 256);
  JsonArray array = doc.to<JsonArray>();
  array.add(event);
  DynamicJsonDocument jsonDoc(256);
  deserializeJson(jsonDoc, json);
  array.add(jsonDoc);

  String output;
  serializeJson(doc, output);

  emit(output);
}

emitText(), emitJSON()を呼び出す部分は以下になります。

void loop()
{
  ...
  socketIO.loop();

  // Receive UI Event
  ui_evt_t ui_evt_id;
  if (xQueueReceive(ui_evt_queue, &ui_evt_id, 0)) {
    switch (ui_evt_id) {
      case EVT_TOGGLE:
        emitJSON("toggle", "{}");
        break;
      case EVT_VOLUME_DOWN:
        emitText("volume", "-");
        break;
      case EVT_VOLUME_UP:
        emitText("volume", "+");
        break;
      case EVT_RANDOM_ALBUM:
        emitJSON("callMethod", "{\"endpoint\": \"miscellanea/randomizer\", \"method\": \"randomAlbum\"}");
        // Wait 3 sec for new play list to be reflected
        for (int i = 0; i < 30; i++) {
          socketIO.loop();
          delay(100);
        }
        emitJSON("play", "{\"value\": 0}");
        break;
      default:
        break;
    }
  }
  ...
}

Volumio2のWebSocket API仕様

Volumio2のWebSocket API仕様はこちらに記載がありますが、情報が非常に不足していますので、正しい送出コマンドを調べるのに苦労しました。例として今回使用した以下のWebSocket APIコマンドを挙げます。

 

再生/停止: emitJSON("toggle", "{}")
ボリューム調整: emitText("volume", "+"), emitText("volume", "-")
キューの先頭から再生: emitJSON("play", "{\"value\": 0}")

 

toggleコマンドの場合は、パラメータとして空のJSONフォーマットが必要です。ここが空文字""の場合は受け付けてくれません。volumeコマンドの場合は"+", "-"をPlainTextで送る必要があります。playコマンドの場合はパラメータNを与えるためにJSONでvalue属性にNを設定して送出する必要があります。

このような詳細がAPI仕様説明ページのみからは不明であるため、index.jsを見ながらSocket.io Client Toolで試し打ちして確認していく方法をとりました。

Socket.io Client Tool

最後に

このようにして製作したシンプルなVolumio2向けのリモコンですが、とても活躍しています。Socket IOの項にも書きましたが、常時Volumio2側と接続しているおかげで、操作に対する応答が速く使い勝手が良いです。またあくまで当方の使用環境での話になりますが、丸一日放置しておいてもそのまま接続が維持されており、切れたり応答速度が遅くなったりすることは今のところ全くなく、非常に安定して使用できています。

自己紹介

自分の写真
電子工作&プログラミング、オーディオ・音楽

注目の投稿

Raspberry Pi Pico Wで電波時計を合わせる (JJY標準電波エミュレータ)

Raspberry Pi Pico Wのアプリケーションとして 最少の周辺部品で電波時計むけJJYエミュレータ(時刻合わせ用)を製作しました。 ※2023年6月6日: ソースコード修正の内容を反映させました。 時刻合わせ風景 概要 電波時計は電波が届くところで使...

QooQ