← Scrimptech ☰ Contents
Complete Build Guide

serial-bt: BLE → USB Keyboard Bridge

A from-scratch guide to building, flashing, and using a Bluetooth-to-USB-HID bridge on the ESP32-S3 — so you can type from your phone into any computer.

Platform: ESP32-S3 · Framework: Arduino · BLE: NimBLE · USB: TinyUSB HID · ~30 min

⚡ Quick Start

  1. Flash — connect the board, run pio run -t upload in PlatformIO
  2. Pair — in Serial Bluetooth Terminal, connect to the device named Serial-BT
  3. Type — focus any text field on your PC, then type in the app and hit send

Full details below ⬇️

1. What It Does

The serial-bt project turns an ESP32-S3 into a wireless keyboard bridge. It listens for text sent over Bluetooth (BLE) and retypes that text into whatever computer the board is plugged into — as if it were a physical USB keyboard.

In plain terms: you pair your phone to the board, type something in a BLE serial app, and the text appears on your PC. No drivers, no special software on the PC — the ESP32-S3 presents itself as a standard USB keyboard.

Why you'd want it

Send text or commands from a phone to a PC that has no Bluetooth, add quick shortcuts, or type into machines where you can't install anything.

Why it's clever

USB HID is a universal standard. Any OS (Windows, macOS, Linux) recognizes the board instantly as a keyboard with zero configuration.

💡 This is the "plain typing" version. The bt_kb variant on your machine adds hotkey triggers like !term and !cc — this guide covers the simpler serial-bt firmware.

2. How It Works

Three moving parts pass the text along a chain:

📱 Phone BLE Serial app writes text to NUS BLE ⚡ ESP32-S3 NimBLE server Nordic UART Service writes → HIDKeyboard USB HID Keyboard class USB 🖥️ PC sees a keyboard text appears as typing

The protocol in detail

The board runs a BLE server exposing the Nordic UART Service (NUS) — a widely-used profile with the UUID 6E400001-B5A3-F393-E0A9-E50E24DCCA9E. NUS is the de-facto standard for "send raw text over BLE" apps, so most BLE serial apps in app stores work with it out of the box. When the phone writes a chunk of bytes to the write characteristic, the firmware's callback grabs those bytes and feeds them one character at a time into the USB HID keyboard object, which the host PC reads as keystrokes.

3. Parts You Need

Because everything happens over the board's built-in USB port and radio, the parts list is tiny:

ItemWhyNotes
ESP32-S3 dev boardRuns the firmware; has native USB + BLEAny S3 board with a USB port works (DevKitC-1, M5Stack, etc.)
USB-C / micro-USB cablePower + HID output to the PCMust be a data cable, not charge-only
A PC (Windows/macOS/Linux)The thing you type intoAny OS — HID works everywhere
A phone with a BLE serial appSource of the textRecommended: Serial Bluetooth Terminal by Kai Morich (Android)
ESP32-S3 development board

Your ESP32-S3 dev board — the only real hardware needed.

ESP32-S3 pinout reference

Reference pinout for the ESP32-S3 DevKitC-1. This project needs no GPIO wiring — only the USB port.

⚠️ A "charge-only" USB cable will power the board but will not appear as a keyboard. If the board doesn't enumerate, try a known data cable.

4. Toolchain Setup

1

Install VS Code

Download and install Visual Studio Code for your OS.

2

Install the PlatformIO extension

In VS Code, open the Extensions panel (Ctrl+Shift+X), search for PlatformIO IDE, and install it. PlatformIO handles the entire ESP32 toolchain — compiler, libraries, flashing — so you don't need to install Arduino IDE or ESP-IDF separately.

💡 PlatformIO bundles the ESP32 toolchain automatically on first build (a few hundred MB download the first time).
3

Clone the repo

Clone the project to your machine (or create the files manually in the next section):

git clone https://github.com/charlesscrimpshire/serial-bt.git
cd serial-bt
4

Open the project in PlatformIO

In VS Code: File → Open Folder, pick the serial-bt folder. PlatformIO auto-detects the platformio.ini project and loads it into its Project Explorer.

5. Project Structure

A PlatformIO project is intentionally small. Here's exactly what you need:

serial-bt/
├── platformio.ini   ← build config (board, framework, libraries)
└── src/
    └── main.cpp     ← all the firmware lives here

That's it — two files. Everything else (compiler, USB stack, BLE stack) is pulled in by PlatformIO automatically.

6. platformio.ini Explained

