Close

OpenMote Working as a Spotify Controller

A project log for OpenMote: Arduino-Compatible Controller for Makers

Transform Your Old Wii Remotes into a Versatile Tool for Home Automation, Gaming, and DIY Projects

gangwa-labsGangwa Labs 10/28/2025 at 03:150 Comments

Hello Again!

I've been hard at work posting daily TikToks showing off the different capabilities of openmote, one of the best features that I personally love is as a media controller.

The ability to pull out your phone and skip, pause, even change the volume all with a wii-remote is the funniest and most head turning thing I can think of. The applications and devices that it works with is kinda shocking.

- In the car, even with carplay you can pause, skip

- a smartphone

- wearing headphones

- a computer

Check out my Tiktoks @Bird.Builds and see what I'm building, I've attached the Bluetooth media controller code for anyone interested.

Learn more about this project and subscribe for updates when the campaign goes live: openmote.io

/*
 * OpenMote BLE Media Controller
 *
 * Turn your OpenMote into a Bluetooth media remote control!
 * Control music playback and volume on any Bluetooth device.
 *
 * Controls:
 * - Plus Button      → Volume Up
 * - Minus Button     → Volume Down
 * - D-Pad Left       → Previous Track
 * - D-Pad Right      → Next Track
 * - A Button         → Play/Pause Toggle
 *
 * Features:
 * - BLE HID Consumer Control (works with phones, tablets, computers)
 * - LED feedback for button presses
 * - Haptic feedback on actions
 * - Connection status indicators
 */

#include <Arduino.h>
#include <OpenMote.h>
#include <NimBLEDevice.h>
#include <NimBLEHIDDevice.h>

// ===== DEBUG CONFIGURATION =====
#define DEBUG_SERIAL true

// ===== MEDIA CONTROL KEY CODES (Consumer Page) =====
#define MEDIA_PLAY_PAUSE    0xCD
#define MEDIA_NEXT_TRACK    0xB5
#define MEDIA_PREV_TRACK    0xB6
#define MEDIA_VOLUME_UP     0xE9
#define MEDIA_VOLUME_DOWN   0xEA
#define MEDIA_MUTE          0xE2

// ===== DEBOUNCE SETTINGS =====
#define DEBOUNCE_DELAY_MS   50
#define VOLUME_REPEAT_DELAY 200  // Faster repeat for volume buttons

// ===== GLOBAL OBJECTS =====
OpenMote mote;

// BLE HID Device
NimBLEHIDDevice* hid;
NimBLECharacteristic* input;

// ===== STATE VARIABLES =====
bool isConnected = false;

// Button state tracking
bool plusButtonPressed = false;
bool minusButtonPressed = false;
bool dpadLeftPressed = false;
bool dpadRightPressed = false;
bool aButtonPressed = false;

unsigned long lastDebounceTime = 0;
unsigned long lastVolumeRepeatTime = 0;

// ===== HID REPORT DESCRIPTOR - CONSUMER CONTROL ONLY =====
// This descriptor defines a pure media controller (no keyboard functionality)
const uint8_t hidReportDescriptor[] = {
  0x05, 0x0C,                    // Usage Page (Consumer Devices)
  0x09, 0x01,                    // Usage (Consumer Control)
  0xA1, 0x01,                    // Collection (Application)
  0x85, 0x01,                    //   Report ID (1)
  0x75, 0x10,                    //   Report Size (16 bits)
  0x95, 0x01,                    //   Report Count (1)
  0x15, 0x00,                    //   Logical Minimum (0)
  0x26, 0xFF, 0x07,              //   Logical Maximum (2047)
  0x19, 0x00,                    //   Usage Minimum (0)
  0x2A, 0xFF, 0x07,              //   Usage Maximum (2047)
  0x81, 0x00,                    //   Input (Data, Array, Absolute)
  0xC0                           // End Collection
};

// ===== CONSUMER CONTROL REPORT STRUCTURE =====
typedef struct {
  uint16_t usage;  // 16-bit consumer control usage code
} ConsumerReport;

ConsumerReport consumerReport = {0};

// ===== BLE SERVER CALLBACKS =====
class ServerCallbacks: public NimBLEServerCallbacks {
  void onConnect(NimBLEServer* pServer) {
    isConnected = true;
    #if DEBUG_SERIAL
    Serial.println(">>> BLE Device Connected!");
    #endif

    // Visual feedback: Quick LED flash
    mote.turnOnAllLEDs();
    delay(100);
    mote.turnOffAllLEDs();
    mote.rumblePulse(100);
  }

