feat(hid): Elgato StreamDeck+ support — encoders, LCD buttons, touchscreen labels (#1510)#3236
Conversation
…rsdr#1510) Add Elgato StreamDeck+ (VID 0x0FD9 / PID 0x0084) as a supported HID encoder device with 4 independently configurable encoder dials. - HidDeviceParser: add encoderIndex field to HidEvent (placed last to preserve existing 4-field aggregate inits); add virtual encoderCount(); add StreamDeckPlusParser handling rotation (type 0x02), encoder button (type 0x03), and LCD key (type 0x00) reports; uses len to discriminate 65-byte (Linux/Windows, report ID prepended) from 64-byte (macOS) reads, same pattern as IcomRC28Parser - HidEncoderManager: tuneSteps(int) → tuneSteps(int encoderIndex, int steps) - MainWindow: remove per-device HID coalesce timer; route each encoder through AppSettings("HidEncoderAction<N>") → applyFlexControlWheelAction; defaults: enc0=Frequency, enc1=RIT, enc2=XIT, enc3=Volume - RadioSetupDialog: add "HID Encoder / StreamDeck+ Encoders" group to Serial tab with 4 action combos (WheelFrequency/RIT/XIT/Volume/etc.) Single-encoder devices (RC-28, PowerMate, ShuttleXpress) continue to work unchanged — they emit encoderIndex=0 and read HidEncoderAction0. Principle VII. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Initial implementation assumed 64-byte reports with type byte at buf[0], matching generic vendor HID guesses. Verified against python-elgato-streamdeck v0.9.8 source against the actual device: - reportSize: 65 → 14 (device advertises 512-byte descriptor but only 14 bytes carry live event data; the Python reference library reads exactly 14) - Report ID 0x01 is always at buf[0] on all platforms — no len-based discrimination needed - Event type is at buf[1] (not buf[0]) - Dial events are type 0x03 with sub-type at buf[4]: 0x01=turn, 0x00=push (not separate type codes 0x02/0x03 as originally guessed) - Dial values at buf[5..8], key states at buf[4..11] Tested on Elgato StreamDeck+ firmware j2.0.3.7 on Fedora 44. Principle XI (Evidence Over Assertion). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…actions
Encoder 0 push cycles the tuning step size (StepCycle), encoder 1 push
toggles RIT, encoder 2 push toggles XIT by default. All four are
user-configurable via a new "StreamDeck+ Encoder Push Actions" group box
in Settings → Radio → Serial tab. Uses HidEncoderPushAction{0-3} keys
in AppSettings; release events are suppressed so each press fires exactly
once.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Adds real-time label rendering on all 8 StreamDeck+ LCD keys. Keys 1-4 (top row) show each encoder's turn action (TUNE/RIT/XIT/VOL). Keys 5-8 (bottom row) show each encoder's push action; RIT and XIT keys change background color (green=ON, dark=OFF) and show ON/OFF text as the active slice state changes. Images are rendered via QPainter to 120x120 QImage, flipped 180° per StreamDeck+ KEY_FLIP spec, JPEG-encoded, and written over HID feature reports on the ext-ctrl thread via hid_send_feature_report. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…e call hid_send_feature_report uses a HIDIOCSFEATURE ioctl on Linux which the StreamDeck+ firmware doesn't support for image writes, corrupting device state and breaking hid_read. Switch to hid_write matching the Python reference library (transport.write → hid_write). Also batches all 8 key image writes into a single QueuedConnection invocation (setKeyImages) instead of 8 separate queued calls to avoid starving the 5ms poll timer on the ext-ctrl thread. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
StreamDeck+ firmware displays the image as-sent; the KEY_FLIP=(True,True) in the Python library applies at the PIL render stage, not on the wire. Removing mirrored(true,true) corrects the upside-down labels. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Replaces the per-LCD-key labels with a single 800x100 JPEG written to the touchscreen strip. Four 200x100 sections, one per encoder: top half shows the turn action (TUNE/RIT/XIT/VOL), bottom half shows the push action label and ON/OFF state. RIT/XIT sections go green when active. LCD keys are blanked. Protocol uses command 0x0c with a 16-byte header, verified against python-elgato-streamdeck StreamDeckPlus.set_touchscreen_image(). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Adds a "StreamDeck+ LCD Button Actions" group to Settings → Radio → Serial tab with a dropdown for each of the 8 LCD keys. Supports 19 actions (MOX, Tune, RIT/XIT toggle+clear, Step Up/Down, Mute, Lock, APF, AGC, Band/Segment Zoom, Next/Prev Slice, Volume ±5, Split). Settings stored as HidKeyAction0-7 in AppSettings. Key images are rendered with the action label and a color-coded background (red=TX, green=RIT/XIT, purple=volume, etc.) so buttons are self-labeling on the device. Labels update whenever refreshStreamDeckLabels is called (on connect and active-slice change). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
serialSettingsChanged was not wired to refreshStreamDeckLabels, so LCD key labels only updated on device connect or slice change. Now any combo change in the Serial tab pushes the new images to the device immediately. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Move StreamDeck+ LCD Button Actions above the encoder dial/push groups so the UI order (LCD buttons → dial turn → dial push) matches the physical device layout top to bottom. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
|
Hi @motoham88 — thanks for putting this together, the StreamDeck+ work is a great addition and the HID parser comments are exceptionally clear. 👏 The CI failure is Windows-only ( I can't pull the raw build log from here, but I can see two highly likely culprits in 1.
|
MSVC's STL does not pull <algorithm> transitively through Qt headers, causing std::min to be unresolved in setKeyImage and setTouchscreenImage on Windows builds. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
There was a problem hiding this comment.
Thanks for the very thorough work here, @motoham88 — the device-protocol notes in the header are great, and the additive encoderIndex placement preserving the existing 4-field aggregate inits for RC-28/PowerMate/ShuttleXpress is a nice touch. Files all fall inside the stated scope, AppSettings is used correctly throughout, and routing through applyFlexControlWheelAction cleanly inherits the existing coalescer (the old m_hidCoalesceTimer removal in onSliceAdded checks out — m_flexCoalesceTimer now covers HID-driven frequency tuning too).
A few things worth a look before merge:
1. StreamDeckPlusParser::parse drops simultaneous events (src/core/HidDeviceParser.cpp:209 and :241). In both the encoder-push path and the LCD-key path, m_prevEncBtns/m_prevKeys is overwritten with newState before iterating to find the first changed bit, then only the first changed bit is emitted. If two keys or two encoder pushes change in the same HID report, the second event is permanently lost — the next poll will see no change. In practice this is rare, but it's silently lossy. Easiest fix is to clear only the bit you're consuming from newState (i.e. mask the just-reported bit out of m_prevKeys/m_prevEncBtns) so the next poll() iteration re-enters and picks up the next bit, or have parse() return a small queue.
2. hid_write return values are ignored in setKeyImage and setTouchscreenImage (src/core/HidEncoderManager.cpp:182,232). If the device is unplugged mid-write, the loop will spin through all 1024-byte packets writing into a dead handle without any signal back. Even a qWarning on a negative return inside the loop (and breaking out) would help operators correlate "deck went blank" with logs, and could let HidEncoderManager close the handle so hotplugCheck reopens cleanly.
3. Minor thread-safety nit: refreshStreamDeckLabels() runs on the main thread and reads m_hidEncoder->isOpen() / isStreamDeckPlus(), which look at m_device / m_openVid / m_openPid mutated on m_extCtrlThread in poll()/hotplugCheck(). The actual image writes are correctly marshalled via QMetaObject::invokeMethod(..., Qt::QueuedConnection) (good), but the gate read isn't synchronized. Benign on common platforms but technically a data race — std::atomic<hid_device*> or making the gate check inside the queued lambda would tighten it.
Otherwise this looks solid. Test plan in the PR description is the right shape; the RC-28/PowerMate regression check is the one I'd most want to see confirmed before merge given the tuneSteps(int) → tuneSteps(int,int) signal-signature change.
🤖 aethersdr-agent · cost: $7.7971 · model: claude-opus-4-7
|
🎉 Welcome to the AetherSDR contributor list, @motoham88 — and congratulations on landing your first PR! Closing out a long-standing tracking issue (#1510 has been open since the early days) with full StreamDeck+ support is a fantastic way to start. The device-protocol documentation in your header is especially nice work — future contributors touching the HID parser will thank you for the layout notes and command-code references. We've filed #3248 to track three robustness follow-ups surfaced during review (lost-events on simultaneous press, hid_write hot-unplug handling, and a minor thread-safety nit on the gate read). None are blockers, all are good-first-issue territory if you want to keep going. Otherwise we'll get to them in a polish pass. Thanks for the great contribution. Looking forward to seeing what you ship next. 73, Jeremy KK7GWY & Claude (AI dev partner) |
…#3250) Closes #3248. Implements the three small robustness fixes flagged during PR #3236's review and tracked in #3248. Each is independent and scoped to one file. ## 1. \`StreamDeckPlusParser::parse\` — don't drop simultaneous events Both the encoder-push and LCD-key paths overwrote \`m_prevEncBtns\` / \`m_prevKeys\` with \`newState\` *before* the loop that finds the first changed bit, so any second-changed-bit got silently dropped (the next \`poll()\` saw no remaining change). Switch to one-bit-at-a-time commit: \`\`\`cpp m_prevKeys ^= (1u << i); // consume just this bit return {.button = i + 1, ...}; \`\`\` Now if two keys change in the same HID report, the first poll iteration emits one and the next poll iteration emits the other. Verified by hand-trace: press both → emit press 1 → emit press 2 → release both → emit release 1 → emit release 2. ✅ ## 2. \`HidEncoderManager\` — check \`hid_write\` return on image writes \`setKeyImage\` and \`setTouchscreenImage\` looped through 1024-byte feature reports ignoring the return. If the device unplugs mid-stream the loop spins through the rest writing into a dead handle with no log signal. Now: \`\`\`cpp const int written = hid_write(m_device, pkt, PACKET_SIZE); if (written < 0) { qCWarning(lcDevices) << \"HidEncoderManager::setKeyImage: hid_write failed\" << \"key=\" << key << \"page=\" << pageNumber << \"— device disconnected? Will retry on hotplug.\"; return; } \`\`\` The next \`poll()\`'s \`hid_read() < 0\` triggers \`close()\` + hotplug reopen, which is now correlatable in logs with the user-visible \"deck went blank\". ## 3. \`HidEncoderManager\` — atomic gate-check members \`m_device\` / \`m_openVid\` / \`m_openPid\` are read from the main thread (refreshStreamDeckLabels at MainWindow.cpp:6778, status snapshot at :7061+) and written from \`m_extCtrlThread\` (open / close / hotplugCheck). Wrap in \`std::atomic\` so cross-thread reads are well-defined. Relaxed memory order is sufficient because callers treat the result as a hint and the real gate is re-checked inside the queued slot. \`std::atomic<hid_device*>\` and \`std::atomic<uint16_t>\` have implicit conversion / assignment operators, so no .cpp call-site needs explicit \`.load()\` / \`.store()\` — the source diff is purely the header change plus the existing reads continuing to compile under the new types. ## Test plan - [x] Build clean on Linux (627/627 targets, no new warnings) - [ ] Confirm check-windows and check-macos pass (now always run per #3244) - [ ] Hand-trace the lost-events fix once more - [ ] Optional: hotplug a StreamDeck+ during a label refresh and watch for the new \`hid_write failed\` warning Diff stats: +61 / -25 across 3 files. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…creen labels (aethersdr#1510) (aethersdr#3236) ## Summary Implements full Elgato StreamDeck+ (VID 0x0FD9, PID 0x0084) support, closing aethersdr#1510. - **4 encoder dials** — each independently configurable to any wheel action (Tune, RIT, XIT, Volume, AGC-T, APF, CW Speed, RF Power, Headphone Volume) - **Encoder push buttons** — each independently configurable; defaults: Enc 1 = Cycle Tuning Step, Enc 2 = Toggle RIT, Enc 3 = Toggle XIT - **8 LCD buttons** — each configurable to 19 actions (MOX, Tune, RIT/XIT toggle+clear, Step Up/Down, Mute, Lock, APF, AGC, Band/Segment Zoom, Next/Prev Slice, Volume ±5, Split); button images update in real-time to show the assigned label - **Touchscreen strip** — 800×100 JPEG above the dials showing turn action (top) and push action + live ON/OFF state (bottom); RIT/XIT sections go green when active - **Settings UI** — three group boxes in Settings → Radio → Serial tab ordered to match the physical device layout (LCD buttons → dial turn → dial push); all changes push to device immediately ### udev rule required for non-root access ``` SUBSYSTEM=="hidraw", ATTRS{idVendor}=="0fd9", ATTRS{idProduct}=="0084", MODE="0666" ``` ### Protocol notes - Input: 14-byte reports, report ID `0x01` at `buf[0]`, event type at `buf[1]`; dial turns type `0x03`/sub `0x01`, pushes `0x03`/`0x00`, LCD keys `0x00` - Key image output: 1024-byte `hid_write`, command `0x07`, 8-byte header, 120×120 JPEG - Touchscreen output: 1024-byte `hid_write`, command `0x0c`, 16-byte header with x/y/w/h, 800×100 JPEG - Protocol verified against python-elgato-streamdeck v0.9.8 ### Compatibility Existing single-encoder devices (Icom RC-28, AetherPad emulator, Griffin PowerMate, Contour ShuttleXpress/Pro v2) are unaffected — `encoderIndex` was added as the last field of `HidEvent` to preserve all existing 4-field aggregate initializers. ## Test plan - [ ] StreamDeck+ dials tune frequency, RIT, XIT, volume by default - [ ] Encoder 1 push cycles tuning step; Enc 2/3 push toggles RIT/XIT - [ ] Touchscreen strip shows correct labels; RIT/XIT sections change color with state - [ ] LCD buttons execute configured actions and display matching labels - [ ] Settings combos in Serial tab update device labels immediately on change - [ ] RC-28 / PowerMate / ShuttleXpress still work normally (no regression) - [ ] Build succeeds with and without `HAVE_HIDAPI` 🤖 Generated with [Claude Code](https://claude.ai/claude-code) --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
aethersdr#3248) (aethersdr#3250) Closes aethersdr#3248. Implements the three small robustness fixes flagged during PR aethersdr#3236's review and tracked in aethersdr#3248. Each is independent and scoped to one file. ## 1. \`StreamDeckPlusParser::parse\` — don't drop simultaneous events Both the encoder-push and LCD-key paths overwrote \`m_prevEncBtns\` / \`m_prevKeys\` with \`newState\` *before* the loop that finds the first changed bit, so any second-changed-bit got silently dropped (the next \`poll()\` saw no remaining change). Switch to one-bit-at-a-time commit: \`\`\`cpp m_prevKeys ^= (1u << i); // consume just this bit return {.button = i + 1, ...}; \`\`\` Now if two keys change in the same HID report, the first poll iteration emits one and the next poll iteration emits the other. Verified by hand-trace: press both → emit press 1 → emit press 2 → release both → emit release 1 → emit release 2. ✅ ## 2. \`HidEncoderManager\` — check \`hid_write\` return on image writes \`setKeyImage\` and \`setTouchscreenImage\` looped through 1024-byte feature reports ignoring the return. If the device unplugs mid-stream the loop spins through the rest writing into a dead handle with no log signal. Now: \`\`\`cpp const int written = hid_write(m_device, pkt, PACKET_SIZE); if (written < 0) { qCWarning(lcDevices) << \"HidEncoderManager::setKeyImage: hid_write failed\" << \"key=\" << key << \"page=\" << pageNumber << \"— device disconnected? Will retry on hotplug.\"; return; } \`\`\` The next \`poll()\`'s \`hid_read() < 0\` triggers \`close()\` + hotplug reopen, which is now correlatable in logs with the user-visible \"deck went blank\". ## 3. \`HidEncoderManager\` — atomic gate-check members \`m_device\` / \`m_openVid\` / \`m_openPid\` are read from the main thread (refreshStreamDeckLabels at MainWindow.cpp:6778, status snapshot at :7061+) and written from \`m_extCtrlThread\` (open / close / hotplugCheck). Wrap in \`std::atomic\` so cross-thread reads are well-defined. Relaxed memory order is sufficient because callers treat the result as a hint and the real gate is re-checked inside the queued slot. \`std::atomic<hid_device*>\` and \`std::atomic<uint16_t>\` have implicit conversion / assignment operators, so no .cpp call-site needs explicit \`.load()\` / \`.store()\` — the source diff is purely the header change plus the existing reads continuing to compile under the new types. ## Test plan - [x] Build clean on Linux (627/627 targets, no new warnings) - [ ] Confirm check-windows and check-macos pass (now always run per aethersdr#3244) - [ ] Hand-trace the lost-events fix once more - [ ] Optional: hotplug a StreamDeck+ during a label refresh and watch for the new \`hid_write failed\` warning Diff stats: +61 / -25 across 3 files. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Summary
Implements full Elgato StreamDeck+ (VID 0x0FD9, PID 0x0084) support, closing #1510.
udev rule required for non-root access
Protocol notes
0x01atbuf[0], event type atbuf[1]; dial turns type0x03/sub0x01, pushes0x03/0x00, LCD keys0x00hid_write, command0x07, 8-byte header, 120×120 JPEGhid_write, command0x0c, 16-byte header with x/y/w/h, 800×100 JPEGCompatibility
Existing single-encoder devices (Icom RC-28, AetherPad emulator, Griffin PowerMate, Contour ShuttleXpress/Pro v2) are unaffected —
encoderIndexwas added as the last field ofHidEventto preserve all existing 4-field aggregate initializers.Test plan
HAVE_HIDAPI🤖 Generated with Claude Code