Skip to content

feat(input): cross-platform Ulanzi Dial support (BT-HID encoder, exclusive claim) #3232

Description

@ten9876

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

  • Ulanzi Dial enumerated and exclusively claimed on Linux, Windows, macOS
  • Rotation tunes the active slice (default mapping), AetherSDR-only (no global media-key leak)
  • All four buttons (dial press + 3 side) emit distinct buttonPressed events with configurable actions
  • Hot-plug (BT connect/disconnect) emits connectionChanged reliably on all three platforms
  • Battery level exposed if reachable on each platform's HID stack
  • Settings UI mirrors the existing HidEncoderManager layout
  • Existing HID encoders (Icom RC-28, AetherPad) keep working unchanged

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)

Metadata

Metadata

Assignees

No one assigned

    Labels

    New FeatureNew feature requestenhancementImprovement to existing featureexternal devicesFlexControl, MIDI, serial port, USB peripheralsmaintainer-reviewRequires maintainer review before any action is taken

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions