LinuxBlueZPython + bleakVoice microphone

Use the Onn / Google TV Bluetooth remote on Linux

Pairing, every key code, and the built-in microphone - decoded and turned into working Python code. Use the remote as a keyboard shortcut pad or as a push-to-talk dictation microphone for your desktop.

Start with pairing View on GitHub
The Onn / Google TV Bluetooth remote

Overview

The remote is a plain Bluetooth LE device with a standard HID service and Google's proprietary voice service. It works with any Linux machine that has a BlueZ-supported Bluetooth adapter - but out of the box you get no key events and no microphone. This page explains why, and how to get both anyway.

What works

  • Pairing with bluetoothctl, auto-reconnect after a key press
  • All 22 buttons decoded (table below)
  • Microphone: 8 kHz voice audio streamed to your PC
  • Push-to-talk dictation into the focused window

What does not

  • The Linux kernel rejects the remote's HID descriptor, so there is no /dev/input device
  • The remote does not report the release of the Assistant button
  • Voice streaming is capped at ~15 s per session (can be re-opened seamlessly)
  • Power / Input are sent over Bluetooth only as HID codes - no TV is controlled

Get the remote

This is the remote used for everything on this page (white, Google Assistant button, YouTube / Netflix / Disney+ / HBO max buttons, advertises as Onn-Remote):

View the remote on Amazon

Affiliate link (advertising): if you buy through it, the author may receive a small commission. The price stays the same for you.

Pairing on Linux

You need a working Bluetooth adapter and bluez (bluetoothctl).

