Skip to content

Commit 1c5fb3b

Browse files
author
LumenPNP
committed
Initial commit: Stream Deck + XL OpenPnP controller
MIT-licensed controller and OpenPnP bridge for LumenPNP machines. Includes Ubuntu .deb packaging, layered user config, and documentation.
0 parents  commit 1c5fb3b

51 files changed

Lines changed: 6521 additions & 0 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.gitignore

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
# Python
2+
__pycache__/
3+
*.py[cod]
4+
*$py.class
5+
*.so
6+
.Python
7+
*.egg
8+
*.egg-info/
9+
.eggs/
10+
dist/
11+
pip-wheel-metadata/
12+
*.manifest
13+
*.spec
14+
15+
# Virtual environments
16+
.venv/
17+
venv/
18+
ENV/
19+
env/
20+
21+
# Packaging / build
22+
build/
23+
*.deb
24+
*.ddeb
25+
*.changes
26+
*.buildinfo
27+
*.tar.gz
28+
29+
# Testing / type checking / lint
30+
.pytest_cache/
31+
.mypy_cache/
32+
.ruff_cache/
33+
.coverage
34+
htmlcov/
35+
.tox/
36+
.nox/
37+
coverage.xml
38+
39+
# Editors and OS
40+
.DS_Store
41+
Thumbs.db
42+
*~
43+
*.swp
44+
*.swo
45+
.idea/
46+
.vscode/
47+
*.code-workspace
48+
49+
# Local overrides (user config lives in ~/.config/streamdeck/)
50+
.env
51+
.env.*
52+
local.config.yaml
53+
54+
# Logs (runtime; OpenPnP also writes under ~/.openpnp2/log/)
55+
*.log

HID-NOTES.md

Lines changed: 192 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,192 @@
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

LICENSE

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
MIT License
2+
3+
Copyright (c) 2026 LumenPNP
4+
5+
Permission is hereby granted, free of charge, to any person obtaining a copy
6+
of this software and associated documentation files (the "Software"), to deal
7+
in the Software without restriction, including without limitation the rights
8+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9+
copies of the Software, and to permit persons to whom the Software is
10+
furnished to do so, subject to the following conditions:
11+
12+
The above copyright notice and this permission notice shall be included in all
13+
copies or substantial portions of the Software.
14+
15+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21+
SOFTWARE.

0 commit comments

Comments
 (0)