Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

ir-bt-bridge

ir-bt-bridge turns any IR remote into a Bluetooth (BLE HID) remote for Google TV. An ESP32-S3 receives the remote’s IR codes, looks each one up in a keymap and sends the mapped key to the TV as a paired Bluetooth remote.

[Any IR Remote] → [IR receiver] → [ESP32-S3] → [BLE HID] → [Google TV]
                                      │
                        [USB-C] ←→ [irbt CLI / library on a PC]

The keymap is edited over USB at runtime, so changing it needs no reflash. Use the irbt command-line tool, or the irbt Rust library to script it.

This guide covers:

The source is at code.selu.dev/selu/ir-bt-bridge. Building from source is covered in its README.

Hardware

The board is a Waveshare ESP32-S3-Zero (ESP32-S3FH4R2, 4 MB flash, 2 MB PSRAM, BLE 5.0). The full parts list, the design decisions and the build phases are in SPEC.md.

Parts

PartRole
Waveshare ESP32-S3-ZeroMCU, BLE, USB-C
VS1838B IR receiver moduleIR receiver (38 kHz)
100 Ω resistor, 100 nF capacitorsupply filter for the IR receiver
TSAL-6200 IR LED, 2N2222 transistor, 330 Ω, 33 Ω and 10 kΩ resistorsIR blaster (optional)
SS14 Schottky diode, 2× 100 kΩ resistors, 100 nF capacitorbattery input and voltage sense (optional)

Pins

SignalGPIO
IR receiver outputGPIO 1
IR blaster (transistor base, via 330 Ω)GPIO 2
Battery voltage sense (optional)GPIO 4
Status LEDonboard WS2812 (GPIO 21)
USB control linkthe USB-C port (built-in USB-Serial-JTAG)

Schematic

Schematic

The KiCad project is in hardware/. It is a schematic only: the parts have no footprints, and the ESP32-S3-Zero symbol shows only the edge header pins. The dashed boxes are the optional parts.

Wiring

The VS1838B’s pin order is OUT, GND, VCC, the same as a TSOP38238.

VS1838B pin 1 (OUT) → GPIO 1
VS1838B pin 2 (GND) → GND
VS1838B pin 3 (VCC) ──[100Ω]── 3.3V
100nF from VCC (after the 100Ω) to GND, close to the module

The 100 Ω and 100 nF filter the receiver’s supply. The IR blaster draws ~53 mA bursts at 38 kHz from the same 3.3 V rail, and that ripple would otherwise reach the receiver’s sensitive amplifier.

The optional IR blaster:

3.3V ──[33Ω]── TSAL-6200 anode (long leg)
               TSAL-6200 cathode (short leg)
                    │
               2N2222 collector
               2N2222 emitter ── GND
               2N2222 base ──[330Ω]── GPIO 2
               2N2222 base ──[10kΩ]── GND

The 10 kΩ keeps the LED off while GPIO 2 floats during reset. The transistor is drawn E-B-C (PN2222A), but the TO-92 pinout depends on the vendor: the P2N2222A, many 2N2222A and the BC337 are C-B-E. Check the datasheet of the part you have.

The optional battery:

battery + ──[SS14, anode → cathode]── "5V" pad
battery + ──[100kΩ]──┬──[100kΩ]── GND
                      ├──[100nF]── GND
                   GPIO 4
battery − ── GND

The module’s “5V” pad is wired straight to USB VBUS, with no diode. Without the Schottky, plugging in USB with a battery attached pushes 5 V into the cells, and the battery feeds the host’s USB port. The divider taps the battery side of the diode, so it reads the battery and not USB. The diode drops about 0.3 V, so use 3×AA, or expect the 3.3 V rail to sag once a LiPo falls below about 3.8 V.

The firmware’s battery-monitor feature (on by default) reads the divider once a minute and reports the charge to the TV as the Bluetooth battery level. It assumes 3×AA cells; for a LiPo, set CELL to LIPO_1S in firmware/src/battery.rs. Without a battery it reports 100%.