  void onDisconnect(NimBLEServer* pServer) {
    isConnected = false;
    #if DEBUG_SERIAL
    Serial.println(">>> BLE Device Disconnected!");
    #endif

    // Restart advertising
    NimBLEDevice::startAdvertising();

    // Visual feedback: Slow blink
    mote.blinkAllLEDs(300);
  }
};

// ===== MEDIA CONTROL FUNCTIONS =====

// Send a consumer control command (press and release)
void sendMediaKey(uint16_t keyCode) {
  if (!isConnected) return;

  // Press key
  consumerReport.usage = keyCode;
  input->setValue((uint8_t*)&consumerReport, sizeof(consumerReport));
  input->notify();

  delay(50);

  // Release key
  consumerReport.usage = 0;
  input->setValue((uint8_t*)&consumerReport, sizeof(consumerReport));
  input->notify();
}

// ===== FEEDBACK FUNCTIONS =====

// Visual feedback for different button types
void showButtonFeedback(int buttonType) {
  switch(buttonType) {
    case 1:  // Play/Pause - All LEDs pulse
      mote.turnOnAllLEDs();
      delay(80);
      mote.turnOffAllLEDs();
      break;
    case 2:  // Volume Up - LED1 & LED2
      mote.turnOnLED1();
      mote.turnOnLED2();
      delay(80);
      mote.turnOffLED1();
      mote.turnOffLED2();
      break;
    case 3:  // Volume Down - LED3 & LED4
      mote.turnOnLED3();
      mote.turnOnLED4();
      delay(80);
      mote.turnOffLED3();
      mote.turnOffLED4();
      break;
    case 4:  // Next Track - Right LEDs (2 & 4)
      mote.turnOnLED2();
      mote.turnOnLED4();
      delay(80);
      mote.turnOffLED2();
      mote.turnOffLED4();
      break;
    case 5:  // Previous Track - Left LEDs (1 & 3)
      mote.turnOnLED1();
      mote.turnOnLED3();
      delay(80);
      mote.turnOffLED1();
      mote.turnOffLED3();
      break;
  }
}

// ===== SETUP =====
void setup() {
  #if DEBUG_SERIAL
  Serial.begin(115200);
  delay(1000);

  Serial.println("==========================================");
  Serial.println("OpenMote BLE Media Controller");
  Serial.println("==========================================");
  Serial.println("Plus/Minus: Volume Up/Down");
  Serial.println("D-Pad L/R: Previous/Next Track");
  Serial.println("A Button: Play/Pause");
  Serial.println("==========================================");
  #endif

  // Initialize OpenMote (no IMU needed for this project)
  mote.begin();

  #if DEBUG_SERIAL
  Serial.println("✓ OpenMote initialized");
  #endif

  // Initialize NimBLE
  NimBLEDevice::init("OpenMote Media Remote");

  #if DEBUG_SERIAL
  Serial.println("✓ BLE initialized");
  #endif

  // Create BLE Server
  NimBLEServer *pServer = NimBLEDevice::createServer();
  pServer->setCallbacks(new ServerCallbacks());

  // Create HID Device
  hid = new NimBLEHIDDevice(pServer);

  // Set HID parameters
  hid->manufacturer()->setValue("OpenMote");
  hid->pnp(0x02, 0xe502, 0xa111, 0x0210);
  hid->hidInfo(0x00, 0x01);

  // Set Report Map
  hid->reportMap((uint8_t*)hidReportDescriptor, sizeof(hidReportDescriptor));

  // Create input report characteristic
  input = hid->inputReport(1);

  // Start HID service
  hid->startServices();

  // Start advertising
  NimBLEAdvertising *pAdvertising = NimBLEDevice::getAdvertising();
  pAdvertising->setAppearance(0x0180); // Generic Remote Control appearance
  pAdvertising->addServiceUUID(hid->hidService()->getUUID());
  pAdvertising->start();

  #if DEBUG_SERIAL
  Serial.println("✓ BLE HID Media Controller started!");
  Serial.println("Connect via Bluetooth to 'OpenMote Media Remote'");
  Serial.println("==========================================");
  Serial.println("");
  #endif

  // Startup sequence - cascade LEDs
  mote.turnOnLED1();
  delay(100);
  mote.turnOnLED2();
  delay(100);
  mote.turnOnLED3();
  delay(100);
  mote.turnOnLED4();
  delay(100);
  mote.turnOffAllLEDs();

  mote.rumblePulse(100);
}