Put the remote into pairing mode: hold Back + Home together for 2–3 seconds until the remote enters pairing mode (the LED usually blinks). Pairing mode does not last long, so start the scan first and then put the remote into pairing mode.

  1. Start bluetoothctl and switch on the adapter and the agent:
    bluetoothctl
    [bluetooth]# power on
    [bluetooth]# agent on
    [bluetooth]# default-agent
  2. Start scanning, then put the remote into pairing mode. It shows up as Onn-Remote:
    [bluetooth]# scan on
    [NEW] Device AA:BB:CC:DD:EE:FF Onn-Remote
  3. Pair, trust and connect (use your remote's address):
    [bluetooth]# pair AA:BB:CC:DD:EE:FF
    Pairing successful
    [bluetooth]# trust AA:BB:CC:DD:EE:FF
    [bluetooth]# connect AA:BB:CC:DD:EE:FF
    [bluetooth]# scan off
  4. Check the result - you should see Paired: yes, Trusted: yes, the HID service and a battery percentage:
    [bluetooth]# info AA:BB:CC:DD:EE:FF
        Name: Onn-Remote
        Paired: yes   Bonded: yes   Trusted: yes   Connected: yes
        UUID: Human Interface Device    (00001812-...)
        UUID: Battery Service           (0000180f-...)
        UUID: Vendor specific           (ab5e0001-5a21-4f05-bc7d-af01f617b664)
        Battery Percentage: 0x64 (100)

Because the device is trusted, it reconnects on its own the next time you press a button. The remote goes to sleep after a short idle time; the first key press after that only wakes it up and is usually lost.

Scan finds nothing at all? Then the problem is probably your adapter, not the remote. Cheap "CSR8510" USB dongles are often counterfeit (Barrot 8041a02) and cannot receive advertisements reliably - see troubleshooting.

Why there are no key events on Linux

After pairing, BlueZ connects the HID service and hands the report descriptor to the kernel. For this remote the kernel logs:

hid-generic 0005:0957:0005.006E: item fetching failed at offset 22/23
hid-generic 0005:0957:0005.006E: probe with driver hid-generic failed with error -22

The descriptor is slightly malformed, so no input device is created and desktop shortcuts never see the keys. The good news: the remote still sends the HID reports as GATT notifications, and you can read them directly - no root, no kernel patch. That is what the Python examples do, using bleak.

GATT layout

ServiceCharacteristicPurpose
0x1812 HID0x2A4D Report (several)Key presses (notify)
0x180F Battery0x2A19Battery level
ab5e0001-5a21-4f05-bc7d-af01f617b664ab5e0002 (write)
ab5e0003 (notify)
ab5e0004 (notify)
Google voice service (ATVV): commands, audio data, control messages
d343bfc0-5a21-4f05-bc7d-af01f617b664d343bfc1 ... bfc5Vendor-specific (not needed)

Key codes

Schematic drawing of the remote with all buttons labelled

Every button arrives as an HID report on 0x2A4D. Short reports (2 or 4 bytes) carry a 16-bit usage of the Consumer Control page in the first two bytes (little endian); the volume keys use an 8-byte keyboard report instead. Releasing sends an all-zero report right away - the remote reports taps, not "held" keys. Codes were determined by pressing every button in a fixed order and logging the raw notifications.

ButtonReport (hex)UsageNotes
Power30 00 00 000x0030HID Power
Inputbb 01 00 000x01BB
Assistant21 020x0221AC Search. Also sends ATVV control message 08 (start search)
Bookmark2a 02 00 000x022AAC Bookmarks
Settings (gear)96 00 00 000x0096
D-pad Up42 00 00 000x0042Menu Up
D-pad Down43 00 00 000x0043Menu Down
D-pad Left44 00 00 000x0044Menu Left
D-pad Right45 00 00 000x0045Menu Right
OK (center)41 00 00 000x0041Menu Pick
Back24 020x0224AC Back
Home23 020x0223AC Home
TV8d 00 00 000x008DProgram guide
Mutee2 00 00 000x00E2Mute
Channel +9c 00 00 000x009CChannel Increment
Channel -9d 00 00 000x009DChannel Decrement
Volume +00 00 80 00 00 00 00 00key 0x808-byte keyboard report, key in byte 2
Volume -00 00 81 00 00 00 00 00key 0x818-byte keyboard report, key in byte 2
YouTube77 00 00 000x0077
Netflix78 00 00 000x0078
Disney+79 00 00 000x0079
HBO max7a 00 00 000x007A

Decoding is a few lines:

def decode_key(data: bytes):
    if not any(data):
        return None                      # release
    if len(data) == 8:                   # keyboard report: volume keys
        return {0x80: "Volume +", 0x81: "Volume -"}.get(data[2])
    usage = data[0] | (data[1] << 8 if len(data) > 1 else 0)
    return CONSUMER.get(usage)           # table above

The microphone (ATVV voice protocol)

The Assistant button does not use a Bluetooth audio profile. Google remotes stream voice over BLE notifications using the ATVV ("Android TV Voice") service ab5e0001-.... Three characteristics matter:

UUIDDirectionUse
ab5e0002-...writeCommands to the remote
ab5e0003-...notifyAudio frames
ab5e0004-...notifyControl messages

Commands (write to ab5e0002)

BytesCommand
0a 00 04 00 01GET_CAPS - protocol 0.4, 8 kHz ADPCM. Send once after connecting; the remote answers 0b ... (frame size 134 bytes)
0c 00 01MIC_OPEN - start streaming (8 kHz ADPCM)
0dMIC_CLOSE - stop streaming (also clears a stuck state)

Control messages (notify on ab5e0004)

First byteMeaning
08Assistant button pressed - answer with MIC_OPEN
04Audio stream starts (reset your frame buffer)
0a xx xxSync / timestamp, every ~350 ms while streaming
00Audio stream ended - after MIC_CLOSE or the remote's own ~15 s limit
0b ...Reply to GET_CAPS

Audio format

Frames of 134 bytes arrive split into six 20-byte notifications and one 14-byte notification on ab5e0003. Each frame is [0:2] sequence number, [2] stream id, [3:5] predictor (int16, big endian), [5] step index, [6:134] 128 bytes of IMA ADPCM (high nibble first) = 256 samples of 8 kHz mono, 32 ms per frame.

Two protocol quirks: (1) The remote never reports the release of the Assistant button. Use another key (OK) or a second press to end a recording. (2) The stream is cut off after about 15 s; send MIC_OPEN again on the 00 message to continue seamlessly (up to a minute works fine; a short gap can occur at the seams).

Python code

All code is in the examples folder. Install once:

git clone https://github.com/FloMaetschke/onn-remote-linux
cd onn-remote-linux
python -m venv .venv && .venv/bin/pip install -r requirements.txt

1. See every key press live

.venv/bin/python examples/01_listen_keys.py AA:BB:CC:DD:EE:FF

   3.21s  Up           (report 42 00 00 00)
   3.84s  OK           (report 41 00 00 00)
   5.02s  Assistant button (ATVV START_SEARCH)
   5.02s  Assistant    (report 21 02)

The core is a loop over all notifiable characteristics:

async with BleakClient(device(address), timeout=8) as client:
    for service in client.services:
        for char in service.characteristics:
            if "notify" in char.properties and char.uuid != ATVV_RX:
                await client.start_notify(char, on_report)

2. Record the microphone to a WAV file

.venv/bin/python examples/02_record_voice.py AA:BB:CC:DD:EE:FF
ready - press the Assistant button
recording - press OK to finish
saved: recording-1.wav (6.4 s)

The heart of it - open the mic on the button, decode frames as they arrive:

def on_ctl(_, data):
    if data[0] == CTL_START_SEARCH:                # Assistant button
        asyncio.create_task(send(CMD_MIC_OPEN))

def on_audio(_, data):                             # ab5e0003 notifications
    pcm.extend(frames.push(bytes(data)))           # -> 256 samples per frame

# ...later
write_wav("recording-1.wav", b"".join(pcm))        # 8 kHz, 16 bit, mono

3. Dictation into the focused window

03_dictation_service.py connects the remote to a speech-to-text tool. The tool records from the default microphone, so the service creates a virtual PipeWire/PulseAudio sink, plays the decoded remote audio into it, and temporarily makes its monitor the default source:

pactl load-module module-null-sink sink_name=onn_remote
pactl set-default-source onn_remote.monitor      # while dictating
pacat -p -d onn_remote --format=s16le --rate=8000 --channels=1   # decoded audio goes in here

Push-to-talk flow: Assistant starts the dictation tool, OK stops it and the text is typed into the focused window. Your normal microphone and shortcut keep working; the previous default source is restored after every run. It is written for Omarchy with omarchy-dictation / voxtype, but any command pair works:

ONN_ADDR=AA:BB:CC:DD:EE:FF \
ONN_START_CMD="my-stt start" ONN_STOP_CMD="my-stt stop" \
.venv/bin/python examples/03_dictation_service.py

With ONN_HYPRLAND=1 the D-pad also moves window focus and Channel +/- switches the workspace (Hyprland Lua dispatchers):

hyprctl dispatch 'hl.dsp.focus({ direction = "l" })'
hyprctl dispatch 'hl.dsp.focus({ workspace = "e+1" })'

Run it as a service

mkdir -p ~/.config/systemd/user
cp systemd/onn-voice.service ~/.config/systemd/user/
# edit ONN_ADDR and the paths in the file, then:
systemctl --user daemon-reload
systemctl --user enable --now onn-voice.service
journalctl --user -u onn-voice -f

The service reconnects on its own whenever the remote wakes up from standby.

Troubleshooting

"Device ... was not found" from bleak

The remote sleeps and does not advertise, so BleakClient("AA:BB:...") cannot find it in its scan cache. Build the device from BlueZ's object path instead (already done in atvv.device()):

from bleak.backends.device import BLEDevice
path = f"/org/bluez/hci0/dev_{address.replace(':', '_')}"
client = BleakClient(BLEDevice(address, "Onn-Remote", {"path": path, "props": {}}))

Key presses stop arriving after restarting your program

After several quick restarts BlueZ can stop forwarding notifications although the remote still shows Connected: yes. Reset the link:

bluetoothctl disconnect AA:BB:CC:DD:EE:FF
# restart your program, then press any button on the remote

Sending MIC_CLOSE right after connecting (as the examples do) also clears a stuck voice state; the remote answers with 00.

Scan finds no devices at all / adapter problems

Cheap "CSR8510" USB dongles are frequently counterfeit clones (Barrot 8041a02). The kernel says so:

Bluetooth: hci0: CSR: Unbranded CSR clone detected; adding workarounds and force-suspending once...
Bluetooth: hci0: CSR: Couldn't suspend the device for our Barrot 8041a02 receive-issue workaround

These chips cannot receive advertisements reliably, there is no firmware update, and behind USB hubs the workaround often fails. Use a dongle with a Realtek RTL8761B chipset (e.g. TP-Link UB500), which is supported by the mainline kernel out of the box.

The first key press after some idle time does nothing

Expected: the remote wakes up and reconnects with that press. Press again.

Dictation returns "Thank you." or other filler on silence

Whisper-style models hallucinate on silence. If a recording contains no speech, stop it with OK right away, or check the recorded audio with 02_record_voice.py.

Discovering codes for a different remote

Run 01_listen_keys.py, press each button twice with a pause in between, and note the raw reports. Group by pauses; keys pressed less than ~2 s apart run together in the log.