Skip to content

feat(input): Ulanzi Dial mapper — Linux evdev backend + visual configurator#3238

Merged
ten9876 merged 1 commit into
mainfrom
auto/featinput-Ulanzi-Dial-mapper--Linux-evdev-backend-
May 28, 2026
Merged

feat(input): Ulanzi Dial mapper — Linux evdev backend + visual configurator#3238
ten9876 merged 1 commit into
mainfrom
auto/featinput-Ulanzi-Dial-mapper--Linux-evdev-backend-

Conversation

@ten9876

@ten9876 ten9876 commented May 28, 2026

Copy link
Copy Markdown
Collaborator

First-platform delivery of #3232 (cross-platform BT-HID encoder support).
Linux side complete; Windows/macOS land in follow-up PRs.

Backend (EvdevEncoderManager, Linux-only):

  • Scans /dev/input/event* for "Ulanzi Dial Keyboard" sub-device
  • Opens with EVIOCGRAB to claim it exclusively so the dial's media-key
    emissions (KEY_PREVIOUSSONG/NEXTSONG/PLAYPAUSE/MUTE/Ctrl+chords) don't
    leak to the focused window
  • Event-driven via QSocketNotifier; no polling
  • Chord assembly state machine decodes Ctrl+key + compound chord events
  • Hot-plug via QFileSystemWatcher on /dev/input/

UI (UlanziDialMapperDialog, fixed 640x640):

  • Product image of the dial in the centre, action-combo "pills"
    positioned around it at each physical control
  • Each pill is a QComboBox listing every registered ShortcutManager
    action + every discrete-type MIDI param (shortcut: / midi:
    tagged so MainWindow dispatcher can route correctly)
  • Signature-to-pill mapping is firmware-property (hardcoded in
    kPillSpecs); the function assigned to each pill is user-configurable
    via the combo and persists to AppSettings under
    UlanziDial/action/

