muxplex-deck 0.4.0__py3-none-any.whl

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.
deck_probe/__init__.py ADDED
@@ -0,0 +1,14 @@
1
+ """deck_probe: Elgato Stream Deck+ hardware probe.
2
+
3
+ Proves we can drive every feature of the Stream Deck+ (8 LCD keys, 4 dial
4
+ encoders, 800x100 touch strip) and capture every input, including clean
5
+ hotplug (unplug/replug) handling with no crash and no zombie threads.
6
+
7
+ This is the seed of the future muxplex sidecar's device I/O layer -- no
8
+ server/network integration lives in this package, device I/O only.
9
+ """
10
+
11
+ from .main import main
12
+
13
+ __all__ = ["main"]
14
+ __version__ = "0.2.0"
deck_probe/__main__.py ADDED
@@ -0,0 +1,6 @@
1
+ """Allows `python -m deck_probe` to run the probe."""
2
+
3
+ from .main import main
4
+
5
+ if __name__ == "__main__":
6
+ main()
@@ -0,0 +1,130 @@
1
+ """Capability discovery for any Stream Deck model.
2
+
3
+ The probe adapts to whatever deck is plugged in by querying the library's
4
+ capability methods (present on every model -- the base `StreamDeck` class
5
+ exposes each subclass's constants). The hard rule: **branch only on
6
+ numeric/boolean capability values, never on `deck_type()` strings** --
7
+ model names collide (Original vs MK2 are both 15-key/3x5) and new models
8
+ would need matrix updates. Capabilities self-describe.
9
+
10
+ `describe_capabilities` is pure: it takes any object exposing the
11
+ capability methods (real deck, fake, emulator) and returns a plain dict,
12
+ so the "which deck is this and what can it do" logic is unit-testable
13
+ without hardware.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ from typing import Any, Protocol
19
+
20
+
21
+ class DeckCapabilitySource(Protocol):
22
+ """The capability surface every `StreamDeck` subclass exposes.
23
+
24
+ Structural typing lets tests substitute fakes and keeps this module
25
+ free of hardware imports. `get_serial_number`/`get_firmware_version`
26
+ require an *open* device; everything else reads class constants.
27
+ """
28
+
29
+ def deck_type(self) -> str: ...
30
+ def get_serial_number(self) -> str: ...
31
+ def get_firmware_version(self) -> str: ...
32
+ def vendor_id(self) -> int: ...
33
+ def product_id(self) -> int: ...
34
+ def key_count(self) -> int: ...
35
+ def key_layout(self) -> tuple[int, int]: ...
36
+ def key_image_format(self) -> dict[str, Any]: ...
37
+ def dial_count(self) -> int: ...
38
+ def touch_key_count(self) -> int: ...
39
+ def is_touch(self) -> bool: ...
40
+ def is_visual(self) -> bool: ...
41
+ def touchscreen_image_format(self) -> dict[str, Any]: ...
42
+
43
+
44
+ def describe_capabilities(deck: DeckCapabilitySource) -> dict[str, Any]:
45
+ """Build a capability report dict for the given (open) deck.
46
+
47
+ Pure with respect to the deck object: only calls its capability
48
+ methods, performs no device I/O beyond the serial/firmware reads.
49
+ """
50
+ rows, cols = deck.key_layout()
51
+ is_visual = deck.is_visual()
52
+ key_format = deck.key_image_format() if is_visual else None
53
+ has_touchscreen = deck.is_touch()
54
+ touchscreen_size = (
55
+ tuple(deck.touchscreen_image_format()["size"]) if has_touchscreen else None
56
+ )
57
+ return {
58
+ "model": deck.deck_type(),
59
+ "serial": deck.get_serial_number(),
60
+ "firmware": deck.get_firmware_version(),
61
+ "vendor_id": deck.vendor_id(),
62
+ "product_id": deck.product_id(),
63
+ "key_count": deck.key_count(),
64
+ "key_rows": rows,
65
+ "key_cols": cols,
66
+ "key_image_size": tuple(key_format["size"]) if key_format else None,
67
+ "key_image_format": key_format["format"] if key_format else None,
68
+ "dial_count": deck.dial_count(),
69
+ "touch_key_count": deck.touch_key_count(),
70
+ "has_touchscreen": has_touchscreen,
71
+ "touchscreen_size": touchscreen_size,
72
+ "is_visual": is_visual,
73
+ }
74
+
75
+
76
+ def exercises_keys(caps: dict[str, Any]) -> bool:
77
+ """Should the probe paint and monitor LCD keys? (Pedal has none visual.)"""
78
+ return caps["key_count"] > 0 and caps["is_visual"]
79
+
80
+
81
+ def exercises_dials(caps: dict[str, Any]) -> bool:
82
+ """Should the probe attach dial handling? Only when dials exist."""
83
+ return caps["dial_count"] > 0
84
+
85
+
86
+ def exercises_touchscreen(caps: dict[str, Any]) -> bool:
87
+ """Should the probe paint/monitor the touch strip? Only when one exists."""
88
+ return caps["has_touchscreen"]
89
+
90
+
91
+ def exercises_touch_keys(caps: dict[str, Any]) -> bool:
92
+ """Does this model have discrete touch buttons (e.g. Neo's 2)?"""
93
+ return caps["touch_key_count"] > 0
94
+
95
+
96
+ def format_capability_report(caps: dict[str, Any]) -> str:
97
+ """Render the capability dict as a human-readable multi-line block."""
98
+ key_size = caps["key_image_size"]
99
+ key_size_text = f"{key_size[0]}x{key_size[1]}" if key_size else "none (not visual)"
100
+ key_format_text = caps["key_image_format"] or "-"
101
+ if caps["has_touchscreen"]:
102
+ ts_w, ts_h = caps["touchscreen_size"]
103
+ touchscreen_text = f"yes ({ts_w}x{ts_h})"
104
+ else:
105
+ touchscreen_text = "no"
106
+ lines = [
107
+ "deck capabilities:",
108
+ f" model: {caps['model']}",
109
+ f" serial: {caps['serial']}",
110
+ f" firmware: {caps['firmware']}",
111
+ f" usb id: {caps['vendor_id']:04x}:{caps['product_id']:04x}",
112
+ f" keys: {caps['key_count']} ({caps['key_rows']}x{caps['key_cols']})",
113
+ f" key image: {key_size_text} {key_format_text}",
114
+ f" dials: {caps['dial_count']}",
115
+ f" touch keys: {caps['touch_key_count']}",
116
+ f" touchscreen: {touchscreen_text}",
117
+ f" visual: {'yes' if caps['is_visual'] else 'no'}",
118
+ " probe will exercise: "
119
+ + ", ".join(
120
+ part
121
+ for part, active in (
122
+ ("keys", exercises_keys(caps)),
123
+ ("dials", exercises_dials(caps)),
124
+ ("touchscreen", exercises_touchscreen(caps)),
125
+ ("touch-keys", exercises_touch_keys(caps)),
126
+ )
127
+ if active
128
+ ),
129
+ ]
130
+ return "\n".join(lines)
deck_probe/events.py ADDED
@@ -0,0 +1,211 @@
1
+ """Device-event handling for the Stream Deck probe (any model).
2
+
3
+ Owns the small piece of mutable session state each control's callback needs
4
+ (dial counters, current brightness, last tap marker) and wires the
5
+ `streamdeck` library's key/dial/touchscreen callbacks to logging plus
6
+ `rendering.py` calls that reflect the new state back onto the device.
7
+
8
+ Everything is capability-gated: keys are always exercised (sized from the
9
+ deck's own `key_image_format()`), dials only when `dial_count() > 0`, the
10
+ touch strip only when `is_touch()`. Branching is on numeric/boolean
11
+ capabilities -- never on `deck_type()` strings.
12
+
13
+ No hotplug/lifecycle logic lives here -- that's main.py's job. This module
14
+ only cares about "device is open, here's what happened, here's how to show it."
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import logging
20
+ from dataclasses import dataclass, field
21
+
22
+ from StreamDeck.Devices.StreamDeck import (
23
+ DialEventType,
24
+ StreamDeck,
25
+ TouchscreenEventType,
26
+ )
27
+
28
+ from . import rendering
29
+
30
+ logger = logging.getLogger("deck_probe")
31
+
32
+ BRIGHTNESS_DIAL_INDEX = 0
33
+ DEFAULT_BRIGHTNESS_PERCENT = 75
34
+
35
+
36
+ @dataclass
37
+ class ProbeState:
38
+ """Mutable runtime state for one active (connected) probe session."""
39
+
40
+ brightness_percent: int = DEFAULT_BRIGHTNESS_PERCENT
41
+ dial_counters: list[int] = field(default_factory=list)
42
+ marker_x: int | None = None
43
+
44
+
45
+ def _paint_full_touchscreen(deck: StreamDeck, image: bytes) -> None:
46
+ """Paint the entire touch strip.
47
+
48
+ Only call on decks where `is_touch()` is True. The library requires
49
+ explicit region dimensions for a non-empty image (width/height default
50
+ to 0, which raises `IndexError: Invalid draw width 0.`), so full
51
+ repaints must pass the strip's full size.
52
+ """
53
+ width, height = deck.touchscreen_image_format()["size"]
54
+ deck.set_touchscreen_image(image, 0, 0, width, height)
55
+
56
+
57
+ def paint_initial_state(deck: StreamDeck) -> ProbeState:
58
+ """Paint keys (and the touch strip, when present) for a fresh connection.
59
+
60
+ Returns the fresh `ProbeState` that callbacks should mutate for the
61
+ lifetime of this connection. Adapts to the connected model: key count
62
+ and image size come from the device, and the touch strip is painted
63
+ only when the deck has one.
64
+ """
65
+ state = ProbeState(dial_counters=[0] * deck.dial_count())
66
+ with deck:
67
+ deck.set_brightness(state.brightness_percent)
68
+ for index in range(deck.key_count()):
69
+ deck.set_key_image(index, rendering.render_key_image(deck, index))
70
+ if deck.is_touch():
71
+ _paint_full_touchscreen(
72
+ deck,
73
+ rendering.render_touchscreen_full(
74
+ deck,
75
+ brightness_percent=state.brightness_percent,
76
+ counters=state.dial_counters,
77
+ ),
78
+ )
79
+ return state
80
+
81
+
82
+ def _zone_label_and_value(
83
+ state: ProbeState, dial_index: int, labels: tuple[str, ...]
84
+ ) -> tuple[str, str]:
85
+ if dial_index == BRIGHTNESS_DIAL_INDEX:
86
+ return labels[0], f"{state.brightness_percent}%"
87
+ return labels[dial_index], str(state.dial_counters[dial_index])
88
+
89
+
90
+ def _redraw_zone(deck: StreamDeck, state: ProbeState, dial_index: int) -> None:
91
+ labels = rendering.zone_labels(deck.dial_count())
92
+ label, value = _zone_label_and_value(state, dial_index, labels)
93
+ image, x, y, width, height = rendering.render_touchscreen_zone(
94
+ deck, dial_index, label, value
95
+ )
96
+ deck.set_touchscreen_image(image, x, y, width, height)
97
+
98
+
99
+ def make_key_callback():
100
+ """Build the key-press callback: log + invert colors while held."""
101
+
102
+ def on_key(deck: StreamDeck, key: int, pressed: bool) -> None:
103
+ logger.info("key[%d] %s", key, "PRESSED" if pressed else "released")
104
+ deck.set_key_image(
105
+ key, rendering.render_key_image(deck, key, highlighted=pressed)
106
+ )
107
+
108
+ return on_key
109
+
110
+
111
+ def make_dial_callback(state: ProbeState):
112
+ """Build the dial callback: dial 0 drives brightness, the rest are counters.
113
+
114
+ Zone repaints on the touch strip happen only when the deck has one
115
+ (`is_touch()`); dial-only decks still get full logging + brightness.
116
+ """
117
+
118
+ def on_dial(
119
+ deck: StreamDeck, dial: int, event_type: DialEventType, value: object
120
+ ) -> None:
121
+ if event_type == DialEventType.TURN:
122
+ amount = int(value) # type: ignore[arg-type]
123
+ direction = "clockwise" if amount > 0 else "counter-clockwise"
124
+ logger.info("dial[%d] TURN %+d (%s)", dial, amount, direction)
125
+ with deck:
126
+ if dial == BRIGHTNESS_DIAL_INDEX:
127
+ state.brightness_percent = max(
128
+ 0, min(100, state.brightness_percent + amount)
129
+ )
130
+ deck.set_brightness(state.brightness_percent)
131
+ else:
132
+ state.dial_counters[dial] += amount
133
+ if deck.is_touch():
134
+ _redraw_zone(deck, state, dial)
135
+
136
+ elif event_type == DialEventType.PUSH:
137
+ pressed = bool(value)
138
+ logger.info("dial[%d] %s", dial, "PRESSED" if pressed else "released")
139
+ if pressed:
140
+ with deck:
141
+ if dial == BRIGHTNESS_DIAL_INDEX:
142
+ state.brightness_percent = DEFAULT_BRIGHTNESS_PERCENT
143
+ deck.set_brightness(state.brightness_percent)
144
+ else:
145
+ state.dial_counters[dial] = 0
146
+ if deck.is_touch():
147
+ _redraw_zone(deck, state, dial)
148
+
149
+ return on_dial
150
+
151
+
152
+ def make_touchscreen_callback(state: ProbeState):
153
+ """Build the touchscreen callback: tap draws a marker, drag/long just log."""
154
+
155
+ def on_touch(
156
+ deck: StreamDeck, event_type: TouchscreenEventType, value: dict
157
+ ) -> None:
158
+ if event_type == TouchscreenEventType.SHORT:
159
+ x, y = value["x"], value["y"]
160
+ logger.info("touch SHORT tap at (%d, %d)", x, y)
161
+ state.marker_x = x
162
+ with deck:
163
+ _paint_full_touchscreen(
164
+ deck,
165
+ rendering.render_touchscreen_full(
166
+ deck,
167
+ brightness_percent=state.brightness_percent,
168
+ counters=state.dial_counters,
169
+ marker_x=state.marker_x,
170
+ ),
171
+ )
172
+
173
+ elif event_type == TouchscreenEventType.LONG:
174
+ logger.info("touch LONG press at (%d, %d)", value["x"], value["y"])
175
+
176
+ elif event_type == TouchscreenEventType.DRAG:
177
+ logger.info(
178
+ "touch DRAG from (%d, %d) to (%d, %d)",
179
+ value["x"],
180
+ value["y"],
181
+ value["x_out"],
182
+ value["y_out"],
183
+ )
184
+
185
+ return on_touch
186
+
187
+
188
+ def attach_callbacks(deck: StreamDeck, state: ProbeState) -> None:
189
+ """Register callbacks for every control the connected deck actually has.
190
+
191
+ Capability-gated: keys always, dials only if the deck has dials, the
192
+ touchscreen only if the deck has one. Absent controls are logged so the
193
+ user knows the skip is deliberate, not a failure.
194
+ """
195
+ deck.set_key_callback(make_key_callback())
196
+
197
+ if deck.dial_count() > 0:
198
+ deck.set_dial_callback(make_dial_callback(state))
199
+ else:
200
+ logger.info("no dials on this model -- skipping dial exercise")
201
+
202
+ if deck.is_touch():
203
+ deck.set_touchscreen_callback(make_touchscreen_callback(state))
204
+ else:
205
+ logger.info("no touchscreen on this model -- skipping touch exercise")
206
+
207
+ if deck.touch_key_count() > 0:
208
+ logger.info(
209
+ "model reports %d touch key(s) -- these surface as regular key events",
210
+ deck.touch_key_count(),
211
+ )
deck_probe/main.py ADDED
@@ -0,0 +1,220 @@
1
+ """Stream Deck hardware probe (any model): entry point and hotplug state machine.
2
+
3
+ The probe is capability-driven: it reports what the connected deck can do
4
+ (model, keys, layout, dials, touchscreen, touch keys) and exercises only
5
+ the controls that exist -- one probe for the 15-key Original/MK2, the
6
+ Plus, the XL, the Mini, the Neo, and whatever ships next. Branching is on
7
+ numeric/boolean capabilities (`key_count()`, `dial_count()`, `is_touch()`),
8
+ never on `deck_type()` model-name strings.
9
+
10
+ This state machine (DEVICE_ABSENT <-> DEVICE_ACTIVE) is the seed of the
11
+ future muxplex sidecar's core loop, so it's kept clean and self-contained:
12
+
13
+ - DEVICE_ABSENT: no device found. Re-enumerate every `POLL_INTERVAL_SECONDS`,
14
+ logging once (not spamming every poll), with a periodic heartbeat.
15
+ - DEVICE_ACTIVE: device is open and its callbacks are attached. We poll the
16
+ device's own health (`is_open()` / `connected()`) to detect unplug, since
17
+ the underlying reader thread may or may not surface a read error the
18
+ instant the cable is pulled.
19
+ - Ctrl+C / SIGTERM: clean shutdown from either state -- reset/blank the
20
+ device if one is connected, close the handle, exit 0.
21
+
22
+ No server/network integration lives here -- device I/O only.
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ import logging
28
+ import signal
29
+ import sys
30
+ import threading
31
+ import time
32
+
33
+ from StreamDeck.DeviceManager import DeviceManager, ProbeError
34
+ from StreamDeck.Devices.StreamDeck import StreamDeck
35
+ from StreamDeck.Transport.Transport import TransportError
36
+
37
+ from . import capabilities, events
38
+
39
+ logger = logging.getLogger("deck_probe")
40
+
41
+ POLL_INTERVAL_SECONDS = 2.0
42
+ ABSENT_HEARTBEAT_SECONDS = 30.0
43
+ ACTIVE_HEALTH_CHECK_SECONDS = 2.0
44
+
45
+
46
+ def _configure_logging() -> None:
47
+ logging.basicConfig(
48
+ level=logging.INFO,
49
+ format="%(asctime)s.%(msecs)03d %(levelname)s %(message)s",
50
+ datefmt="%H:%M:%S",
51
+ )
52
+
53
+
54
+ def _explain_missing_hidapi() -> str:
55
+ return (
56
+ "Could not find the native HIDAPI library required to talk to the Stream Deck.\n"
57
+ "Install it for your platform, then try again:\n"
58
+ " macOS: brew install hidapi\n"
59
+ " Debian/Ubuntu: sudo apt install libhidapi-libusb0\n"
60
+ " Windows: bundled with the 'streamdeck' wheel; if missing, install\n"
61
+ " hidapi via your package manager of choice.\n"
62
+ )
63
+
64
+
65
+ def _no_device_guidance() -> str:
66
+ return (
67
+ "No Stream Deck found. Things to check:\n"
68
+ " - All platforms: close the official Elgato Stream Deck app -- it holds\n"
69
+ " exclusive HID access, so this probe cannot open the device while it runs.\n"
70
+ " - Linux (incl. WSL): a udev rule must grant access to Elgato devices\n"
71
+ " (vendor id 0x0fd9). Create /etc/udev/rules.d/70-streamdeck.rules with:\n"
72
+ ' SUBSYSTEM=="hidraw", ATTRS{idVendor}=="0fd9", MODE="0666"\n'
73
+ " then: sudo udevadm control --reload-rules && replug the device.\n"
74
+ " - WSL specifically: USB devices are not visible until attached from\n"
75
+ " Windows via usbipd -- in an admin PowerShell:\n"
76
+ " usbipd list (find the Stream Deck's BUSID)\n"
77
+ " usbipd bind --busid <BUSID> (first time only)\n"
78
+ " usbipd attach --wsl --busid <BUSID>\n"
79
+ " (and remember the Elgato app on the Windows side must be closed).\n"
80
+ )
81
+
82
+
83
+ def _find_deck(manager: DeviceManager) -> StreamDeck | None:
84
+ decks = manager.enumerate()
85
+ return decks[0] if decks else None
86
+
87
+
88
+ def _log_device_info(deck: StreamDeck) -> None:
89
+ """Print the capability report: which deck this is and what it can do."""
90
+ for line in capabilities.format_capability_report(
91
+ capabilities.describe_capabilities(deck)
92
+ ).splitlines():
93
+ logger.info("%s", line)
94
+
95
+
96
+ def _safe_close(deck: StreamDeck) -> None:
97
+ """Reset and close the device, swallowing (but logging) any I/O errors.
98
+
99
+ Used both for clean Ctrl+C shutdown and for recovering after an unplug
100
+ or unexpected error -- either way we must not leave a half-open handle
101
+ or a zombie reader thread behind.
102
+ """
103
+ try:
104
+ if deck.is_open():
105
+ deck.reset()
106
+ except TransportError:
107
+ pass
108
+ except Exception:
109
+ logger.exception("Unexpected error while resetting deck during close")
110
+
111
+ try:
112
+ deck.close()
113
+ except Exception:
114
+ logger.exception("Unexpected error while closing deck")
115
+
116
+
117
+ def _run_active(deck: StreamDeck, shutting_down: threading.Event) -> None:
118
+ """Run one connected-device session until it disconnects or shutdown is requested."""
119
+ _log_device_info(deck)
120
+ state = events.paint_initial_state(deck)
121
+ events.attach_callbacks(deck, state)
122
+ logger.info("%s active -- waiting for input (Ctrl+C to exit)", deck.deck_type())
123
+
124
+ try:
125
+ while True:
126
+ if shutting_down.wait(ACTIVE_HEALTH_CHECK_SECONDS):
127
+ return
128
+ if not deck.is_open() or not deck.connected():
129
+ logger.warning("Stream Deck disconnected")
130
+ return
131
+ finally:
132
+ _safe_close(deck)
133
+
134
+
135
+ def _install_signal_handler() -> threading.Event:
136
+ shutting_down = threading.Event()
137
+
138
+ def handler(signum: int, frame: object) -> None:
139
+ shutting_down.set()
140
+
141
+ signal.signal(signal.SIGINT, handler)
142
+ signal.signal(signal.SIGTERM, handler)
143
+ return shutting_down
144
+
145
+
146
+ def run() -> int:
147
+ _configure_logging()
148
+
149
+ try:
150
+ manager = DeviceManager()
151
+ except ProbeError:
152
+ print(_explain_missing_hidapi(), file=sys.stderr)
153
+ return 1
154
+
155
+ shutting_down = _install_signal_handler()
156
+ logged_waiting = False
157
+ guidance_shown = False
158
+ last_heartbeat = 0.0
159
+
160
+ logger.info("deck-probe starting")
161
+ try:
162
+ while not shutting_down.is_set():
163
+ try:
164
+ deck = _find_deck(manager)
165
+ except Exception:
166
+ logger.exception(
167
+ "Unexpected error while enumerating Stream Deck devices; will retry"
168
+ )
169
+ shutting_down.wait(POLL_INTERVAL_SECONDS)
170
+ continue
171
+
172
+ if deck is None:
173
+ now = time.monotonic()
174
+ if not logged_waiting:
175
+ if not guidance_shown:
176
+ for line in _no_device_guidance().splitlines():
177
+ logger.info("%s", line)
178
+ guidance_shown = True
179
+ logger.info(
180
+ "waiting for a Stream Deck (polling every %.0fs)...",
181
+ POLL_INTERVAL_SECONDS,
182
+ )
183
+ logged_waiting = True
184
+ last_heartbeat = now
185
+ elif now - last_heartbeat >= ABSENT_HEARTBEAT_SECONDS:
186
+ logger.info("still waiting for a Stream Deck...")
187
+ last_heartbeat = now
188
+ shutting_down.wait(POLL_INTERVAL_SECONDS)
189
+ continue
190
+
191
+ logged_waiting = False
192
+ try:
193
+ deck.open()
194
+ except Exception:
195
+ logger.exception("Failed to open Stream Deck device; will retry")
196
+ shutting_down.wait(POLL_INTERVAL_SECONDS)
197
+ continue
198
+
199
+ try:
200
+ _run_active(deck, shutting_down)
201
+ except Exception:
202
+ logger.exception("Unexpected error during active session; recovering")
203
+ _safe_close(deck)
204
+ # Back off before re-enumerating: without this, a deterministic
205
+ # error during session setup (e.g. a bad paint call) spins the
206
+ # connect/fail/reset cycle at full speed -- flooding the log and
207
+ # strobing the device.
208
+ shutting_down.wait(POLL_INTERVAL_SECONDS)
209
+ finally:
210
+ logger.info("deck-probe shutting down")
211
+
212
+ return 0
213
+
214
+
215
+ def main() -> None:
216
+ sys.exit(run())
217
+
218
+
219
+ if __name__ == "__main__":
220
+ main()