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 +14 -0
- deck_probe/__main__.py +6 -0
- deck_probe/capabilities.py +130 -0
- deck_probe/events.py +211 -0
- deck_probe/main.py +220 -0
- deck_probe/rendering.py +166 -0
- muxplex_deck/__init__.py +5 -0
- muxplex_deck/__main__.py +11 -0
- muxplex_deck/attention.py +81 -0
- muxplex_deck/cli.py +1122 -0
- muxplex_deck/config.py +267 -0
- muxplex_deck/device.py +101 -0
- muxplex_deck/device_real.py +157 -0
- muxplex_deck/emulator.py +530 -0
- muxplex_deck/focus.py +92 -0
- muxplex_deck/init_wizard.py +382 -0
- muxplex_deck/interaction.py +418 -0
- muxplex_deck/layout.py +211 -0
- muxplex_deck/main.py +1093 -0
- muxplex_deck/rendering.py +440 -0
- muxplex_deck/service.py +551 -0
- muxplex_deck/statusfile.py +176 -0
- muxplex_deck/views.py +92 -0
- muxplex_deck-0.4.0.dist-info/METADATA +639 -0
- muxplex_deck-0.4.0.dist-info/RECORD +27 -0
- muxplex_deck-0.4.0.dist-info/WHEEL +4 -0
- muxplex_deck-0.4.0.dist-info/entry_points.txt +4 -0
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,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()
|