← back to home

Assignment

For this week's assignment, I wanted to build a wireless remote control for my radio final project using ESP-NOW. Since I'd already finished an initial version of the radio, a remote felt like a natural next step.

I knew I wanted the remote to handle a few key functions:

To do all of that, I planned to put about four buttons and a rotary encoder for volume on a breadboard alongside a XIAO microcontroller. I chose the XIAO because it is small but still has enough pins for this use case. It is also small enough that I could eventually turn the remote into a portable, handheld device. I also wanted to add an LED that changed color depending on the radio's current mode.

Wireless radio remote on a breadboard controlling the radio final project
The wireless remote control for my radio final project

Getting ESP-NOW Working

I started by reading the ESP-NOW tutorial on the PS70 website and the Random Nerd Tutorials guide it links to. ESP-NOW lets two ESP devices talk directly to each other, peer-to-peer, without a router. For that to work, each device needs to know the other's MAC address.

To find the MAC addresses on my system, I called the ESP-IDF function esp_read_mac(), following the Espressif API documentation. Once I had both addresses, I hard-coded each device's MAC into the other's sketch.

// In the XIAO sketch, I entered the radio's MAC address:
uint8_t mainRadioMac[] = { 0x34, 0x5F, 0x45, 0x37, 0x17, 0x4C };

// In the radio sketch, I entered the XIAO's MAC address:
uint8_t xiaoMac[] = { 0xB0, 0xA6, 0x04, 0x06, 0xAF, 0x44 };

With that, the radio and the remote could talk to each other directly.

Solving WiFi Channel Problems

One of the biggest challenges I ran into was that, even though ESP-NOW works without a router, both devices still have to be on the same WiFi channel. My radio has several modes, including internet radio, MP3, Bluetooth, and FM. The radio's ESP32 uses a different WiFi channel for each one, so the remote would work in one mode but not another. For instance, it worked in internet radio mode, but pressing the mode button rebooted the radio into MP3 mode on a different channel, where the remote went dead.

The Serial Monitor helped me see the problem clearly. The remote was still emitting ESP-NOW packets, but the radio was no longer acknowledging them.

Remote control sending an ESP-NOW signal that was not acknowledged by the radio, shown in the Serial Monitor
The remote was emitting a signal, but the radio was not acknowledging it on the Serial Monitor

To solve this, I explored two possible solutions. The first was connecting the XIAO to the same WiFi network as the radio. This forces both devices onto the same channel by default, but it makes the remote depend on a specific WiFi network, like MAKERSPACE, being present. If I took the radio out of the makerspace, the remote would stop working, which was not what I wanted.

The second solution was channel scanning, which is the approach I went with. In this approach, the XIAO sends commands on its current channel. If no acknowledgement comes back within a few milliseconds, it scans channels 1 through 13, trying each until it gets an acknowledgement. Once it finds the radio, it stays on that channel until the next failure.

I ultimately chose channel scanning because it required no changes to the radio at all and was easier for the user. The XIAO automatically finds whatever channel the radio's ESP32 is on.

Some Limitations

While debugging, I realized some radio modes simply cannot coexist with WiFi. For FM radio, WiFi has to be turned off completely. The 2.4 GHz energy that WiFi emits interferes with the nearby analog FM signal and the FM module's circuitry, so every WiFi transmission can garble the FM audio. Here's the code I used to fully shut WiFi down.

WiFi.disconnect(true);
WiFi.persistent(false);
WiFi.mode(WIFI_OFF);
btStop();
delay(200);

I found out that Bluetooth has a similar problem. The ESP32 has only one physical 2.4 GHz radio transceiver, and since WiFi and classic Bluetooth both use that band, they have to share the hardware by taking turns. I couldn't reliably run both at once, so I had to shut off WiFi when using Bluetooth, which made it impossible to control Bluetooth with a remote. I realized therefore that my remote would only be able to control two WiFi-based modes, namely MP3 and internet radio.

Three Improvements

After wiring up the rotary encoder and buttons, much as I had in previous weeks' assignments, I made three additional changes to the system.

Wiring for the assembled remote with a XIAO, buttons, and rotary encoder on a breadboard before adding the LED
Wiring for the assembled remote with the XIAO, buttons, and rotary encoder on the breadboard, before adding the LED
The assembled remote working with the rotary encoder and buttons before I added the LED strip