// ===== MAIN LOOP =====
void loop() {
  unsigned long currentTime = millis();

  // Check BLE connection status
  if (isConnected) {

    // ===== PLUS BUTTON - VOLUME UP =====
    bool plusState = mote.isPlusButtonPressed();
    if (plusState) {
      // Allow repeat for volume (hold button = continuous volume change)
      if (!plusButtonPressed || (currentTime - lastVolumeRepeatTime >= VOLUME_REPEAT_DELAY)) {
        if (currentTime - lastDebounceTime >= DEBOUNCE_DELAY_MS) {
          #if DEBUG_SERIAL
          Serial.println(">>> Volume Up");
          #endif
          sendMediaKey(MEDIA_VOLUME_UP);
          showButtonFeedback(2);
          mote.rumblePulse(30);

          plusButtonPressed = true;
          lastDebounceTime = currentTime;
          lastVolumeRepeatTime = currentTime;
        }
      }
    } else {
      plusButtonPressed = false;
    }

    // ===== MINUS BUTTON - VOLUME DOWN =====
    bool minusState = mote.isMinusButtonPressed();
    if (minusState) {
      // Allow repeat for volume (hold button = continuous volume change)
      if (!minusButtonPressed || (currentTime - lastVolumeRepeatTime >= VOLUME_REPEAT_DELAY)) {
        if (currentTime - lastDebounceTime >= DEBOUNCE_DELAY_MS) {
          #if DEBUG_SERIAL
          Serial.println(">>> Volume Down");
          #endif
          sendMediaKey(MEDIA_VOLUME_DOWN);
          showButtonFeedback(3);
          mote.rumblePulse(30);

          minusButtonPressed = true;
          lastDebounceTime = currentTime;
          lastVolumeRepeatTime = currentTime;
        }
      }
    } else {
      minusButtonPressed = false;
    }

    // ===== D-PAD RIGHT - NEXT TRACK =====
    bool dpadRightState = mote.isDPadRightPressed();
    if (dpadRightState && !dpadRightPressed) {
      if (currentTime - lastDebounceTime >= DEBOUNCE_DELAY_MS) {
        #if DEBUG_SERIAL
        Serial.println(">>> Next Track");
        #endif
        sendMediaKey(MEDIA_NEXT_TRACK);
        showButtonFeedback(4);
        mote.rumblePulse(80);

        dpadRightPressed = true;
        lastDebounceTime = currentTime;
      }
    } else if (!dpadRightState) {
      dpadRightPressed = false;
    }

    // ===== D-PAD LEFT - PREVIOUS TRACK =====
    bool dpadLeftState = mote.isDPadLeftPressed();
    if (dpadLeftState && !dpadLeftPressed) {
      if (currentTime - lastDebounceTime >= DEBOUNCE_DELAY_MS) {
        #if DEBUG_SERIAL
        Serial.println(">>> Previous Track");
        #endif
        sendMediaKey(MEDIA_PREV_TRACK);
        showButtonFeedback(5);
        mote.rumblePulse(80);

        dpadLeftPressed = true;
        lastDebounceTime = currentTime;
      }
    } else if (!dpadLeftState) {
      dpadLeftPressed = false;
    }

    // ===== A BUTTON - PLAY/PAUSE =====
    bool aState = mote.isAButtonPressed();
    if (aState && !aButtonPressed) {
      if (currentTime - lastDebounceTime >= DEBOUNCE_DELAY_MS) {
        #if DEBUG_SERIAL
        Serial.println(">>> Play/Pause");
        #endif
        sendMediaKey(MEDIA_PLAY_PAUSE);
        showButtonFeedback(1);
        mote.rumblePulse(100);

        aButtonPressed = true;
        lastDebounceTime = currentTime;
      }
    } else if (!aState) {
      aButtonPressed = false;
    }

  } else {
    // Not connected - reset button states
    plusButtonPressed = false;
    minusButtonPressed = false;
    dpadLeftPressed = false;
    dpadRightPressed = false;
    aButtonPressed = false;

    // Show BLE disconnected status with LED2 slow blink
    static unsigned long lastBlinkTime = 0;
    static bool blinkState = false;
    if (currentTime - lastBlinkTime >= 1000) {
      blinkState = !blinkState;
      if (blinkState) {
        mote.turnOnLED2();
      } else {
        mote.turnOffLED2();
      }
      lastBlinkTime = currentTime;
    }
  }

  delay(10);
}

Discussions