Status LED

The onboard RGB LED shows the Bluetooth state:

LEDMeaning
Blue, blinking (2×/s)No Bluetooth host connected; advertising
Green, steady (dim)Connected and paired to a host
Purple flashA key was sent to the host

Keys pressed while the LED is blinking blue are dropped (no flash).

Flashing the firmware

Prebuilt firmware is on the releases page:

FileWhat
ir-bt-bridge-<tag>-esp32s3.elffirmware, for espflash
ir-bt-bridge-<tag>-esp32s3-merged.binfirmware as a single image for offset 0x0
SHA256SUMSchecksums

Connect the board’s USB-C port. If flashing fails to connect, hold BOOT while plugging it in to force download mode.

Updating a device

Flash the ELF with espflash. This keeps the stored mappings and pairings:

espflash flash ir-bt-bridge-<tag>-esp32s3.elf

A new board

The merged image needs only esptool (or a browser-based flasher), but it erases the mappings and pairings because it overwrites NVS:

esptool.py --chip esp32s3 write_flash 0x0 ir-bt-bridge-<tag>-esp32s3-merged.bin

After flashing

The device’s USB output is framed binary, so espflash monitor shows garbage. Use irbt instead:

irbt status
irbt monitor --logs

To pair with the TV, go to Settings → Remotes & Accessories → Add accessory → IR Bridge.

Serial port access

  • Linux: your user needs access to the serial port. Join the dialout group (sudo usermod -aG dialout $USER, then log in again).
  • WSL2: attach the board to WSL with usbipd-win (usbipd attach --wsl --busid <id>). Re-attach it after every replug or reset into download mode.

Installing the CLI

Prebuilt binaries

The releases page has irbt for Linux (static, x86_64 and aarch64) and Windows:

FileWhat
irbt-<tag>-{x86_64,aarch64}-linux.tar.gzstatic irbt for Linux
irbt-<tag>-x86_64-windows.zipirbt.exe

From the Cargo registry

The crates are published to the Cargo registry on code.selu.dev. Add it to ~/.cargo/config.toml:

[registries.selu]
index = "sparse+https://code.selu.dev/api/packages/selu/cargo/"

Then install the irbt binary (from the irbt-cli crate):

cargo install irbt-cli --registry selu

This also works on macOS, which has no prebuilt binary.

Version

irbt -V shows the version and the commit it was built from, e.g. 0.2.1 (c7f30e8 2026-10-27). irbt --version adds its toolchain and dependency versions.

Using the CLI

irbt finds the bridge by its USB ID (303A:1001). If several are connected, or auto-detection fails, pass --port <path> or set IRBT_PORT. irbt ports lists the detected bridges.

Every command accepts --json for machine-readable output.

Mapping buttons

irbt learn home          # press a remote button to bind it to `home`
irbt list                # show mappings
irbt keys                # all key names

learn waits 10 seconds for a button press (--timeout to change it). To find a button’s code without mapping it, run irbt monitor and press it.

Mappings can also be edited by code. IR codes are NEC codes, in hex (0x00FF02FD) or decimal:

irbt set 0x00FF02FD home
irbt rm 0x00FF02FD
irbt clear               # remove every mapping
irbt reset               # restore the firmware's default keymap

Every change is stored on the device right away.

Backing up the keymap

irbt export keymap.toml
irbt import keymap.toml            # add to / overwrite existing mappings
irbt import keymap.toml --replace  # remove all existing mappings first

The file format:

[[mapping]]
ir = "0x00FF02FD"
key = "home"

[[mapping]]
ir = "0x00FF629D"
key = "up"

import checks the whole file before it changes anything on the device.

Testing and debugging