Improvement 1: Two-Way Communication

My first improvement was making the communication bidirectional. Originally the XIAO only sent commands to the radio. To help the remote understand the status of the radio in real time, I wanted the radio to also send its status back to the XIAO. This way, the remote always knows the radio's true state, even if someone changes something on the radio physically.

Based on my previous code, the XIAO already sends a one-byte remote packet to the radio.

struct RemotePacket { uint8_t cmd; };

#define CMD_PLAY  1
#define CMD_VOLUP 2
#define CMD_VOLDN 3
#define CMD_NEXT  4
#define CMD_MODE  5
#define CMD_PREV  6
#define CMD_MUTE  7

To send status back, I defined an identical StatePacket struct in both sketches, holding everything the remote needs to know about the radio's condition. One important design rule came out of this. Since ESP-NOW just copies raw memory, if the radio's struct and the XIAO's struct disagreed on their layout by even a single byte, the XIAO would copy garbage into the wrong fields.

struct StatePacket {
  uint8_t mode;       // 0=MP3, 1=BT, 2=RADIO, 3=FM
  uint8_t volumePct;  // 0-100
  uint8_t paused;     // 0/1
  uint8_t muted;      // 0/1
  char    name[40];   // track name or station name
};
StatePacket lastState;
bool haveState = false;

To keep the two packet types from ever being confused, I distinguish them by size. The remote packet is 1 byte and the state packet is 44 bytes. Since that is a hard rule, the XIAO's receive callback can simply check the packet length.

void onDataRecv(const uint8_t* mac, const uint8_t* data, int len) {
  if (len == sizeof(StatePacket)) {
    memcpy(&lastState, data, sizeof(StatePacket));
    // ... use it
  }
}

However, since each radio mode stores its state differently, I needed a function to translate any mode's state into the common state packet format before broadcasting it.

void broadcastState() {
  StatePacket pkt;
  pkt.mode = (uint8_t)currentMode;

  if (currentMode == MODE_FM) {
    pkt.volumePct = (fmVolumeQ7 * 100) / 128;   // FM volume is a Q7 fixed-point value
    pkt.muted = fmMuted ? 1 : 0;
    pkt.paused = 0;
  } else {
    pkt.volumePct = (uint8_t)(vol * 100);        // other modes use a 0.0-1.0 float
    pkt.muted = muted ? 1 : 0;
    pkt.paused = paused ? 1 : 0;
  }

  // ... name field filled per-mode ...
  esp_now_send(xiaoMac, (uint8_t*)&pkt, sizeof(pkt));
  lastStateBroadcast = millis();
}

With two-way communication in place, the radio became a much more accurate and dynamic device to use.

Improvement 2: A Web App

My second improvement was a web app for controlling the radio, in case I didn't have the physical remote on hand.

My idea was to have my radio ESP boot up an HTTP server. Then, when my laptop, which has to be on the same WiFi network as the radio, opens the radio's IP address in a browser, the page shows the radio's current state and offers control buttons. Tapping a button sends an HTTP request back to the radio, which acts on it exactly as if a physical button had been pressed.

To start, I used the WebServer library on the radio's ESP32 to get an HTTP server up and running.

#include <WebServer.h>

WebServer webServer(80);
bool webServerRunning = false;

void startWebServer() {
  if (webServerRunning) return;

  webServer.on("/", handleRoot);        // serves the HTML page
  webServer.on("/state", handleState);  // serves current state as JSON
  webServer.on("/cmd", handleCmd);      // executes a command
  webServer.on("/pick", handlePick);    // jumps to a station/track
  webServer.begin();

  webServerRunning = true;
  Serial.print("Web server up at http://");
  Serial.println(WiFi.localIP());
}

In addition, just as the radio reports its state to the XIAO over ESP-NOW, I also needed it to report its state to the web app as a JSON snapshot. I wrote a handleState() function to do this.

