A Raspberry Pi makes a capable headless audio player for multi-room setups (Snapcast, Music Assistant, etc.). But headless means no physical controls.
This project turns a Microsoft Surface Dial into one: rotate to change volume, press to mute. You end up with a systemd service that connects to the Dial, auto-starts on boot, and survives reboots.
The Dial pairs as a standard Bluetooth HID device.
It exposes rotation (REL_DIAL) and button (BTN_0) events on a single input node.
dial-volume.py reads those events with evdev.
A single twist of the Dial fires a burst of rotation events, so the script batches them before acting.
It then drives the ALSA mixer via amixer to change or mute the volume.
- Microsoft Surface Dial (Bluetooth)
- Raspberry Pi or other Linux with a Bluetooth adapter
- A working ALSA output with an
amixercontrol (this setup usesPCM) python3-evdev(installed in Setup step 2)
Note: This guide was written and tested on DietPi (Debian-based). Steps should work as-is on any Debian-based distro. Commands, package names and some paths may differ with other distros.
Enable Bluetooth and pair the Dial (hold its button until the LED pulses):
sudo systemctl enable --now bluetooth
sudo bluetoothctl
# inside the shell:
power on
agent on
default-agent
scan on
# wait for: [NEW] Device <MAC> Surface Dial
pair <MAC>
trust <MAC>
connect <MAC>
scan off
quitEnable auto-reconnect by adding to /etc/bluetooth/main.conf:
[Policy]
AutoEnable=truesudo systemctl restart bluetoothsudo apt install python3-evdev -y
cat /proc/bus/input/devices | grep -A4 "Surface Dial System Multi Axis" # note Handlers=eventX
amixer scontrols # note the control nameUpdate DEVICE_PATH and MIXER_CONTROL at the top of dial-volume.py to match. Note: the event number can change across reboots — if the Dial stops working, re-check and update it.
sudo cp scripts/dial-volume.py /usr/local/bin/dial-volume.py
sudo chmod +x /usr/local/bin/dial-volume.py
sudo cp scripts/dial-volume.service /etc/systemd/system/dial-volume.service
sudo systemctl daemon-reload
sudo systemctl enable --now dial-volume
systemctl status dial-volume| Action | Result |
|---|---|
| Rotate clockwise | Volume up |
| Rotate counter-clockwise | Volume down |
| Press | Toggle mute |
Edit the constants at the top of dial-volume.py:
| Variable | Meaning |
|---|---|
VOLUME_STEP |
% change per accepted rotation batch |
MIN_THRESHOLD |
Minimum net rotation per batch before volume changes — higher = less sensitive (5 is a good default) |
Then apply the changes by restarting the service:
sudo nano /usr/local/bin/dial-volume.py # edit the constants
sudo systemctl restart dial-volume # applyTo reverse direction, swap the sign in current_vol += VOLUME_STEP if net > 0 else -VOLUME_STEP.
sudo journalctl -u dial-volume -f # live logs
sudo bluetoothctl connect <MAC> # manual reconnect
cat /proc/bus/input/devices | grep -A4 "Surface Dial System Multi" # re-check event number
sudo python3 scripts/dial-debug.py # inspect raw events / amixer output- Volume jumps wildly: raise
MIN_THRESHOLD. Don't use fractionalVOLUME_STEP—amixeronly handles integers reliably. - "not found": the event path likely changed after a reboot; update
DEVICE_PATH. - Permissions: the service runs as root so this isn't an issue. To run manually without
sudo, add your user to theinputandaudiogroups.
The Dial emits these input events (codes):
| Event type | Code | Meaning |
|---|---|---|
EV_REL |
REL_DIAL |
Rotation (positive = clockwise) |
EV_KEY |
BTN_0 (256) |
Button press |
EV_MSC |
MSC_SCAN |
Scancode metadata — ignored |
EV_SYN |
— | Event separator — ignored |
- python-evdev by Georgi Valkov — Python bindings for the Linux input subsystem, used here to read raw events from the Dial.
MIT — see LICENSE.