This file tells PlatformIO what hardware you're targeting and which libraries to use. Every line matters:

[env:s3-devkit]
platform = espressif32       ; ESP32 chip family support
board = esp32-s3-devkitc1-n16r8   ; exact board = S3 + 16MB flash + 8MB PSRAM
framework = arduino          ; use the Arduino framework (simple API)
monitor_speed = 115200       ; baud rate for the serial debug console

build_flags =
    -D ARDUINO_USB_MODE=1            ; enable native USB (not USB-serial-JTAG)
    -D ARDUINO_USB_CDC_ON_BOOT=1     ; keep serial console on native USB
    -D CONFIG_TINYUSB_HID_ENABLED=1  ; turn on TinyUSB's HID support
    -D CONFIG_TINYUSB_CDC_ENABLED=1  ; turn on TinyUSB serial (debug)

lib_deps =
    h2zero/NimBLE-Arduino@^1.4.0     ; the NimBLE BLE stack library
SettingWhat it doesWhy it matters
boardChooses the device profileMust match your S3 variant (flash/PSRAM sizes)
ARDUINO_USB_MODE=1Uses native USB peripheralRequired for HID keyboard functionality
CONFIG_TINYUSB_HID_ENABLED=1Enables HID in TinyUSBWithout it, USBHIDKeyboard won't work
NimBLE-ArduinoBLE stack libraryLightweight, stable BLE implementation
⚠️ The #1 cause of "keyboard doesn't show up" is forgetting the build_flags. Without native USB + HID enabled, the board defaults to a plain serial device.

7. Code Walkthrough

Here's the entire src/main.cpp, explained section by section.

7.1 Includes & Configuration

#include <Arduino.h>
#include <NimBLEDevice.h>      // BLE stack
#include <USB.h>               // native USB setup
#include <USBHIDKeyboard.h>    // the HID keyboard class

// Nordic UART Service UUIDs - the "phone writes here" contract
#define SERVICE_UUID        "6E400001-B5A3-F393-E0A9-E50E24DCCA9E"
#define CHARACTERISTIC_UUID "6E400002-B5A3-F393-E0A9-E50E24DCCA9E"

static BLECharacteristic *pCharacteristic;  // handle to our write characteristic
static bool deviceConnected = false;        // BLE link state

// The keyboard object - typing happens through this
USBHIDKeyboard HIDKeyboard;

The two UUIDs are the Nordic UART Service standard: the service itself, and the characteristic the phone writes text to. Keeping them standard means any NUS-aware app connects without custom code on the phone side.

7.2 Connection callbacks

class ServerCallbacks : public NimBLEServerCallbacks {
    void onConnect(NimBLEServer* pServer) {
        deviceConnected = true;
        Serial.println("BLE Client Connected!");
    }
    void onDisconnect(NimBLEServer* pServer) {
        deviceConnected = false;
        Serial.println("BLE Client Disconnected!");
        NimBLEDevice::startAdvertising();  // go back to being discoverable
    }
};

These fire when a phone pairs and unpairs. The important trick is on disconnect: the board immediately restarts advertising, so you can reconnect without re-flashing or rebooting.

7.3 The core: writing text as keystrokes

class CharacteristicCallbacks : public NimBLECharacteristicCallbacks {
    void onWrite(NimBLECharacteristic* pCharacteristic) {
        std::string value = pCharacteristic->getValue();   // grab the BLE payload
        if (value.length() > 0) {
            Serial.printf("Received %d bytes via BLE\n", value.length());

            // The heart of the project: type each byte into the PC
            for (size_t i = 0; i < value.length(); i++) {
                char c = value[i];
                HIDKeyboard.write(c);   // send one keystroke
                delay(10);              // let the PC keep up
            }
        }
    }
};

Every time the phone writes a chunk of text, this callback loops over the bytes and hands each one to HIDKeyboard.write(). The delay(10) spaces keystrokes out so the PC's input buffer doesn't drop characters during fast bursts.

7.4 setup(): bring it all up

