Utilizing ESP32: A Remote Control for Volumio2

Saturday, September 4, 2021

ESP32 Volumio

t f B! P L

I built a remote control using an ESP32 module, limited to the minimum functions such as play/stop and volume adjustment for Volumio2.

Utilizing ESP32: Remote Control for Volumio2 (right)

Background

Recently, I started using Volumio2 at home on a Raspberry Pi 3 Model B+ for background music. Volumio2 is very convenient as it runs headlessly on a Raspberry Pi and can be fully controlled from a PC browser or smartphone app. However, after using it for a while, I found it tedious to open a browser or app every time I wanted to quickly pause the music or adjust the volume. There are methods to control Volumio2 via hardware, such as adding GPIO switches or IR remote functionality to the Raspberry Pi itself, but in my setup, the Raspberry Pi and amplifier are placed out of sight, so I decided to control it via WiFi using Volumio2's WebSocket API.

The goal is to keep it as simple as possible, supporting only the following functions.

  • Play/Stop
  • Volume adjustment
  • Random album playback (requires the Randomizer plugin)

Regarding the random album playback, I generally don't create playlists and tend to listen to music on an album-by-album basis from my music library. I use the RandomAlbum feature of the Randomizer plugin as a quick way to select the next album when one finishes playing, so I decided to assign this function to the remote control.

About Socket IO

Volumio2's WebSocket API is implemented using Socket IO, where Volumio acts as the Socket IO server and the remote control as the Socket IO client. A distinguishing feature of Socket IO compared to HTTP is that once a connection is established between host and client, communication can continue while maintaining the connection. In addition to the WebSocket API, Volumio2 also provides the REST API as a network API, but this is a URL-based exchange similar to HTTP GET requests, where the connection begins with a request from the client and ends when the host returns a response.

The difference between HTTP and WebSocket is explained clearly here.


What I learned from this build is that by using Socket IO to maintain a constant connection and sending commands in response to button presses, the response time is always minimal, resulting in an excellent feel as a remote control. On the other hand, assuming a constant network connection makes battery operation difficult.

Components

Since detailed Volumio2 operations are assumed to be performed from a browser or app, the remote control is designed to handle only very limited operations, so it is composed as simply as possible with just an ESP32 and push buttons.

ESP32

Even though power is supplied from USB, since I wanted to connect to the network via WiFi, the choice of microcontroller board was limited to the ESP series. This time I used a NodeMCU ESP32 (ESP32-DevkitC) that I had on hand.

Button Assignment

Three buttons are placed: the main button (hereafter referred to as the center button) is assigned multiple functions through multi-click detection, and the remaining two buttons are assigned volume up and volume down respectively. I also considered assigning all functions to a single rotary encoder with a push switch for greater simplicity, but attaching a rotary knob to a small unit would require using both hands to prevent the unit itself from rotating during operation, so I decided to simply use push switches.

For the center button, a single press is assigned to play/stop (toggle), and a triple press is assigned to random album playback.

Button Assignment

Schematic

The connection circuit is extremely simple, consisting only of connecting three push switches between GPIO and GND. The GPIOs connected to the push switches are configured to be pulled up in the program.

Schematic

Building the Project

The project is built using Arduino. The Arduino version used was 1.8.15.

The ESP32 project has been placed on GitHub, so please git clone it and open VolumioSimpleControlButtons.ino in Arduino.

https://github.com/elehobica/VolumioSimpleControlButtons

Please install the following libraries before building.

WiFi Manager

The WiFi Manager library was used to allow WiFi SSID and password to be configured from a browser at startup instead of hardcoding them directly in the source code. Version 2.0.3-alpha was installed from Arduino's "Tools" → "Manage Libraries".

Installing the WiFiManager Library

ArduinoJson

Since Socket IO basically handles data in JSON format, the ArduinoJson library was used. Version 6.18.3 was installed from Arduino's "Tools" → "Manage Libraries".

Installing the ArduinoJson Library

arduinoWebSockets

I searched for a library to handle Socket IO, and decided to use arduinoWebSockets, a library that comprehensively handles WebSocket, as it appeared to offer high flexibility. This library cannot be found through Arduino's "Manage Libraries", so download arduinoWebSockets-2.3.4.zip and install it from "Sketch" → "Include Library" → "Add .ZIP Library". In my environment, the latest version 2.3.5 produced build errors and could not be used.

