Use this guide when you want to assemble the physical controller for Ioruba.
The active repository is designed around a practical Arduino Nano + 3 potentiometers build that feeds the Tauri desktop app.
1x Arduino Nano ATmega328P3x 10klinear potentiometers- optional: momentary push buttons or rotary encoders for
mute/next/prev 1x USB data cable- jumper wires
- breadboard, perfboard, or enclosure
| Control | Left pin | Center pin | Right pin |
|---|---|---|---|
| Knob 1 | GND |
A0 |
5V |
| Knob 2 | GND |
A1 |
5V |
| Knob 3 | GND |
A2 |
5V |
If clockwise/counter-clockwise movement feels reversed, swap the two outer pins on that potentiometer.
Arduino Nano
┌──────────────────────┐
│ A0 ───── knob 1 │
│ A1 ───── knob 2 │
│ A2 ───── knob 3 │
│ 5V ───── outer pins │
│ GND ──── outer pins │
│ USB ──── computer │
└──────────────────────┘
- keep all three potentiometers on a shared
GND - keep all three potentiometers on a shared
5V - connect only the center pin of each knob to an analog input
- use a USB data cable, not a charge-only cable
- leave enough slack if you plan to mount everything in an enclosure
The current firmware reads the three analog inputs, persists tuning and calibration in EEPROM, and emits lines such as:
HELLO board=Ioruba Nano; fw=0.6.2; protocol=2; knobs=3; buttons=0; encoders=0; knobPins=A0,A1,A2; buttonPins=none; encoderPins=none; mcu=ATmega328P; adcBits=10; threshold=4; deadzone=7; smooth=75; mins=0,0,0; maxs=1023,1023,1023
512|768|1023
That maps directly to the active desktop runtime. The runtime also accepts the older legacy packet style, but the current build target is the full-frame format above plus the handshake metadata used to sync controller tuning.
The mcu and adcBits fields are additive protocol-v2 metadata: older firmware that omits them still works (the desktop assumes 10-bit). adcBits lets the desktop normalize readings for boards with a different ADC resolution — AVR boards report 10 (0..1023), while ESP32 and RP2040/Pico report 12 (0..4095). The firmware auto-detects the bit depth from the target architecture; override it at compile time with -DIORUBA_ADC_BITS=<n> if needed.
Digital controls are disabled by default. Enable them at compile time:
arduino-cli compile --fqbn arduino:avr:nano \
--build-property "compiler.cpp.extra_flags=-DIORUBA_NUM_BUTTONS=1 -DIORUBA_NUM_ENCODERS=1" \
firmware/arduino/ioruba-controllerThe firmware now provides a board-specific, conflict-free default map. On a Nano/Uno, it uses the following enabled pin order:
| Input | Pins | Wiring |
|---|---|---|
| Buttons | D2 D3 D4 D5 D6 D7 D8 D9 |
one side to the pin, the other to GND; firmware uses INPUT_PULLUP |
| Encoders | D10/D11, D12/D13 |
channel A/B to the pair, common to GND; firmware uses INPUT_PULLUP |
The build fails with a readable static_assert if an enabled pin overlaps a knob, another button, or either encoder channel. The active map is included in every HELLO packet (knobPins, buttonPins, encoderPins) and appears in the desktop Hardware panel.
Override the default lists at compile time when your enclosure needs a different layout. Lists are comma-separated; the first IORUBA_NUM_* entries are used.
arduino-cli compile --fqbn arduino:avr:nano \
--build-property "compiler.cpp.extra_flags=-DIORUBA_NUM_BUTTONS=2 -DIORUBA_NUM_ENCODERS=1 -DIORUBA_BUTTON_PINS=4,5 -DIORUBA_ENCODER_A_PINS=10 -DIORUBA_ENCODER_B_PINS=11" \
firmware/arduino/ioruba-controllerEach enabled pin must be unique. On Nano/Uno, the supplied default map supports up to eight buttons or two encoders; boards with more usable GPIO (Mega, Leonardo/Micro, ESP32, RP2040) expose four default encoder pairs. ESP8266 exposes four button pins and one encoder pair, avoiding boot-strapping pins.
The desktop opts in by sending EVENTS ON after connecting. Until that command is received, the firmware only emits knob frames, which keeps older desktop builds compatible. Once enabled, event frames look like:
EV type=button; id=0; event=press
EV type=encoder; id=0; delta=1
Add bindings to a profile with the controls array:
"controls": [
{ "input": "button", "id": 0, "name": "Mute", "event": "press", "action": "mute" },
{ "input": "encoder", "id": 0, "name": "Next track", "direction": "clockwise", "action": "next" },
{ "input": "encoder", "id": 0, "name": "Previous track", "direction": "counterclockwise", "action": "prev" }
]On Linux, mute uses pactl and can target the default output, a sink, a source, or an application; next and prev use playerctl when installed. Windows currently supports mute for the default output. Unsupported actions are reported in the watch log instead of failing the serial runtime. Bindings can be created in Settings → Profile editor → Buttons and encoders without editing JSON.
The reference build is the Nano with 3 knobs, but the firmware is parametric. The number of knobs is set with -DIORUBA_NUM_KNOBS=<n> at compile time, and the analog pins are chosen from a per-board table (the first n channels). A static_assert fails the build if n exceeds the board's analog channels.
| Board | MCU | ADC bits | Analog channels | Max knobs | Pin order (first knobs use these in order) |
|---|---|---|---|---|---|
| Arduino Nano | ATmega328P | 10 | 8 | 8 | A0 A1 A2 A3 A4 A5 A6 A7 |
| Arduino Uno | ATmega328P | 10 | 6 | 6 | A0 A1 A2 A3 A4 A5 |
| Arduino Mega2560 | ATmega2560 | 10 | 16 | 16 | A0 A1 … A15 |
| Leonardo / Micro | ATmega32U4 | 10 | 12 | 12 | A0 A1 … A11 |
| ESP32 | ESP32 | 12 | 6 (ADC1 only) | 6 | A0 A3 A4 A5 A6 A7 (ADC2 is reserved for Wi-Fi) |
| RP2040 / Pico | RP2040 | 12 | 3 | 3 | A0 A1 A2 |
| ESP8266 (NodeMCU) | ESP8266 | 10 | 1 (A0 only) | 1 | A0 (single ADC pin exposed by the Arduino core) |
Compile for a specific board with arduino-cli, e.g. a Mega with 8 knobs:
arduino-cli compile --fqbn arduino:avr:mega \
--build-property "compiler.cpp.extra_flags=-DIORUBA_NUM_KNOBS=8" \
firmware/arduino/ioruba-controllernpm run firmware:compile:matrix compiles the firmware for every AVR board above in one shot (the same matrix the CI runs).
ESP32, RP2040 and ESP8266 need their own arduino-cli cores. CI builds all three in a dedicated firmware-arch job; install them locally with:
# ESP32
arduino-cli core install esp32:esp32 \
--additional-urls https://espressif.github.io/arduino-esp32/package_esp32_index.json
arduino-cli compile --fqbn esp32:esp32:esp32 firmware/arduino/ioruba-controller
# RP2040 / Pico (earlephilhower core)
arduino-cli core install rp2040:rp2040 \
--additional-urls https://github.com/earlephilhower/arduino-pico/releases/download/global/package_rp2040_index.json
arduino-cli compile --fqbn rp2040:rp2040:rpipico firmware/arduino/ioruba-controller
# ESP8266 (NodeMCU) — only exposes 1 analog pin, so IORUBA_NUM_KNOBS must be
# overridden to 1 or the static_assert(IORUBA_NUM_KNOBS <= ANALOG_PIN_COUNT) trips.
arduino-cli core install esp8266:esp8266 \
--additional-urls http://arduino.esp8266.com/stable/package_esp8266com_index.json
arduino-cli compile --fqbn esp8266:esp8266:nodemcuv2 \
--build-property "build.extra_flags=-DIORUBA_NUM_KNOBS=1" \
firmware/arduino/ioruba-controllerNext steps:
- flash the board using NANO_SETUP.md
- start the app with
npm run desktop:watch - verify the
Watchtab receives serial frames - on Linux, confirm the default targets react as expected
Default profile behavior:
- knob 1 controls
master - knob 2 targets applications like
Spotify,Google Chrome, andFirefox - knob 3 targets
default_microphone
- check the USB cable first
- confirm the firmware is flashed
- confirm
115200baud for the current firmware - try the Arduino serial monitor before blaming the desktop app
- try the old bootloader Nano profile
- press
RESETjust before upload begins - check whether another app is already holding
/dev/ttyUSB0
sudo usermod -a -G dialout $USER
sudo usermod -a -G uucp $USERLog out and back in before testing again.