-
X-Rays, Microphones and More!
12/05/2025 at 21:42 • 0 commentsHello Hackers!
I'm back with another update to OpenMote, we are closer than we've ever been to the launch and start of our crowdsupply campaign! https://www.crowdsupply.com/hat-and-hammer/openmote
Things have been very busy behind the scenes!
- a major layout overhaul as we migrated from SoC to Wroom for the esp32s3- added a I2S PDM microphone for that sweet, sweet voice integration
- removed the microSD card slot on the board as the Wroom has larger internal storage to make up for the lost storage
- added a bunch of RF shielding and via stitching to improve RF preformance
- Optimized trace width and routing for better power management and heat dissipation
I'm really excited to get some finalized demo code running on these boards and get them into the hands of some lovely creators and makers in the electronics space William Osman
I've attached a fun little screenshot from our manufacturer who did an X-Ray of the boards to verify part soldering. (grrr hackaday won't let me upload the image) google drive link to image
Stay tuned as we spin up towards production, the team is really excited to get OpenMote into the world and the hands of makers and electronics people. -
Wii remote as a TV remote!
11/04/2025 at 02:14 • 0 commentsWelcome 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 :)---------- more ----------/* * 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); } -
OpenMote Working as a Spotify Controller
10/28/2025 at 03:15 • 0 commentsHello 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); } -
BLE keyboard for computer gaming
10/13/2025 at 19:36 • 0 commentsGreetings! As promised today I'm going to be talking about turning OpenMote into a BLE keyboard for computer control!
For some low level gaming and simple keystroke replacement this project log is all you need to know! Today I configured OpenMote to play the Dino game and pacman on my computer wirelessly!
I'm hoping to get some more complex and complicated gaming controller connections -- think dolphin -- in the next couple of weeks!
If you're looking for a fun and silly way to connect and control your computer with a wii-remote then look no further than the code I provide today. I tried getting it to work with a couple other BLE libraries and found the NimBLE was easily the best one as it worked first time out of the box.
Enjoy and stay fun!#include <Arduino.h> #include <NimBLEDevice.h> #include <NimBLEHIDDevice.h> // Pin definitions based on your custom board #define A_BUTT 14 #define UP_BUTT 11 #define DOWN_BUTT 12 #define LEFT_BUTT 4 #define RIGHT_BUTT 15 // BLE HID Keyboard NimBLEHIDDevice* hid; NimBLECharacteristic* input; NimBLECharacteristic* output; // Button state variables bool aButtonPressed = false; bool upButtonPressed = false; bool downButtonPressed = false; bool leftButtonPressed = false; bool rightButtonPressed = false; unsigned long lastDebounceTime = 0; const unsigned long debounceDelay = 50; // Status tracking unsigned long lastStatusTime = 0; const unsigned long statusInterval = 5000; bool isConnected = false; // HID Report Descriptor for Keyboard const uint8_t hidReportDescriptor[] = { 0x05, 0x01, // Usage Page (Generic Desktop) 0x09, 0x06, // Usage (Keyboard) 0xA1, 0x01, // Collection (Application) 0x85, 0x01, // Report ID (1) 0x05, 0x07, // Usage Page (Key Codes) 0x19, 0xE0, // Usage Minimum (224) 0x29, 0xE7, // Usage Maximum (231) 0x15, 0x00, // Logical Minimum (0) 0x25, 0x01, // Logical Maximum (1) 0x75, 0x01, // Report Size (1) 0x95, 0x08, // Report Count (8) 0x81, 0x02, // Input (Data, Variable, Absolute) 0x95, 0x01, // Report Count (1) 0x75, 0x08, // Report Size (8) 0x81, 0x01, // Input (Constant) 0x95, 0x06, // Report Count (6) 0x75, 0x08, // Report Size (8) 0x15, 0x00, // Logical Minimum (0) 0x25, 0x65, // Logical Maximum (101) 0x05, 0x07, // Usage Page (Key Codes) 0x19, 0x00, // Usage Minimum (0) 0x29, 0x65, // Usage Maximum (101) 0x81, 0x00, // Input (Data, Array) 0xC0 // End Collection }; // Keyboard report structure typedef struct { uint8_t modifiers; uint8_t reserved; uint8_t keys[6]; } KeyReport; KeyReport keyReport = {0}; // BLE Server Callbacks class ServerCallbacks: public NimBLEServerCallbacks { void onConnect(NimBLEServer* pServer) { isConnected = true; Serial.println(">>> BLE Client Connected!"); } void onDisconnect(NimBLEServer* pServer) { isConnected = false; Serial.println(">>> BLE Client Disconnected!"); NimBLEDevice::startAdvertising(); } }; void sendKey(uint8_t key) { keyReport.keys[0] = key; input->setValue((uint8_t*)&keyReport, sizeof(keyReport)); input->notify(); delay(50); // Release keyReport.keys[0] = 0; input->setValue((uint8_t*)&keyReport, sizeof(keyReport)); input->notify(); } void setup() { // Initialize serial for debugging Serial.begin(115200); delay(1000); Serial.println("================================="); Serial.println("OpenMote NimBLE Keyboard Starting..."); Serial.println("================================="); // Configure button pins pinMode(A_BUTT, INPUT_PULLUP); pinMode(UP_BUTT, INPUT_PULLUP); pinMode(DOWN_BUTT, INPUT_PULLUP); pinMode(LEFT_BUTT, INPUT_PULLUP); pinMode(RIGHT_BUTT, INPUT_PULLUP); // Initialize NimBLE NimBLEDevice::init("OpenMote Controller"); // 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(0x03C1); // Keyboard appearance pAdvertising->addServiceUUID(hid->hidService()->getUUID()); pAdvertising->start(); Serial.println("BLE HID Keyboard started!"); Serial.println("Connect via Bluetooth to 'OpenMote Controller'"); Serial.println("================================="); } void loop() { unsigned long currentTime = millis(); // Periodic status message if (currentTime - lastStatusTime > statusInterval) { lastStatusTime = currentTime; Serial.print("Status: "); Serial.print(isConnected ? "BLE Connected ✓" : "BLE Disconnected ✗"); Serial.print(" | A Button: "); Serial.println(digitalRead(A_BUTT) == LOW ? "PRESSED" : "Released"); } // Check if BLE keyboard is connected if(isConnected) { // A Button - Spacebar bool aState = (digitalRead(A_BUTT) == LOW); if (aState && !aButtonPressed) { if ((currentTime - lastDebounceTime) > debounceDelay) { aButtonPressed = true; lastDebounceTime = currentTime; Serial.println(">>> A Button - Spacebar"); sendKey(0x2C); // Spacebar } } else if (!aState && aButtonPressed) { aButtonPressed = false; } // UP Button - Up Arrow bool upState = (digitalRead(UP_BUTT) == LOW); if (upState && !upButtonPressed) { if ((currentTime - lastDebounceTime) > debounceDelay) { upButtonPressed = true; lastDebounceTime = currentTime; Serial.println(">>> UP Button - Up Arrow"); sendKey(0x52); // Up Arrow } } else if (!upState && upButtonPressed) { upButtonPressed = false; } // DOWN Button - Down Arrow bool downState = (digitalRead(DOWN_BUTT) == LOW); if (downState && !downButtonPressed) { if ((currentTime - lastDebounceTime) > debounceDelay) { downButtonPressed = true; lastDebounceTime = currentTime; Serial.println(">>> DOWN Button - Down Arrow"); sendKey(0x51); // Down Arrow } } else if (!downState && downButtonPressed) { downButtonPressed = false; } // LEFT Button - Left Arrow bool leftState = (digitalRead(LEFT_BUTT) == LOW); if (leftState && !leftButtonPressed) { if ((currentTime - lastDebounceTime) > debounceDelay) { leftButtonPressed = true; lastDebounceTime = currentTime; Serial.println(">>> LEFT Button - Left Arrow"); sendKey(0x50); // Left Arrow } } else if (!leftState && leftButtonPressed) { leftButtonPressed = false; } // RIGHT Button - Right Arrow bool rightState = (digitalRead(RIGHT_BUTT) == LOW); if (rightState && !rightButtonPressed) { if ((currentTime - lastDebounceTime) > debounceDelay) { rightButtonPressed = true; lastDebounceTime = currentTime; Serial.println(">>> RIGHT Button - Right Arrow"); sendKey(0x4F); // Right Arrow } } else if (!rightState && rightButtonPressed) { rightButtonPressed = false; } } else { // Reset button states when disconnected aButtonPressed = false; upButtonPressed = false; downButtonPressed = false; leftButtonPressed = false; rightButtonPressed = false; } // Small delay to prevent overwhelming the processor delay(10); } -
OpenMote Is Compatible with Esphome & Home Assistant
10/11/2025 at 04:57 • 0 commentsHello again hackers!
I come bearing good news about the universal remote aspect of OpenMote! After 4 hours of bug fixing and chasing a corrupt platformio file I am happy to announce that OpenMote is fully compatible with Esphome and by extension Home assistant! I've provided a lovely gif showing the wireless and automation features of OpenMote!https://drive.google.com/file/d/1KpuRrlwi4Olt1J8T5HpseYAywZVrz9lH/view?usp=sharing (sorry for the link can't figure out how to attach a gif or webp...)
In this demo OpenMote is Connecting to a home Wifi Network, sending MQTT calls to another esp32s3 and has full OTA (over the air updating) functionality.
I'm quite proud of the universality of OpenMote and am excited to see what everyone is able to build and control with it!
Tune in and follow this project as next update will be about Bluetooth keyboards and the possibilities that this opens up for OpenMote to control not just your computer but even your phone!?I've attached the YAML file for OpenMote and the Light in this post so check it out if you want to replicate my project, you'll just have to switch keys for your own configurations.
esphome: name: light friendly_name: Light esp32: board: esp32-s3-devkitc-1 framework: type: arduino # Enable logging logger: # Enable Home Assistant API api: encryption: key: "" ota: - platform: esphome password: "" wifi: ssid: !secret wifi_ssid password: !secret wifi_password # Enable fallback hotspot (captive portal) in case wifi connection fails ap: ssid: "Light Fallback Hotspot" password: "" mqtt: broker: # 👈 MUST be your computer’s local IP port: 1883 discovery: false # Listen for commands from the remote on_message: # Toggle command for the A button - topic: light_strip/command/toggle then: - light.toggle: my_led_strip # Next Color command for Button 1 - topic: light_strip/command/color_up then: - lambda: |- static const std::vector<Color> colors = { Color(255, 0, 0), // Red Color(255, 165, 0), // Orange Color(255, 255, 0), // Yellow Color(0, 255, 0), // Green Color(0, 0, 255), // Blue Color(75, 0, 130), // Indigo Color(238, 130, 238),// Violet Color(255, 255, 255) // White }; id(color_index) = (id(color_index) + 1) % colors.size(); auto new_color = colors[id(color_index)]; auto call = id(my_led_strip).turn_on(); // ✅ CORRECTED LINE: Pass the R, G, and B components separately. call.set_rgb(new_color.r / 255.0f, new_color.g / 255.0f, new_color.b / 255.0f); call.perform(); # Previous Color command for Button 2 - topic: light_strip/command/color_down then: - lambda: |- static const std::vector<Color> colors = { Color(255, 0, 0), // Red Color(255, 165, 0), // Orange Color(255, 255, 0), // Yellow Color(0, 255, 0), // Green Color(0, 0, 255), // Blue Color(75, 0, 130), // Indigo Color(238, 130, 238),// Violet Color(255, 255, 255) // White }; id(color_index)--; if (id(color_index) < 0) { id(color_index) = colors.size() - 1; } auto new_color = colors[id(color_index)]; auto call = id(my_led_strip).turn_on(); // ✅ CORRECTED LINE: Pass the R, G, and B components separately. call.set_rgb(new_color.r / 255.0f, new_color.g / 255.0f, new_color.b / 255.0f); call.perform(); # A global variable to keep track of which color is currently selected globals: - id: color_index type: int restore_value: no initial_value: '0' # Define the LED strip light: - platform: fastled_clockless id: my_led_strip name: "LED Strip" pin: GPIO8 num_leds: 86 chipset: WS2812B rgb_order: GRB captive_portal:esphome: name: openmote friendly_name: openmote esp32: variant: ESP32S3 framework: type: arduino # Enable logging logger: mqtt: broker: xxx.xxx.x.xxx # 👈 replace with your computer’s local IP or your MQTT server's IP port: 1883 discovery: false id: mqtt_client ssl: false # Enable Home Assistant API api: encryption: key: "" #replace with your own Key! ota: - platform: esphome password: "" #replace with your own Key! wifi: ssid: !secret wifi_ssid password: !secret wifi_password # Enable fallback hotspot (captive portal) in case wifi connection fails ap: ssid: "Openmote Fallback Hotspot" password: "" #replace with your own Key! binary_sensor: - platform: gpio name: "A Button" pin: number: GPIO14 mode: INPUT_PULLUP inverted: True on_press: - mqtt.publish: topic: light_strip/command/toggle payload: "TOGGLE" - platform: gpio name: "Button 1 (Next Color)" pin: number: GPIO37 mode: INPUT_PULLUP inverted: True on_press: - mqtt.publish: topic: light_strip/command/color_up payload: "UP" - platform: gpio name: "Button 2 (Previous Color)" pin: number: GPIO38 mode: INPUT_PULLUP inverted: True on_press: - mqtt.publish: topic: light_strip/command/color_down payload: "DOWN" captive_portal: -
OpenMote is Alive and Well!
10/08/2025 at 01:16 • 0 commentsHey everyone! Sorry for the radio silence, life kinda happens sometimes! Enough of that lets talk OpenMote...
Updates are going to start flowing in! From some silkscreen errors to fix to building out a comprehensive library there's much work to do on OpenMote before the launch.I'm currently working on building out an OpenMote library and Board definitions to make it more approachable and easier to program for makers just getting started on their hardware journey. I had an idea for a drag and drop OpenMote website for programming and wanted to know if anyone had any experience with a dev board providing that level of support to makers.
Enjoy a sneak-peak screenshot at the OpenMote library. Check back in the next couple days and I'll be going over manufacturing updates, Wiki deep-dives and other goodies!Keep hacking :)
-
OpenMote Gen 3.0 boards!
03/12/2025 at 05:14 • 0 commentsBIG announcement for openmote coming soon! PS. We're re-launching better than ever! Expect a TON more documentation and social posts about it soon!
OpenMote has been slowly improving in the background and I'm more than proud to announce that our most recent generation of designs is ready to hit the shelves! (well metaphorical shelves) complete with an micro SD card reader for those large audio files. A QWIIC connector for any additional boards that you want to add on, thanks everyone who joined us in the adafruit show and tell streams for this amazing add on.
Some Quality of life improvements we made were:- moving the LiPo connector to be accessible from the back battery clip
- Moving the Qwiic connector to be accessible again from the back battery clip
- finally put the IR Leds on the board (control your TV with a wii remote?!)
- a bunch of other under the hood things that will make working with openmote a makers dream
enjoy this artsy picture of our hotel room for CES 2025 this year where we were hard at work writing code and testing 3d prints for OpenMote Gen3.0
![]()
-
Building a Testbed for getting Audio Out
07/15/2024 at 23:51 • 0 commentsOne of the more difficult things we've had to deal with was the loss of the DAC on the ESP32-S3 framework. Without an onboard DAC we've had to get creative with driving the speaker on OpenMote. We started out with a MAX98357 a very popular I2S DAC + AMP combo IC that manages all things mono speakers. However this component was proving to be unreliable from our PCBA supplier, they were pricey and stocked in scary short numbers.
To solve this problem we dove into how to get an analog signal from the ESP without having it be an actual analog signal. This lead to the discovery of PDM and sigma delta modulation. Special shoutout to @atomic14 (Chris Greening) for his illuminating blog post about how to drive a speaker from an esp32-s3 without the need for an external DAC.
After doing some research on amp and looking at Espressif's own documentation and guides we found an active amp that uses a TL07x. A few hours later and we've whipped up a testbed for the active amp and a microSD card slot to test all our audio needs. On this testbed we also included a 3.3V LDO to test the difference in volume from the speaker between 3.3v and 5v. Lastly we also included a little notch near the USB-C port to remove the need to clip a little piece of plastic on the old game controllers.
![]()
![]()
Gangwa Labs

