muxplex-deck 0.4.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,639 @@
1
+ Metadata-Version: 2.3
2
+ Name: muxplex-deck
3
+ Version: 0.4.0
4
+ Summary: Hardware probe/diagnostic app for the Elgato Stream Deck+ (seed of the muxplex sidecar)
5
+ Author: Amplifier
6
+ Author-email: Amplifier <240397093+microsoft-amplifier@users.noreply.github.com>
7
+ Requires-Dist: streamdeck>=0.9.5
8
+ Requires-Dist: pillow>=10.0.0
9
+ Requires-Dist: httpx>=0.27.0
10
+ Requires-Dist: muxplex-client>=0.19.0
11
+ Requires-Python: >=3.11
12
+ Description-Content-Type: text/markdown
13
+
14
+ # muxplex-deck: Stream Deck+ hardware probe & muxplex sidecar
15
+
16
+ This repo has two apps, sharing one `uv` project:
17
+
18
+ - **`deck-probe`** -- a PoC/spike app that proves we can drive every feature
19
+ of **any Elgato Stream Deck model** and capture every input it can
20
+ produce, including clean hotplug (unplug/replug without restarting the
21
+ process). It is capability-driven: on connect it prints a capability
22
+ report (model, serial, firmware, key count/layout/size, dials, touch
23
+ strip, touch keys) and exercises only the controls the connected deck
24
+ actually has -- keys always, dials only if `dial_count() > 0`, the touch
25
+ strip only if `is_touch()`. Verified on the Stream Deck+ (8 keys, 4
26
+ dials, touch strip); the 15-key Original/MK2 (3x5, 72x72 keys, no
27
+ dials/touch) runs the same probe with dial/touch exercises skipped.
28
+ Device I/O only, no server/network integration. See "Stream Deck
29
+ hardware probe" below.
30
+ - **`muxplex-deck`** -- the actual product: a sidecar that shows your
31
+ muxplex tmux sessions on the deck's 8 keys and switches sessions on key
32
+ press. See "muxplex sidecar" below.
33
+
34
+ `muxplex-deck` talks to the deck only through a small `DeckDevice` protocol
35
+ (`src/muxplex_deck/device.py`), with two interchangeable backends: the real
36
+ hardware (`device_real.py`, a thin wrapper over the `streamdeck` library)
37
+ and an in-process **emulator** (`emulator.py`) with a localhost web UI. This
38
+ means the whole sidecar -- state machine, muxplex client, rendering,
39
+ interaction flow -- can be developed and tested with zero hardware, on any
40
+ machine, with no `hidapi` installed. See "Stream Deck+ emulator" below.
41
+
42
+ ## Quickstart (recommended)
43
+
44
+ ```sh
45
+ uv tool install git+https://github.com/bkrabach/muxplex-deck
46
+ muxplex-deck init https://your-server:8088
47
+ muxplex-deck service install
48
+ ```
49
+
50
+ `muxplex-deck init` is a turnkey setup wizard: it validates the server URL,
51
+ auto-fetches the server's CA certificate when one is needed (no `scp`/SSH
52
+ required -- and no risk of grabbing the *leaf* cert by mistake, a real
53
+ gotcha this project has hit before), walks you through pasting the
54
+ federation key, writes `config.json`, and re-verifies the server, CA, and
55
+ Stream Deck before handing off. It's idempotent -- re-run it anytime;
56
+ existing values become the defaults, nothing is clobbered.
57
+
58
+ **`uv sync` is for developing this repo, not installing the tool.** It only
59
+ builds `muxplex-deck`'s own `.venv` and does **not** put `muxplex-deck` on
60
+ your `PATH` -- this exact confusion has already cost real setup time. Use
61
+ `uv tool install` (above) to actually install and run the sidecar; reach
62
+ for `uv sync` only if you're hacking on muxplex-deck's source.
63
+
64
+ The manual/advanced setup (hand-editing `config.json`, `scp`-ing files
65
+ yourself) is still documented further down for anyone who wants it --
66
+ see "Service install walkthrough" and "muxplex sidecar" -> "Config" below.
67
+
68
+ ## CLI
69
+
70
+ `muxplex-deck` has the same command shape as its sibling `muxplex` server
71
+ tool: a default action, a `config` group, a `service` group (systemd on
72
+ Linux, launchd on macOS), `doctor`, `update`, and `version`. Bare
73
+ `muxplex-deck` and `muxplex-deck run` do exactly the same thing.
74
+
75
+ | Command | What it does |
76
+ |---|---|
77
+ | `muxplex-deck` / `muxplex-deck run` | Run the sidecar (the default action) |
78
+ | `muxplex-deck --emulator` | Run against the in-process emulator instead of real hardware |
79
+ | `muxplex-deck config` / `config list` | Show all config keys and their current values |
80
+ | `muxplex-deck config get <key>` | Show one config value |
81
+ | `muxplex-deck config set <key> <value>` | Set a config value (type auto-detected) |
82
+ | `muxplex-deck config reset [key]` | Reset one key, or all keys, to defaults |
83
+ | `muxplex-deck service install` | Install + enable + start the background service |
84
+ | `muxplex-deck service uninstall` | Stop + disable + remove the service |
85
+ | `muxplex-deck service start` / `stop` / `restart` | Control the service |
86
+ | `muxplex-deck service status` | Show service status |
87
+ | `muxplex-deck service logs` | Tail service logs |
88
+ | `muxplex-deck status` / `status --json` | Show connected hardware, server reachability, and active session/view/page -- read from the running sidecar's published status, so it never contends with a running service for the (exclusive) HID handle |
89
+ | `muxplex-deck doctor` | Check Python version, install source, config, federation key permissions, `ca_file` validity, Stream Deck detection, HID openability, server reachability, and service status |
90
+ | `muxplex-deck update` (alias `upgrade`) | Update to the latest `main` and restart the service |
91
+ | `muxplex-deck version` / `--version` | Show the installed version |
92
+
93
+ Every `config` key maps 1:1 to a `config.json` field: `server_url`,
94
+ `key_file`, `ca_file`, `poll_interval`, `sort`, `focus_app`. See "Config"
95
+ under "muxplex sidecar" below for what each one means.
96
+
97
+ ### The HID-permission caveat (why `service install` prints a udev block)
98
+
99
+ Unlike the muxplex *server* (a plain user process), the sidecar needs raw
100
+ USB HID access to the Stream Deck -- which a non-root Linux user does not
101
+ have by default. This is why you've been running `sudo muxplex-deck`. A
102
+ systemd **user** service, however, runs as your normal user, not root -- so
103
+ without a udev rule granting your user access to the device (vendor id
104
+ `0fd9`), the installed service will start but fail to open the deck.
105
+
106
+ `muxplex-deck service install` checks for an existing rule under
107
+ `/etc/udev/rules.d/` or `/usr/lib/udev/rules.d/` and, if none is found,
108
+ prints a copy-pasteable remediation block (the same rule shown in
109
+ "Permissions" under the hardware probe section above) instead of silently
110
+ installing a service that won't work. It never writes to `/etc` itself --
111
+ only detects and reports. Run the printed `sudo tee ... && sudo udevadm
112
+ control --reload-rules && sudo udevadm trigger` commands, then unplug and
113
+ replug the deck (or re-`usbipd attach` under WSL) and `muxplex-deck service
114
+ restart`.
115
+
116
+ On Linux, `service install` also attempts `loginctl enable-linger
117
+ <you>` (best-effort, non-fatal) so the service keeps running after you log
118
+ out -- appropriate for a headless, always-on sidecar. `muxplex-deck doctor`
119
+ reports both the udev rule and HID-openable status.
120
+
121
+ ### Service install walkthrough (Linux)
122
+
123
+ ```sh
124
+ uv tool install git+https://github.com/bkrabach/muxplex-deck
125
+ muxplex-deck init https://<your-server>:8088 # sets server_url, ca_file, federation key
126
+ muxplex-deck doctor # confirms config, key file, CA, and deck are all in order first
127
+ muxplex-deck service install # writes the systemd user unit, enables linger, warns about udev if needed
128
+ muxplex-deck service status
129
+ ```
130
+
131
+ On macOS the same commands install a launchd agent instead (no udev/linger
132
+ step -- macOS needs no special HID permissions).
133
+
134
+ ## macOS setup
135
+
136
+ 1. Install [uv](https://docs.astral.sh/uv/) if you don't have it:
137
+ ```sh
138
+ curl -LsSf https://astral.sh/uv/install.sh | sh
139
+ ```
140
+ 2. Install the native HIDAPI library (the `streamdeck` library needs it at runtime):
141
+ ```sh
142
+ brew install hidapi
143
+ ```
144
+ 3. **Quit the official Elgato Stream Deck app first.** It holds exclusive
145
+ access to the device's USB HID interface -- if it's running, this probe
146
+ will not be able to open the device.
147
+ 4. From this directory, sync dependencies:
148
+ ```sh
149
+ uv sync
150
+ ```
151
+
152
+ ## Stream Deck hardware probe (any model)
153
+
154
+ ### Running
155
+
156
+ ```sh
157
+ uv run deck-probe
158
+ ```
159
+
160
+ #### Windows + WSL
161
+
162
+ USB devices are not visible inside WSL until attached from the Windows
163
+ side. In an **admin PowerShell**:
164
+
165
+ ```powershell
166
+ usbipd list # find the Stream Deck's BUSID (VID 0fd9)
167
+ usbipd bind --busid <BUSID> # first time only
168
+ usbipd attach --wsl --busid <BUSID>
169
+ ```
170
+
171
+ Then inside WSL, grant HID access via a udev rule (first time only):
172
+
173
+ ```sh
174
+ sudo tee /etc/udev/rules.d/70-streamdeck.rules >/dev/null \
175
+ <<< 'SUBSYSTEM=="hidraw", ATTRS{idVendor}=="0fd9", MODE="0666"'
176
+ sudo udevadm control --reload-rules
177
+ # then detach/re-attach the device (usbipd detach + attach)
178
+ sudo apt install libhidapi-libusb0 # native HIDAPI, if not present
179
+ ```
180
+
181
+ **Close the Elgato Stream Deck app on Windows first** -- it holds
182
+ exclusive HID access. The probe prints this same guidance when it finds
183
+ no device.
184
+
185
+ Or equivalently:
186
+
187
+ ```sh
188
+ uv run python -m deck_probe
189
+ ```
190
+
191
+ Press `Ctrl+C` to exit cleanly at any time -- the deck is reset (blanked)
192
+ and the device handle is closed before the process exits.
193
+
194
+ ### Verification checklist
195
+
196
+ Walk through these in order. Everything is logged to the console with a
197
+ timestamp; watch both the terminal and the physical device.
198
+
199
+ 1. **Cold start, no device connected**
200
+ - Run `uv run deck-probe` with nothing plugged in.
201
+ - Expect: no-device guidance (udev/usbipd/Elgato-app hints) followed by
202
+ `waiting for a Stream Deck (polling every 2s)...` logged once,
203
+ not repeated every 2 seconds (a "still waiting" heartbeat is fine
204
+ roughly every 30s).
205
+
206
+ 2. **Plug in the Stream Deck+**
207
+ - Within ~2 seconds, expect a `deck capabilities:` report block with
208
+ model, serial number, firmware version, key count/layout/size, dial count,
209
+ key image format, and touch strip format.
210
+ - Expect all 8 keys to light up immediately, each showing a distinct
211
+ background color and its own index number (0-7).
212
+ - Expect the touch strip to show 4 labeled zones: `D0 BRIGHTNESS`,
213
+ `D1 COUNTER`, `D2 COUNTER`, `D3 COUNTER`, each with a starting value.
214
+
215
+ 3. **Key presses**
216
+ - Press and hold any key: it should invert to a black background with
217
+ white text while held, and log `key[N] PRESSED`.
218
+ - Release it: it should return to its original color, and log
219
+ `key[N] released`.
220
+ - Try all 8 keys.
221
+
222
+ 4. **Dial 0 (brightness)**
223
+ - Rotate dial 0: the overall deck brightness should change live and
224
+ smoothly, the `D0 BRIGHTNESS` zone should update to show the new
225
+ percentage, and each tick should log `dial[0] TURN ... (clockwise` or
226
+ `counter-clockwise)`.
227
+ - Press dial 0: brightness resets to the default (75%), the zone
228
+ updates, and it logs `dial[0] PRESSED` / `released`.
229
+
230
+ 5. **Dials 1-3 (counters)**
231
+ - Rotate each dial: its zone's counter value should update on the touch
232
+ strip (only that zone repaints -- proving partial touch-strip
233
+ updates), and it logs `dial[N] TURN ...`.
234
+ - Press each dial: its counter resets to 0 (zone updates), logging
235
+ `dial[N] PRESSED` / `released`.
236
+
237
+ 6. **Touch strip -- short tap**
238
+ - Tap anywhere on the touch strip: expect a vertical yellow marker line
239
+ drawn at the tapped x position (full strip repaint), and a log line
240
+ `touch SHORT tap at (x, y)`.
241
+
242
+ 7. **Touch strip -- long press**
243
+ - Press and hold a point on the touch strip: expect a log line
244
+ `touch LONG press at (x, y)`. No visual change is expected for this one.
245
+
246
+ 8. **Touch strip -- drag/swipe**
247
+ - Swipe across the touch strip: expect a log line
248
+ `touch DRAG from (x1, y1) to (x2, y2)`.
249
+
250
+ 9. **Unplug while active**
251
+ - Unplug the Stream Deck+ USB cable while the probe is running.
252
+ - Expect a loud `Stream Deck+ disconnected` warning within a couple of
253
+ seconds, the process should NOT crash, and it should return to the
254
+ `waiting for Stream Deck+...` polling state.
255
+
256
+ 10. **Replug**
257
+ - Plug the Stream Deck+ back in.
258
+ - Expect it to be detected automatically within ~2 seconds and fully
259
+ repainted (step 2 again), with no restart of the process required.
260
+ - Repeat steps 9-10 a few times to confirm the cycle is reliable.
261
+
262
+ 11. **Ctrl+C shutdown**
263
+ - With the device connected and active, press `Ctrl+C`.
264
+ - Expect the deck to reset/blank, a `deck-probe shutting down` log
265
+ line, and the process to exit with code 0.
266
+ - Also try `Ctrl+C` while in the `waiting for Stream Deck+...` state
267
+ (no device connected) -- same clean exit expected.
268
+
269
+ ### Troubleshooting
270
+
271
+ **Device not found / stuck waiting forever**
272
+ - Is the official Elgato Stream Deck app running? Quit it -- it holds
273
+ exclusive HID access and this probe cannot open the device while it's
274
+ running.
275
+ - Is the USB cable a data cable (not charge-only) and firmly seated? Try a
276
+ different cable or port.
277
+ - Confirm the OS sees the device at all: on macOS, `system_profiler
278
+ SPUSBDataType | grep -A 5 Elgato` should show a "Stream Deck +" entry.
279
+
280
+ **`Could not find the native HIDAPI library...` error at startup**
281
+ - macOS: `brew install hidapi`, then retry.
282
+ - Debian/Ubuntu: `sudo apt install libhidapi-libusb0`, then retry.
283
+ - This error means the library is missing entirely -- it is not a
284
+ permissions issue and won't resolve itself by retrying without installing
285
+ the package.
286
+
287
+ **Permissions**
288
+ - macOS: no special permissions are needed for USB HID access with this
289
+ library.
290
+ - Linux: you need a udev rule granting your user access to the Stream
291
+ Deck+'s USB HID interface (VID `0fd9`). Create
292
+ `/etc/udev/rules.d/50-elgato-streamdeck.rules` with:
293
+ ```
294
+ SUBSYSTEM=="usb", ATTRS{idVendor}=="0fd9", MODE="0666"
295
+ SUBSYSTEM=="hidraw", ATTRS{idVendor}=="0fd9", MODE="0666"
296
+ ```
297
+ Then reload rules and replug the device:
298
+ ```sh
299
+ sudo udevadm control --reload-rules
300
+ sudo udevadm trigger
301
+ ```
302
+
303
+ ## muxplex sidecar (`muxplex-deck`)
304
+
305
+ Shows your muxplex tmux sessions on the deck's 8 keys -- each key rendered
306
+ as a mini terminal preview of that session -- and switches the active
307
+ session on key press. Dial 0 cycles views; dial 1 pages within the current
308
+ view. Polls `GET /api/sessions` + `GET /api/state` + `GET /api/settings` on
309
+ the muxplex server every `poll_interval` seconds; repaints only the keys
310
+ (and strip) whose content actually changed (no flicker, no wasted JPEG
311
+ encodes for an idle session).
312
+
313
+ **View-following:** the deck mirrors whatever view is active in the muxplex
314
+ PWA. Keys show the sessions belonging to the server's current view
315
+ (`active_view` from `GET /api/state`) -- `all`, `hidden`, or a user-defined
316
+ view -- filtered the same way the PWA does. An unknown/deleted view name
317
+ shows honestly as zero sessions rather than silently falling back to `all`.
318
+ The needs-attention predicate is the exact one the PWA uses: a session
319
+ needs attention iff `unseen_count > 0` and either it has never been seen or
320
+ the most recent fire is newer than the last time it was seen
321
+ (`Bell.needs_attention` in `client.py`) -- so an old, acknowledged bell
322
+ doesn't keep glowing forever. See "Key previews" below for how this and the
323
+ active-session state are shown (colored borders, matched to muxplex's own
324
+ brand palette).
325
+
326
+ **Dial 0 -- view cycling:** turning steps through `["all"] + <your named
327
+ views> + ["hidden"]` (wraps at the ends); the strip immediately echoes the
328
+ candidate view name while turning, and after ~400ms of no further ticks it
329
+ commits via `PATCH /api/state` -- so a fast spin sends exactly one request,
330
+ not one per tick. `hidden` is a reserved pseudo-view exactly like `all` --
331
+ never a member of your named views, but reachable the same way -- and
332
+ switching to it shows only the sessions you've hidden in the PWA (a
333
+ property of the session, orthogonal to view membership; a session can be
334
+ both hidden and a member of a named view). `active_view` is *global*
335
+ server-side state (last writer wins across every device/tab watching the
336
+ server), so turning the dial changes what the PWA shows too, exactly like
337
+ switching views there would. **Pressing** opens the **view picker** (see
338
+ "Dial-press picker mode" below) instead of jumping straight to `all`.
339
+
340
+ **Dial 1 -- paging:** turning moves ±1 page within the current view (8
341
+ sessions/page, clamped at the first/last page -- no wrap). Purely local --
342
+ no server writes. The strip shows a `pN/M` indicator whenever a view has
343
+ more than one page. Paging resets to page 1 whenever the active view
344
+ changes (from either dial 0 or the PWA). **Pressing** opens the **page
345
+ picker** (see "Dial-press picker mode" below) instead of resetting to page 1.
346
+
347
+ **Dial-press picker mode:** pressing either dial hands the 8 keys over to
348
+ a chooser instead of performing an immediate action -- useful once there
349
+ are more views or pages than there are keys (e.g. 9 views, or 9 pages of
350
+ sessions). Pressing **dial 0** opens the **view picker**: each key shows
351
+ one view name (`["all"] + <your named views> + ["hidden"]`, same list dial
352
+ 0 cycles through); tapping a key switches to that view (`PATCH /api/state`) and
353
+ returns to the normal session display. Pressing **dial 1** opens the
354
+ **page picker**: each key shows a page number for the current view; tapping
355
+ one jumps straight there. While a picker is open: turning its *own* dial
356
+ scrolls the window if there are more than 8 options (the strip shows e.g.
357
+ `VIEW PICKER -- tap to choose · 1-8/9`); pressing the *same* dial again
358
+ closes the picker with no change; pressing the *other* dial switches
359
+ straight to that dial's picker. The option matching today's actual active
360
+ view/page is marked with the same cyan border used for the active session,
361
+ so it's obvious what you'd be leaving. A session key's normal connect
362
+ behavior is suspended while a picker is open -- key taps select a picker
363
+ option, not a session, until you pick one or back out.
364
+
365
+ **Sort order -- `"sort"` config field (`"attention"` default, or
366
+ `"server"`):** in `"attention"` mode the view's sessions are reordered
367
+ before paging so the most urgent land on page 1: sessions needing
368
+ attention first (newest bell fire first), then the active session, then
369
+ everything else by most-recent activity (`last_activity_at`, when the
370
+ server exposes it -- older muxplex servers get a one-time INFO log and a
371
+ graceful fallback to server order for that tier). `"server"` mode disables
372
+ all of this and shows exactly what the PWA's own `sort_order` setting
373
+ (`alphabetical` or manual/server order) produces -- the pre-existing
374
+ behavior.
375
+
376
+ **Key previews:** each occupied key renders a small monospace crop of the
377
+ session's live pane (bottom-left corner, ANSI colors stripped in this first
378
+ pass -- see `rendering.py`'s docstring for the honest fidelity tradeoff),
379
+ with the session name banner and status border(s) always layered on top so
380
+ identity stays legible regardless of what's scrolling underneath. Status is
381
+ shown as a colored border rather than a background fill or dot, using the
382
+ exact brand colors from muxplex's own frontend (`frontend/style.css`):
383
+ **cyan `#00D9F5`** for the active session, **amber `#F1A640`** for
384
+ needs-attention. A session that's both active and needs attention gets both
385
+ rings at once (amber outer, cyan inner) rather than losing one status to
386
+ the other.
387
+
388
+ ### Config
389
+
390
+ Config is a JSON file at `~/.config/muxplex-deck/config.json` by default
391
+ (override with `--config <path>` or the `MUXPLEX_DECK_CONFIG` env var):
392
+
393
+ ```json
394
+ {
395
+ "server_url": "https://<your-server>:8088",
396
+ "key_file": "~/.config/muxplex-deck/federation_key",
397
+ "poll_interval": 2.0,
398
+ "sort": "attention",
399
+ "focus_app": "muxplex"
400
+ }
401
+ ```
402
+
403
+ - `server_url` (required) -- the muxplex server's base URL, reached over
404
+ Tailscale from the Mac.
405
+ - `key_file` (optional, defaults to `~/.config/muxplex-deck/federation_key`)
406
+ -- path to a file containing the federation Bearer key, read fresh at
407
+ startup and whitespace-stripped.
408
+ - `ca_file` (optional) -- path to a CA bundle for TLS verification. Python
409
+ does **not** use the macOS Keychain trust store, so if the server's
410
+ certificate was issued by muxplex's own local CA (`muxplex setup-tls`),
411
+ point this at that CA file. Never omit verification instead -- see
412
+ Troubleshooting below.
413
+ - `poll_interval` (optional, default `2.0`) -- seconds between session
414
+ polls while active.
415
+ - `sort` (optional, default `"attention"`) -- `"attention"` reorders each
416
+ view's sessions before paging (attention first, then active, then most
417
+ recently active); `"server"` disables that reordering and shows exactly
418
+ what the PWA's own `sort_order` setting produces. See "Dial 0 / Dial 1 /
419
+ Sort order" above for the full behavior.
420
+ - `focus_app` (optional, default off) -- macOS app name of the locally
421
+ installed muxplex PWA; see "Bringing the PWA to the foreground" below.
422
+
423
+ ### Bringing the PWA to the foreground (macOS)
424
+
425
+ If the muxplex PWA is installed as a standalone app on the same Mac the
426
+ sidecar runs on, set `focus_app` to its application name and every
427
+ key-press session switch will also bring that window to the foreground --
428
+ press a key, see the terminal you just switched to. It runs `open -a
429
+ "<focus_app>"` on a background thread: it never delays the switch itself,
430
+ and a focus failure (wrong name, app not installed) is logged as a warning
431
+ and otherwise ignored.
432
+
433
+ ```json
434
+ {
435
+ "server_url": "https://<your-server>:8088",
436
+ "focus_app": "muxplex"
437
+ }
438
+ ```
439
+
440
+ **Finding the app name:** it's the name macOS shows for the installed PWA
441
+ -- in the menu bar next to the Apple logo while it's frontmost, or under
442
+ its Dock icon. For a PWA installed via Chrome's "Install app" this is the
443
+ PWA's own title (Chrome puts the `.app` bundle under `~/Applications/Chrome
444
+ Apps.localized/`); for Safari's "Add to Dock" it's the name you gave it.
445
+ If the sidecar logs `focus: ... failed`, the name doesn't match -- check
446
+ what `open -a "<name>"` does in a terminal.
447
+
448
+ Scope, deliberately narrow: focus fires **only** on an explicit key-press
449
+ that changes the active session -- never on dial turns, view/page changes,
450
+ poll-driven repaints, or pressing the already-active session's key -- so
451
+ the window is never yanked forward while you're just browsing the deck.
452
+ macOS-only today (on other platforms a configured `focus_app` logs one
453
+ INFO notice and is ignored); a Windows implementation is planned.
454
+
455
+ Any missing/invalid config or unreadable key file produces a clear,
456
+ actionable message on stderr and a non-zero exit -- there is no default
457
+ that silently skips auth or TLS verification.
458
+
459
+ ### Getting the federation key onto the Mac
460
+
461
+ **Easiest:** `muxplex-deck init` (see "Quickstart" above) prompts you to
462
+ paste the key directly -- no SSH access to the server required, and it's
463
+ never echoed back or logged.
464
+
465
+ **Manual method**, if you'd rather not use the wizard: the muxplex server
466
+ already has a federation key generated (`~/.config/muxplex/federation_key`
467
+ on the server, federation enabled). Copy it over:
468
+
469
+ ```sh
470
+ mkdir -p ~/.config/muxplex-deck
471
+ scp <your-server>:.config/muxplex/federation_key ~/.config/muxplex-deck/federation_key
472
+ chmod 600 ~/.config/muxplex-deck/federation_key
473
+ ```
474
+
475
+ Then create `~/.config/muxplex-deck/config.json` with your `server_url`
476
+ (the default `key_file` path above already matches, so you can omit it).
477
+
478
+ ### Running
479
+
480
+ ```sh
481
+ uv run muxplex-deck
482
+ ```
483
+
484
+ Or with an explicit config path:
485
+
486
+ ```sh
487
+ uv run muxplex-deck --config ~/some/other/config.json
488
+ ```
489
+
490
+ `Ctrl+C` exits cleanly at any time -- the deck is reset (blanked) and the
491
+ device handle closed before the process exits.
492
+
493
+ ### Verification checklist
494
+
495
+ 1. **Cold start** -- run with the deck unplugged: waits quietly, no
496
+ server traffic (check with e.g. `tcpdump` or just trust the code: the
497
+ poll loop only starts once a device is open).
498
+ 2. **Plug in, server reachable** -- keys populate with your first 8
499
+ session names within `poll_interval`; the touch strip shows
500
+ `<hostname> · N sessions · ACTIVE: <name>`; the deck is at full
501
+ brightness (100%) even if it powered on dim.
502
+ 3. **Bell indicator** -- trigger a bell in one of your tmux sessions
503
+ (e.g. `printf '\a'`); its key should grow an amber (`#F1A640`) border
504
+ within one poll tick.
505
+ 4. **Press a key** -- switches muxplex's active session (confirm in the
506
+ PWA or by reconnecting a terminal). The cyan (`#00D9F5`) active border
507
+ moves to the pressed key **immediately**, before the server round-trip
508
+ completes -- it does not wait for the next poll tick, and a second key
509
+ press is accepted right away rather than blocking on the first one's
510
+ connect.
511
+ 5. **Server down** -- stop muxplex (or block the URL) while the sidecar
512
+ is running: strip shows `<hostname> UNREACHABLE -- retrying`, keys go
513
+ blank, and it recovers automatically (repainting sessions) once the
514
+ server comes back.
515
+ 6. **Bad key** -- temporarily corrupt the key file: strip shows
516
+ `AUTH FAILED -- check key file`, a `CRITICAL` log line appears, and it
517
+ retries slowly (every 30s) rather than spinning.
518
+ 7. **Unplug/replug** -- same hotplug behavior as the probe: unplug stops
519
+ all server traffic and returns to waiting; replug brings up a fresh
520
+ ACTIVE session (at full brightness again).
521
+ 8. **Dial-press picker mode** -- press dial 0: keys switch to a list of
522
+ view names (strip reads `VIEW PICKER -- tap to choose`); tap one to
523
+ switch views and return to the session display. Press dial 1: keys
524
+ switch to page numbers; tap one to jump there. Pressing the same dial
525
+ again exits without changing anything; pressing the other dial while a
526
+ picker is open switches straight to that dial's picker.
527
+
528
+ ### Troubleshooting
529
+
530
+ **SSL verification failure** (`SSLCertVerificationError` or similar)
531
+ - Your muxplex server is likely using a certificate from its own local CA
532
+ (`muxplex setup-tls`) rather than a publicly trusted one. Set `ca_file`
533
+ in your config to that CA's certificate path and retry.
534
+ - Never work around this by disabling verification -- point at the right
535
+ CA file instead.
536
+
537
+ **401 / 403 -- `AUTH FAILED` on the strip**
538
+ - The federation key is missing, stale, or doesn't match the server's.
539
+ Re-copy it from the server (see "Getting the federation key onto the
540
+ Mac" above) and confirm `key_file` in your config points at it.
541
+
542
+ **Device not found / `Could not find the native HIDAPI library...`**
543
+ - Same as the probe -- see the Stream Deck+ hardware probe's
544
+ Troubleshooting section above (quit the official Elgato app, install
545
+ `hidapi`, check the USB cable).
546
+
547
+ ## Stream Deck+ emulator
548
+
549
+ `muxplex-deck` can run against an **in-process virtual Stream Deck+** instead
550
+ of real hardware -- no device, no `hidapi`, no native library at all. This
551
+ is the same code path as real hardware above the device layer: state
552
+ machine, muxplex client, polling, rendering, key-press-to-session-switch.
553
+ Only the physical HID transport is swapped out (see "How this works" below).
554
+
555
+ ### Running
556
+
557
+ ```sh
558
+ uv run muxplex-deck --emulator
559
+ ```
560
+
561
+ Then open **http://127.0.0.1:8484** in a browser. You'll see the 8 keys and
562
+ the touch strip rendered as images, a Plug in / Unplug toggle, and simple
563
+ dial controls. The emulator starts **unplugged** -- click "Plug in" to bring
564
+ the sidecar's state machine up (same as physically connecting the real
565
+ device), point your `config.json` at a running muxplex server as usual, and
566
+ watch keys populate with session names. Click a key to switch sessions,
567
+ exactly like pressing a physical button.
568
+
569
+ Use a different port with `--emulator-port <N>` if 8484 is taken.
570
+
571
+ The same HTTP endpoints the UI polls (`GET /state`, `GET /keys/<n>.jpg`,
572
+ `GET /strip.jpg`, `POST /plug`, `POST /unplug`, `POST /input/key`,
573
+ `POST /input/dial`, `POST /input/touch`) can be driven directly with
574
+ `curl`/`httpx` -- useful for scripted or agent-driven testing without a
575
+ browser at all.
576
+
577
+ ### What this proves vs. what only real hardware proves
578
+
579
+ **Proves (same code as production):** the hotplug state machine
580
+ (DEVICE_ABSENT/ACTIVE/UNREACHABLE/AUTH_FAILED transitions), the muxplex HTTP
581
+ client against a real muxplex server, session-list polling and repaint-only-
582
+ on-change logic, key-press-to-`connect_session` wiring, and all of
583
+ `rendering.py`'s image generation (via the same `PILHelper` calls, same
584
+ image formats/sizes).
585
+
586
+ **Does NOT prove:** anything about the physical USB/HID transport itself --
587
+ whether the real `streamdeck` library's read thread, hidapi bindings, or
588
+ actual device firmware behave as expected. That layer is covered by
589
+ `deck-probe` against real hardware (see above), not by the emulator. Always
590
+ confirm a change against real hardware before considering it fully proven.
591
+
592
+ ### How this works
593
+
594
+ `muxplex_deck.main` depends only on the `DeckDevice` protocol
595
+ (`device.py`), never on the `streamdeck` library directly. `--emulator`
596
+ selects `emulator.EmulatorDeviceManager` instead of
597
+ `device_real.RealDeviceManager` as the one and only backend-selection point
598
+ (`main._build_manager`) -- the two backends are mutually exclusive imports,
599
+ so running with `--emulator` never imports (and thus never risks
600
+ constructing) anything hidapi-dependent.
601
+
602
+ The emulator's HTTP server runs for the whole sidecar process -- it *is*
603
+ the virtual USB bus. "Unplug" doesn't stop the server; it flips a flag so
604
+ the manager's `find_device()` returns nothing (exactly like a real
605
+ `DeviceManager.enumerate()` returning no devices), which drives the sidecar
606
+ into `DEVICE_ABSENT` with zero muxplex traffic, same as pulling a real
607
+ cable. "Plug" flips it back for a fresh `ACTIVE` bring-up.
608
+
609
+ ### Plane mode / fully offline development
610
+
611
+ To develop with **no network at all** (e.g. no Tailscale reachability),
612
+ run a local muxplex server on the same machine as the sidecar:
613
+
614
+ 1. **Before you lose connectivity**, install tmux and muxplex on the Mac.
615
+ muxplex isn't assumed to be on PyPI -- install it from source/git, e.g.:
616
+ ```sh
617
+ brew install tmux
618
+ git clone <muxplex-repo-url> ~/dev/muxplex
619
+ cd ~/dev/muxplex && uv sync
620
+ ```
621
+ 2. Start a local muxplex bound to localhost (no TLS needed for localhost):
622
+ ```sh
623
+ uv run muxplex --host 127.0.0.1 --port 8099
624
+ ```
625
+ 3. Point the sidecar's config at it. Localhost muxplex still expects the
626
+ `Authorization` header to be present and non-empty (check your muxplex
627
+ version's auth requirements), so `key_file` is still required by
628
+ `config.py` -- a dummy file is fine if your local instance doesn't
629
+ enforce a real key:
630
+ ```json
631
+ {"server_url": "http://127.0.0.1:8099", "key_file": "~/.config/muxplex-deck/dummy_key"}
632
+ ```
633
+ ```sh
634
+ mkdir -p ~/.config/muxplex-deck && echo "dummy" > ~/.config/muxplex-deck/dummy_key
635
+ ```
636
+ 4. Run the sidecar in emulator mode as above (`uv run muxplex-deck
637
+ --emulator`) and click "Plug in". You now have a complete, fully offline
638
+ dev loop: virtual deck -> sidecar -> local muxplex -> real tmux sessions
639
+ -- no hardware, no network, no remote server required.