Lewis Reeves
← All projects

Bluetooth steering wheel controls: reading a resistor ladder

My 2008 Mazda 3 has an aux jack and no Bluetooth. Every button on its steering wheel reaches the stereo over just two wires, as one of seven analog voltages. An ESP32 taps that line, recognizes the two buttons I never used, and turns them into next track, play/pause, and previous track on my phone — without cutting a factory wire.

Vehicle
2008 Mazda 3, factory head unit with aux input
Controller
ESP32, BLE (Bluetooth Low Energy) media commands
Signal
0.42–4.4 V resistor ladder, two-wire
Interface
10 kΩ / 22 kΩ divider into GPIO34
Functions
Next, play/pause, previous — from two buttons
Power
USB-C, routed through the dash

01

Problem

The car plays audio from my phone through a 3.5 mm bluetooth aux receiver, which leaves every track change on the phone itself. The usual fix is a replacement head unit with CarPlay, which costs hundreds of dollars and removes the 2000s look to the interior of the car.

The steering wheel already has six buttons wired to the head unit. My first plan was to wire each button to its own GPIO (general-purpose input/output) pin on a microcontroller and read them as switches. Opening the wheel wiring revealed that only two conductors run to the button board. The buttons form a resistor ladder, so each one pulls the signal line to a different voltage, and the head unit decodes which button is pressed from that level alone.

That turned a digital-input problem into an analog one. The job became reading a single voltage accurately enough to tell seven states apart, at a level the microcontroller could safely accept, without disturbing the head unit that still depends on the same line.

02

Requirements & constraints

ParameterValueWhere it came from
Ladder signal, no button pressed4.4 VMeasured at the button line; the ladder runs from a 5 V reference
Lowest ladder level0.422 VMeasured, Vol− pressed
ESP32 ADC input ceiling3.3 VGPIO pins run on 0-3.3V
Media functions needed3Next track, previous track, play/pause
Buttons free to repurpose2Up and Down, which I never use
Buttons left to the head unit4Mute, Mode, Vol+, Vol− keep their factory behavior
Connection to the carParallel tapNo cut or spliced factory conductors; fully reversible
ADC blockADC1The radio is running the whole time; ADC1 is the block to use alongside it

Three functions from two buttons is the constraint that shapes the firmware. The 3.3 V ceiling is the one that shapes the hardware.

03

Design

Characterizing the ladder

I metered the signal line with each button held and with nothing pressed. Each button closes to a different tap on a series string of resistors, so the level steps down from Mute at the far end of the string to Vol− nearest the pull-up.

ButtonAt the ladderAfter the dividerHandled by
No press4.40 V3.03 V
Mute3.83 V2.63 VHead unit
Mode3.15 V2.17 VHead unit
Down2.42 V1.66 VESP32
Up1.68 V1.16 VESP32
Vol+1.00 V0.69 VHead unit
Vol−0.422 V0.29 VHead unit

Bringing 4.4 V under 3.3 V

A two-resistor divider scales the line into range. With 10 kΩ on top and 22 kΩ to ground, the highest level the pin ever sees is 3.03 V.

Divider ratio 22 kΩ ÷ (10 kΩ + 22 kΩ) = 0.6875
Highest level at the pin, no button pressed 4.4 V × 0.6875 = 3.03 V — under the 3.3 V ceiling
Lowest level at the pin, Vol− pressed 0.422 V × 0.6875 = 0.29 V
Current the divider draws from the ladder, worst case 4.4 V ÷ 32 kΩ = 0.14 mA

Choosing the input pin

The divider output lands on GPIO34. It is an input-only pin on ADC1 with no internal pull-up or pull-down, so nothing inside the ESP32 can drive or bias the shared line. The choice of ADC block matters as much as the pin: the ESP32 shares ADC2 with its radio driver, and this board has Bluetooth running continuously.

Schematic: a pull-up resistor R3 from 5 V feeds the button output node. A series string of resistors R4 through R9 runs from that node, with switches SW1 through SW6 (Vol-, Vol+, Up, Down, Mode, Mute) each closing a tap on the string to ground. The button output node also feeds a divider of R1, 10 kilohms, and R2, 22 kilohms, to ground; the divider midpoint connects to GPIO34 on the ESP32, which is powered from 5 V.
Drawn in KiCad. The steering wheel ladder and the head unit pull-up are on top; the divider and ESP32 tap the same node in parallel, so the head unit still sees every press.

Deciding in ADC counts, not volts

The ESP32 ADC is not especially linear, and its reference varies from chip to chip, so converting readings back to volts would add error without adding anything useful. Instead the firmware stores the raw 12-bit count for each button, measured on this board with the divider in circuit, and places each decision threshold at the midpoint between neighboring buttons. Recalibrating means holding each button, reading the settled count from the serial monitor, and changing seven numbers.

Measured ADC counts against the three decision thresholds