void handleState() {
  String json = "{";
  const char* modeName = (currentMode == MODE_MP3) ? "MP3" : ...;
  json += "\"modeName\":\""; json += modeName; json += "\",";
  // name, volume, paused, muted ...

  if (currentMode == MODE_RADIO) {
    json += "\"listLabel\":\"Stations\",";
    json += "\"idx\":"; json += currentStation; json += ",";
    json += "\"list\":[";

    for (int i = 0; i < NUM_STATIONS; i++) {
      json += "\""; json += stations[i].name; json += "\"";
      if (i < NUM_STATIONS - 1) json += ",";
    }

    json += "]";
  } else if (currentMode == MODE_MP3) {
    // same shape, but the track list
  }

  json += "}";
  webServer.send(200, "application/json", json);
}

My first attempt at the web app got the basic page loading and the regular control buttons talking to the radio, but it failed when I tried to switch modes from the browser. The radio stayed stuck instead of moving between MP3 and internet radio, which showed me that mode switching needed more careful handling than the simpler play, pause, and volume commands.

This was my first web app attempt. The browser controls were connected, but once I switched to MP3, I couldn't switch out of it

After debugging the mode command, the final web app worked properly on my laptop. The browser could show the radio's current state and make all of the main controls available, including play/pause, volume, mute, previous/next, mode switching, and selecting stations or tracks.

HTTP remote control web app showing the radio mode station list and browser controls
The HTTP remote control web app in internet radio mode, showing the live station list and playback controls
HTTP remote control web app showing MP3 mode with track selection and playback controls
The same browser interface but in MP3 mode
The final version of the web app working properly on my laptop, with all of the radio controls successfully available

This worked very well. Like the ESP-NOW remote, though, the web app depends on WiFi. It goes a step further by needing an actual WiFi network, since the radio has to have an IP address. So it carries the same limitation in that it only works in MP3 and internet radio modes.

Improvement 3: A NeoPixel Mode Indicator

My third improvement was adding a NeoPixel LED strip to show which mode the radio is in, either internet radio or MP3. I wanted the remote to do something visible with the status it was receiving from the radio, and it was also a good excuse for me to learn how to use an LED strip, which I'd never done before. The key piece of code that allowed for the NeoPixel color change was updateLedForMode(), which mapped a color onto a music mode and turned on the corresponding pixels.

// Mode colors:
// MP3   = purple (128, 0, 200)
// BT    = blue   (0, 60, 255)
// RADIO = green  (0, 200, 80)
// FM    = amber  (255, 140, 0)
void updateLedForMode(uint8_t mode) {
  uint8_t r = 0, g = 0, b = 0;
  switch (mode) {
    case 0: r = 128; g =   0; b = 200; break;
    case 1: r =   0; g =  60; b = 255; break;
    case 2: r =   0; g = 200; b =  80; break;
    case 3: r = 255; g = 140; b =   0; break;
    default: r = 30; g = 30; b = 30; break;
  }
  for (int i = 0; i < NUM_PIXELS; i++) pixels.setPixelColor(i, pixels.Color(r, g, b));
  pixels.show();
  displayedMode = mode;
}

Then, to turn on the NeoPixel, I implemented this code, which turned the strip on to a brightness of only 60 out of a max of 255. I didn't want the strip to be too bright so this was a good brightness.

pixels.begin();
pixels.setBrightness(60);
pixels.clear();
pixels.show();

The most important thing I learned was that the strip has to be soldered in the right direction, toward the input end, not the output end. I went through several strips from the bin and even soldered one myself the wrong way, so I learned that lesson the hard way. I followed another Random Nerd Tutorial to wire the NeoPixel strip to the XIAO, though the wiring was simple since two of the strip's pins are just power and the third is the data pin.

Broken LED strip segment with taped wiring before soldering a working strip
One of the broken LED strip segments I tested before soldering my own
Broken LED strip segment with loose wires before soldering a working strip
Another broken strip with loose wiring
NeoPixel LED strip glowing purple when the radio is in internet radio mode
Purple means the radio is in internet radio mode
NeoPixel LED strip glowing green when the radio is in MP3 mode
Green means the radio is in MP3 mode

With all of these changes, I had a working wireless remote for the radio that could change the volume, skip songs, mute, and switch modes, plus an LED that changes color with the radio's mode. Thanks to the two-way communication and channel scanning, the remote worked very smoothly.

Final wiring for the wireless remote, including rotary encoder, buttons, and NeoPixel LED
Final wiring for my remote, including the rotary encoder, buttons, and LED
The finished remote controlling the radio with the buttons, rotary encoder, and NeoPixel mode indicator

Files

Download Week 9 files (.zip)