|
| 1 | +# Stream Deck + XL HID — proper approach |
| 2 | + |
| 3 | +Notes from validated work on the LumenPNP OpenPnP deck (PID `0x00C6`). |
| 4 | +Use this as the reference when touching input, debugging keys, or extending the driver. |
| 5 | + |
| 6 | +## Official spec (not the Node.js plugin SDK) |
| 7 | + |
| 8 | +We use the **Elgato USB HID protocol**, not the desktop plugin SDK. |
| 9 | + |
| 10 | +- General reference: https://docs.elgato.com/streamdeck/hid/general/ |
| 11 | +- Stream Deck + XL: https://docs.elgato.com/streamdeck/hid/stream-deck-plus-xl/ |
| 12 | + |
| 13 | +### Input report layout (all devices) |
| 14 | + |
| 15 | +| Offset | Field | |
| 16 | +|--------|-------| |
| 17 | +| `0x00` | Report ID — always `0x01` for input | |
| 18 | +| `0x01` | Command — `0x00` keys, `0x02` touch, `0x03` dials | |
| 19 | +| `0x02` | Payload length — UINT16 little-endian | |
| 20 | +| `0x04` | Payload | |
| 21 | + |
| 22 | +### + XL specifics |
| 23 | + |
| 24 | +- **36 keys** (9×4), payload length `36` for key reports |
| 25 | +- Each key byte: `0x00` = released, `0x01` = pressed |
| 26 | +- **6 encoders** — dial payload length = subtype + 6 |
| 27 | +- Touch payload lengths: TAP `0x10`, PRESS `0x0A`, FLICK `0x0E` |
| 28 | +- **Poll interval: 50 ms** (general reference recommendation) |
| 29 | + |
| 30 | +### Feature report 0x08 (unit information) |
| 31 | + |
| 32 | +Expected for + XL: |
| 33 | + |
| 34 | +| Field | Value | |
| 35 | +|-------|-------| |
| 36 | +| rows | 4 | |
| 37 | +| cols | 9 | |
| 38 | +| key size | 112×112 px | |
| 39 | +| LCD | 1280×800 px | |
| 40 | + |
| 41 | +## Code map — single source of truth |
| 42 | + |
| 43 | +| File | Role | |
| 44 | +|------|------| |
| 45 | +| `streamdeck_app/hid_spec.py` | **Authoritative parser** — `parse_input_report()`, validators, driver comparison helpers | |
| 46 | +| `streamdeck_app/devices/streamdeck_plus_xl.py` | XL driver — `_read_control_states`, `_read`, lock-aware dispatch | |
| 47 | +| `streamdeck_app/patch_hid_transport.py` | `hid_read_timeout` for blocking reads without starving USB writes | |
| 48 | +| `streamdeck_app/controller.py` | App logic — lock gesture, reader pause during bulk render | |
| 49 | +| `log-events.py` | **Spec validation test** — do not duplicate parsing elsewhere | |
| 50 | + |
| 51 | +**Rule:** parse raw HID bytes only through `hid_spec.py`. Compare driver output with `compare_key_states()` / `driver_key_states_from_report()`. |
| 52 | + |
| 53 | +## Key index layout (physical → HID) |
| 54 | + |
| 55 | +Index formula: `row * 9 + col` (row 0 = top). |
| 56 | + |
| 57 | +``` |
| 58 | +Row 0: [0 HOME] ... [8 LOCK] |
| 59 | +Row 1: [9 PWR] [10 X-] [11 PkXY] [12 X+] [13 PkZ] ... [17 JOB] |
| 60 | +Row 2: [19 Y-] [20 Z-] ... [23 Cam→N] ... [26 STEP] |
| 61 | +Row 3: [27 TOOL] ... [34 VAC1][35 VAC2] |
| 62 | +``` |
| 63 | + |
| 64 | +Constants live in `streamdeck_app/layout.py` (`LOCK_KEY_INDEX = 8`, etc.). |
| 65 | + |
| 66 | +**Common mistake:** assuming the top-left LOG/test button is a different index — it is **key 0**. LOCK is **key 8** (top-right). |
| 67 | + |
| 68 | +## Input reader — two valid modes |
| 69 | + |
| 70 | +### 1. `xl-driver` (controller path) — default for this project |
| 71 | + |
| 72 | +- Uses `StreamDeckPlusXL._read()` + `hid_read_timeout` |
| 73 | +- Default timeout: **50 ms** (`hid_spec.SPEC_POLL_INTERVAL_MS`) |
| 74 | +- Drains burst after first blocking read (timeout → 0 for queue drain) |
| 75 | +- `seed_input_baseline()` at startup syncs key state without firing callbacks |
| 76 | + |
| 77 | +### 2. `library` (canonical python-elgato-streamdeck) |
| 78 | + |
| 79 | +- Uses base `StreamDeck._read()` + `read_poll_hz=20` (50 ms sleep on timeout) |
| 80 | +- `input_read_timeout_ms = 0` — pure poll + sleep |
| 81 | +- Useful for A/B comparison in `./log-events --mode library` |
| 82 | + |
| 83 | +**Do not** mix custom parsers, ad-hoc offset math, or duplicate `_parse_report()` helpers in test scripts. |
| 84 | + |
| 85 | +## Lock key gesture (controller) |
| 86 | + |
| 87 | +Configured in `config.yaml`: `default_locked: true`. |
| 88 | + |
| 89 | +| Starting state | DOWN | UP | |
| 90 | +|----------------|------|-----| |
| 91 | +| **Locked** | Unlock immediately | Stay unlocked (no re-lock) | |
| 92 | +| **Unlocked** | Show pressed state | Lock | |
| 93 | + |
| 94 | +Implementation: `_handle_lock_key_edge()` in `controller.py` with: |
| 95 | + |
| 96 | +- `_lock_key_events` queue (decouple callback from render) |
| 97 | +- `_lock_gesture_started_locked` — prevents unlock-then-relock on first tap |
| 98 | +- `_lock_gesture_cooldown_until` — debounce after gesture completes |
| 99 | +- Level-trigger fallback via `_update_lock_key_from_hardware()` for missed edges |
| 100 | + |
| 101 | +**Bug that was fixed:** first tap while locked unlocked on DOWN and re-locked on UP. Fix depends on tracking gesture start state, not treating UP as always “lock”. |
| 102 | + |
| 103 | +## Bulk USB writes vs input |
| 104 | + |
| 105 | +Image uploads must not starve HID reads. |
| 106 | + |
| 107 | +1. **`_deck_update()`** — pauses reader (`_setup_reader(None)`), renders, then `_drain_deck_input()` and restarts reader |
| 108 | +2. **`patch_hid_transport`** — blocking `hid_read_timeout` reads **do not** hold `device.mutex`; writes do |
| 109 | +3. Prefer partial key updates (`_dirty_control_keys`) over full-frame re-render on every action |
| 110 | + |
| 111 | +## Testing workflow |
| 112 | + |
| 113 | +Only **one process** may open the HID device at a time. |
| 114 | + |
| 115 | +```bash |
| 116 | +# Stop anything holding the deck |
| 117 | +pkill -f streamdeck_app.controller |
| 118 | +pkill -f log-events |
| 119 | + |
| 120 | +# Spec validation test (recommended before controller changes) |
| 121 | +./log-events # xl-driver, 50 ms, display flash on edge |
| 122 | +./log-events --no-display # HID-only, no image upload interference |
| 123 | +./log-events --mode library # compare canonical library reader |
| 124 | +./log-events --strict # exit 1 on any spec violation |
| 125 | + |
| 126 | +# Key index discovery (stop log-events/controller first!) |
| 127 | +.venv/bin/python identify-keys |
| 128 | + |
| 129 | +# Run controller |
| 130 | +./run |
| 131 | +``` |
| 132 | + |
| 133 | +### What to look for in logs |
| 134 | + |
| 135 | +| Log file | Contents | |
| 136 | +|----------|----------| |
| 137 | +| `~/.openpnp2/log/streamdeck-events.log` | Spec test — `SPEC KEY`, `EDGE`, `VIOLATION`, `DRIVER` | |
| 138 | +| `~/.openpnp2/log/streamdeck-hid.log` | Driver-level raw + edges | |
| 139 | +| `~/.openpnp2/log/streamdeck-controller.log` | App — `key N down/up`, lock/unlock | |
| 140 | + |
| 141 | +**Healthy key press:** |
| 142 | + |
| 143 | +``` |
| 144 | +SPEC KEY pressed=[8] |
| 145 | +EDGE key 8 DOWN |
| 146 | +EDGE key 8 UP |
| 147 | +``` |
| 148 | + |
| 149 | +**Healthy first lock tap (started locked):** |
| 150 | + |
| 151 | +``` |
| 152 | +key 8 down (hw) |
| 153 | +machine controls unlocked |
| 154 | +key 8 up (hw) |
| 155 | +``` |
| 156 | + |
| 157 | +No `machine controls locked` immediately after that UP. |
| 158 | + |
| 159 | +### Validated 2026-06-08 |
| 160 | + |
| 161 | +- Keys 0, 8, and middle-row keys — clean single DOWN/UP per press |
| 162 | +- Spec parser matches driver parser — zero `DRIVER_MISMATCH` |
| 163 | +- Unit info feature report 0x08 — matches XL geometry |
| 164 | +- Controller lock cycle after restart — first press unlocks without re-lock on release |
| 165 | + |
| 166 | +## Pitfalls |
| 167 | + |
| 168 | +1. **Stale process holds device** — `identify-keys`, `log-events`, or a crashed controller blocks `./run` with `TransportError` |
| 169 | +2. **Duplicate parsers** — causes false “missed press” diagnosis when parser and driver disagree |
| 170 | +3. **Wrong poll rate** — 100 ms works but spec says 50 ms; use `SPEC_POLL_INTERVAL_MS` |
| 171 | +4. **Assuming press without checking index** — always confirm `pressed=[N]` in log, not just “a report arrived” |
| 172 | +5. **Empty `pressed=[]` report** — may be release-only if DOWN was missed during heavy render; check `_deck_update` pause/drain |
| 173 | +6. **Elgato plugin SDK docs** — wrong API; we are on raw HID |
| 174 | + |
| 175 | +## OpenPnP target position buttons (keys 5 and 23) |
| 176 | + |
| 177 | +Matches `MachineControlsPanel.enableToolActions()` in OpenPnP: |
| 178 | + |
| 179 | +| Key | Command | Enabled when | |
| 180 | +|-----|---------|--------------| |
| 181 | +| **5** | `MOVE_TOOL_TO_CAMERA` | Machine on **and camera selected** | |
| 182 | +| **23** | `MOVE_CAMERA_TO_TOOL` | Machine on **and nozzle/non-camera selected** | |
| 183 | + |
| 184 | +The controller derives this from `selected_tool_kind` polled via `GET_POSITION` (no OpenPnP restart required). After an OpenPnP restart, the bridge also reports `move_tool_to_camera_enabled` / `move_camera_to_tool_enabled` from `action.isEnabled()` directly. |
| 185 | + |
| 186 | +## Adding or changing input handling |
| 187 | + |
| 188 | +1. Change parsing in `hid_spec.py` only |
| 189 | +2. Run `./log-events --no-display --strict` and press keys 0, 8, and a few others |
| 190 | +3. Confirm `DRIVER` lines are absent (spec == driver) |
| 191 | +4. Run `./run`, test lock first-press and full lock/unlock cycle |
| 192 | +5. Check `streamdeck-controller.log` for single unlock/lock per gesture |
0 commit comments