Skip to content

Hardware buttons

Physical buttons can control playback directly from the device, with no phone involved. They work with both AirPlay and Bluetooth sources — but there is an important caveat for AirPlay, covered below.

Read this first: the AirPlay v1 requirement

Button-driven remote control (play/pause, next/previous track) relies on DACP, a protocol where iOS sends a session ID and port that the receiver uses to send commands back to the source. iOS only sends DACP headers in AirPlay v1 (classic) mode. In AirPlay 2 mode Apple uses MRP (Media Remote Protocol) instead, which this firmware does not implement.

In practice:

Source Volume Play/pause and track skip
AirPlay 2 (default) Works, applied locally on the DAC Falls back to local mute — cannot control the source
AirPlay v1 (forced) Works Works fully via DACP
Bluetooth Works Works fully via AVRCP passthrough

To get full button control over AirPlay, switch the receiver to v1 mode in the web interface: Device Settings → AirPlay Mode → Legacy (v1), then restart the device.

Trade-off

AirPlay v1 disables AirPlay 2 features: HomeKit pairing, encrypted transport and multi-room sync. The device still appears in AirPlay menus on iOS, but as a classic receiver. Bluetooth is unaffected.

Supported actions

Button Action
Play/pause Toggle playback
Volume up Increase volume, roughly 3 dB per step, auto-repeats
Volume down Decrease volume, roughly 3 dB per step, auto-repeats
Next track Skip to the next track
Previous Go to the previous track
Rotary encoder Clockwise raises the volume, anti-clockwise lowers it

Volume buttons auto-repeat: hold for 500 ms and the action repeats every 200 ms.

Wiring

Buttons are active-low — wire each one between its GPIO and GND.

flowchart LR
    GPIO["ESP32 GPIO<br/><small>internal pull-up</small>"]
    BTN(["Button"])
    GND["GND"]

    GPIO --- BTN --- GND

No resistor is needed on most pins; the internal pull-up holds the line high while the button is open.

  • Internal pull-ups are enabled automatically on GPIOs 0–33.
  • GPIOs 34–39 are input-only on the ESP32 and have no internal pull-up, so they need an external pull-up resistor. The driver warns at boot if you use one of them.

Input is interrupt-driven with a 50 ms software debounce, so there is no polling overhead. Actions are dispatched to whichever source is active: DACP for AirPlay v1, AVRCP passthrough for Bluetooth.

Rotary encoder

A quadrature rotary encoder — an EC11 or similar — can be used for volume instead of, or alongside, the volume buttons. Wire its two signal pins to the channel A and channel B GPIOs and its common pin to GND.

flowchart LR
    A["GPIO — channel A"]
    B["GPIO — channel B"]
    ENC["Rotary encoder"]
    GND["GND"]

    A --- ENC
    B --- ENC
    ENC --- GND

The same pull-up rules apply as for buttons, so GPIOs 34–39 need external pull-ups on both channels. The encoder is decoded in the GPIO interrupt handler with a quadrature state machine, and one detent — four quadrature transitions on a typical encoder — produces one volume step.

Turning the wrong way?

If clockwise lowers the volume, swap the channel A and channel B GPIOs.

Many encoder modules also have a push switch. It is a plain button, so wire it to any of the button GPIOs above — the play/pause pin is the usual choice.

Configuration

All button GPIOs default to -1, meaning disabled.

idf.py menuconfig
# AirPlay Receiver → Button Configuration
# Set each GPIO pin, or leave at -1 to disable

With PlatformIO:

pio run -e <env> -t menuconfig
Option Default Description
Play/pause button GPIO -1 GPIO for play/pause
Volume up button GPIO -1 GPIO for volume up, auto-repeats
Volume down button GPIO -1 GPIO for volume down, auto-repeats
Next track button GPIO -1 GPIO for next track
Previous track button GPIO -1 GPIO for previous track
Rotary encoder channel A GPIO -1 GPIO for encoder channel A
Rotary encoder channel B GPIO -1 GPIO for encoder channel B

Note

Both rotary GPIOs must be set. If either is left at -1 the encoder is compiled out entirely.

Note

The button driver installs the shared GPIO ISR service (board_gpio_isr_init()) itself if the board support layer has not already done so.