Scale is the full 12-bit range, 0–4095. Only the Down and Up bands produce a command; every other level reads as no press.

No press
3673 ignored
Mute
3264 ignored
Mode
2693 ignored
Down
2060 Down
Up
1440 Up
Vol+
856 ignored
Vol−
360 ignored
↑ 1148↑ 1750↑ 2376

The thresholds that matter all sit between 1148 and 2376 counts, roughly 0.9 to 1.9 V at the pin. That is the part of the ESP32’s range, at 11 dB attenuation, where the converter behaves best. Mute and the idle level sit near the top of the range, where the ADC is least linear, but both only ever need to read as “not Down,” so the distortion up there costs nothing.

Two buttons, three functions

Up is the simple case: a completed press sends next track. Down carries two jobs. A single press sends play/pause, and two presses in quick succession send previous track. To tell them apart, the firmware holds a Down release for 300 ms. If a second release arrives inside that window it sends previous track; if the window expires first it sends play/pause. Every command fires on release rather than on press, which is what makes that wait possible.

Two filters sit in front of that logic. A reading only counts once five consecutive samples, taken about 10 ms apart, agree on the same button — each sample itself the average of eight conversions. That rejects contact bounce, and it also rejects the instant a volume press passes through the Up or Down band on its way to a lower level. Second, a press held longer than 500 ms is discarded, so holding a button for its factory function never produces a stray track change. While no phone is connected, the firmware tracks the line without acting on it, so a button held through a reconnect does not fire the moment the link comes up.

Firmware (Arduino, ESP32)
// Mazda 3 steering wheel control adapter.
//
// Taps the factory SWC resistor ladder through a 10k/22k divider into GPIO34
// and translates Up and Down presses into BLE media commands. Mute, Mode,
// Vol+ and Vol- are left alone so the head unit keeps handling them.
#include <BleMediaControl.h>
// This enum must stay above the first function definition in the file. The
// Arduino build injects auto-generated prototypes at the location of the first
// function it finds, and several of those prototypes take a Button.
enum Button { BTN_NONE, BTN_DOWN, BTN_UP };
BleMediaControl media("Car Audio Controller");
const int PIN_SWC = 34;  // input-only, correct for a passive parallel tap

// ---------------------------------------------------------------------------
// CALIBRATION
//
// Raw ADC counts read on GPIO34 with the divider in circuit. Open the serial
// monitor at 115200, hold each button, and record the settled "ADC:" value.
// Boundaries recompute themselves from these seven numbers.
// ---------------------------------------------------------------------------
const int RAW_STANDBY = 3673;
const int RAW_MUTE    = 3264;
const int RAW_MODE    = 2693;
const int RAW_DOWN    = 2060;
const int RAW_UP      = 1440;
const int RAW_VOL_UP  =  856;
const int RAW_VOL_DN  =  360;

constexpr int midpoint(int a, int b) { return (a + b) / 2; }
const int THRESH_DOWN_MAX = midpoint(RAW_MODE, RAW_DOWN);
const int THRESH_DOWN_MIN = midpoint(RAW_DOWN, RAW_UP);
const int THRESH_UP_MIN   = midpoint(RAW_UP, RAW_VOL_UP);

const int SAMPLE_COUNT    = 5;    // consecutive reads that must agree
const int SAMPLE_INTERVAL = 10;   // ms between samples
const unsigned long HOLD_IGNORE_MS = 500;  // ignore a button held longer
const unsigned long DOUBLE_TAP_MS  = 300;  // double-press window on DOWN

Button stableButton    = BTN_NONE;
Button candidateButton = BTN_NONE;
int    candidateCount  = 0;
unsigned long buttonPressTime = 0;
bool          downPendingSingle = false;
unsigned long downReleaseTime   = 0;

int readADC() {
  const int READS = 8;
  long sum = 0;
  for (int i = 0; i < READS; i++) {
    sum += analogRead(PIN_SWC);
    delayMicroseconds(200);
  }
  return (int)(sum / READS);
}

Button classifyADC(int adcVal) {
  if (adcVal >= THRESH_DOWN_MAX) return BTN_NONE;  // standby, Mute or Mode
  if (adcVal >= THRESH_DOWN_MIN) return BTN_DOWN;
  if (adcVal >= THRESH_UP_MIN)   return BTN_UP;
  return BTN_NONE;                                 // Vol+ or Vol-
}

void handleDownTimeout() {
  if (downPendingSingle && (millis() - downReleaseTime) > DOUBLE_TAP_MS) {
    downPendingSingle = false;
    media.send(MEDIA_PLAY_PAUSE);
  }
}

void onButtonReleased(Button btn) {
  if (btn == BTN_UP) {
    media.send(MEDIA_NEXT_TRACK);
    return;
  }
  // BTN_DOWN: single tap = play/pause, double tap = previous track
  unsigned long now = millis();
  if (downPendingSingle && (now - downReleaseTime) <= DOUBLE_TAP_MS) {
    downPendingSingle = false;
    media.send(MEDIA_PREVIOUS_TRACK);
  } else {
    downPendingSingle = true;
    downReleaseTime   = now;
  }
}

