Close

Wii remote as a TV remote!

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 11/04/2025 at 02:140 Comments

Welcome back Hackers!

I have finally completed a big goal and test that I set out way back when this project was simply just an idea: What if you could use a wii remote to control your TV? A pretty simple idea in concept and a pretty silly one at that.

Turns out smart TVs while being smart can also be a propriety pain in the behind. For example, the FireTV that I have doesn't actually communicate with the TV only over IR! In fact it communicates through bluetooth! and uses the IR as a backup. This is good and bad for OpenMote. 

It's good because writing a sketch to turn openmote into a bluetooth keyboard is SUPER easy. It Does mean however that I need to bust out the IR receiver to borrow the NEC IR code from the stock remote to use it to power on my TV. 


All that to say, I am the proud owner of a WII remote that acts as a tv remote, connects via Bluetooth (meaning you don't actually need to point the remote at the TV to get the signals to send) and it can even turn the TV on or off a pretty important feature of a remote. Even better is that you can turn up and down the volume, do pretty much anything the old remote could do.

I'm pretty proud of this update as it's proved a vitally important feature of OpenMote is feasible beyond just theory but in practice it works.


As usual I've attached the code below, be mindful that it might not work with your TV and could require some reworking. Also huge shout out to the wonderful humans that wrote the OMOTE-Firmware as I used a heavy amount of inspiration and their code to get this working on my TV.

See you next week :)

/*
 * OpenMote BLE TV Remote for Fire TV + IR Power Control
 *
 * Turn your OpenMote into a hybrid remote control!
 * - BLE HID keyboard for Fire TV navigation
 * - IR transmission for TV power control
 *
 * Button Mappings:
 * - Power Button     → IR Power Command (NEC: 0x40BE629D, 38kHz)
 * - A Button         → Select/OK (Enter key)
 * - B Button         → Back (Escape key)
 * - Home Button      → Home Menu (consumer control)
 * - D-Pad Arrows     → Navigation (Arrow keys)
 * - Plus Button      → Volume Up (consumer control)
 * - Minus Button     → Volume Down (consumer control)
 *
 * Features:
 * - BLE HID Keyboard (Fire TV compatible)
 * - IR transmission using NEC protocol (38kHz carrier)
 * - Consumer control for media functions
 * - LED feedback for button presses
 * - Haptic feedback on actions
 * - Connection status indicators
 * - Automatic pairing/bonding support
 *
 * Libraries:
 * - ESP32-BLE-Keyboard by T-vK (with NimBLE mode)
 * - ESP32 LEDC for IR PWM generation
 */

#include <Arduino.h>
#include <OpenMote.h>
#include <BleKeyboard.h>

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

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

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

// BLE Keyboard with custom name
BleKeyboard bleKeyboard("OpenMote", "OpenMote.io", 100);

// ===== STATE VARIABLES =====
bool wasConnected = false;

// Button state tracking
bool powerButtonPressed = false;
bool homeButtonPressed = false;
bool aButtonPressed = false;
bool bButtonPressed = false;
bool plusButtonPressed = false;
bool minusButtonPressed = false;
bool dpadUpPressed = false;
bool dpadDownPressed = false;
bool dpadLeftPressed = false;
bool dpadRightPressed = false;

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

// ===== IR TRANSMISSION CONFIGURATION =====
#define IR_LED_PIN_40DEG    10  // GPIO 10 - 40° viewing angle IR LED
#define IR_LED_PIN_20DEG    16  // GPIO 16 - 20° viewing angle IR LED
#define IR_PWM_CHANNEL_1    0   // LEDC channel 0 for 40° LED
#define IR_PWM_CHANNEL_2    1   // LEDC channel 1 for 20° LED
#define IR_CARRIER_FREQ     38000  // 38kHz carrier for NEC protocol
#define IR_DUTY_CYCLE       85  // 33% duty cycle (85/255)

// NEC Protocol timing (in microseconds)
#define NEC_HDR_MARK    9000
#define NEC_HDR_SPACE   4500
#define NEC_BIT_MARK    560
#define NEC_ONE_SPACE   1690
#define NEC_ZERO_SPACE  560

// ===== IR TRANSMISSION FUNCTIONS =====

// Setup PWM for IR carrier frequency on 40° LED
void setupIRPWM() {
  // Configure LEDC for 38kHz PWM on 40° IR LED pin
  ledcSetup(IR_PWM_CHANNEL_1, IR_CARRIER_FREQ, 8); // 8-bit resolution
  ledcAttachPin(IR_LED_PIN_40DEG, IR_PWM_CHANNEL_1);
  ledcWrite(IR_PWM_CHANNEL_1, 0); // Start with IR off

  // Disable the 20° LED for now
  pinMode(IR_LED_PIN_20DEG, OUTPUT);
  digitalWrite(IR_LED_PIN_20DEG, LOW);
}