Source Code Description

Starting WiFi Manager

#include <WiFi.h>
#include <WebServer.h>
#include <DNSServer.h>
#include <WiFiManager.h>
 
void setup()
{
  ...
  // Launch WiFiManager
  WiFiManager wifiManager;
  wifiManager.autoConnect("OnDemandAP");

Basically, just writing the above will launch a WiFi access point with the SSID "OnDemandAP" on the first boot. Selecting the desired SSID and entering the password will save the settings, and from the next boot onwards, it will automatically connect to that SSID. Even with just this single line, if it cannot connect to the SSID, OnDemandAP will launch again for reconfiguration. However, for intentional reconfiguration, the following code was added so that the WiFi SSID settings are cleared when the device is started with all three buttons pressed simultaneously.

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

Below are screenshots of performing WiFi configuration by connecting a smartphone to the OnDemandAP access point that launches on the first boot.

WiFi Connection Configuration with WiFiManager

Name Resolution via mDNS

With the default settings, Volumio should be running on the LAN with the name "volumio" (same as volumio.local). Therefore, instead of hardcoding the IP address, the mDNS function is used to resolve the Volumio name to obtain the IP address. In this use case, there is no need to set a local hostname for the remote control itself, so mdns_init() is used for initialization instead of MDNS.begin("hostname"). After calling MDNS.queryHost(), if volumioIpAddr is not 0.0.0.0, name resolution was successful.

#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 (Button Input Detection)

The button input detection part is launched as UI_Task. It executes update_button_action() at 50ms intervals.

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);

Inside update_button_action(), click count or long press detection is performed for the center button, and single press or long press detection is performed for the other two buttons. The following section handles the function assignment. Since UI_Task and the actual Socket IO transmission are separate tasks, events are notified via a 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);
    }
  ...
}

Connecting to the Socket IO Server

Connection to the Socket IO server is established with socketIO.begin(server IP address, port number), but during the switch from HTTP to Socket IO immediately after connection starts, a sIOtype_CONNECT response must be returned. Since this project is dedicated to command transmission, the only part that needs to return a response is this Socket IO connection initiation section.

#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 Transmission

Below is the Socket IO transmission section. Unlike typical Socket IO client-specific libraries that have a higher-level member function called emit(), when using the arduinoWebSockets library, emit must be implemented manually. Specifically, when sending commands, sendEVENT() must be used to send them as sIOtype_EVENT. The first data element becomes the command name and the second becomes the command parameter, and it is necessary to use PlainText or JSON format for the command parameter depending on the command. Conversely, using the arduinoWebSockets library enables fine-grained control over such primitive aspects.

#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);
}

The section that calls emitText() and emitJSON() is as follows.

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 Specification

The Volumio2 WebSocket API specification is documented here, but the information is severely lacking, so it was difficult to determine the correct commands to send. As examples, here are the WebSocket API commands used this time.

 

Play/Stop: emitJSON("toggle", "{}")
Volume adjustment: emitText("volume", "+"), emitText("volume", "-")
Play from the beginning of the queue: emitJSON("play", "{\"value\": 0}")

 

For the toggle command, an empty JSON format is required as a parameter. An empty string "" will not be accepted. For the volume command, "+" and "-" must be sent as PlainText. For the play command, the value attribute must be set to N in JSON format to provide parameter N.

Since such details are not clear from the API specification page alone, the approach taken was to check by trial and error using the index.js while referencing Socket.io Client Tool.

Socket.io Client Tool

Conclusion

This simple remote control built for Volumio2 has been very useful. As mentioned in the Socket IO section, thanks to maintaining a constant connection with Volumio2, the response to operations is fast and the usability is excellent. Also, speaking only of my usage environment, the connection is maintained even when left for a full day, and so far there have been no disconnections or slowdowns in response speed, making it extremely stable to use.

About Me

My photo
Electronics, programming & audio

Featured Post

Synchronizing Radio-Controlled Clocks with Raspberry Pi Pico W (JJY Standard Radio Wave Emulator)

As a Raspberry Pi Pico W application, I built a JJY emulator for radio-controlled clocks (for time synchronization) with minimal peripheral...

QooQ