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
muxplex_deck/config.py
ADDED
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
"""Configuration loading for the muxplex-deck sidecar.
|
|
2
|
+
|
|
3
|
+
Config is a JSON file (default ``~/.config/muxplex-deck/config.json``,
|
|
4
|
+
overridable via the ``--config`` CLI flag or the ``MUXPLEX_DECK_CONFIG``
|
|
5
|
+
env var) plus a federation-key file referenced from it. All paths support
|
|
6
|
+
``~``, expanded relative to the *invoking* user's home: the sidecar is
|
|
7
|
+
typically launched as ``sudo muxplex-deck`` (HID access), and under sudo a
|
|
8
|
+
plain ``expanduser()`` would resolve to ``/root`` -- so ``~`` honors
|
|
9
|
+
``SUDO_USER`` when present (see `_expand`).
|
|
10
|
+
|
|
11
|
+
Any missing/invalid config or unreadable key file is a fail-loud, actionable
|
|
12
|
+
error -- there is no default that silently skips auth or TLS verification.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import json
|
|
18
|
+
import os
|
|
19
|
+
from dataclasses import dataclass
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
|
|
22
|
+
try:
|
|
23
|
+
import pwd
|
|
24
|
+
except ImportError: # non-POSIX (Windows) -- no sudo there, plain expanduser is fine
|
|
25
|
+
pwd = None # type: ignore[assignment]
|
|
26
|
+
|
|
27
|
+
DEFAULT_CONFIG_PATH = "~/.config/muxplex-deck/config.json"
|
|
28
|
+
DEFAULT_KEY_FILE = "~/.config/muxplex-deck/federation_key"
|
|
29
|
+
DEFAULT_POLL_INTERVAL_SECONDS = 2.0
|
|
30
|
+
DEFAULT_SORT_MODE = "attention"
|
|
31
|
+
VALID_SORT_MODES = ("attention", "server")
|
|
32
|
+
|
|
33
|
+
# The full set of keys `config.json` supports, with their default values --
|
|
34
|
+
# drives the `muxplex-deck config` CLI group (list/get/set/reset) the same
|
|
35
|
+
# way muxplex's own DEFAULT_SETTINGS drives its `config` group. Unlike
|
|
36
|
+
# muxplex's server settings, `server_url` has no real default (it's a
|
|
37
|
+
# required field per `load_config`) -- "" here means "not configured".
|
|
38
|
+
DEFAULT_CONFIG: dict = {
|
|
39
|
+
"server_url": "",
|
|
40
|
+
"key_file": DEFAULT_KEY_FILE,
|
|
41
|
+
"ca_file": "",
|
|
42
|
+
"poll_interval": DEFAULT_POLL_INTERVAL_SECONDS,
|
|
43
|
+
"sort": DEFAULT_SORT_MODE,
|
|
44
|
+
"focus_app": "",
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class ConfigError(Exception):
|
|
49
|
+
"""Raised for any config problem. The message is written to stderr as-is."""
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _invoking_user_home() -> Path:
|
|
53
|
+
"""Home directory of the user who launched the process, even under sudo.
|
|
54
|
+
|
|
55
|
+
Under ``sudo``, ``$HOME``/``Path.home()`` resolve to ``/root`` -- but the
|
|
56
|
+
config and key files live in the *invoking* user's home. When ``SUDO_USER``
|
|
57
|
+
names a non-root user, resolve that user's home via the passwd database;
|
|
58
|
+
otherwise fall back to the normal home.
|
|
59
|
+
"""
|
|
60
|
+
sudo_user = os.environ.get("SUDO_USER")
|
|
61
|
+
if pwd is not None and sudo_user and sudo_user != "root":
|
|
62
|
+
try:
|
|
63
|
+
return Path(pwd.getpwnam(sudo_user).pw_dir)
|
|
64
|
+
except KeyError:
|
|
65
|
+
pass # unknown user -- fall through to the normal home
|
|
66
|
+
return Path.home()
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _expand(path: str | Path) -> Path:
|
|
70
|
+
"""Expand ``~`` relative to the invoking user's home (sudo-aware).
|
|
71
|
+
|
|
72
|
+
Non-tilde paths pass through unchanged (modulo ``expanduser`` for the
|
|
73
|
+
``~otheruser`` form, which keeps its standard behavior).
|
|
74
|
+
"""
|
|
75
|
+
text = str(path)
|
|
76
|
+
if text == "~":
|
|
77
|
+
return _invoking_user_home()
|
|
78
|
+
if text.startswith(("~/", "~\\")):
|
|
79
|
+
return _invoking_user_home() / text[2:]
|
|
80
|
+
return Path(text).expanduser()
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
@dataclass(frozen=True)
|
|
84
|
+
class Config:
|
|
85
|
+
"""Validated sidecar configuration, ready to hand to `MuxplexClient`."""
|
|
86
|
+
|
|
87
|
+
server_url: str
|
|
88
|
+
federation_key: str
|
|
89
|
+
ca_file: Path | None
|
|
90
|
+
poll_interval: float
|
|
91
|
+
sort: str
|
|
92
|
+
""""attention" (default): needs-attention sessions first, then the active
|
|
93
|
+
session, then everything else by recent activity. "server": exactly the
|
|
94
|
+
pre-existing behavior -- honor muxplex's own `sort_order` (alphabetical
|
|
95
|
+
vs server/manual order) with no client-side reordering. See `.attention`
|
|
96
|
+
for the "attention" mode's tie-break rules.
|
|
97
|
+
"""
|
|
98
|
+
focus_app: str
|
|
99
|
+
"""macOS application name of the locally installed muxplex PWA to bring
|
|
100
|
+
to the foreground when a key press switches the active session (see
|
|
101
|
+
`.focus`). Empty (the default) disables the feature entirely -- no
|
|
102
|
+
subprocess calls, no log noise. macOS-only today; on other platforms a
|
|
103
|
+
configured value logs one INFO notice and is otherwise ignored.
|
|
104
|
+
"""
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _resolve_config_path(explicit: str | None) -> Path:
|
|
108
|
+
if explicit:
|
|
109
|
+
return _expand(explicit)
|
|
110
|
+
env_value = os.environ.get("MUXPLEX_DECK_CONFIG")
|
|
111
|
+
if env_value:
|
|
112
|
+
return _expand(env_value)
|
|
113
|
+
return _expand(DEFAULT_CONFIG_PATH)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _load_federation_key(key_file: Path) -> str:
|
|
117
|
+
if not key_file.exists():
|
|
118
|
+
raise ConfigError(
|
|
119
|
+
f"Federation key file not found: {key_file}\n"
|
|
120
|
+
"Copy it from the muxplex server, e.g.:\n"
|
|
121
|
+
f" mkdir -p {key_file.parent}\n"
|
|
122
|
+
f" scp <your-server>:.config/muxplex/federation_key {key_file}\n"
|
|
123
|
+
f" chmod 600 {key_file}"
|
|
124
|
+
)
|
|
125
|
+
try:
|
|
126
|
+
key = key_file.read_text(encoding="utf-8").strip()
|
|
127
|
+
except OSError as exc:
|
|
128
|
+
raise ConfigError(
|
|
129
|
+
f"Could not read federation key file {key_file}: {exc}"
|
|
130
|
+
) from exc
|
|
131
|
+
if not key:
|
|
132
|
+
raise ConfigError(f"Federation key file {key_file} is empty")
|
|
133
|
+
return key
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def load_config(config_path: str | None = None) -> Config:
|
|
137
|
+
"""Load and validate configuration.
|
|
138
|
+
|
|
139
|
+
Raises `ConfigError` (with a message ready to print to stderr) for any
|
|
140
|
+
problem: missing/invalid config file, missing required fields, missing
|
|
141
|
+
or unreadable key file, or a `ca_file` that doesn't exist.
|
|
142
|
+
"""
|
|
143
|
+
path = _resolve_config_path(config_path)
|
|
144
|
+
|
|
145
|
+
if not path.exists():
|
|
146
|
+
raise ConfigError(
|
|
147
|
+
f"Config file not found: {path}\n"
|
|
148
|
+
"Create it with at least a 'server_url' field, e.g.:\n"
|
|
149
|
+
' {"server_url": "https://<your-server>:8088"}\n'
|
|
150
|
+
"See README.md for the full example."
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
try:
|
|
154
|
+
raw = json.loads(path.read_text(encoding="utf-8"))
|
|
155
|
+
except json.JSONDecodeError as exc:
|
|
156
|
+
raise ConfigError(f"Config file {path} is not valid JSON: {exc}") from exc
|
|
157
|
+
|
|
158
|
+
if not isinstance(raw, dict):
|
|
159
|
+
raise ConfigError(
|
|
160
|
+
f"Config file {path} must contain a JSON object, got {type(raw).__name__}"
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
server_url = raw.get("server_url")
|
|
164
|
+
if not server_url or not isinstance(server_url, str):
|
|
165
|
+
raise ConfigError(
|
|
166
|
+
f"Config file {path} is missing required field 'server_url' (string)"
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
key_file = _expand(raw.get("key_file", DEFAULT_KEY_FILE))
|
|
170
|
+
federation_key = _load_federation_key(key_file)
|
|
171
|
+
|
|
172
|
+
ca_file_value = raw.get("ca_file")
|
|
173
|
+
ca_file = _expand(ca_file_value) if ca_file_value else None
|
|
174
|
+
if ca_file is not None and not ca_file.exists():
|
|
175
|
+
raise ConfigError(f"Config field 'ca_file' does not exist: {ca_file}")
|
|
176
|
+
|
|
177
|
+
poll_interval = raw.get("poll_interval", DEFAULT_POLL_INTERVAL_SECONDS)
|
|
178
|
+
if (
|
|
179
|
+
not isinstance(poll_interval, int | float)
|
|
180
|
+
or isinstance(poll_interval, bool)
|
|
181
|
+
or poll_interval <= 0
|
|
182
|
+
):
|
|
183
|
+
raise ConfigError(
|
|
184
|
+
f"Config field 'poll_interval' must be a positive number, got {poll_interval!r}"
|
|
185
|
+
)
|
|
186
|
+
|
|
187
|
+
sort = raw.get("sort", DEFAULT_SORT_MODE)
|
|
188
|
+
if sort not in VALID_SORT_MODES:
|
|
189
|
+
raise ConfigError(
|
|
190
|
+
f"Config field 'sort' must be one of {VALID_SORT_MODES}, got {sort!r}"
|
|
191
|
+
)
|
|
192
|
+
|
|
193
|
+
focus_app = raw.get("focus_app", "")
|
|
194
|
+
if not isinstance(focus_app, str):
|
|
195
|
+
raise ConfigError(
|
|
196
|
+
f"Config field 'focus_app' must be a string (macOS app name), "
|
|
197
|
+
f"got {focus_app!r}"
|
|
198
|
+
)
|
|
199
|
+
|
|
200
|
+
return Config(
|
|
201
|
+
server_url=server_url.rstrip("/"),
|
|
202
|
+
federation_key=federation_key,
|
|
203
|
+
ca_file=ca_file,
|
|
204
|
+
poll_interval=float(poll_interval),
|
|
205
|
+
sort=sort,
|
|
206
|
+
focus_app=focus_app.strip(),
|
|
207
|
+
)
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
# ---------------------------------------------------------------------------
|
|
211
|
+
# Raw config file access -- drives `muxplex-deck config` (list/get/set/reset)
|
|
212
|
+
# ---------------------------------------------------------------------------
|
|
213
|
+
#
|
|
214
|
+
# These operate on the *unvalidated* JSON dict, unlike `load_config()` above
|
|
215
|
+
# (which returns a fully-validated `Config`, fails loud on any problem, and
|
|
216
|
+
# is what `run()` actually uses). The CLI's config commands intentionally
|
|
217
|
+
# tolerate a missing/partial file -- `config list` on a fresh install should
|
|
218
|
+
# show defaults, not crash -- mirroring muxplex's own
|
|
219
|
+
# load_settings/save_settings/patch_settings pattern (defaults-merge-overlay,
|
|
220
|
+
# known-keys-only filtering).
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def load_raw_config(config_path: str | None = None) -> dict:
|
|
224
|
+
"""Load the config file, merging saved values over `DEFAULT_CONFIG`.
|
|
225
|
+
|
|
226
|
+
Returns `DEFAULT_CONFIG` (a copy) if the file does not exist or contains
|
|
227
|
+
corrupt JSON. Unknown keys in the file are ignored.
|
|
228
|
+
"""
|
|
229
|
+
import copy
|
|
230
|
+
|
|
231
|
+
result = copy.deepcopy(DEFAULT_CONFIG)
|
|
232
|
+
path = _resolve_config_path(config_path)
|
|
233
|
+
try:
|
|
234
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
235
|
+
if isinstance(data, dict):
|
|
236
|
+
for key in DEFAULT_CONFIG:
|
|
237
|
+
if key in data:
|
|
238
|
+
result[key] = data[key]
|
|
239
|
+
except (FileNotFoundError, json.JSONDecodeError):
|
|
240
|
+
pass
|
|
241
|
+
return result
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def save_raw_config(data: dict, config_path: str | None = None) -> None:
|
|
245
|
+
"""Save `data` to the config file, merging with `DEFAULT_CONFIG` first.
|
|
246
|
+
|
|
247
|
+
Only known keys are written; creates parent directories as needed.
|
|
248
|
+
"""
|
|
249
|
+
import copy
|
|
250
|
+
|
|
251
|
+
merged = copy.deepcopy(DEFAULT_CONFIG)
|
|
252
|
+
for key in DEFAULT_CONFIG:
|
|
253
|
+
if key in data:
|
|
254
|
+
merged[key] = data[key]
|
|
255
|
+
path = _resolve_config_path(config_path)
|
|
256
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
257
|
+
path.write_text(json.dumps(merged, indent=2) + "\n", encoding="utf-8")
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def patch_raw_config(patch: dict, config_path: str | None = None) -> dict:
|
|
261
|
+
"""Merge known keys from `patch` into the current config, save, and return the result."""
|
|
262
|
+
current = load_raw_config(config_path)
|
|
263
|
+
for key in DEFAULT_CONFIG:
|
|
264
|
+
if key in patch:
|
|
265
|
+
current[key] = patch[key]
|
|
266
|
+
save_raw_config(current, config_path)
|
|
267
|
+
return current
|
muxplex_deck/device.py
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
"""Device seam: the `DeckDevice` protocol the sidecar depends on.
|
|
2
|
+
|
|
3
|
+
`main.py` talks to a Stream Deck+ only through this protocol -- it never
|
|
4
|
+
imports `StreamDeck.DeviceManager` or constructs a device itself. Two
|
|
5
|
+
backends implement it:
|
|
6
|
+
|
|
7
|
+
- `device_real.py` -- the actual hardware, via the `streamdeck` library.
|
|
8
|
+
- `emulator.py` -- an in-process virtual deck driven by a localhost
|
|
9
|
+
HTTP UI, for development/testing with no hardware.
|
|
10
|
+
|
|
11
|
+
Importing this module -- including the `DialEventType` / `TouchscreenEventType`
|
|
12
|
+
enums re-exported below -- never touches hidapi. Only *constructing* a real
|
|
13
|
+
backend's manager does that (see `device_real.RealDeviceManager`).
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
from collections.abc import Callable
|
|
19
|
+
from typing import Protocol, runtime_checkable
|
|
20
|
+
|
|
21
|
+
from StreamDeck.Devices.StreamDeck import DialEventType, TouchscreenEventType
|
|
22
|
+
|
|
23
|
+
__all__ = [
|
|
24
|
+
"DeckDevice",
|
|
25
|
+
"DeviceManager",
|
|
26
|
+
"DeviceProbeError",
|
|
27
|
+
"DialCallback",
|
|
28
|
+
"DialEventType",
|
|
29
|
+
"KeyCallback",
|
|
30
|
+
"TouchCallback",
|
|
31
|
+
"TouchscreenEventType",
|
|
32
|
+
]
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class DeviceProbeError(Exception):
|
|
36
|
+
"""Raised when a backend cannot be constructed (e.g. hidapi missing).
|
|
37
|
+
|
|
38
|
+
The message is pre-formatted, ready to print to stderr as-is.
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
# Callback shapes match the `streamdeck` library's own callback signatures
|
|
43
|
+
# exactly (see StreamDeck.Devices.StreamDeck.KeyCallback / DialCallback /
|
|
44
|
+
# TouchScreenCallback), so both backends can be handed to the same code.
|
|
45
|
+
KeyCallback = Callable[["DeckDevice", int, bool], None]
|
|
46
|
+
DialCallback = Callable[["DeckDevice", int, DialEventType, object], None]
|
|
47
|
+
TouchCallback = Callable[["DeckDevice", TouchscreenEventType, dict], None]
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@runtime_checkable
|
|
51
|
+
class DeckDevice(Protocol):
|
|
52
|
+
"""Exactly the Stream Deck+ surface `muxplex_deck.main` uses.
|
|
53
|
+
|
|
54
|
+
Both `device_real.RealDeckDevice` (wrapping the physical device) and
|
|
55
|
+
`emulator.EmulatorDevice` (the virtual one) implement this in full.
|
|
56
|
+
"""
|
|
57
|
+
|
|
58
|
+
def open(self) -> None: ...
|
|
59
|
+
def close(self) -> None: ...
|
|
60
|
+
def reset(self) -> None: ...
|
|
61
|
+
def is_open(self) -> bool: ...
|
|
62
|
+
def connected(self) -> bool: ...
|
|
63
|
+
|
|
64
|
+
def key_count(self) -> int: ...
|
|
65
|
+
def key_layout(self) -> tuple[int, int]: ...
|
|
66
|
+
def dial_count(self) -> int: ...
|
|
67
|
+
def is_touch(self) -> bool: ...
|
|
68
|
+
def touch_key_count(self) -> int: ...
|
|
69
|
+
def is_visual(self) -> bool: ...
|
|
70
|
+
def vendor_id(self) -> int: ...
|
|
71
|
+
def product_id(self) -> int: ...
|
|
72
|
+
def deck_type(self) -> str: ...
|
|
73
|
+
def get_serial_number(self) -> str: ...
|
|
74
|
+
def get_firmware_version(self) -> str: ...
|
|
75
|
+
|
|
76
|
+
def key_image_format(self) -> dict: ...
|
|
77
|
+
def touchscreen_image_format(self) -> dict: ...
|
|
78
|
+
def set_brightness(self, percent: float) -> None: ...
|
|
79
|
+
def set_key_image(self, key: int, image: bytes) -> None: ...
|
|
80
|
+
def set_touchscreen_image(
|
|
81
|
+
self,
|
|
82
|
+
image: bytes,
|
|
83
|
+
x_pos: int = 0,
|
|
84
|
+
y_pos: int = 0,
|
|
85
|
+
width: int = 0,
|
|
86
|
+
height: int = 0,
|
|
87
|
+
) -> None: ...
|
|
88
|
+
|
|
89
|
+
def set_key_callback(self, callback: KeyCallback | None) -> None: ...
|
|
90
|
+
def set_dial_callback(self, callback: DialCallback | None) -> None: ...
|
|
91
|
+
def set_touchscreen_callback(self, callback: TouchCallback | None) -> None: ...
|
|
92
|
+
|
|
93
|
+
def __enter__(self) -> None: ...
|
|
94
|
+
def __exit__(self, exc_type: object, exc_val: object, exc_tb: object) -> None: ...
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
@runtime_checkable
|
|
98
|
+
class DeviceManager(Protocol):
|
|
99
|
+
"""Backend-level 'find a device' seam -- the virtual or real USB bus."""
|
|
100
|
+
|
|
101
|
+
def find_device(self) -> DeckDevice | None: ...
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
"""Real Stream Deck+ backend: thin wrapper over the `streamdeck` library.
|
|
2
|
+
|
|
3
|
+
All hidapi-touching imports (`StreamDeck.DeviceManager`, `TransportError`)
|
|
4
|
+
are isolated in this module. `main.py` never imports them and never
|
|
5
|
+
constructs a `DeviceManager` itself -- that only happens here, behind
|
|
6
|
+
`RealDeviceManager`, so the emulator code path can run with hidapi absent.
|
|
7
|
+
|
|
8
|
+
`RealDeckDevice` wraps the library's `StreamDeck` instance and delegates
|
|
9
|
+
every call straight through, except `reset()`/`close()`, which swallow
|
|
10
|
+
`TransportError` -- the expected error when the cable is pulled mid-call --
|
|
11
|
+
exactly as `main.py`'s `_safe_close` did before this refactor. Everything
|
|
12
|
+
else (behavior, timing, callback shapes) is unchanged.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
from StreamDeck.DeviceManager import DeviceManager as _LibDeviceManager
|
|
18
|
+
from StreamDeck.DeviceManager import ProbeError as _LibProbeError
|
|
19
|
+
from StreamDeck.Devices.StreamDeck import StreamDeck as _LibStreamDeck
|
|
20
|
+
from StreamDeck.Transport.Transport import TransportError
|
|
21
|
+
|
|
22
|
+
from .device import (
|
|
23
|
+
DeckDevice,
|
|
24
|
+
DeviceProbeError,
|
|
25
|
+
DialCallback,
|
|
26
|
+
KeyCallback,
|
|
27
|
+
TouchCallback,
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
__all__ = ["RealDeckDevice", "RealDeviceManager"]
|
|
31
|
+
|
|
32
|
+
_MISSING_HIDAPI_MESSAGE = (
|
|
33
|
+
"Could not find the native HIDAPI library required to talk to the Stream Deck.\n"
|
|
34
|
+
"Install it for your platform, then try again:\n"
|
|
35
|
+
" macOS: brew install hidapi\n"
|
|
36
|
+
" Debian/Ubuntu: sudo apt install libhidapi-libusb0\n"
|
|
37
|
+
" Windows: bundled with the 'streamdeck' wheel; if missing, install\n"
|
|
38
|
+
" hidapi via your package manager of choice.\n"
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class RealDeckDevice:
|
|
43
|
+
"""Wraps a real `StreamDeck` instance to satisfy the `DeckDevice` protocol."""
|
|
44
|
+
|
|
45
|
+
def __init__(self, deck: _LibStreamDeck) -> None:
|
|
46
|
+
self._deck = deck
|
|
47
|
+
|
|
48
|
+
def open(self) -> None:
|
|
49
|
+
self._deck.open()
|
|
50
|
+
|
|
51
|
+
def close(self) -> None:
|
|
52
|
+
try:
|
|
53
|
+
self._deck.close()
|
|
54
|
+
except TransportError:
|
|
55
|
+
pass
|
|
56
|
+
|
|
57
|
+
def reset(self) -> None:
|
|
58
|
+
try:
|
|
59
|
+
if self._deck.is_open():
|
|
60
|
+
self._deck.reset()
|
|
61
|
+
except TransportError:
|
|
62
|
+
pass
|
|
63
|
+
|
|
64
|
+
def is_open(self) -> bool:
|
|
65
|
+
return self._deck.is_open()
|
|
66
|
+
|
|
67
|
+
def connected(self) -> bool:
|
|
68
|
+
return self._deck.connected()
|
|
69
|
+
|
|
70
|
+
def key_count(self) -> int:
|
|
71
|
+
return self._deck.key_count()
|
|
72
|
+
|
|
73
|
+
def key_layout(self) -> tuple[int, int]:
|
|
74
|
+
return self._deck.key_layout()
|
|
75
|
+
|
|
76
|
+
def dial_count(self) -> int:
|
|
77
|
+
return self._deck.dial_count()
|
|
78
|
+
|
|
79
|
+
def is_touch(self) -> bool:
|
|
80
|
+
return self._deck.is_touch()
|
|
81
|
+
|
|
82
|
+
def touch_key_count(self) -> int:
|
|
83
|
+
return self._deck.touch_key_count()
|
|
84
|
+
|
|
85
|
+
def is_visual(self) -> bool:
|
|
86
|
+
return self._deck.is_visual()
|
|
87
|
+
|
|
88
|
+
def vendor_id(self) -> int:
|
|
89
|
+
return self._deck.vendor_id()
|
|
90
|
+
|
|
91
|
+
def product_id(self) -> int:
|
|
92
|
+
return self._deck.product_id()
|
|
93
|
+
|
|
94
|
+
def deck_type(self) -> str:
|
|
95
|
+
return self._deck.deck_type()
|
|
96
|
+
|
|
97
|
+
def get_serial_number(self) -> str:
|
|
98
|
+
return self._deck.get_serial_number()
|
|
99
|
+
|
|
100
|
+
def get_firmware_version(self) -> str:
|
|
101
|
+
return self._deck.get_firmware_version()
|
|
102
|
+
|
|
103
|
+
def key_image_format(self) -> dict:
|
|
104
|
+
return self._deck.key_image_format()
|
|
105
|
+
|
|
106
|
+
def touchscreen_image_format(self) -> dict:
|
|
107
|
+
return self._deck.touchscreen_image_format()
|
|
108
|
+
|
|
109
|
+
def set_brightness(self, percent: float) -> None:
|
|
110
|
+
self._deck.set_brightness(percent)
|
|
111
|
+
|
|
112
|
+
def set_key_image(self, key: int, image: bytes) -> None:
|
|
113
|
+
self._deck.set_key_image(key, image)
|
|
114
|
+
|
|
115
|
+
def set_touchscreen_image(
|
|
116
|
+
self,
|
|
117
|
+
image: bytes,
|
|
118
|
+
x_pos: int = 0,
|
|
119
|
+
y_pos: int = 0,
|
|
120
|
+
width: int = 0,
|
|
121
|
+
height: int = 0,
|
|
122
|
+
) -> None:
|
|
123
|
+
self._deck.set_touchscreen_image(image, x_pos, y_pos, width, height)
|
|
124
|
+
|
|
125
|
+
def set_key_callback(self, callback: KeyCallback | None) -> None:
|
|
126
|
+
self._deck.set_key_callback(callback)
|
|
127
|
+
|
|
128
|
+
def set_dial_callback(self, callback: DialCallback | None) -> None:
|
|
129
|
+
self._deck.set_dial_callback(callback)
|
|
130
|
+
|
|
131
|
+
def set_touchscreen_callback(self, callback: TouchCallback | None) -> None:
|
|
132
|
+
self._deck.set_touchscreen_callback(callback)
|
|
133
|
+
|
|
134
|
+
def __enter__(self) -> None:
|
|
135
|
+
self._deck.__enter__()
|
|
136
|
+
|
|
137
|
+
def __exit__(self, exc_type: object, exc_val: object, exc_tb: object) -> None:
|
|
138
|
+
self._deck.__exit__(exc_type, exc_val, exc_tb)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
class RealDeviceManager:
|
|
142
|
+
"""Wraps `StreamDeck.DeviceManager.DeviceManager` -- the real USB bus.
|
|
143
|
+
|
|
144
|
+
Construction is what probes hidapi; a missing library surfaces as
|
|
145
|
+
`DeviceProbeError` with an actionable message rather than the library's
|
|
146
|
+
own `ProbeError`.
|
|
147
|
+
"""
|
|
148
|
+
|
|
149
|
+
def __init__(self) -> None:
|
|
150
|
+
try:
|
|
151
|
+
self._manager = _LibDeviceManager()
|
|
152
|
+
except _LibProbeError as exc:
|
|
153
|
+
raise DeviceProbeError(_MISSING_HIDAPI_MESSAGE) from exc
|
|
154
|
+
|
|
155
|
+
def find_device(self) -> DeckDevice | None:
|
|
156
|
+
decks = self._manager.enumerate()
|
|
157
|
+
return RealDeckDevice(decks[0]) if decks else None
|