MainWindow integration:

  • Instantiates the manager on the existing ExtControllers thread
  • Rotary tuneSteps coalesces into the active slice tune step
    (same pattern as HidEncoderManager from feat(hid): recognize aether-pad RC-28 emulator (Arduino VID/PID alias) #3171)
  • buttonEvent dispatcher parses the prefix and invokes the matching
    ShortcutManager handler or MIDI param setter; falls back to the
    per-pill defaultAction so first-launch bindings work without the
    user ever opening the mapper

Files:

  • src/core/EvdevEncoderManager.{h,cpp} - new, Linux-only
  • src/gui/UlanziDialMapperDialog.{h,cpp} - new, Linux-only
  • resources/images/ulanzi-dial.png - product image
  • src/gui/MainWindow.{h,cpp} - wiring + menu entry
  • CMakeLists.txt - gate new sources on UNIX AND NOT APPLE

Co-Authored-By: Claude Opus 4.7 (1M context) noreply@anthropic.com

…urator

First-platform delivery of #3232 (cross-platform BT-HID encoder support).
Linux side complete; Windows/macOS land in follow-up PRs.

Backend (EvdevEncoderManager, Linux-only):
- Scans /dev/input/event* for "Ulanzi Dial Keyboard" sub-device
- Opens with EVIOCGRAB to claim it exclusively so the dial's media-key
  emissions (KEY_PREVIOUSSONG/NEXTSONG/PLAYPAUSE/MUTE/Ctrl+chords) don't
  leak to the focused window
- Event-driven via QSocketNotifier; no polling
- Chord assembly state machine decodes Ctrl+key + compound chord events
- Hot-plug via QFileSystemWatcher on /dev/input/

UI (UlanziDialMapperDialog, fixed 640x640):
- Product image of the dial in the centre, action-combo "pills"
  positioned around it at each physical control
- Each pill is a QComboBox listing every registered ShortcutManager
  action + every discrete-type MIDI param (shortcut:<id> / midi:<id>
  tagged so MainWindow dispatcher can route correctly)
- Signature-to-pill mapping is firmware-property (hardcoded in
  kPillSpecs); the function assigned to each pill is user-configurable
  via the combo and persists to AppSettings under
  UlanziDial/action/<pillId>

MainWindow integration:
- Instantiates the manager on the existing ExtControllers thread
- Rotary tuneSteps coalesces into the active slice tune step
  (same pattern as HidEncoderManager from #3171)
- buttonEvent dispatcher parses the prefix and invokes the matching
  ShortcutManager handler or MIDI param setter; falls back to the
  per-pill defaultAction so first-launch bindings work without the
  user ever opening the mapper

Files:
- src/core/EvdevEncoderManager.{h,cpp}   - new, Linux-only
- src/gui/UlanziDialMapperDialog.{h,cpp} - new, Linux-only
- resources/images/ulanzi-dial.png       - product image
- src/gui/MainWindow.{h,cpp}             - wiring + menu entry
- CMakeLists.txt                         - gate new sources on UNIX AND NOT APPLE

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@ten9876 ten9876 requested review from a team as code owners May 28, 2026 05:50
@ten9876 ten9876 enabled auto-merge (squash) May 28, 2026 05:50
@ten9876 ten9876 merged commit 7325f54 into main May 28, 2026
5 checks passed
@ten9876 ten9876 deleted the auto/featinput-Ulanzi-Dial-mapper--Linux-evdev-backend- branch May 28, 2026 06:06
ten9876 added a commit that referenced this pull request May 28, 2026
…per (#3239)

Completes the platform matrix promised in #3232.  Linux (evdev) landed
in #3238; this PR adds the Windows (hidapi) and macOS (IOKit) sides.
The mapper dialog drops its Linux guard and now builds everywhere.

Architecture
------------
All three backends expose the same Qt signal contract:
    tuneSteps(int)               - rotary delta
    buttonEvent(sig, action)     - canonical Linux-keycode-name strings
    connectionChanged(bool, name)

A new typedef `UlanziDialBackend` (src/core/UlanziDialBackend.h)
resolves to the right concrete class per platform.  Mapper dialog and
MainWindow dispatcher use only the alias and have no platform switches
of their own.

Windows (UlanziDialWindowsManager)
----------------------------------
- hidapi (already a project dep) enumerates HID devices, matches by
  product_string substring "Ulanzi Dial"
- Opens every matching interface (the dial enumerates as multiple
  HID interfaces on Windows: Keyboard + Consumer Control, possibly
  Mouse) and polls each via QTimer at 5ms intervals
- HID report parsing: standard 8-byte keyboard report (modifier mask
  + up-to-6-keys array, diffed across frames for transitions) and
  Consumer Control reports (2-byte usage code)
- HID usage codes mapped to canonical Linux keycode integers so the
  signature strings (`KEY_PLAYPAUSE`, `Ctrl+V`, ...) match Linux
  exactly and `kPillSpecs` is unchanged
- Chord assembly state machine identical to the Linux backend
- Hot-plug via 3s rescan timer
- LIMITATION: no exclusive-claim equivalent yet.  The dial's keystrokes
  still reach the focused window because hidapi doesn't suppress OS
  input routing.  RawInput + RIDEV_NOLEGACY is the proper fix; tracked
  for a follow-up

macOS (UlanziDialMacOSManager)
------------------------------
- IOKit HID Manager with device-matching dictionary
  ({ kIOHIDProductKey: "Ulanzi Dial" })
- `IOHIDManagerOpen` with `kIOHIDOptionsTypeSeizeDevice` -- the
  documented macOS exclusive-claim mechanism.  Equivalent to Linux
  EVIOCGRAB: when seized, the dial's events stop reaching the OS
  keyboard stack, so its media keys don't leak to the focused window
- Event-driven via `IOHIDManagerRegisterInputValueCallback`; no polling
- Same usage-page-to-Linux-keycode mapping and chord assembly as
  Windows backend
- Scheduled on `CFRunLoopGetMain()` (IOKit requires a CFRunLoop, and
  only the main thread has one integrated with Qt's event loop on
  macOS).  MainWindow conditionally skips `moveToThread` on macOS to
  match this constraint
- Hot-plug native via IOHIDManager device-matching/removal callbacks

Build wiring (CMakeLists.txt)
-----------------------------
- UlanziDialBackend.h + UlanziDialMapperDialog.{cpp,h}: unconditional
- EvdevEncoderManager.{cpp,h}: UNIX AND NOT APPLE (Linux)
- UlanziDialWindowsManager.{cpp,h}: WIN32
- UlanziDialMacOSManager.{cpp,h}: APPLE
- macOS links -framework IOKit and -framework CoreFoundation

Tested
------
- Linux: compiles + runs as before (Linux behaviour unchanged; same
  EvdevEncoderManager underneath the alias)
- Windows + macOS: compile validation gated by CI (check-windows from
  #3210 and check-macos from #3223).  Live testing on hardware happens
  in the user's environment

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
jensenpat added a commit that referenced this pull request Jun 2, 2026
## Summary

Adds **`plugins/ulanzi-aethersdr/`** as the third Stream-Deck-style
sibling alongside `elgato-aethersdr` and `streamcontroller-aethersdr`.
Drives AetherSDR over TCI WebSocket from Ulanzi-Studio-compatible macro
keypads / dials.

- **22 actions**, as both keypad buttons and rotary (Encoder) knobs:
- **Encoder (dial):** VFO Tune, plus rotary **AF Gain / RF Power / Mic
Gain / Filter Width / Squelch Level** (rotate to adjust, press+rotate =
coarse). VFO dial press toggles MOX; level-knob press is a deliberate
no-op so a knob can't key TX.
  - **TX:** MOX Toggle, TUNE / ATU, RIT Toggle.
  - **Modes:** Mode Cycle, plus direct USB / LSB / CW / DIGU.
  - **Bands:** Band Up, Band Down.
  - **Gain (keypad):** AF / RF / Mic ±5 per press.
- TCI command vocabulary kept faithful to `elgato-aethersdr` so both
behave identically against the same radio.
- Iconography generated programmatically from a single config
(`scripts/Generate-Icons.ps1`, PowerShell + `System.Drawing`, no
npm/Node deps) → 196×196 PNGs matching Ulanzi Studio's marketplace icon
convention. Currently Windows-only; a cross-platform port (ImageMagick /
SkiaSharp) is future work.
- 5 coordinated shack-companion launcher tiles (AetherSDR / TCI Monitor
/ ShackLog / IQ Capture / aether-pad) for pairing with Studio's built-in
`System → Open` action.
- Bundled D100H default profile
(`profiles/aethersdr-d100h-default.ulanziDeckProfile`) — one-click
Studio import.
- **License: GPL-3.0-or-later**, matching the parent project. The
vendored Ulanzi SDK in `libs/common-node/` and `libs/common-html/` keeps
its own license.

The change is fully self-contained under `plugins/ulanzi-aethersdr/` —
no C++, CMake, or CI changes.

## Status

- **D100H / KEHWIN Dial_Lite** (6 keys + 1 dial, BLE HOGP): smoke-tested
end-to-end; MOX, AF, RF and **mic gain all confirmed live** against the
radio. (I run this unit on CAD duty now, but it remains a fully
supported radio surface for anyone who wants it.)
- **D200 / Zkswe "ulanzi"** (USB): used for the gain-seeding and
coexistence verification below.
- **D200H** (14 keys): plugin code complete, profile layout in progress.
- **D200X** (14 keys + 3 knobs): unit arriving shortly; profile follows
once on the bench.

### Gain seeding (fixes first-press jump)

On TCI connect the plugin issues GET `volume;` / `drive;` / `mic_level;`
so the AF / RF / mic local mirrors start from the radio's real values.
Without this the first ±5 press jumped from the hard-coded defaults (50
/ 100 / 50). AE's init burst does not emit `volume:`, so this pairs with
the server-side init-burst fix in **#3245** — the GET-on-connect makes
the plugin robust regardless of which AE build it talks to. Verified
live 2026-05-28: all three step smoothly with no jump.

### Mic gain

`mic_level:` is an AE extension (not in the published TCI spec) but it
**is honoured** — verified live 2026-05-28 to actually drive TX mic
gain.

## Coexistence with the native UlanziDial backend

AetherSDR `main` now ships a native UlanziDial HID backend (**#3238**
Linux evdev, **#3239** Windows hidapi + macOS IOKit). This plugin and
that backend do **not** collide — verified live 2026-05-28:

- The native Windows matcher keeps only HID devices whose hidapi
`product_string` contains the case-sensitive substring `"Ulanzi Dial"`
(`src/core/UlanziDialWindowsManager.cpp`, `kProductMatch`).
- `hid.enumerate()` on this machine reports: D100H → product `Dial_Lite`
(manuf `KEHWIN`, VID `0xFFF1` / PID `0x0082`, BLE HOGP); D200 → product
`ulanzi` (manuf `Zkswe`, VID `0x2207` / PID `0x0019`, USB). **Neither
contains `"Ulanzi Dial"`**, so the native backend claims neither device
— both stay free for this plugin via Ulanzi Studio.
- Tested against a clean build of this branch (rebased on `main`,
`-DHAVE_HIDAPI`, native backend compiled in) with both Ulanzi units
plugged in: no double-claim, no conflict.
- Trap for reviewers: Windows Device Manager may display the D100H's PnP
*FriendlyName* as "Ulanzi Dial", but that is a **different field** from
the hidapi `product_string` the matcher reads (which is `Dial_Lite`).

## Repository relationship

Independent public-facing releases / Ulanzi Marketplace path lives at
**[nigelfenton/aethersdr-ulanzi-plugin](https://github.com/nigelfenton/aethersdr-ulanzi-plugin)**.
Both repos stay alive — the standalone is canonical for end-user
downloads; this in-tree copy is bundled context for AetherSDR
developers. The README inside the plugin documents the relationship
explicitly and is the authoritative description of the plugin.

## Test plan

- [x] Plugin source loads in Ulanzi Studio without errors (D100H,
Windows)
- [x] TCI WebSocket connects to AetherSDR on `ws://127.0.0.1:40001`
- [x] MOX action flips radio state (`trx:0,<bool>;`) on D100H
- [x] AF / RF / mic gain seed from the radio's real values on connect —
no first-press jump (2026-05-28)
- [x] `mic_level:` verified to actually drive TX mic gain on AE
(2026-05-28)
- [x] Coexists with the native UlanziDial backend — neither Ulanzi unit
matches `kProductMatch` (2026-05-28)
- [x] D100H default profile imports cleanly into a fresh Studio
- [x] Icon generator (`scripts/Generate-Icons.ps1`) regenerates the PNGs
idempotently
- [ ] D200H full-keypad layout validated (in progress)
- [ ] D200X layout validated (waiting on hardware)
- [ ] macOS Studio compatibility re-confirmed against this in-repo path

## Out of scope / future work

- Per-app sub-profiles (TCI Monitor / ShackLog / IQ Capture /
aether-pad) using Studio's built-in `System.Hotkey` — will land here
once each app's hotkey table is known.
- Cross-platform icon generator (port `Generate-Icons.ps1` to
ImageMagick / SkiaSharp so macOS/Linux contributors can regenerate
without `pwsh`).
- Marketplace submission (independent of this PR).
- A properly-stateful slice-cycle action (the earlier local-index
version was removed in v0.1.7; a future one should be driven by AE's
slice-list event rather than a local counter).
- Broadening the native backend's device match so it also recognises
OEM-rebranded Ulanzi units (`Dial_Lite` / `ulanzi`) — raised separately
on #3239.

---

<sub>_PR description refreshed to match v0.1.7 (rotary knobs,
slice-cycle removed, `Generate-Icons.ps1`, GPL-3.0-or-later). The
in-plugin README remains the authoritative source of truth._</sub>

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: jensenpat <patjensen@gmail.com>
G6PWY-Chris pushed a commit to G6PWY-Chris/AetherSDR that referenced this pull request Jun 22, 2026
…urator (aethersdr#3238)

First-platform delivery of aethersdr#3232 (cross-platform BT-HID encoder
support).
Linux side complete; Windows/macOS land in follow-up PRs.

Backend (EvdevEncoderManager, Linux-only):
- Scans /dev/input/event* for "Ulanzi Dial Keyboard" sub-device
- Opens with EVIOCGRAB to claim it exclusively so the dial's media-key
  emissions (KEY_PREVIOUSSONG/NEXTSONG/PLAYPAUSE/MUTE/Ctrl+chords) don't
  leak to the focused window
- Event-driven via QSocketNotifier; no polling
- Chord assembly state machine decodes Ctrl+key + compound chord events
- Hot-plug via QFileSystemWatcher on /dev/input/

UI (UlanziDialMapperDialog, fixed 640x640):
- Product image of the dial in the centre, action-combo "pills"
  positioned around it at each physical control
- Each pill is a QComboBox listing every registered ShortcutManager
  action + every discrete-type MIDI param (shortcut:<id> / midi:<id>
  tagged so MainWindow dispatcher can route correctly)
- Signature-to-pill mapping is firmware-property (hardcoded in
  kPillSpecs); the function assigned to each pill is user-configurable
  via the combo and persists to AppSettings under
  UlanziDial/action/<pillId>

MainWindow integration:
- Instantiates the manager on the existing ExtControllers thread
- Rotary tuneSteps coalesces into the active slice tune step
  (same pattern as HidEncoderManager from aethersdr#3171)
- buttonEvent dispatcher parses the prefix and invokes the matching
  ShortcutManager handler or MIDI param setter; falls back to the
  per-pill defaultAction so first-launch bindings work without the
  user ever opening the mapper

Files:
- src/core/EvdevEncoderManager.{h,cpp}   - new, Linux-only
- src/gui/UlanziDialMapperDialog.{h,cpp} - new, Linux-only
- resources/images/ulanzi-dial.png       - product image
- src/gui/MainWindow.{h,cpp}             - wiring + menu entry
- CMakeLists.txt - gate new sources on UNIX AND NOT APPLE

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
G6PWY-Chris pushed a commit to G6PWY-Chris/AetherSDR that referenced this pull request Jun 22, 2026
…per (aethersdr#3239)

Completes the platform matrix promised in aethersdr#3232.  Linux (evdev) landed
in aethersdr#3238; this PR adds the Windows (hidapi) and macOS (IOKit) sides.
The mapper dialog drops its Linux guard and now builds everywhere.

Architecture
------------
All three backends expose the same Qt signal contract:
    tuneSteps(int)               - rotary delta
    buttonEvent(sig, action)     - canonical Linux-keycode-name strings
    connectionChanged(bool, name)

A new typedef `UlanziDialBackend` (src/core/UlanziDialBackend.h)
resolves to the right concrete class per platform.  Mapper dialog and
MainWindow dispatcher use only the alias and have no platform switches
of their own.

Windows (UlanziDialWindowsManager)
----------------------------------
- hidapi (already a project dep) enumerates HID devices, matches by
  product_string substring "Ulanzi Dial"
- Opens every matching interface (the dial enumerates as multiple
  HID interfaces on Windows: Keyboard + Consumer Control, possibly
  Mouse) and polls each via QTimer at 5ms intervals
- HID report parsing: standard 8-byte keyboard report (modifier mask
  + up-to-6-keys array, diffed across frames for transitions) and
  Consumer Control reports (2-byte usage code)
- HID usage codes mapped to canonical Linux keycode integers so the
  signature strings (`KEY_PLAYPAUSE`, `Ctrl+V`, ...) match Linux
  exactly and `kPillSpecs` is unchanged
- Chord assembly state machine identical to the Linux backend
- Hot-plug via 3s rescan timer
- LIMITATION: no exclusive-claim equivalent yet.  The dial's keystrokes
  still reach the focused window because hidapi doesn't suppress OS
  input routing.  RawInput + RIDEV_NOLEGACY is the proper fix; tracked
  for a follow-up

macOS (UlanziDialMacOSManager)
------------------------------
- IOKit HID Manager with device-matching dictionary
  ({ kIOHIDProductKey: "Ulanzi Dial" })
- `IOHIDManagerOpen` with `kIOHIDOptionsTypeSeizeDevice` -- the
  documented macOS exclusive-claim mechanism.  Equivalent to Linux
  EVIOCGRAB: when seized, the dial's events stop reaching the OS
  keyboard stack, so its media keys don't leak to the focused window
- Event-driven via `IOHIDManagerRegisterInputValueCallback`; no polling
- Same usage-page-to-Linux-keycode mapping and chord assembly as
  Windows backend
- Scheduled on `CFRunLoopGetMain()` (IOKit requires a CFRunLoop, and
  only the main thread has one integrated with Qt's event loop on
  macOS).  MainWindow conditionally skips `moveToThread` on macOS to
  match this constraint
- Hot-plug native via IOHIDManager device-matching/removal callbacks

Build wiring (CMakeLists.txt)
-----------------------------
- UlanziDialBackend.h + UlanziDialMapperDialog.{cpp,h}: unconditional
- EvdevEncoderManager.{cpp,h}: UNIX AND NOT APPLE (Linux)
- UlanziDialWindowsManager.{cpp,h}: WIN32
- UlanziDialMacOSManager.{cpp,h}: APPLE
- macOS links -framework IOKit and -framework CoreFoundation

Tested
------
- Linux: compiles + runs as before (Linux behaviour unchanged; same
  EvdevEncoderManager underneath the alias)
- Windows + macOS: compile validation gated by CI (check-windows from
  aethersdr#3210 and check-macos from aethersdr#3223).  Live testing on hardware happens
  in the user's environment

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
G6PWY-Chris pushed a commit to G6PWY-Chris/AetherSDR that referenced this pull request Jun 22, 2026
…r#3227)

## Summary

Adds **`plugins/ulanzi-aethersdr/`** as the third Stream-Deck-style
sibling alongside `elgato-aethersdr` and `streamcontroller-aethersdr`.
Drives AetherSDR over TCI WebSocket from Ulanzi-Studio-compatible macro
keypads / dials.

- **22 actions**, as both keypad buttons and rotary (Encoder) knobs:
- **Encoder (dial):** VFO Tune, plus rotary **AF Gain / RF Power / Mic
Gain / Filter Width / Squelch Level** (rotate to adjust, press+rotate =
coarse). VFO dial press toggles MOX; level-knob press is a deliberate
no-op so a knob can't key TX.
  - **TX:** MOX Toggle, TUNE / ATU, RIT Toggle.
  - **Modes:** Mode Cycle, plus direct USB / LSB / CW / DIGU.
  - **Bands:** Band Up, Band Down.
  - **Gain (keypad):** AF / RF / Mic ±5 per press.
- TCI command vocabulary kept faithful to `elgato-aethersdr` so both
behave identically against the same radio.
- Iconography generated programmatically from a single config
(`scripts/Generate-Icons.ps1`, PowerShell + `System.Drawing`, no
npm/Node deps) → 196×196 PNGs matching Ulanzi Studio's marketplace icon
convention. Currently Windows-only; a cross-platform port (ImageMagick /
SkiaSharp) is future work.
- 5 coordinated shack-companion launcher tiles (AetherSDR / TCI Monitor
/ ShackLog / IQ Capture / aether-pad) for pairing with Studio's built-in
`System → Open` action.
- Bundled D100H default profile
(`profiles/aethersdr-d100h-default.ulanziDeckProfile`) — one-click
Studio import.
- **License: GPL-3.0-or-later**, matching the parent project. The
vendored Ulanzi SDK in `libs/common-node/` and `libs/common-html/` keeps
its own license.

The change is fully self-contained under `plugins/ulanzi-aethersdr/` —
no C++, CMake, or CI changes.

## Status

- **D100H / KEHWIN Dial_Lite** (6 keys + 1 dial, BLE HOGP): smoke-tested
end-to-end; MOX, AF, RF and **mic gain all confirmed live** against the
radio. (I run this unit on CAD duty now, but it remains a fully
supported radio surface for anyone who wants it.)
- **D200 / Zkswe "ulanzi"** (USB): used for the gain-seeding and
coexistence verification below.
- **D200H** (14 keys): plugin code complete, profile layout in progress.
- **D200X** (14 keys + 3 knobs): unit arriving shortly; profile follows
once on the bench.

### Gain seeding (fixes first-press jump)

On TCI connect the plugin issues GET `volume;` / `drive;` / `mic_level;`
so the AF / RF / mic local mirrors start from the radio's real values.
Without this the first ±5 press jumped from the hard-coded defaults (50
/ 100 / 50). AE's init burst does not emit `volume:`, so this pairs with
the server-side init-burst fix in **aethersdr#3245** — the GET-on-connect makes
the plugin robust regardless of which AE build it talks to. Verified
live 2026-05-28: all three step smoothly with no jump.

### Mic gain

`mic_level:` is an AE extension (not in the published TCI spec) but it
**is honoured** — verified live 2026-05-28 to actually drive TX mic
gain.

## Coexistence with the native UlanziDial backend

AetherSDR `main` now ships a native UlanziDial HID backend (**aethersdr#3238**
Linux evdev, **aethersdr#3239** Windows hidapi + macOS IOKit). This plugin and
that backend do **not** collide — verified live 2026-05-28:

- The native Windows matcher keeps only HID devices whose hidapi
`product_string` contains the case-sensitive substring `"Ulanzi Dial"`
(`src/core/UlanziDialWindowsManager.cpp`, `kProductMatch`).
- `hid.enumerate()` on this machine reports: D100H → product `Dial_Lite`
(manuf `KEHWIN`, VID `0xFFF1` / PID `0x0082`, BLE HOGP); D200 → product
`ulanzi` (manuf `Zkswe`, VID `0x2207` / PID `0x0019`, USB). **Neither
contains `"Ulanzi Dial"`**, so the native backend claims neither device
— both stay free for this plugin via Ulanzi Studio.
- Tested against a clean build of this branch (rebased on `main`,
`-DHAVE_HIDAPI`, native backend compiled in) with both Ulanzi units
plugged in: no double-claim, no conflict.
- Trap for reviewers: Windows Device Manager may display the D100H's PnP
*FriendlyName* as "Ulanzi Dial", but that is a **different field** from
the hidapi `product_string` the matcher reads (which is `Dial_Lite`).

## Repository relationship

Independent public-facing releases / Ulanzi Marketplace path lives at
**[nigelfenton/aethersdr-ulanzi-plugin](https://github.com/nigelfenton/aethersdr-ulanzi-plugin)**.
Both repos stay alive — the standalone is canonical for end-user
downloads; this in-tree copy is bundled context for AetherSDR
developers. The README inside the plugin documents the relationship
explicitly and is the authoritative description of the plugin.

## Test plan

- [x] Plugin source loads in Ulanzi Studio without errors (D100H,
Windows)
- [x] TCI WebSocket connects to AetherSDR on `ws://127.0.0.1:40001`
- [x] MOX action flips radio state (`trx:0,<bool>;`) on D100H
- [x] AF / RF / mic gain seed from the radio's real values on connect —
no first-press jump (2026-05-28)
- [x] `mic_level:` verified to actually drive TX mic gain on AE
(2026-05-28)
- [x] Coexists with the native UlanziDial backend — neither Ulanzi unit
matches `kProductMatch` (2026-05-28)
- [x] D100H default profile imports cleanly into a fresh Studio
- [x] Icon generator (`scripts/Generate-Icons.ps1`) regenerates the PNGs
idempotently
- [ ] D200H full-keypad layout validated (in progress)
- [ ] D200X layout validated (waiting on hardware)
- [ ] macOS Studio compatibility re-confirmed against this in-repo path

## Out of scope / future work

- Per-app sub-profiles (TCI Monitor / ShackLog / IQ Capture /
aether-pad) using Studio's built-in `System.Hotkey` — will land here
once each app's hotkey table is known.
- Cross-platform icon generator (port `Generate-Icons.ps1` to
ImageMagick / SkiaSharp so macOS/Linux contributors can regenerate
without `pwsh`).
- Marketplace submission (independent of this PR).
- A properly-stateful slice-cycle action (the earlier local-index
version was removed in v0.1.7; a future one should be driven by AE's
slice-list event rather than a local counter).
- Broadening the native backend's device match so it also recognises
OEM-rebranded Ulanzi units (`Dial_Lite` / `ulanzi`) — raised separately
on aethersdr#3239.

---

<sub>_PR description refreshed to match v0.1.7 (rotary knobs,
slice-cycle removed, `Generate-Icons.ps1`, GPL-3.0-or-later). The
in-plugin README remains the authoritative source of truth._</sub>

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: jensenpat <patjensen@gmail.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant