Goal
Add first-class support for the Ulanzi Dial — a Bluetooth-HID rotary encoder + 4 buttons — as an AetherSDR external controller on Linux, Windows, and macOS. The dial must be private to AetherSDR while the app is running so its default media-key emissions (KEY_PREVIOUSSONG, KEY_NEXTSONG, KEY_PLAYPAUSE, and three Ctrl+key chords) don't leak to the focused window. Pattern extends to future BT-HID dials (Loupedeck CT, Logitech MX Master rotary, etc.).
Background — what we learned
- Hardware: Ulanzi Dial, BLE 5.x, advertises HID Keyboard appearance (
0x03C1). Modalias usb:vFFF1p0082 (generic vendor placeholder — can't be identified by VID/PID).
- The dial's custom GATT service
0x1971 (chars fd00/fd01) is NOT a real-time event channel — a 45 s notify subscription on fd00 with active dial interaction produced zero data. The custom service appears to be a config/ACK channel for Ulanzi's companion app, not a streaming HID source. Conclusion: the kernel BT-HID path is the only real input channel.
- The dial's hardware emits one event per detent — there is no finer-grained rotary delta available regardless of transport. The HID profile advertises a mouse interface with
REL_WHEEL_HI_RES but the firmware doesn't actually route rotation through it; everything goes via the keyboard interface.
- Default firmware key map (decoded from
evtest capture):
- Rotary CW →
KEY_NEXTSONG (autorepeats with value=2 at ~30 Hz while turning)
- Rotary CCW →
KEY_PREVIOUSSONG (same autorepeat behaviour)
- Dial press →
KEY_PLAYPAUSE
- Side button A →
Ctrl+Y
- Side button B →
Ctrl+V
- Side button C →
Ctrl+Z
- Long-press dial → likely emits a different report; needs confirmation
- On Linux the dial creates four
/dev/input/event* nodes; only the one named Ulanzi Dial Keyboard carries the actual rotary + button keystrokes. The other three (Ulanzi Dial Mouse, two Ulanzi Dial ABS_MISC sensors) are dead-end in current firmware. One of the ABS_MISC nodes is probably battery percentage (0-255 range).
Design
Sibling to the existing HidEncoderManager, which handles USB-HID encoders (Icom RC-28, AetherPad emulator). Same Qt signal contract — drop-in compatible with the existing External Controllers settings page:
void tuneSteps(int steps);
void buttonPressed(int button, int action);
void connectionChanged(bool connected, const QString& deviceName);
Per-platform transport
| Platform |
API |
Exclusive-claim mechanism |
Notes |
| Linux |
evdev (/dev/input/event*) + QSocketNotifier |
ioctl(fd, EVIOCGRAB, &one) |
Event-driven, no polling. udev/QFileSystemWatcher for hot-plug. Match by EVIOCGNAME returning Ulanzi Dial Keyboard. |
| Windows |
hidapi (already a project dep) |
CreateFile without FILE_SHARE_READ/FILE_SHARE_WRITE flags (effectively exclusive). Alternative: RegisterRawInputDevices with RIDEV_NOLEGACY to consume keystrokes before they reach the focused window. |
hidapi handles BT-HID on Windows via the same HID interface as USB. Identify by HID usage page + product string (since VID/PID is generic). |
| macOS |
IOKit HID Manager (IOHIDDeviceOpen with kIOHIDOptionsTypeSeizeDevice) |
The seize-device option is the documented exclusive-claim mechanism on Darwin. |
BT-HID surfaces as a normal IOHIDDevice on macOS, same code path as USB-HID. Identify by kIOHIDProductKey matching Ulanzi Dial*. |
Shared layer
A single UlanziDialDecoder translates platform-native input into the semantic Qt signals. On Linux it operates on struct input_event; on Windows/macOS it operates on raw HID input report bytes. Most logic is shared:
- Rotary:
KEY_NEXTSONG and KEY_PREVIOUSSONG (press OR autorepeat) → tuneSteps(±1). The autorepeat rate at ~30 Hz gives a natural acceleration feel — fast rotation = many ticks per second.
- Chord assembly: state machine tracks
LEFTCTRL down, emits buttonPressed(N, 1) on the non-modifier-key press, buttonPressed(N, 0) on its release. Button indices 1/2/3 for the three Ctrl-chord buttons.
- Long-press: timer started on
KEY_PLAYPAUSE press; if still held after 750 ms, emit buttonPressed(0, 2) and suppress the eventual release event from emitting a normal release. Threshold should be configurable.
Settings UI
Add a Ulanzi Dial section to the existing External Controllers settings dialog. Required controls:
- Enable toggle (drives exclusive-claim lifecycle per platform)
- Rotary action — default
Tune slice frequency using the current tuning step; alternates: Audio gain, RIT, AF filter width, RF gain
- Direction invert checkbox
- Long-press action dropdown
- Side button A/B/C action dropdowns (PTT, slice cycle, mode cycle, custom keybind, etc.)
- Battery indicator if the Linux
ABS_MISC sub-device on the right event node maps cleanly to a percentage (TBD across platforms — Windows and macOS expose HID Battery via the Battery Usage Page)
Hot-plug
- Linux:
QFileSystemWatcher on /dev/input/. On add → scan for Ulanzi Dial Keyboard. On remove of the held fd → emit connectionChanged(false, ...).
- Windows:
WM_DEVICECHANGE via RegisterDeviceNotification (existing pattern for the USB HID side). Translate to the same signal.
- macOS: IOKit's
IOHIDManagerSetDeviceMatchingMultiple + IOHIDManagerRegisterDeviceMatchingCallback / IOHIDManagerRegisterDeviceRemovalCallback.
Detection strategy
VID/PID identification doesn't work — FFF1:0082 is a generic vendor placeholder used by Ulanzi (and an unknown number of other devices). Identification is by product name string match (Ulanzi Dial*) on each platform. Long-term, extend the device catalog in HidDeviceParser.cpp to support name-regex entries alongside the existing VID/PID rows so future BT-HID dials slot in by adding one line.
Acceptance criteria
Out of scope / future work
- Ulanzi's companion-app configuration protocol (writes to GATT
fd01). Would let users remap firmware-level button mappings from inside AetherSDR. Requires reverse-engineering Ulanzi's BLE protocol from a Windows/Mac config-app trace. Defer until the basic device works.
- Custom-mode firmware that streams rotary deltas via the
0x1971 GATT custom service. Would need cooperation from Ulanzi or a firmware reverse-engineering effort. Not on the path.
- Other BT-HID dials (Loupedeck CT, MX Master rotary, generic BLE knobs). Once the pattern is in place, these are name-regex additions plus per-device button maps.
References
73, Jeremy KK7GWY & Claude (AI dev partner)
Goal
Add first-class support for the Ulanzi Dial — a Bluetooth-HID rotary encoder + 4 buttons — as an AetherSDR external controller on Linux, Windows, and macOS. The dial must be private to AetherSDR while the app is running so its default media-key emissions (
KEY_PREVIOUSSONG,KEY_NEXTSONG,KEY_PLAYPAUSE, and threeCtrl+keychords) don't leak to the focused window. Pattern extends to future BT-HID dials (Loupedeck CT, Logitech MX Master rotary, etc.).Background — what we learned
0x03C1). Modaliasusb:vFFF1p0082(generic vendor placeholder — can't be identified by VID/PID).0x1971(charsfd00/fd01) is NOT a real-time event channel — a 45 s notify subscription onfd00with active dial interaction produced zero data. The custom service appears to be a config/ACK channel for Ulanzi's companion app, not a streaming HID source. Conclusion: the kernel BT-HID path is the only real input channel.REL_WHEEL_HI_RESbut the firmware doesn't actually route rotation through it; everything goes via the keyboard interface.evtestcapture):KEY_NEXTSONG(autorepeats withvalue=2at ~30 Hz while turning)KEY_PREVIOUSSONG(same autorepeat behaviour)KEY_PLAYPAUSECtrl+YCtrl+VCtrl+Z/dev/input/event*nodes; only the one namedUlanzi Dial Keyboardcarries the actual rotary + button keystrokes. The other three (Ulanzi Dial Mouse, twoUlanzi DialABS_MISC sensors) are dead-end in current firmware. One of the ABS_MISC nodes is probably battery percentage (0-255 range).Design
Sibling to the existing
HidEncoderManager, which handles USB-HID encoders (Icom RC-28, AetherPad emulator). Same Qt signal contract — drop-in compatible with the existing External Controllers settings page:Per-platform transport
/dev/input/event*) +QSocketNotifierioctl(fd, EVIOCGRAB, &one)QFileSystemWatcherfor hot-plug. Match byEVIOCGNAMEreturningUlanzi Dial Keyboard.CreateFilewithoutFILE_SHARE_READ/FILE_SHARE_WRITEflags (effectively exclusive). Alternative:RegisterRawInputDeviceswithRIDEV_NOLEGACYto consume keystrokes before they reach the focused window.IOHIDDeviceOpenwithkIOHIDOptionsTypeSeizeDevice)IOHIDDeviceon macOS, same code path as USB-HID. Identify bykIOHIDProductKeymatchingUlanzi Dial*.Shared layer
A single
UlanziDialDecodertranslates platform-native input into the semantic Qt signals. On Linux it operates onstruct input_event; on Windows/macOS it operates on raw HID input report bytes. Most logic is shared:KEY_NEXTSONGandKEY_PREVIOUSSONG(press OR autorepeat) →tuneSteps(±1). The autorepeat rate at ~30 Hz gives a natural acceleration feel — fast rotation = many ticks per second.LEFTCTRLdown, emitsbuttonPressed(N, 1)on the non-modifier-key press,buttonPressed(N, 0)on its release. Button indices 1/2/3 for the three Ctrl-chord buttons.KEY_PLAYPAUSEpress; if still held after 750 ms, emitbuttonPressed(0, 2)and suppress the eventual release event from emitting a normal release. Threshold should be configurable.Settings UI
Add a Ulanzi Dial section to the existing External Controllers settings dialog. Required controls:
Tune slice frequencyusing the current tuning step; alternates:Audio gain,RIT,AF filter width,RF gainABS_MISCsub-device on the right event node maps cleanly to a percentage (TBD across platforms — Windows and macOS expose HID Battery via the Battery Usage Page)Hot-plug
QFileSystemWatcheron/dev/input/. On add → scan forUlanzi Dial Keyboard. On remove of the held fd → emitconnectionChanged(false, ...).WM_DEVICECHANGEviaRegisterDeviceNotification(existing pattern for the USB HID side). Translate to the same signal.IOHIDManagerSetDeviceMatchingMultiple+IOHIDManagerRegisterDeviceMatchingCallback/IOHIDManagerRegisterDeviceRemovalCallback.Detection strategy
VID/PID identification doesn't work —
FFF1:0082is a generic vendor placeholder used by Ulanzi (and an unknown number of other devices). Identification is by product name string match (Ulanzi Dial*) on each platform. Long-term, extend the device catalog inHidDeviceParser.cppto support name-regex entries alongside the existing VID/PID rows so future BT-HID dials slot in by adding one line.Acceptance criteria
buttonPressedevents with configurable actionsconnectionChangedreliably on all three platformsOut of scope / future work
fd01). Would let users remap firmware-level button mappings from inside AetherSDR. Requires reverse-engineering Ulanzi's BLE protocol from a Windows/Mac config-app trace. Defer until the basic device works.0x1971GATT custom service. Would need cooperation from Ulanzi or a firmware reverse-engineering effort. Not on the path.References
fd00, evdev path confirmed as the only real input channel)73, Jeremy KK7GWY & Claude (AI dev partner)