void setup() {
    Serial.begin(115200);
    Serial.println("Serial-BT Bridge Starting...");

    // 1) USB side first - become a keyboard
    HIDKeyboard.begin();
    USB.begin();
    delay(1000);  // let the host enumerate the USB device

    // 2) BLE side - become a server named "Serial-BT"
    NimBLEDevice::init("Serial-BT");
    NimBLEDevice::setPower(ESP_PWR_LVL_P9);  // max TX power for range

    NimBLEServer *pServer = NimBLEDevice::createServer();
    pServer->setCallbacks(new ServerCallbacks());

    // 3) Expose the Nordic UART Service
    NimBLEService *pService = pServer->createService(SERVICE_UUID);
    pCharacteristic = pService->createCharacteristic(
        CHARACTERISTIC_UUID,
        NIMBLE_PROPERTY::WRITE     // phone may only write to this
    );
    pCharacteristic->setCallbacks(new CharacteristicCallbacks());
    pService->start();

    // 4) Advertise so phones can find us
    NimBLEAdvertising *pAdvertising = NimBLEDevice::getAdvertising();
    pAdvertising->addServiceUUID(SERVICE_UUID);
    pAdvertising->setScanResponse(true);
    NimBLEDevice::startAdvertising();

    Serial.println("BLE Advertising as 'Serial-BT'");
    Serial.println("Send text via BLE -> typed as USB HID keyboard");
}

void loop() {
    delay(10);  // all the work happens in callbacks
}

Note the order: USB first, then BLE. The board must be recognized as a keyboard before it starts handling BLE writes, and the 1-second delay lets Windows/Linux finish enumerating the device.

8. Build & Flash

1

Plug in the board

Connect your ESP32-S3 to your PC with a data USB cable. On Linux, check it shows up:

ls /dev/ttyACM*  # typical ESP32-S3 serial port
2

Build the firmware

In PlatformIO, click the ✓ Build button (or run from terminal):

pio run

The first build downloads the ESP32 toolchain and dependencies — expect it to take several minutes.

3

Flash it

Click → Upload in PlatformIO, or:

pio run -t upload

PlatformIO auto-detects the serial port. If it picks the wrong one, set it in platformio.ini:

upload_port = /dev/ttyACM0
4

Watch the debug console (optional)

Open the PlatformIO Serial Monitor (🔌 icon). You should see:

Serial-BT Bridge Starting...
BLE Advertising as 'Serial-BT'
Waiting for connection...
💡 On Linux you may need to be in the dialout group to access /dev/ttyACM*: sudo usermod -a -G dialout $USER, then log out and back in.

9. Using It

1

Check it appears as a keyboard

After flashing, the board enumerates as a USB keyboard. On Linux, verify with:

ls /dev/input/by-id/ | grep -i espressif
# usb-Espressif_Systems_ESP32S3_DEV_...-event-kbd
2

Install Serial Bluetooth Terminal (Kai Morich)

This is the app I use to control the board. Serial Bluetooth Terminal by Kai Morich is a line-oriented serial/UART terminal for Android used to interact with microcontrollers and embedded systems via Bluetooth. Get it here:

📲 Download from Google Play →
💡 Serial Bluetooth Terminal uses the Nordic UART Service (NUS) by default — the same profile this firmware exposes — so it connects with zero configuration. Just scan, pair to "Serial-BT", and type.
3

Connect to "Serial-BT"

Open the app, scan, and pair with the device named Serial-BT. The debug console shows BLE Client Connected! when the link is established.

4

Type away

Focus any text field on your PC (Notepad, a terminal, a browser), then type in the phone app and hit send. The text appears on the PC as if you typed it.

💡 Serial Bluetooth Terminal connects to the NUS service by default — that's the same profile this firmware exposes, so it connects with no configuration. Install it from Google Play.

10. Troubleshooting

SymptomLikely causeFix
Board not seen as keyboardMissing build flags, or charge-only cableVerify CONFIG_TINYUSB_HID_ENABLED=1; try a data cable
Can't find "Serial-BT" in appBoard not advertising yet, or out of rangeCheck the serial log for "Advertising"; move phone closer; reboot board
Connects but no typingApp writes to wrong characteristicUse an NUS-compatible app; this firmware uses the NUS write characteristic
Missing characters in fast burstsThe PC can't keep upIncrease delay(10) to delay(20) in the write loop
Flashing fails / port not foundPort permissions or wrong portJoin dialout group; set upload_port

11. Next Steps & Ideas

Now that the bridge works, there are easy ways to level it up:

  • Command triggers — send !term and have it press Ctrl+Alt+T to open a terminal (already done in the bt_kb variant).
  • Multi-line buffering — collect a full message before typing, instead of per-chunk.
  • Auto-reconnect feedback — blink an LED when the phone connects/disconnects.
  • Macro keys — map short tokens to long phrases you type often.
  • Encryption — require a BLE passkey so only you can send keystrokes.
View source on GitHub →