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);
}
Gangwa Labs
Discussions
Become a Hackaday.io Member
Create an account to leave a comment. Already have an account? Log In.