Repository navigation
Releases: batubozkan/pods-head-tracker
Release list
v0.5.5 — Roll axis + ear auto-pause
Everything since v0.4, confirmed on real hardware (Pi Zero 2 W + AirPods 4 ANC).
Roll axis (ROLL=1)
The bridge can now send roll (head tilt, ear to shoulder) together with yaw and pitch. The formula comes from per-motion CSV captures on AirPods 4: the packet field o1 contains pitch+roll, the pitch pair (o2+o3)/2 contains pitch−roll, and half of their difference is the roll angle. Measured quality: 0.2° of noise with a still head, 3–4° of unwanted roll during pure yaw/pitch movement, a correct ±40° response to tilts, and no drift. Off by default; on other AirPods models, verify the mapping once with the probe protocol in the README.
Ear-detection auto-pause
- While no bud is in an ear, the bridge holds the last pose — pulling the buds out is itself head motion, and that garbage no longer reaches the game. Status shows
paused - buds out of ear. - When the buds return, the neutral pose is re-captured automatically (a re-seated bud never sits exactly where it was). Face forward as you put them in.
- Opt out with
EAR_PAUSE=0/EAR_RECENTER=0.
Setup and daily-use polish
install.shsuggests your AirPods MAC from the Pi's bonded devices when the config still holds the placeholder (suggestion only — it never writes the address).install.shnow installsairpods_ht_probe.pytoo, and gained--uninstall [--purge].- The bridge gained
--config FILEfor manual runs outside systemd. Precedence: CLI > environment > file. HT_START=bothnow sticks to the winning START variant for the whole process (reconnects no longer alternate) and logs ahint:line with the value to persist.- The probe gained
--csv PATH/--labelfor per-motion sensor recordings.
Tests and CI
- 49 → 102 tests. The main loop,
connect_l2cap(), and the CLI/config validation are now covered by a fake-socket/fake-clock harness with zero real sleeps. - CI runs on Python 3.9 / 3.11 / 3.13 — the Pythons of Raspberry Pi OS Bullseye, Bookworm, and Trixie.
Upgrade
scp *.py *.sh *.service *.conf.example pi@<PI_HOST>:~/
ssh pi@<PI_HOST> 'sudo ./install.sh'Your configuration file is never overwritten. To enable roll, add ROLL=1 and restart the service.
Full changelog: v0.4...v0.5.5
v0.4 — Selectable start packet (HT_START)
One config line for AirPods that connect fine but never stream: the new HT_START option lets the bridge send the other head-tracking start packet — or alternate between both until data arrives.
New
HT_START=alt|def|both(config file or--ht-start): selects which START packet begins the sensor stream.altis the previous hard-coded behavior and stays the default — nothing changes for working setups.defsends the LibrePodspackets.hvariant instead.bothalternates between the two on every dry re-send until data arrives.- Self-diagnosing journal lines: each re-send now names the variant (
no data - re-sent start DEF (buds in-ear?)), and when the stream finally starts inbothmode the bridge logsstream started after start DEF— telling you exactly which packet your model answered, so you can pinHT_STARTto it permanently. - README gained a Cautions entry for the symptom ("no data with the buds in your ears") with the
HT_START=bothrecovery procedure.
Why this exists
Issue #2 showed a setup where everything on the wire was healthy — L2CAP channel open, battery reports decoding, ear detection working — yet the sensor stream never started, no matter how often the bridge re-sent its start command.
The AACP protocol (reverse-engineered, via LibrePods) has two known start packets, and which one actually begins the stream depends on the AirPods model/firmware. The bridge hard-coded START_ALT because that is the variant confirmed on the tested hardware (AirPods 4 ANC); START_DEF — the LibrePods default — was only reachable through the diagnostic probe. Any model that only answers DEF was therefore permanently silent in the bridge while looking perfectly connected.
Rather than swap one hard-coded packet for another, the start command is now configurable, with a both mode that doubles as its own diagnostic: run it once, read which variant your buds answered in the journal, pin it. Two constraints shaped the design:
- ALT always goes first in
bothmode. Probing showed that DEF sent before ALT makes the buds emit a different packet family that contaminates the ALT stream — so the alternation order is a protocol requirement, not a style choice. - The default stays
alt. Existing installations see zero behavior change;bothis opt-in for exactly the "connected but silent" symptom.
Upgrading from v0.3
scp the new files to the Pi (README step 2), then sudo ./install.sh. No config changes needed — HT_START defaults to the previous behavior. Only set HT_START=both if the journal shows no data - re-sent start continuously while the buds are in your ears.
v0.3 — Re-centering + battery
Re-centering without touching SSH — the bridge now re-centers itself every time you press Start in OpenTrack — and the AirPods' battery is finally visible on the PC side: in the journal, and one systemctl status away.
New
- Auto-recenter on OpenTrack Start (serial transport): the bridge notices the COM port being read again after a stop of ≥ 3 s and re-captures the neutral pose. Sit straight, press Start, done — no token, no configuration. The 3 s floor keeps a brief PC stall from re-centering you mid-race;
RECENTER_ON_START=0turns it off. - Re-center any time, on any transport:
sudo systemctl kill --signal=SIGUSR1 pods-head-trackerre-centers without a restart. On serial, OpenTrack's Hatire dialog can also send aRECENTER_CMDtoken (defaultRECENTER) — put it in the Reset command field and the dialog's Reset button re-centers the bridge mid-session. (OpenTrack's Center hotkey never reaches the device — its serial center/zero commands are dead code upstream — so the hotkey stays PC-side software centering.) - Battery reporting: the bridge decodes the battery notifications the AirPods push over the same connection — a journal line on every change plus a heartbeat every 15 minutes (
BATTERY_LOG_SECS), and a low-battery warning at 20 % (BATTERY_WARN_PCT) that repeats at 15/10/5 %. - Live status:
systemctl status pods-head-trackernow shows the bridge state at a glance, e.g.Status: "streaming | battery L 82% R 80%". - Ear detection: in-ear transitions are logged, and the stream-start command goes quiet (30 s cadence) while the buds are known to be out of ear.
Fixed
- The stream-start watchdog counted receive timeouts, so any non-tracking traffic on the channel could keep it from ever re-sending the start command. It now keys on head-tracking silence directly.
Upgrading from v0.2
scp the new files to the Pi (README step 2), then sudo ./install.sh. No config changes needed — the new knobs (RECENTER_ON_START, RECENTER_CMD, BATTERY_LOG_SECS, BATTERY_WARN_PCT) all default sensibly. The battery/ear packet formats come from LibrePods, like the rest of the AACP protocol knowledge.
v0.2 — USB audio
The Pi now doubles as a USB sound card: Windows game audio travels over the same USB cable as the head-tracking data and comes out of the same AirPods — audio and tracking together, one cable, one pair of buds.
New
- USB audio (optional):
setup-usb-audio.shreplaces theg_serialgadget with a composite one — serial + UAC2 sound card, built at boot bypods-usb-gadget.sh/.service— andpods-usb-audio.sh/.serviceforwards the audio to the AirPods over Bluetooth A2DP (BlueALSA, SBC, 48 kHz). Nothing to install on Windows: the Pi shows up as a normal USB playback device next to the usual COM port. AUDIO_ENABLE=0in the config turns audio off while keeping head tracking. Never runningsetup-usb-audio.shkeeps the plain serial-only setup — nothing changes for tracking-only users.AUDIO_LATENCY_MStunes the audio loop buffer (default 50; 20 tested clean on a Pi Zero 2 W with tracking active).AUDIO_VOLUMEsets the buds' Bluetooth volume ceiling — day-to-day loudness is the normal Windows volume slider.
Fixed
- Wi-Fi/SSH starvation: every Bluetooth connect attempt to in-case AirPods monopolizes the Pi's shared 2.4 GHz radio for ~5 seconds, and the old 1.5-second retry loop could kill Wi-Fi outright. The bridge now backs off 10 s when the buds don't answer, and the audio forwarder never pages at all — it waits for the buds to appear on the link (they reconnect on their own when leaving the case).
Upgrading from v0.1
scp the new files to the Pi (README step 2), then sudo ./setup-usb-audio.sh and reboot. The COM port number changes once (the composite gadget is a new USB device) — re-select it in OpenTrack's Hatire settings. Serial-only setups need no changes at all.
Expect normal Bluetooth-audio latency (~150–300 ms end to end): great for immersion and music, not for competitive audio cues. See the README's "USB audio" section for setup, routing, and troubleshooting.
First Public Release
Turns AirPods into a head tracker for OpenTrack,using a Raspberry Pi as the Bluetooth bridge — built for sim racing (iRacing etc.),works with anything OpenTrack feeds.
Highlights
- AirPods head-tracking over Bluetooth - the Pi opens the AirPods' private AACP L2CAP channel via BlueZ and decodes the orientation stream (protocol derived from LibrePods)
- Two transports to OpenTrack:
- Wi-Fi UDP - OpenTrack's "FreePIE UDP receiver" input, zero cables
- USB serial - the Pi becomes a USB serial gadget (a plain COM port onWindows) speaking the "Hatire Arduino" protocol. No network in the path,no dropouts; one-shot
setup-usb-serial.shdoes the Pi-side setup TRANSPORT=bothstreams over both at once.
- Boot-and-forget - systemd service, auto-reconnects, re-centers on restart.
All personal settings live in/etc/pods-head-tracker.conf; the unit is generic. - Zero dependencies - Python 3 standard library only.
Tested
Pi Zero 2 W (Raspberry Pi OS, BlueZ 5.82) + AirPods 4 (ANC) + OpenTrack onWindows 11, both transports. Should work with every AirPods model after AirPods (2nd gen) and on any Linux/BlueZ machine - reports welcome.
Known limitations
- Yaw + pitch only - roll isn't derivable from the sensor data and is sent as 0.
- One pair of AirPods per bridge.
Full setup guide in the README