// Turn 40° IR LED on (with carrier)
void irOn() {
  ledcWrite(IR_PWM_CHANNEL_1, IR_DUTY_CYCLE);  // 33% duty cycle
}

// Turn IR LED off
void irOff() {
  ledcWrite(IR_PWM_CHANNEL_1, 0);
}

// Send a mark (IR on) for specified microseconds
void mark(uint16_t time) {
  irOn();
  delayMicroseconds(time);
}

// Send a space (IR off) for specified microseconds
void space(uint16_t time) {
  irOff();
  delayMicroseconds(time);
}

// Send a single NEC bit
void sendNECBit(bool bit) {
  mark(NEC_BIT_MARK);
  space(bit ? NEC_ONE_SPACE : NEC_ZERO_SPACE);
}

// Send complete NEC code (32 bits)
void sendNECCode(uint32_t code) {
  // Send AGC burst (header)
  mark(NEC_HDR_MARK);
  space(NEC_HDR_SPACE);

  // Send 32 bits (LSB first)
  for (int i = 0; i < 32; i++) {
    sendNECBit(code & 1);
    code >>= 1;
  }

  // Send final mark (stop bit)
  mark(NEC_BIT_MARK);
  irOff();
}

// Send raw timing data (copied from TV remote IR capture)
void sendRawTiming() {
  uint16_t rawData[68] = {
    9072, 4470, 606, 562, 584, 1680, 560, 584,
    586, 560, 584, 562, 558, 588, 558, 586,
    560, 586, 562, 1704, 560, 586, 562, 1702,
    584, 1682, 562, 1704, 558, 1706, 564, 1704,
    562, 582, 566, 580, 586, 1680, 562, 1704,
    560, 586, 560, 584, 562, 584, 560, 1704,
    560, 586, 558, 1706, 560, 566, 578, 570,
    576, 1706, 562, 1682, 580, 1686, 604, 540,
    580, 1684, 580
  };

  // Send alternating mark/space pairs from raw timing
  for (int i = 0; i < 67; i += 2) {
    mark(rawData[i]);
    if (i + 1 < 67) {
      space(rawData[i + 1]);
    }
  }

  // Final mark
  mark(rawData[67]);
  irOff();
}

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

void showButtonFeedback(int buttonType) {
  switch(buttonType) {
    case 1:  // Power - All LEDs pulse
      mote.turnOnAllLEDs();
      delay(100);
      mote.turnOffAllLEDs();
      break;
    case 2:  // Select - LED1 & LED3 diagonal
      mote.turnOnLED1();
      mote.turnOnLED3();
      delay(80);
      mote.turnOffLED1();
      mote.turnOffLED3();
      break;
    case 3:  // Navigation - Single LED flash
      mote.turnOnLED2();
      delay(60);
      mote.turnOffLED2();
      break;
    case 4:  // Volume Up - LED1 & LED2
      mote.turnOnLED1();
      mote.turnOnLED2();
      delay(70);
      mote.turnOffLED1();
      mote.turnOffLED2();
      break;
    case 5:  // Volume Down - LED3 & LED4
      mote.turnOnLED3();
      mote.turnOnLED4();
      delay(70);
      mote.turnOffLED3();
      mote.turnOffLED4();
      break;
    case 6:  // Back - LED2 & LED4 diagonal
      mote.turnOnLED2();
      mote.turnOnLED4();
      delay(80);
      mote.turnOffLED2();
      mote.turnOffLED4();
      break;
    case 7:  // Home - All LEDs quick pulse
      mote.turnOnAllLEDs();
      delay(120);
      mote.turnOffAllLEDs();
      break;
  }
}

