← all posts

Getting my Fitbit Air heart rate into VRChat, no phone app needed

How a small Python bridge reads the Fitbit Air's Bluetooth LE heart rate and sends it to VRChat over OSC, plus the pitfalls that cost me days.

vrchatbluetooth-leoscpythonreverse-engineering
Contents
  1. The standard part: Heart Rate Service
  2. How I figured out the sequence
  3. The pitfalls
  4. The OSC side
  5. Making it live in the background
  6. What's next

I wanted a simple thing: my real heart rate on my VRChat avatar. No phone app running in the background, no cloud service, no account. Just the band on my wrist and a program on my PC. It took longer than expected, mostly because of Bluetooth quirks, so here is how it came together.

The result is a small Windows tool that reads the heart rate from a Fitbit Air over Bluetooth LE and forwards it to VRChat via OSC. It's on GitHub; the link is at the end.

The standard part: Heart Rate Service

Bluetooth LE has a standardised Heart Rate Service. It's service 0x180D, and the interesting characteristic is Heart Rate Measurement, 0x2A37. You connect, switch on notifications for that characteristic (by writing to its CCCD descriptor), and the device pushes a small packet every second or so.

The first byte of that packet is a flags field. Bit 0 tells you how wide the value is: 0 means a single byte, 1 means a 16-bit little-endian number. That's all the parsing you need for the heart rate itself:

def parse_hr(data: bytes) -> int:
    flags = data[0]
    if flags & 0x01:                      # bit 0 set -> 16-bit value
        return int.from_bytes(data[1:3], "little")
    return data[1]                        # otherwise one byte

The Fitbit Air only advertises this service when heart-rate sharing is switched on in the Google Health app ("Share heart rate", plus "Always visible" so it keeps going after the app is closed). That setting lives in the band itself, so the phone is only needed once. After that it can sit in another room.

How I figured out the sequence

I wasn't sure what the band expects from a client, so I looked at how the official HypeRate app talks to it, only the Bluetooth side. At a high level it does what you'd expect: scan for 0x180D for a few seconds, connect, subscribe to notifications on 0x2A37, and read what comes in. That confirmed the band doesn't need any proprietary handshake. It's a plain heart-rate peripheral.

I also noticed the app picks the value out of the packet in a way that ignores the flags byte, which means it would misread 16-bit values. Fitbit-style bands normally send single-byte values, so it works in practice, but my bridge checks the flags properly.

The pitfalls

This is where most of the time went.

Rotating random addresses. The band advertises with a private random address that changes now and then. Anything that remembers "the band's address" eventually goes stale. After unpairing, Windows also refused to resolve the old address, and only a fresh one (toggling "Share heart rate" off and on) worked. So the bridge scans, takes whatever address shows up, and if a paired band is unreachable twice in a row it unpairs and starts over.

Pairing is required. Without bonding, the band drops the connection after 20 to 30 seconds. Android pairs implicitly the first time a protected attribute is touched. Windows doesn't; you have to ask. I ended up using the Windows pairing API directly and accepting whatever confirmation type the band asks for. One lesson: don't hold a second connection from another library while pairing. My first attempt did that, pairing failed and the band stopped advertising.

One listener at a time. The band shares with a single device. If a heart-rate app on the phone is open, the PC never gets a turn. Closing it fixed several "the band is invisible" mysteries.

The bad Bluetooth dongle. For days I blamed the band. The real culprit was my cheap USB adapter, a CSR8510 clone. It reported Bluetooth 5.0, but identified itself as a "CSR8510 A10", which is the sign of a counterfeit. After about 15 seconds of scanning it simply went deaf, and while a device was paired it couldn't scan at all. I built workarounds (cycling the radio off and on), but the connection never stayed up longer than 16 seconds or so.

Then I plugged in a TP-Link UB500 (Realtek-based, Bluetooth 5.4). Signal strength went from around -85 dBm to -52 dBm, and it ran for four minutes without a single drop on the first try. I deleted all the workarounds the same day. If your Bluetooth project behaves mysteriously, try a different adapter before you rewrite anything.

The OSC side

VRChat listens for OSC on UDP port 9000 on localhost. To set an avatar parameter you send a message to /avatar/parameters/<name>. The protocol is simple enough that I wrote the encoder myself: padded address string, padded type tags, big-endian payload. No extra dependency.

Because avatars use different heart-rate prefabs, the bridge sends several parameter families at once:

  • the VRCOSC Heartrate parameters (VRCOSC/Heartrate/Value, Normalised, and the digits),
  • HRtoVRChat-style names: HR, onesHR, tensHR, hundredsHR, HRPercent, FullHRPercent, isHRConnected, isHRActive,
  • a beat toggle (isHRBeat, HeartBeatToggle) flipped in time with the pulse, so a heart can actually pulse.

A 0 BPM reading means the band is lying on a desk, so the bridge sends nothing to the avatar until a real value arrives. There's also a demo mode that sends a synthetic pulse, which is handy for checking an avatar without touching Bluetooth at all. Remember that many prefabs have a menu toggle that has to be switched on.

Making it live in the background

A tool you have to start by hand gets forgotten, so two small conveniences:

  • Tray icon. The bridge runs without a console window. The heart is grey when no band is connected and red when values are flowing; hovering shows the current BPM, and the menu has "Open log" and "Quit". A mutex prevents a second instance from fighting over the band.
  • SteamVR autostart. A small script registers the bridge as a SteamVR overlay application with autostart, the same mechanism other VR tools use. SteamVR launches it, and it quits itself about 20 seconds after SteamVR closes (it polls for the SteamVR server process).

One more reliability fix: Windows doesn't always tell you when a connection dies. If no measurement arrives for 20 seconds, the bridge treats the connection as dead and reconnects.

What's next

The band itself works, but I want to see more of it in actual sessions. Things on my list: testing with other bands that expose the standard heart-rate service (only the Fitbit Air is tested so far) and making first-time setup less fiddly. Feedback and bug reports are welcome.

The code, setup guide and troubleshooting table are here: github.com/Hemiize/fitbit-vrchat-osc