Use the first-generation Apple Siri Remote on Linux: the touch remote of the 2015 and 2017 Apple TVs (A1513, A1962), not the older infrared Apple Remote. Touch surface, buttons and tvOS-style gestures are decoded in plain Python 3 with no dependencies; an optional virtual keyboard makes an old remote work in Kodi, a browser, an emulator or any other app, and the remote's microphone records what you say while you hold Siri (decoding it needs libopus).
Paired with Linux, the remote does nothing: BlueZ exposes it only as a raw /dev/hidrawN node with a vendor-defined
report map, and it stays silent until it is unlocked. This package unlocks it, decodes its reports, and turns them
into events, gestures or key presses. docs/PROTOCOL.md documents everything it sends.
Status: 0.1.0, an early release, tested on an A1962 with Linux: finding, unlocking and reading the remote as a normal
user, and reconnecting after it sleeps. The virtual keyboard (keys) is not hardware-tested yet, and the voice
(voice, not released yet) hasn't run live: it is tested on synthetic captures, one of them built from another
project's recorded gen-1 frames. Reports are welcome (see CHANGELOG.md).
You need Linux with BlueZ (its hog plugin is on by default) and Python 3.8 or newer. There is nothing to install:
clone the repository and run the commands below from its top directory.
1. Pair the remote (once). Hold Menu + Volume Up for about 5 seconds next to the computer, then pair it in
bluetoothctl. After scan on, the remote shows up named after its serial number; use its address:
$ bluetoothctl
[bluetooth]# agent NoInputNoOutput
[bluetooth]# default-agent
[bluetooth]# scan on
[bluetooth]# pair AA:BB:CC:DD:EE:FF
[bluetooth]# trust AA:BB:CC:DD:EE:FF
[bluetooth]# connect AA:BB:CC:DD:EE:FF
[bluetooth]# scan off
2. Let your user read it (once), instead of running everything as root:
sudo cp udev/71-appleremote.rules /etc/udev/rules.d/
sudo udevadm control --reload && sudo udevadm triggerThen press a button on the remote. The rules file explains the options for /dev/uinput and for headless machines.
3. Watch it:
python3 -m appleremote list # finds the remote's hidraw node
python3 -m appleremote monitor # decoded events, one JSON object per line
python3 -m appleremote gestures # tvOS-style moves, tilts and buttons
python3 -m appleremote lab # live web lab: open http://127.0.0.1:8791
sudo python3 -B -m appleremote voice --out voice/ # each Siri press as a WAV file: see VoiceThe lab listens on 127.0.0.1 only. On a headless machine, forward it with ssh -L 8791:127.0.0.1:8791 <host> and
open the same address on your computer.
4. Use it as a keyboard:
python3 -m appleremote keysThis needs write access to /dev/uinput (root, or the optional line in the udev rules). The default key map:
| On the remote | Key |
|---|---|
| slide or flick up, down, left, right | arrow keys |
| click (press the touch surface) | Enter |
| Menu | Esc (back) |
| TV / Home | Home page (KEY_HOMEPAGE) |
| Play/Pause | Play/Pause |
| Volume + / − | Volume up / down |
| Siri | nothing |
Change it with a JSON file, python3 -m appleremote keys --map keys.json. Values are key names (KEY_A to KEY_Z,
digits, KEY_F1 to KEY_F12, arrows, media keys: see NAMES in appleremote/keys.py), numbers from
linux/input-event-codes.h, or null to turn an input off:
{"click": "KEY_SPACE", "siri": "KEY_C", "tv": null}To start it with your session, see systemd/appleremote-keys.service.
No remote at hand? monitor, gestures, keys and lab take --replay FILE to play a capture instead; all
but keys work on any OS. Try the lab with a real capture from the test data:
python3 -m appleremote lab --replay tests/sample.jsonlRecord your own with python3 -m appleremote monitor --raw > capture.jsonl: --raw also logs each report with its
time, which is what a replay plays.
monitor and gestures print one JSON object per line, ready for jq or any language:
{"kind": "touch", "t": 0.0, "x": -849, "y": -828, "down": true, "fingers": [[-849, -828, 3]], "raw": "fc010032b8ff01af4ccc3e210a23"}
{"kind": "buttons", "mask": 128, "pressed": ["click"], "released": [], "raw": "fc0080"}
{"kind": "gesture", "type": "move", "dir": "right", "src": "flick"}
{"kind": "gesture", "type": "tilt", "x": -0.083, "y": 0.134}touch:tis seconds on the remote's own clock,xandythe first finger (+X right, +Y up, about 1650 units across),fingersevery finger as[x, y, state].buttons:maskis the buttons held; names aretv,volup,voldown,playpause,siri,menu,click.voicechunk: an audio report while Siri is held. Through hidraw it holds only the start of an Opus frame: its level and sequence number, not enough to decode (docs/PROTOCOL.md).voicegets the whole frames (see Voice).gesture:touch(down or up),move(a focus step:srcisslideorflick),tilt(a resting finger leaning the focused tile, -1 to 1).
From Python:
from appleremote.decode import Decoder, Touch
from appleremote.device import reports
from appleremote.gestures import Gestures
decode = Decoder()
engine = Gestures(lambda kind, **f: print(kind, f))
for t, report in reports(): # survives sleep and reconnects
for ev in decode(report): # Touch, Buttons or VoiceChunk
if isinstance(ev, Touch):
engine.frame(ev.t, ev.x, ev.y, ev.down)
else:
print(ev)
engine.tick() # lets flicks coastThe gesture engine's feel (step size, pacing, flick speed, coasting) is set by the constants at the top of
appleremote/gestures.py.
Hold Siri and speak: voice saves each press as a WAV file (16 kHz, mono) for whisper or any other speech
recognizer, and prints a line per press. A quick try, on Linux, with the udev rule of Quick start in place:
sudo apt install libopus0 # the decoder (Debian, Ubuntu); nothing else needs it
sudo python3 -B -m appleremote voice --out voice/ # Ctrl-C to stop; -B: no root-owned __pycache__ in your clone{"kind": "press", "wav": "voice/siri-20261011-201500-01.wav", "frames": 213, "lost": 0, "held": 163, "seconds": 4.26, "level": 21034, "ended": true}frames counts the press's 20 ms Opus frames: while Siri is held, then about a second more after you let go. lost
counts frames that never arrived (libopus fills them in), held the frames sent with Siri held, level is the
highest loudness the remote reported, and ended says whether the stream's end marker came. Without --out it only
prints the lines. The WAV files are yours alone (0600, in a directory made 0700). A press with no frame held gets no
WAV ("wav": null): a Siri press that wakes the remote loses its first frames before the link is up, and what arrives
of it is only its tail. Wake the remote with another button.
Why sudo. The remote sends each frame whole, but BlueZ keeps only its first 20 bytes before hidraw
(docs/PROTOCOL.md), so voice reads the frames
below BlueZ, from the kernel's HCI monitor (what btmon reads). Opening it needs CAP_NET_RAW. Under sudo, voice
opens it, then goes back to your user, with no capability left, before it waits for the remote or reads a packet; the
WAV files are yours. As plain root (su, a root shell) it refuses: there would be no user to go back to.
It keeps the remote unlocked. The remote sends nothing, its voice included, until it is unlocked through its
hidraw node, again on every connect (docs/PROTOCOL.md). voice does that as your user, as
monitor does, so it needs the udev rule; when it can't open the node, it says so (hidraw: cannot open ...).
What it keeps. The HCI monitor shows every Bluetooth device's traffic on the machine: keyboard input, pairing keys, headsets. appleremote/hcitap.py, the only code that opens it (standard library only, short enough to read before you run it), keeps the remote's voice and nothing else:
- data only from the remote's own link, and only once that link is encrypted with its bond's key: the frame layout is public and an address can be borrowed, but the key can't;
- of that, only voice frames and end markers (notifications on ATT handle
0x0023); - everything else is dropped as it is parsed, before any of it is buffered. It never logs a payload or an address.
Under sudo, the tap runs as your user: your other processes can't attach to it while it runs, but where
kernel.yama.ptrace_scope is 0 one of them could kill it and take its open monitor socket as it exits. That is
enough for a try. To keep it running, use the unit below, which runs the tap as a user of its own.
Finding the remote. voice reads the address BlueZ gives the remote's hidraw node, so it waits until the remote
is connected (press a button). If it keeps waiting, give the address: --remote AA:BB:CC:DD:EE:FF
(bluetoothctl devices Paired lists it).
To keep it running without sudo, systemd/appleremote-voice.service runs a
root-owned copy of hcitap.py as a user of its own (DynamicUser=), with CAP_NET_RAW alone (AmbientCapabilities=,
NoNewPrivileges= and a tight sandbox), serving the frames to your user's processes on /run/appleremote/voice.sock.
Its first lines say how to install it. Then, as your user:
python3 -m appleremote voice --socket /run/appleremote/voice.sock --out voice/The socket is there as soon as the unit runs, and voice waits with the tap for the remote. The tap never touches
hidraw, so it can't unlock the remote: voice --socket does. Don't give the capability to the Python interpreter
(setcap cap_net_raw+ep /usr/bin/python3): every Python script on the machine would get it.
Other apps. --serve PATH serves the frames instead of decoding them, as the unit does: a SOCK_SEQPACKET unix
socket, for your user's processes only (checked with SO_PEERCRED), 4 at most. Clients only read; one that sends
anything is dropped. Each message is the CLOCK_MONOTONIC time in nanoseconds (8 bytes, little-endian), then a frame
(101 bytes) or an end marker (2 bytes). voice --serve keeps the remote unlocked; with the unit, your app must. From
Python:
from appleremote import device, voice
device.keep_unlocked() # the remote sends no voice until it is unlocked
for press in voice.presses(voice.client("/run/appleremote/voice.sock")):
voice.write_wav("press.wav", voice.decode(press.packets()))No remote at hand, or another OS: --replay FILE decodes a capture made with sudo btmon -w FILE, adding
--remote if the remote connected during the capture. A btmon capture holds all the machine's Bluetooth traffic,
your voice included: keep it to yourself.
Through hidraw alone, with no capability, the lab shows the voice reports' level and sequence number while Siri is held.
- A couch remote for Kodi, Jellyfin, mpv, VLC, a browser in TV mode, or a game launcher.
- A presentation clicker: swipe for the next slide, click to start a video.
- A home-automation remote: pipe
gesturesinto a script that calls your lights, scenes or music. - A voice remote: hold Siri, say a command, and hand the WAV to a speech recognizer (whisper.cpp, Vosk).
- A small touchpad for a kiosk or a Raspberry Pi media box.
- An instrument: the touch surface streams x/y at 100 Hz, enough for a theremin or a filter sweep.
- Two-finger gestures: the decoder reports both fingers; nothing uses them yet.
- Tested: Siri Remote gen 1, model A1962, on BlueZ 5.72, Linux 6.17 (arm64, MediaTek MT7925 Bluetooth). The
0.1.0 code passed there:
list,monitorandlabas a normal user through the udev rule, and the reconnect after sleep once LE privacy is off. The virtual keyboard (keys) isn't hardware-tested yet. - Should work, untested: the A1513 (the original gen-1 remote, which the A1962 is a variant of) and other Linux machines with a recent BlueZ. Reports welcome.
- Not supported yet: Siri Remote gen 2 and gen 3. Per the prior art they use other report IDs and another
unlock (sources).
listshows them as another Apple device. - Voice (
voice, not released yet): tested on synthetic frames, and its decoding on the recorded gen-1 frames of Jack-R1/SiriRemoteVoiceDecoder, replayed. It hasn't run live in this package yet. It ports code from the private project this package came from, which picked exactly the remote's whole voice frames out of a btmon capture of an A1962. - Live use is Linux-only (hidraw, uinput, the HCI monitor);
--replayand the tests run anywhere.
- Voice needs more than the rest: BlueZ keeps only the first 20 bytes of each voice frame before hidraw sees it
(docs/PROTOCOL.md), so
voicereads the frames from the HCI monitor, which takesCAP_NET_RAW(sudo, or the system unit), and decodes them with libopus. Without those, hidraw gives each frame's level and sequence number only. - After a long sleep, the button press that wakes the remote may be lost.
- If the remote won't reconnect after sleeping, BlueZ is probably using LE privacy: set
Privacy = offin/etc/bluetooth/main.conf(details in docs/PROTOCOL.md). - Short, fast swipes (about a tenth of a second on the surface) often don't move focus: the gesture constants were tuned before touches were decoded exactly, and want a re-tune on hardware.
- One remote at a time: the first one found, or the one given with
--dev. - The ioctl numbers assume the common Linux layout (x86, ARM, RISC-V), not powerpc, mips or sparc.
python3 -m unittest -vThey run on any OS; the voice tests that need libopus skip without it, and those of the HCI tap's privileges and socket skip off Linux. tests/sample.jsonl is a real capture of a guided session (swipes, flicks, taps, clicks, every button, two fingers), cut down to report bytes, relative times and step labels; it has no audio. The voice tests build their frames; a recorded voice never goes in this repository.
Issues and pull requests are welcome. Keep it small: standard library only, no frameworks, a test for any new
logic. Comments that start with ponytail: mark a deliberate shortcut and say where it stops being enough.
- Another remote model: please send the output of
python3 -m appleremote listwith the quoted name blanked out (a remote's name is its serial number, and other devices' names often include yours), the report descriptor (xxd /sys/class/hidraw/hidrawN/device/report_descriptor), and a shortmonitor --rawcapture: each button once, one swipe per direction, a click. Leave Siri out unless you're happy to share your voice: audio reports carry it. Never attach a btmon capture or a WAV ofvoice: they hold your voice, and a btmon capture all your Bluetooth traffic. - Protocol findings: add them to docs/PROTOCOL.md with a label (Measured, Reported or Guess) and, where you can, a capture that shows them.
The protocol knowledge builds on these projects (no code was copied): Jakkumn/SiriRemoteESP, Yanndroid/SiriRemote-Linux, azais-corentin/siri-remote and Jack-R1/SiriRemoteVoiceDecoder. The gesture smoothing is the One Euro filter (Casiez, Roussel and Vogel, CHI 2012). This code started inside a private TV-launcher project and was extracted to share.
Apple, Siri and Apple TV are trademarks of Apple Inc. This project is not affiliated with or endorsed by Apple.
MIT, see LICENSE.