void setup() {
  Serial.begin(115200);
  analogReadResolution(12);
  analogSetAttenuation(ADC_11db);
  media.begin();
}

void loop() {
  int adcVal     = readADC();
  Button current = classifyADC(adcVal);

  // Runs whether or not a phone is connected, so calibration works on the
  // bench. Comment out once the RAW_* values are dialed in.
  Serial.print("ADC: ");     Serial.print(adcVal);
  Serial.print("  Button: "); Serial.print((int)current);
  Serial.print("  BLE: ");    Serial.println(media.isConnected() ? 1 : 0);

  if (!media.isConnected()) {
    // Track the reading without firing, so a button held through a reconnect
    // does not register as a press the moment the link comes up.
    stableButton    = current;
    candidateButton = current;
    candidateCount  = 0;
    downPendingSingle = false;
    delay(SAMPLE_INTERVAL);
    return;
  }

  handleDownTimeout();

  if (current == candidateButton) {
    candidateCount++;
  } else {
    candidateButton = current;
    candidateCount  = 1;
  }

  if (candidateCount >= SAMPLE_COUNT && current != stableButton) {
    if (current == BTN_NONE) {
      if (stableButton != BTN_NONE &&
          (millis() - buttonPressTime) < HOLD_IGNORE_MS) {
        onButtonReleased(stableButton);
      }
    } else {
      buttonPressTime = millis();
    }
    stableButton = current;
  }

  delay(SAMPLE_INTERVAL);
}

04

Build

The divider is soldered on perfboard alongside the ESP32 development board. To find the steering wheel pair at the back of the stereo, I used a published harness pinout for the first-generation Mazda 3, which lists the steering wheel control leads on pins 12 (white/black) and 13 (brown/yellow). The divider input connects to the signal conductor and the board ground to the return, both as parallel taps, so the head unit connector stays fully populated and the factory path is unchanged.

The ESP32 is powered through a USB-C cable routed from a cigarette lighter through the dash, which keeps the 12 V system out of the build entirely: the only connection to the car’s wiring is the two-wire tap.

ConnectionFromTo
Steering wheel signalHead unit harness, SWC pairDivider input (10 kΩ side)
Steering wheel returnHead unit harness, SWC pairESP32 GND and divider bottom (22 kΩ side)
Scaled signalDivider midpointGPIO34 (ADC1)
PowerUSB-C cable through the dashESP32 board

05

Verification

Each calibration value is the settled reading on GPIO34 with the button held and the divider in circuit. Margin is the distance from that reading to the nearest threshold — how far a level can drift before the firmware would misread it.

ButtonPredicted at pinMeasuredReads asMargin to nearest threshold
No press3.03 V3673None1297
Mute2.63 V3264None888
Mode2.17 V2693None317
Down1.66 V2060Down310
Up1.16 V1440Up292
Vol+0.69 V856None292
Vol−0.29 V360None788

The tightest margin is 292 counts. That is the Up/Vol+ boundary, about 0.23 V at the pin and 0.34 V back at the ladder. The two levels the firmware acts on each have at least 290 counts of room on both sides — a drift of more than 20 percent of the Up reading before it could be mistaken for a neighbor.

The measured counts fall in the same order as the scaled voltages, with no two levels crowding each other after scaling. Placing each threshold at a midpoint means neighboring buttons share their margin evenly, so no single boundary is the weak one by construction.

06

What I’d do differently

Time the double press from release to next press

The 300 ms window runs from the first release to the second release. A second press that starts inside the window but is held a little too long lets the window expire mid-press: play/pause fires, and the second release then starts a new single press that fires play/pause again. Measuring from the first release to the start of the second press, and suppressing the timeout while Down is held, would make a deliberate double press register as previous track regardless of how long each tap lasts.

Load the ladder less

The 32 kΩ divider draws 0.14 mA from a line the head unit is also reading. That is small, but it shifts every level the head unit sees by an amount that depends on a pull-up I have not measured. A 100 kΩ / 220 kΩ divider keeps the same 0.6875 ratio at a tenth of the load, and a small capacitor at GPIO34 gives the ADC a low-impedance source to sample from.

Protect the input

A car’s wiring is not a bench supply. A series resistor and a clamp diode to 3.3 V ahead of GPIO34 would cost a few cents and keep a transient on the steering wheel line from reaching the ESP32 pin.

Move from perfboard to a board

A small printed circuit board would carry the divider, the protection parts, and a connector that mates with the harness tap, replacing hand wiring that has to survive vibration behind the dash.

Harness pin assignments from a published first-generation Mazda 3 stereo wiring chart. Schematic drawn in KiCad. Ladder voltages measured with a multimeter; ADC counts read from the ESP32 serial monitor with the divider installed.