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.
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