Electronic bike shifting (SRAM AXS): battery levels from a $5 ESP32, no app, no pairing

My bike has electronic shifting (SRAM AXS), which is great until the derailleur battery dies mid-ride. The two official ways to check it are the SRAM phone app, which you open while standing next to the bike, and the head unit, which tells you mid-ride when it is already too late. I wanted to know before I kit up, and I wanted Home Assistant to notify me the way it does about smoke detector batteries, so I built esphome-sram-axs.

Every awake AXS component broadcasts its battery state in its BLE advertisements. No pairing, no connection, and no app are required; any BLE scanner can hear it. I wrote an ESPHome external component for an ESP32 that lives near where the bike is parked. The decode happens on the ESP32, so there is no HA Bluetooth stack involved and the entities arrive over the native API.

The coin-cell components (the shift controllers) broadcast no battery percentage. That byte comes back 0xFF, a not-supported sentinel, even on a nearly flat cell. They do broadcast live voltage in millivolts. I did not have to run a cell down to find the useful thresholds, because when I started this both of my shifters’ original cells were already nearly dead, at 2.75 and 2.71 V, and the app was calling everything “Battery Good”. The app shows no percentages, just three coarse bands, while the hardware underneath broadcasts millivolts.

The coin-cell entity is a status string derived from voltage: “OK”, “Low - replace soon”, “Critical - replace now”. Warn sits at 2.80 V, just past the CR2032 discharge knee; critical at 2.75 V, a little above the ~2.7 V where my dying cells were still running (one even survived a firmware update there, so there is some margin, but not much).

The rest is ordinary ESPHome. One entry per physical component, matched by serial, every sensor opt-in:

external_components:
  - source: github://tarekrached/esphome-sram-axs
    components: [sram_axs]

esp32_ble_tracker:

sram_axs:
  - serial: 1234567890        # the decimal number in your component's advertised
    name: "Left Shifter"      # name, "SRAM <serial>". Finding it is the first flash.
    battery_status:           # coin-cell components: "OK", "Low - replace soon",
      name: "Left Shifter Battery Status"    # "Critical - replace now"
    battery_voltage:
      name: "Left Shifter Battery Voltage"

  - serial: 1234567891
    name: "Rear Derailleur"
    battery_voltage:
      name: "Rear Derailleur Battery Voltage"
    battery_percent:          # AXS-pack components only
      name: "Rear Derailleur Battery Percent"

Finding your serials is the first flash. Discovery is always on, with no flag and no phone app: flash the example config as-is (the placeholder serials are fine), leave esphome logs running, wake the bike, and every component you haven’t configured yet announces itself once:

[20:31:10][I][sram_axs:171]: Discovered SRAM AXS serial=1304203903 (coin-cell, 3.05 V, device_id=10, rssi -76 dBm) - add to your sram_axs config to publish sensors

Packs (~8 V) are the derailleur and the dropper, coin cells (~3 V) are the shift controllers, and that label is decoded out of the advertisement, so the line tells you which serial is which. The SRAM app’s Component Detail page shows the same serial too, if you’d rather transcribe it from there. Paste them in, run again, and the second flash goes over the air in seconds.

Per component you get voltage in mV, percent for the pack components, the status string for coin cells, RSSI, and last-seen.

This is a burst-on-wake protocol, not a polling one. Components advertise for a few minutes after movement or a button press, go quiet, and stop entirely the moment the official app connects. A listener has to be built around that, because the ESP32 is awake all the time and the bike is not. The entities hold their last decoded values, and those get written to flash, so a reboot or an OTA update does not leave you with a dashboard of blanks until the next time someone touches the bike.

Scope: this is validated on one bike. Rival XPLR AXS drivetrain, Reverb AXS dropper, two shift controllers, all four decoding live on an ESP32 right next to the bike in the shed, feeding a dashboard and an automation that pings my phone. I don’t have a front derailleur, Blips or brake levers to test against. There are things I still don’t understand: the byte prior work maps as a device-type code reads 0x03 on all four of my components, so under that mapping my dropper would decode as a rear derailleur; and one session had a derailleur advertising for 10+ minutes against a measured ~5 min 20 s window, with shifting happening mid-window. Those are marked open in the docs.

A few more things from the teardown (docs/protocol.md, which is the most complete public write-up of the AXS advertisement format):

  • Wake triggers differ by component. The rear derailleur wakes on movement, while the controllers and the dropper wake on an AXS-button press. Shift-paddle clicks wake nothing at all. I checked this with single clicks against a continuously broadcasting canary device because I would have assumed otherwise. Pack components advertise for about 5 minutes, and coin-cell controllers advertise for 20 to 24 seconds regardless of the cell charge. Anyone writing a listener has to design for that 13x asymmetry.
  • The same advertisement arrives sliced differently depending on your BLE stack. ESP-IDF splits the extended variants into two manufacturer records, but macOS CoreBluetooth coalesces them into one. This makes two people’s captures of the same device disagree.
  • The adverts also carry the component’s firmware version. I confirmed this by capturing the same four devices before and after a firmware update.

Two HACS integrations for AXS batteries already exist. If you run HA’s Bluetooth stack, one of them may suit you. home-assistant-sram-axs decodes the same advertisements HA-side, and sram-axs-for-ha connects and reads the standard GATT battery service. Mine is the on-device option: standalone ESP32, no HA Bluetooth involvement, and deeper protocol documentation.

Repo: tarekrached/esphome-sram-axs (v0.1.1). Unofficial, built for my own bike. I drove, Claude typed. If you have component types I don’t, captures and corrections are welcome; the capture tools are in the repo.

6 Likes