// ===== SETUP =====
void setup() {
  // Initialize Serial FIRST with longer delay for USB CDC
  Serial.begin(115200);
  delay(3000);  // Longer delay for USB CDC to fully enumerate

  Serial.println("\n\n\n");
  Serial.println("==========================================");
  Serial.println("SERIAL OUTPUT TEST - If you see this, serial works!");
  Serial.println("OpenMote BLE TV Remote for Fire TV + IR");
  Serial.println("==========================================");
  Serial.flush();

  // Initialize OpenMote (no IMU needed)
  Serial.println("Initializing OpenMote...");
  Serial.flush();
  mote.begin();
  Serial.println("✓ OpenMote initialized");
  Serial.flush();

  // Initialize IR transmission hardware
  Serial.println("Initializing IR transmitter...");
  Serial.flush();
  setupIRPWM();
  Serial.println("✓ IR transmitter initialized (38kHz NEC protocol)");
  Serial.flush();

  // Start BLE Keyboard
  Serial.println("Starting BLE Keyboard...");
  Serial.flush();
  bleKeyboard.begin();
  Serial.println("✓ BLE Keyboard started!");
  Serial.flush();

  Serial.println("==========================================");
  Serial.println("Button Mappings:");
  Serial.println("  Power: IR Power Command");
  Serial.println("  Button1: Raw IR LED test");
  Serial.println("  Button2: PWM IR LED test");
  Serial.println("  A/B/Home/DPad/+/-: BLE controls");
  Serial.println("==========================================");
  Serial.println("Setup complete! Waiting for button presses...");
  Serial.println("");
  Serial.flush();

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

  Serial.println("LED startup sequence complete!");
  Serial.flush();
}

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

  // Check connection status changes
  if (bleKeyboard.isConnected()) {
    if (!wasConnected) {
      wasConnected = true;
      #if DEBUG_SERIAL
      Serial.println(">>> Fire TV Connected!");
      #endif

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

    // ===== A BUTTON - SELECT/OK =====
    bool aState = mote.isAButtonPressed();
    if (aState && !aButtonPressed) {
      if (currentTime - lastDebounceTime >= DEBOUNCE_DELAY_MS) {
        #if DEBUG_SERIAL
        Serial.println(">>> Select (Enter)");
        #endif
        bleKeyboard.write(KEY_RETURN);
        showButtonFeedback(2);
        mote.rumblePulse(80);

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

    // ===== B BUTTON - BACK =====
    bool bState = mote.isBButtonPressed();
    if (bState && !bButtonPressed) {
      if (currentTime - lastDebounceTime >= DEBOUNCE_DELAY_MS) {
        #if DEBUG_SERIAL
        Serial.println(">>> Back (Escape)");
        #endif
        bleKeyboard.write(KEY_ESC);
        showButtonFeedback(6);
        mote.rumblePulse(80);

        bButtonPressed = true;
        lastDebounceTime = currentTime;
      }
    } else if (!bState) {
      bButtonPressed = false;
    }

    // ===== HOME BUTTON - HOME MENU =====
    bool homeState = mote.isHomeButtonPressed();
    if (homeState && !homeButtonPressed) {
      if (currentTime - lastDebounceTime >= DEBOUNCE_DELAY_MS) {
        #if DEBUG_SERIAL
        Serial.println(">>> Home");
        #endif
        // Fire TV Home button - try consumer control
        bleKeyboard.press(KEY_MEDIA_WWW_HOME);
        delay(50);
        bleKeyboard.release(KEY_MEDIA_WWW_HOME);
        showButtonFeedback(7);
        mote.rumblePulse(100);

        homeButtonPressed = true;
        lastDebounceTime = currentTime;
      }
    } else if (!homeState) {
      homeButtonPressed = false;
    }

    // ===== 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
          bleKeyboard.press(KEY_MEDIA_VOLUME_UP);
          delay(50);
          bleKeyboard.release(KEY_MEDIA_VOLUME_UP);
          showButtonFeedback(4);
          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
          bleKeyboard.press(KEY_MEDIA_VOLUME_DOWN);
          delay(50);
          bleKeyboard.release(KEY_MEDIA_VOLUME_DOWN);
          showButtonFeedback(5);
          mote.rumblePulse(30);

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

    // ===== D-PAD UP - NAVIGATE UP =====
    bool dpadUpState = mote.isDPadUpPressed();
    if (dpadUpState && !dpadUpPressed) {
      if (currentTime - lastDebounceTime >= DEBOUNCE_DELAY_MS) {
        #if DEBUG_SERIAL
        Serial.println(">>> Navigate Up");
        #endif
        bleKeyboard.write(KEY_UP_ARROW);
        showButtonFeedback(3);
        mote.rumblePulse(40);

        dpadUpPressed = true;
        lastDebounceTime = currentTime;
      }
    } else if (!dpadUpState) {
      dpadUpPressed = false;
    }

    // ===== D-PAD DOWN - NAVIGATE DOWN =====
    bool dpadDownState = mote.isDPadDownPressed();
    if (dpadDownState && !dpadDownPressed) {
      if (currentTime - lastDebounceTime >= DEBOUNCE_DELAY_MS) {
        #if DEBUG_SERIAL
        Serial.println(">>> Navigate Down");
        #endif
        bleKeyboard.write(KEY_DOWN_ARROW);
        showButtonFeedback(3);
        mote.rumblePulse(40);

        dpadDownPressed = true;
        lastDebounceTime = currentTime;
      }
    } else if (!dpadDownState) {
      dpadDownPressed = false;
    }

    // ===== D-PAD LEFT - NAVIGATE LEFT =====
    bool dpadLeftState = mote.isDPadLeftPressed();
    if (dpadLeftState && !dpadLeftPressed) {
      if (currentTime - lastDebounceTime >= DEBOUNCE_DELAY_MS) {
        #if DEBUG_SERIAL
        Serial.println(">>> Navigate Left");
        #endif
        bleKeyboard.write(KEY_LEFT_ARROW);
        showButtonFeedback(3);
        mote.rumblePulse(40);

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

    // ===== D-PAD RIGHT - NAVIGATE RIGHT =====
    bool dpadRightState = mote.isDPadRightPressed();
    if (dpadRightState && !dpadRightPressed) {
      if (currentTime - lastDebounceTime >= DEBOUNCE_DELAY_MS) {
        #if DEBUG_SERIAL
        Serial.println(">>> Navigate Right");
        #endif
        bleKeyboard.write(KEY_RIGHT_ARROW);
        showButtonFeedback(3);
        mote.rumblePulse(40);

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

  } else {
    // Not connected - reset connection state and button states
    if (wasConnected) {
      wasConnected = false;
      #if DEBUG_SERIAL
      Serial.println(">>> Fire TV Disconnected!");
      Serial.println("Waiting for reconnection...");
      #endif
    }

    // Don't reset IR button states - they work without BLE
    homeButtonPressed = false;
    aButtonPressed = false;
    bButtonPressed = false;
    plusButtonPressed = false;
    minusButtonPressed = false;
    dpadUpPressed = false;
    dpadDownPressed = false;
    dpadLeftPressed = false;
    dpadRightPressed = 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;
    }
  }

  // ===== IR BUTTONS - WORK REGARDLESS OF BLE CONNECTION =====

  // ===== POWER BUTTON - IR POWER COMMAND =====
  bool powerState = mote.isPowerButtonPressed();
  if (powerState && !powerButtonPressed) {
    if (currentTime - lastDebounceTime >= DEBOUNCE_DELAY_MS) {
      Serial.println(">>> POWER BUTTON PRESSED");
      Serial.println("Sending IR code: 0xB9467D02");
      Serial.flush();

      // Send the code that the IR reader detects
      sendNECCode(0xB9467D02);

      Serial.println("IR code sent!");
      Serial.flush();

      showButtonFeedback(1);
      mote.rumblePulse(150);

      powerButtonPressed = true;
      lastDebounceTime = currentTime;
    }
  } else if (!powerState) {
    powerButtonPressed = false;
  }

  // ===== BUTTON 1 - IR LED RAW TEST (Direct GPIO, no PWM) =====
  static bool button1Pressed = false;
  bool button1State = mote.isButton1Pressed();
  if (button1State && !button1Pressed) {
    if (currentTime - lastDebounceTime >= DEBOUNCE_DELAY_MS) {
      Serial.println(">>> BUTTON 1 PRESSED - IR RAW TEST");
      Serial.flush();

      // Detach from PWM and drive GPIO directly
      ledcDetachPin(IR_LED_PIN_40DEG);
      pinMode(IR_LED_PIN_40DEG, OUTPUT);
      digitalWrite(IR_LED_PIN_40DEG, HIGH);
      delay(1000);
      digitalWrite(IR_LED_PIN_40DEG, LOW);

      // Re-attach to PWM
      ledcAttachPin(IR_LED_PIN_40DEG, IR_PWM_CHANNEL_1);

      Serial.println("IR raw test complete!");
      Serial.flush();

      showButtonFeedback(2);
      mote.rumblePulse(100);

      button1Pressed = true;
      lastDebounceTime = currentTime;
    }
  } else if (!button1State) {
    button1Pressed = false;
  }

  // ===== BUTTON 2 - IR LED PWM TEST (38kHz carrier) =====
  static bool button2Pressed = false;
  bool button2State = mote.isButton2Pressed();
  if (button2State && !button2Pressed) {
    if (currentTime - lastDebounceTime >= DEBOUNCE_DELAY_MS) {
      Serial.println(">>> BUTTON 2 PRESSED - IR PWM TEST");
      Serial.flush();

      // Turn on IR LED with carrier for 1 second
      irOn();
      delay(1000);
      irOff();

      Serial.println("IR PWM test complete!");
      Serial.flush();

      showButtonFeedback(6);
      mote.rumblePulse(100);

      button2Pressed = true;
      lastDebounceTime = currentTime;
    }
  } else if (!button2State) {
    button2Pressed = false;
  }

  delay(10);
}

Discussions