irbt status              # firmware version and build, BLE state, mapping count, battery
irbt monitor             # received IR codes and their keys, until Ctrl-C
irbt monitor --logs      # ... plus the device's log output
irbt send-hid up         # send a key over BLE without IR
irbt send-ir 0x00FF02FD  # transmit a code through the IR blaster

irbt status also shows the firmware’s commit, its rustc, ESP-IDF and crate versions.

Commands

CommandPurpose
irbt statusfirmware/protocol version, build info, uptime, BLE state, mapping count, battery
irbt monitor [--logs]live IR codes (with mapped key), optionally device logs
irbt learn <key>bind the next remote button press to <key>
irbt list / set <ir> <key> / rm <ir>inspect/edit mappings
irbt export <file> / import <file> [--replace]TOML backup/restore of the keymap
irbt clear / resetempty the keymap / restore the built-in defaults
irbt send-hid <key>press a key over BLE without IR
irbt send-ir <code>fire the IR blaster
irbt keys / portslist key names / detected bridges

irbt help <command> shows the options of each.

Using the irbt library

The irbt crate is the library behind the CLI: port discovery, framing and a typed client for every command. Add the registry (see Installing the CLI), then:

cargo add irbt --registry selu

Example

use std::time::Duration;

use irbt::{Device, HidKey, Notification};

fn main() -> Result<(), irbt::Error> {
    let device = Device::open(None)?; // auto-detect the bridge

    let info = device.info()?;
    println!("firmware {} (protocol v{})", info.firmware_version, info.protocol_version);

    device.set_mapping(0x00FF_02FD, HidKey::Home)?;
    for mapping in device.keymap()? {
        println!("0x{:08X} -> {}", mapping.ir, mapping.key);
    }

    // Stream received IR codes.
    device.set_monitor(true)?;
    while let Some(notification) = device.next_notification(Duration::from_secs(30))? {
        if let Notification::Event(event) = notification {
            println!("{event:?}");
        }
    }
    Ok(())
}
  • Device::open takes a port path, or None to pick the only connected bridge. find_ports lists them.
  • Each method sends one command and waits for its reply. set_timeout changes how long it waits.
  • Events, log lines and unframed output (bootloader text, panic dumps) arrive as Notifications through next_notification.
  • Device::from_io runs the client over any reader/writer pair, e.g. in tests.

The protocol types are re-exported from irbt-proto as irbt::proto.

Wire protocol

The device and the host talk over the ESP32-S3’s built-in USB-Serial-JTAG, with the protocol in the irbt-proto crate. It is no_std and shared verbatim by the firmware and the host.

Framing

Every message is a Frame, serialized with postcard, followed by a CRC-16, COBS-encoded and delimited by 0x00 bytes on both sides:

0x00 | COBS( postcard(frame) ‖ crc16_le ) | 0x00

COBS guarantees that the encoded body never contains 0x00, so the delimiter is unambiguous and a receiver can resynchronize at the next 0x00 after garbage.

All device output, logs included, is framed, so responses, events and logs share one stream. Bytes that don’t form a valid frame (ROM bootloader text, panic dumps) are surfaced by the Decoder as Decoded::Raw instead of being lost.

Messages

  • The host sends a Request: an id and a Command.
  • The device answers each with a response carrying the same id and a Reply or an error.
  • Unprompted, the device sends Events (IR received, learn results, BLE connection changes) and Log lines.

Compatibility

postcard is not self-describing: enum variant order is wire ABI. Variants and fields are only ever appended at the end, and PROTOCOL_VERSION is bumped for anything that changes existing encodings. Golden tests in the crate pin the bytes. irbt status shows the firmware’s protocol version.

API reference

rustdoc for the published crates:

  • irbt: host library for configuring and controlling a bridge over USB.
  • irbt_proto: the wire protocol, shared by the firmware and the host.
  • irbt_build_info: build-script helper that embeds the git commit and toolchain/dependency versions.

The irbt command-line tool is covered in Using the CLI.