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/cli.py
ADDED
|
@@ -0,0 +1,1122 @@
|
|
|
1
|
+
"""muxplex-deck CLI -- sidecar for driving an Elgato Stream Deck against muxplex.
|
|
2
|
+
|
|
3
|
+
Mirrors muxplex's own CLI shape (`muxplex/cli.py`) so the two tools feel
|
|
4
|
+
identical: a default-action root parser (`muxplex-deck` == `muxplex-deck
|
|
5
|
+
run`), 3-tier config resolution (CLI flag > config.json > hardcoded
|
|
6
|
+
default), a `config` group, a `service` group (systemd/launchd), `doctor`,
|
|
7
|
+
and `update`/`upgrade`. Two things are sidecar-specific and have no muxplex
|
|
8
|
+
analog: the HID-permission/udev caveat (a service-managed sidecar runs as
|
|
9
|
+
a non-root user, which by default cannot open the Stream Deck) and a
|
|
10
|
+
standalone `version` command.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import argparse
|
|
16
|
+
import contextlib
|
|
17
|
+
import json
|
|
18
|
+
import platform
|
|
19
|
+
import subprocess
|
|
20
|
+
import sys
|
|
21
|
+
import time
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
from typing import Any
|
|
24
|
+
|
|
25
|
+
from . import config as config_mod
|
|
26
|
+
from .config import DEFAULT_CONFIG, ConfigError
|
|
27
|
+
|
|
28
|
+
# ---------------------------------------------------------------------------
|
|
29
|
+
# Default action: `muxplex-deck` == `muxplex-deck run`
|
|
30
|
+
# ---------------------------------------------------------------------------
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _add_run_flags(parser: argparse.ArgumentParser) -> None:
|
|
34
|
+
"""Add --config, --emulator, --emulator-port to a parser.
|
|
35
|
+
|
|
36
|
+
Applied to BOTH the root parser and the `run` subcommand so bare
|
|
37
|
+
`muxplex-deck` and `muxplex-deck run` behave identically. `--config`
|
|
38
|
+
defaults to None so `run()` can distinguish "not passed" from "passed
|
|
39
|
+
the default path" (3-tier resolution -- CLI flag > config.json >
|
|
40
|
+
hardcoded default -- lives inside `config.load_config`, keyed off this
|
|
41
|
+
same None-vs-explicit distinction).
|
|
42
|
+
"""
|
|
43
|
+
parser.add_argument(
|
|
44
|
+
"--config",
|
|
45
|
+
default=None,
|
|
46
|
+
help="Path to config JSON file (overrides MUXPLEX_DECK_CONFIG and the "
|
|
47
|
+
"default ~/.config/muxplex-deck/config.json)",
|
|
48
|
+
)
|
|
49
|
+
parser.add_argument(
|
|
50
|
+
"--emulator",
|
|
51
|
+
action="store_true",
|
|
52
|
+
help="Run against the in-process Stream Deck+ emulator (localhost web UI) "
|
|
53
|
+
"instead of real hardware -- no device, no hidapi required.",
|
|
54
|
+
)
|
|
55
|
+
parser.add_argument(
|
|
56
|
+
"--emulator-port",
|
|
57
|
+
type=int,
|
|
58
|
+
default=8484,
|
|
59
|
+
help="Port for the emulator's web UI (default: 8484). Ignored without --emulator.",
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def run(
|
|
64
|
+
config_path: str | None = None,
|
|
65
|
+
*,
|
|
66
|
+
emulator: bool = False,
|
|
67
|
+
emulator_port: int = 8484,
|
|
68
|
+
) -> int:
|
|
69
|
+
"""Load config, build the device backend, and run the sidecar's main loop.
|
|
70
|
+
|
|
71
|
+
This is the CLI-level entry ("the default action"); the actual hotplug
|
|
72
|
+
state machine lives in `muxplex_deck.main.run(config, manager)`.
|
|
73
|
+
"""
|
|
74
|
+
from . import main as main_mod
|
|
75
|
+
from .device import DeviceProbeError
|
|
76
|
+
|
|
77
|
+
try:
|
|
78
|
+
cfg = config_mod.load_config(config_path)
|
|
79
|
+
except ConfigError as exc:
|
|
80
|
+
print(str(exc), file=sys.stderr)
|
|
81
|
+
return 1
|
|
82
|
+
|
|
83
|
+
try:
|
|
84
|
+
manager = main_mod._build_manager(
|
|
85
|
+
emulator=emulator, emulator_port=emulator_port
|
|
86
|
+
)
|
|
87
|
+
except DeviceProbeError as exc:
|
|
88
|
+
print(str(exc), file=sys.stderr)
|
|
89
|
+
return 1
|
|
90
|
+
|
|
91
|
+
return main_mod.run(cfg, manager)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
# ---------------------------------------------------------------------------
|
|
95
|
+
# version
|
|
96
|
+
# ---------------------------------------------------------------------------
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def get_version() -> str:
|
|
100
|
+
"""Return the installed muxplex-deck version, or "dev" if not installed."""
|
|
101
|
+
try:
|
|
102
|
+
from importlib.metadata import version as pkg_version
|
|
103
|
+
|
|
104
|
+
return pkg_version("muxplex-deck")
|
|
105
|
+
except Exception: # noqa: BLE001 -- importlib.metadata can fail in several ways when not installed
|
|
106
|
+
return "dev"
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def print_version() -> None:
|
|
110
|
+
print(f"muxplex-deck {get_version()}")
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
# ---------------------------------------------------------------------------
|
|
114
|
+
# config group -- list / get / set / reset
|
|
115
|
+
# ---------------------------------------------------------------------------
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def config_list(config_path: str | None = None) -> None:
|
|
119
|
+
"""Show all config keys with their current values."""
|
|
120
|
+
settings = config_mod.load_raw_config(config_path)
|
|
121
|
+
resolved_path = config_mod._resolve_config_path(config_path)
|
|
122
|
+
print(f"\nmuxplex-deck config ({resolved_path})\n")
|
|
123
|
+
|
|
124
|
+
for key in DEFAULT_CONFIG:
|
|
125
|
+
value = settings.get(key)
|
|
126
|
+
default = DEFAULT_CONFIG[key]
|
|
127
|
+
is_default = value == default
|
|
128
|
+
marker = "" if is_default else " (modified)"
|
|
129
|
+
if isinstance(value, str):
|
|
130
|
+
display = f'"{value}"' if value else '""'
|
|
131
|
+
elif value is None:
|
|
132
|
+
display = "null"
|
|
133
|
+
elif isinstance(value, bool):
|
|
134
|
+
display = "true" if value else "false"
|
|
135
|
+
else:
|
|
136
|
+
display = str(value)
|
|
137
|
+
print(f" {key}: {display}{marker}")
|
|
138
|
+
print()
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def config_get(key: str, config_path: str | None = None) -> None:
|
|
142
|
+
"""Show one config value."""
|
|
143
|
+
if key not in DEFAULT_CONFIG:
|
|
144
|
+
print(f"Unknown setting: {key}", file=sys.stderr)
|
|
145
|
+
print(
|
|
146
|
+
f"Valid keys: {', '.join(sorted(DEFAULT_CONFIG.keys()))}", file=sys.stderr
|
|
147
|
+
)
|
|
148
|
+
sys.exit(1)
|
|
149
|
+
|
|
150
|
+
settings = config_mod.load_raw_config(config_path)
|
|
151
|
+
value = settings.get(key)
|
|
152
|
+
if isinstance(value, bool):
|
|
153
|
+
print("true" if value else "false")
|
|
154
|
+
elif value is None:
|
|
155
|
+
print("null")
|
|
156
|
+
else:
|
|
157
|
+
print(value)
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def config_set(key: str, raw_value: str, config_path: str | None = None) -> None:
|
|
161
|
+
"""Set a config value. Type is auto-detected from the default's type."""
|
|
162
|
+
if key not in DEFAULT_CONFIG:
|
|
163
|
+
print(f"Unknown setting: {key}", file=sys.stderr)
|
|
164
|
+
print(
|
|
165
|
+
f"Valid keys: {', '.join(sorted(DEFAULT_CONFIG.keys()))}", file=sys.stderr
|
|
166
|
+
)
|
|
167
|
+
sys.exit(1)
|
|
168
|
+
|
|
169
|
+
default = DEFAULT_CONFIG[key]
|
|
170
|
+
value: Any
|
|
171
|
+
try:
|
|
172
|
+
if isinstance(default, bool):
|
|
173
|
+
value = raw_value.lower() in ("true", "1", "yes", "on")
|
|
174
|
+
elif isinstance(default, int) and not isinstance(default, bool):
|
|
175
|
+
value = int(raw_value)
|
|
176
|
+
elif isinstance(default, float):
|
|
177
|
+
value = float(raw_value)
|
|
178
|
+
else:
|
|
179
|
+
value = raw_value
|
|
180
|
+
except ValueError as exc:
|
|
181
|
+
print(f"Invalid value for {key}: {exc}", file=sys.stderr)
|
|
182
|
+
sys.exit(1)
|
|
183
|
+
|
|
184
|
+
config_mod.patch_raw_config({key: value}, config_path)
|
|
185
|
+
print(f" {key}: {value}")
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def config_reset(key: str | None = None, config_path: str | None = None) -> None:
|
|
189
|
+
"""Reset one or all config keys to defaults."""
|
|
190
|
+
import copy
|
|
191
|
+
|
|
192
|
+
if key is not None:
|
|
193
|
+
if key not in DEFAULT_CONFIG:
|
|
194
|
+
print(f"Unknown setting: {key}", file=sys.stderr)
|
|
195
|
+
print(
|
|
196
|
+
f"Valid keys: {', '.join(sorted(DEFAULT_CONFIG.keys()))}",
|
|
197
|
+
file=sys.stderr,
|
|
198
|
+
)
|
|
199
|
+
sys.exit(1)
|
|
200
|
+
config_mod.patch_raw_config({key: DEFAULT_CONFIG[key]}, config_path)
|
|
201
|
+
print(f" {key} reset to: {DEFAULT_CONFIG[key]}")
|
|
202
|
+
else:
|
|
203
|
+
config_mod.save_raw_config(copy.deepcopy(DEFAULT_CONFIG), config_path)
|
|
204
|
+
resolved_path = config_mod._resolve_config_path(config_path)
|
|
205
|
+
print(f" All settings reset to defaults ({resolved_path})")
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
# ---------------------------------------------------------------------------
|
|
209
|
+
# doctor -- pure check helpers (each returns (status, message); status is
|
|
210
|
+
# "ok" | "warn" | "fail") plus the orchestrating doctor() that prints them.
|
|
211
|
+
# Kept pure/injectable so tests can exercise each check without hardware,
|
|
212
|
+
# a server, or a service manager.
|
|
213
|
+
# ---------------------------------------------------------------------------
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def check_python_version() -> tuple[str, str]:
|
|
217
|
+
py_version = platform.python_version()
|
|
218
|
+
ok = tuple(int(x) for x in py_version.split(".")[:2]) >= (3, 11)
|
|
219
|
+
if ok:
|
|
220
|
+
return "ok", f"Python {py_version}"
|
|
221
|
+
return "fail", f"Python {py_version} (3.11+ required)"
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def _get_install_info() -> dict:
|
|
225
|
+
"""Detect how muxplex-deck was installed using PEP 610 direct_url.json.
|
|
226
|
+
|
|
227
|
+
Same shape/logic as muxplex's own `_get_install_info()`, targeting the
|
|
228
|
+
`muxplex-deck` distribution instead.
|
|
229
|
+
"""
|
|
230
|
+
from importlib.metadata import PackageNotFoundError, distribution
|
|
231
|
+
|
|
232
|
+
info: dict = {"source": "unknown", "version": "0.0.0", "commit": None, "url": None}
|
|
233
|
+
try:
|
|
234
|
+
dist = distribution("muxplex-deck")
|
|
235
|
+
info["version"] = dist.metadata["Version"]
|
|
236
|
+
du_text = dist.read_text("direct_url.json")
|
|
237
|
+
if du_text:
|
|
238
|
+
du = json.loads(du_text)
|
|
239
|
+
if "vcs_info" in du:
|
|
240
|
+
info["source"] = "git"
|
|
241
|
+
info["commit"] = du["vcs_info"].get("commit_id", "")
|
|
242
|
+
info["url"] = du.get("url", "")
|
|
243
|
+
elif "dir_info" in du and du["dir_info"].get("editable"):
|
|
244
|
+
info["source"] = "editable"
|
|
245
|
+
else:
|
|
246
|
+
info["source"] = "unknown"
|
|
247
|
+
else:
|
|
248
|
+
info["source"] = "pypi"
|
|
249
|
+
except PackageNotFoundError:
|
|
250
|
+
pass
|
|
251
|
+
return info
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def _check_for_update(info: dict) -> tuple[bool, str]:
|
|
255
|
+
"""Check if an update is available.
|
|
256
|
+
|
|
257
|
+
muxplex-deck is git-only (no PyPI release) -- only the git comparison
|
|
258
|
+
path applies. Editable installs are never flagged.
|
|
259
|
+
"""
|
|
260
|
+
if info["source"] == "editable":
|
|
261
|
+
return False, "editable install -- manage updates manually"
|
|
262
|
+
|
|
263
|
+
if info["source"] == "git":
|
|
264
|
+
try:
|
|
265
|
+
result = subprocess.run(
|
|
266
|
+
["git", "ls-remote", info["url"], "HEAD"],
|
|
267
|
+
capture_output=True,
|
|
268
|
+
text=True,
|
|
269
|
+
timeout=10,
|
|
270
|
+
check=False,
|
|
271
|
+
)
|
|
272
|
+
if result.returncode != 0:
|
|
273
|
+
return True, "could not check remote -- upgrading to be safe"
|
|
274
|
+
remote_sha = (
|
|
275
|
+
result.stdout.strip().split()[0] if result.stdout.strip() else ""
|
|
276
|
+
)
|
|
277
|
+
local_sha = info["commit"] or ""
|
|
278
|
+
if not remote_sha:
|
|
279
|
+
return True, "could not read remote sha -- upgrading to be safe"
|
|
280
|
+
if local_sha == remote_sha:
|
|
281
|
+
return False, f"up to date (commit {local_sha[:8]})"
|
|
282
|
+
return True, f"update available ({local_sha[:8]} -> {remote_sha[:8]})"
|
|
283
|
+
except Exception: # noqa: BLE001 -- any git/network failure means "upgrade to be safe"
|
|
284
|
+
return True, "check failed -- upgrading to be safe"
|
|
285
|
+
|
|
286
|
+
return True, "unknown install source -- could not check"
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
def check_install_and_update() -> tuple[str, str]:
|
|
290
|
+
"""Combined install-source + update-available check, doctor-line ready."""
|
|
291
|
+
info = _get_install_info()
|
|
292
|
+
commit_suffix = f" @ {info['commit'][:8]}" if info["commit"] else ""
|
|
293
|
+
version_str = get_version()
|
|
294
|
+
update_available, message = _check_for_update(info)
|
|
295
|
+
base = f"muxplex-deck {version_str} (installed via {info['source']}{commit_suffix})"
|
|
296
|
+
if update_available:
|
|
297
|
+
return "warn", f"{base} -- {message} (run: muxplex-deck update)"
|
|
298
|
+
return "ok", f"{base} -- {message}"
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
def check_config_file(config_path: str | None = None) -> tuple[str, str]:
|
|
302
|
+
resolved = config_mod._resolve_config_path(config_path)
|
|
303
|
+
if resolved.exists():
|
|
304
|
+
return "ok", f"Config: {resolved}"
|
|
305
|
+
return "warn", f"Config: {resolved} (not yet created -- see README.md)"
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
def check_federation_key(key_file: Path) -> tuple[str, str]:
|
|
309
|
+
"""Federation key presence + permission check. Never prints the key itself."""
|
|
310
|
+
if not key_file.exists():
|
|
311
|
+
return "warn", f"Federation key file not found: {key_file}"
|
|
312
|
+
try:
|
|
313
|
+
mode = key_file.stat().st_mode & 0o777
|
|
314
|
+
except OSError as exc:
|
|
315
|
+
return "warn", f"Could not stat federation key file {key_file}: {exc}"
|
|
316
|
+
if mode & 0o077:
|
|
317
|
+
return "warn", (
|
|
318
|
+
f"Federation key file {key_file} is readable by others "
|
|
319
|
+
f"(mode {oct(mode)}) -- run: chmod 600 {key_file}"
|
|
320
|
+
)
|
|
321
|
+
return "ok", f"Federation key: {key_file} (mode {oct(mode)})"
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
def check_ca_file(ca_file: Path | None) -> tuple[str, str]:
|
|
325
|
+
"""ca_file configured, readable, and actually a CA (not a server leaf cert).
|
|
326
|
+
|
|
327
|
+
This exact mistake -- pointing ca_file at the server's LEAF certificate
|
|
328
|
+
instead of its CA -- is a known real-world gotcha (see AGENTS.md) that
|
|
329
|
+
manifests as "unable to get local issuer certificate".
|
|
330
|
+
"""
|
|
331
|
+
if ca_file is None:
|
|
332
|
+
return "ok", "ca_file: not configured (fine for a publicly-trusted cert)"
|
|
333
|
+
if not ca_file.exists():
|
|
334
|
+
return "warn", f"ca_file configured but not found: {ca_file}"
|
|
335
|
+
try:
|
|
336
|
+
result = subprocess.run(
|
|
337
|
+
[
|
|
338
|
+
"openssl",
|
|
339
|
+
"x509",
|
|
340
|
+
"-in",
|
|
341
|
+
str(ca_file),
|
|
342
|
+
"-noout",
|
|
343
|
+
"-ext",
|
|
344
|
+
"basicConstraints",
|
|
345
|
+
],
|
|
346
|
+
capture_output=True,
|
|
347
|
+
text=True,
|
|
348
|
+
timeout=5,
|
|
349
|
+
check=False,
|
|
350
|
+
)
|
|
351
|
+
except FileNotFoundError:
|
|
352
|
+
return "warn", "openssl not found -- cannot verify ca_file is a CA"
|
|
353
|
+
except Exception as exc: # noqa: BLE001 -- doctor check must degrade to warn, never raise
|
|
354
|
+
return "warn", f"Could not inspect ca_file {ca_file}: {exc}"
|
|
355
|
+
|
|
356
|
+
if result.returncode != 0:
|
|
357
|
+
return "warn", f"ca_file {ca_file} does not look like a valid certificate"
|
|
358
|
+
|
|
359
|
+
output = result.stdout
|
|
360
|
+
if "CA:TRUE" in output:
|
|
361
|
+
return "ok", f"ca_file is a valid CA: {ca_file}"
|
|
362
|
+
if "CA:FALSE" in output:
|
|
363
|
+
return "warn", (
|
|
364
|
+
f"ca_file {ca_file} has CA:FALSE -- this looks like the server's "
|
|
365
|
+
"LEAF certificate (muxplex.crt), not its CA. Point ca_file at "
|
|
366
|
+
"~/.config/muxplex/ca/muxplex-ca.crt on the server instead, or TLS "
|
|
367
|
+
"verification will fail with 'unable to get local issuer certificate'."
|
|
368
|
+
)
|
|
369
|
+
return (
|
|
370
|
+
"warn",
|
|
371
|
+
f"ca_file {ca_file}: could not determine CA status from basicConstraints",
|
|
372
|
+
)
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
def probe_deck_status(manager: Any) -> dict:
|
|
376
|
+
"""Pure: given a `DeviceManager`-shaped object, find + describe the deck.
|
|
377
|
+
|
|
378
|
+
Returns {"found": bool, "openable": bool, "caps": dict | None, "error": str | None}.
|
|
379
|
+
Callers pass a fake manager in tests -- no real hardware required.
|
|
380
|
+
"""
|
|
381
|
+
try:
|
|
382
|
+
deck = manager.find_device()
|
|
383
|
+
except Exception as exc: # noqa: BLE001 -- HID backends raise varied errors; report, don't crash
|
|
384
|
+
return {"found": False, "openable": False, "caps": None, "error": str(exc)}
|
|
385
|
+
if deck is None:
|
|
386
|
+
return {"found": False, "openable": False, "caps": None, "error": None}
|
|
387
|
+
try:
|
|
388
|
+
deck.open()
|
|
389
|
+
except Exception as exc: # noqa: BLE001 -- HID backends raise varied errors; report, don't crash
|
|
390
|
+
return {"found": True, "openable": False, "caps": None, "error": str(exc)}
|
|
391
|
+
try:
|
|
392
|
+
from deck_probe.capabilities import describe_capabilities
|
|
393
|
+
|
|
394
|
+
caps = describe_capabilities(deck)
|
|
395
|
+
return {"found": True, "openable": True, "caps": caps, "error": None}
|
|
396
|
+
finally:
|
|
397
|
+
with contextlib.suppress(Exception):
|
|
398
|
+
deck.close()
|
|
399
|
+
|
|
400
|
+
|
|
401
|
+
_NO_DEVICE_GUIDANCE = (
|
|
402
|
+
"No Stream Deck found. Things to check:\n"
|
|
403
|
+
" - All platforms: close the official Elgato Stream Deck app -- it holds\n"
|
|
404
|
+
" exclusive HID access, so muxplex-deck cannot open the device while it runs.\n"
|
|
405
|
+
" - Linux (incl. WSL): a udev rule must grant access to Elgato devices\n"
|
|
406
|
+
" (vendor id 0x0fd9). Run 'muxplex-deck service install' for the exact\n"
|
|
407
|
+
" remediation block, or see AGENTS.md.\n"
|
|
408
|
+
" - WSL specifically: USB devices are not visible until attached from\n"
|
|
409
|
+
" Windows via usbipd -- in an admin PowerShell:\n"
|
|
410
|
+
" usbipd list (find the Stream Deck's BUSID)\n"
|
|
411
|
+
" usbipd bind --busid <BUSID> (first time only)\n"
|
|
412
|
+
" usbipd attach --wsl --busid <BUSID>\n"
|
|
413
|
+
)
|
|
414
|
+
|
|
415
|
+
|
|
416
|
+
def check_deck_detected(config_path: str | None = None) -> tuple[str, str]:
|
|
417
|
+
"""Enumerate + describe the connected Stream Deck (real hardware probe)."""
|
|
418
|
+
try:
|
|
419
|
+
from .device import DeviceProbeError
|
|
420
|
+
from .device_real import RealDeviceManager
|
|
421
|
+
except ImportError as exc:
|
|
422
|
+
return "warn", f"Could not import Stream Deck backend: {exc}"
|
|
423
|
+
|
|
424
|
+
try:
|
|
425
|
+
manager = RealDeviceManager()
|
|
426
|
+
except DeviceProbeError as exc:
|
|
427
|
+
return "warn", str(exc)
|
|
428
|
+
|
|
429
|
+
status = probe_deck_status(manager)
|
|
430
|
+
if not status["found"]:
|
|
431
|
+
if status["error"]:
|
|
432
|
+
return "warn", f"Stream Deck enumeration failed: {status['error']}"
|
|
433
|
+
return "warn", _NO_DEVICE_GUIDANCE
|
|
434
|
+
caps = status["caps"]
|
|
435
|
+
if caps is None:
|
|
436
|
+
# Found but couldn't open -- reported separately by check_hid_openable.
|
|
437
|
+
return "ok", "Stream Deck detected (not yet opened -- see HID check below)"
|
|
438
|
+
return "ok", (
|
|
439
|
+
f"{caps['model']}: {caps['key_count']} keys "
|
|
440
|
+
f"({caps['key_rows']}x{caps['key_cols']}), {caps['dial_count']} dials, "
|
|
441
|
+
f"touchscreen={'yes' if caps['has_touchscreen'] else 'no'}"
|
|
442
|
+
)
|
|
443
|
+
|
|
444
|
+
|
|
445
|
+
def check_hid_openable() -> tuple[str, str]:
|
|
446
|
+
"""Whether the detected Stream Deck can actually be opened (HID permission).
|
|
447
|
+
|
|
448
|
+
HID access is exclusive: once the muxplex-deck *service* is running, it
|
|
449
|
+
holds the device's handle for the whole time it's active, so ANY second
|
|
450
|
+
process (including this very check) will fail to open it -- that is
|
|
451
|
+
correct, expected behavior, not a bug. The robust way to tell "someone
|
|
452
|
+
else has it because our own service is healthy" apart from a genuine
|
|
453
|
+
permission problem is to check SERVICE STATE, not the open() error text:
|
|
454
|
+
hidapi/libusb error strings vary by platform, library version, and
|
|
455
|
+
locale, so pattern-matching on them is unreliable and unverifiable
|
|
456
|
+
without hardware on every platform. Do NOT "fix" this back into a plain
|
|
457
|
+
warning by reintroducing string-matching -- that regresses the exact
|
|
458
|
+
false-positive this check exists to avoid.
|
|
459
|
+
"""
|
|
460
|
+
try:
|
|
461
|
+
from .device import DeviceProbeError
|
|
462
|
+
from .device_real import RealDeviceManager
|
|
463
|
+
from .service import service_is_active, udev_rule_exists
|
|
464
|
+
except ImportError as exc:
|
|
465
|
+
return "warn", f"Could not import Stream Deck backend: {exc}"
|
|
466
|
+
|
|
467
|
+
try:
|
|
468
|
+
manager = RealDeviceManager()
|
|
469
|
+
except DeviceProbeError as exc:
|
|
470
|
+
return "warn", str(exc)
|
|
471
|
+
|
|
472
|
+
status = probe_deck_status(manager)
|
|
473
|
+
if not status["found"]:
|
|
474
|
+
return "warn", "n/a -- no Stream Deck detected"
|
|
475
|
+
if status["openable"]:
|
|
476
|
+
return "ok", "HID: device opened successfully"
|
|
477
|
+
|
|
478
|
+
# Open failed. Check service state BEFORE treating this as an error --
|
|
479
|
+
# see the docstring above for why this must key off service state, not
|
|
480
|
+
# the error string.
|
|
481
|
+
if service_is_active():
|
|
482
|
+
return "ok", "HID: device in use by the muxplex-deck service (expected)"
|
|
483
|
+
|
|
484
|
+
hint = ""
|
|
485
|
+
if sys.platform not in ("darwin", "win32") and not udev_rule_exists():
|
|
486
|
+
hint = " Run: muxplex-deck service install (prints the udev remediation)."
|
|
487
|
+
elif sys.platform not in ("darwin", "win32"):
|
|
488
|
+
hint = " A udev rule exists but the device still could not be opened."
|
|
489
|
+
return "warn", f"HID: could not open device ({status['error']}).{hint}"
|
|
490
|
+
|
|
491
|
+
|
|
492
|
+
def _is_tls_error(exc: Exception) -> bool:
|
|
493
|
+
"""Best-effort detection of a TLS/certificate-verification failure.
|
|
494
|
+
|
|
495
|
+
httpx surfaces cert failures as a plain `httpx.ConnectError` with the
|
|
496
|
+
underlying SSL error text in the message -- there's no dedicated
|
|
497
|
+
exception type to catch, so string-sniffing is the only option. Shared
|
|
498
|
+
by `check_server_reachable` and the `init` wizard so both agree on what
|
|
499
|
+
counts as "this needs a CA file" versus "server is just unreachable".
|
|
500
|
+
"""
|
|
501
|
+
msg = str(exc).lower()
|
|
502
|
+
return "certificate" in msg or "ssl" in msg
|
|
503
|
+
|
|
504
|
+
|
|
505
|
+
def fetch_instance_info(server_url: str, *, verify: bool | str = True) -> dict:
|
|
506
|
+
"""GET /api/instance-info and return the parsed JSON body.
|
|
507
|
+
|
|
508
|
+
Raises the underlying httpx exception on any failure (timeout, connect
|
|
509
|
+
error, non-2xx status) -- unlike `check_server_reachable`, this is a raw
|
|
510
|
+
data-fetch primitive for callers (like the `init` wizard) that need the
|
|
511
|
+
actual response fields (name, version, federation_enabled), not just a
|
|
512
|
+
doctor-style (status, message) verdict.
|
|
513
|
+
"""
|
|
514
|
+
import httpx
|
|
515
|
+
|
|
516
|
+
url = f"{server_url.rstrip('/')}/api/instance-info"
|
|
517
|
+
with httpx.Client(verify=verify, timeout=5.0) as client:
|
|
518
|
+
resp = client.get(url)
|
|
519
|
+
resp.raise_for_status()
|
|
520
|
+
return resp.json()
|
|
521
|
+
|
|
522
|
+
|
|
523
|
+
def fetch_ca_cert(server_url: str) -> bytes | None:
|
|
524
|
+
"""GET /api/ca (TLS verification disabled for this one bootstrap fetch).
|
|
525
|
+
|
|
526
|
+
A CA public certificate is a trust anchor, not a secret -- disabling
|
|
527
|
+
verification only for this single unauthenticated request is safe and
|
|
528
|
+
is what makes self-serving the CA possible at all (the chicken-and-egg
|
|
529
|
+
problem: you can't verify the server until you have its CA). Returns
|
|
530
|
+
the raw cert bytes on 200, or None on 404 (the server has no local CA
|
|
531
|
+
to expose -- e.g. it uses Tailscale/mkcert/a publicly trusted cert,
|
|
532
|
+
which is fine). Any other HTTP or network error is raised.
|
|
533
|
+
"""
|
|
534
|
+
import httpx
|
|
535
|
+
|
|
536
|
+
url = f"{server_url.rstrip('/')}/api/ca"
|
|
537
|
+
with httpx.Client(verify=False, timeout=5.0) as client:
|
|
538
|
+
resp = client.get(url)
|
|
539
|
+
if resp.status_code == 404:
|
|
540
|
+
return None
|
|
541
|
+
resp.raise_for_status()
|
|
542
|
+
return resp.content
|
|
543
|
+
|
|
544
|
+
|
|
545
|
+
def check_server_reachable(server_url: str, ca_file: Path | None) -> tuple[str, str]:
|
|
546
|
+
"""GET /api/instance-info -- the one public, unauthenticated endpoint."""
|
|
547
|
+
if not server_url:
|
|
548
|
+
return "warn", "server_url not configured"
|
|
549
|
+
|
|
550
|
+
import httpx
|
|
551
|
+
|
|
552
|
+
verify: bool | str = str(ca_file) if ca_file else True
|
|
553
|
+
try:
|
|
554
|
+
data = fetch_instance_info(server_url, verify=verify)
|
|
555
|
+
return (
|
|
556
|
+
"ok",
|
|
557
|
+
f"Server reachable: {data.get('name', '?')} (muxplex {data.get('version', '?')})",
|
|
558
|
+
)
|
|
559
|
+
except httpx.TimeoutException as exc:
|
|
560
|
+
return "warn", f"Server check timed out: {exc}"
|
|
561
|
+
except httpx.ConnectError as exc:
|
|
562
|
+
if _is_tls_error(exc):
|
|
563
|
+
return "warn", (
|
|
564
|
+
f"TLS verification failed: {exc} -- check ca_file points at "
|
|
565
|
+
"the server's CA, not its leaf certificate"
|
|
566
|
+
)
|
|
567
|
+
return "warn", f"Server unreachable: {exc}"
|
|
568
|
+
except Exception as exc: # noqa: BLE001 -- catch-all after explicit httpx cases; doctor never raises
|
|
569
|
+
return "warn", f"Server check failed: {exc}"
|
|
570
|
+
|
|
571
|
+
|
|
572
|
+
def check_service_status() -> tuple[str, str]:
|
|
573
|
+
"""Best-effort service-installed/running check (systemd or launchd)."""
|
|
574
|
+
if sys.platform == "darwin":
|
|
575
|
+
import os as _os
|
|
576
|
+
|
|
577
|
+
uid = _os.getuid()
|
|
578
|
+
try:
|
|
579
|
+
result = subprocess.run(
|
|
580
|
+
["launchctl", "print", f"gui/{uid}/com.muxplex-deck"],
|
|
581
|
+
capture_output=True,
|
|
582
|
+
text=True,
|
|
583
|
+
timeout=5,
|
|
584
|
+
check=False,
|
|
585
|
+
)
|
|
586
|
+
except FileNotFoundError:
|
|
587
|
+
return "warn", "Service: launchctl not found"
|
|
588
|
+
if result.returncode == 0:
|
|
589
|
+
return "ok", "Service: installed (launchd)"
|
|
590
|
+
return "warn", "Service: not installed -- run: muxplex-deck service install"
|
|
591
|
+
|
|
592
|
+
try:
|
|
593
|
+
result = subprocess.run(
|
|
594
|
+
["systemctl", "--user", "is-active", "muxplex-deck"],
|
|
595
|
+
capture_output=True,
|
|
596
|
+
text=True,
|
|
597
|
+
timeout=5,
|
|
598
|
+
check=False,
|
|
599
|
+
)
|
|
600
|
+
except FileNotFoundError:
|
|
601
|
+
return "warn", "Service: systemctl not found -- run muxplex-deck directly"
|
|
602
|
+
if result.returncode == 0:
|
|
603
|
+
return "ok", f"Service: {result.stdout.strip()} (systemd)"
|
|
604
|
+
return "warn", "Service: not installed -- run: muxplex-deck service install"
|
|
605
|
+
|
|
606
|
+
|
|
607
|
+
_CHECK_MARKS: dict[str, str] = {
|
|
608
|
+
"ok": "\033[32m\u2713\033[0m",
|
|
609
|
+
"fail": "\033[31m\u2717\033[0m",
|
|
610
|
+
"warn": "\033[33m!\033[0m",
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
|
|
614
|
+
def print_check(status: str, message: str) -> None:
|
|
615
|
+
"""Print one doctor-style check line: colored mark, 2-space indent.
|
|
616
|
+
|
|
617
|
+
Continuation lines (multi-line messages) are indented 4 spaces with no
|
|
618
|
+
mark. Shared by `doctor()` and the `init` wizard so both surfaces present
|
|
619
|
+
checks identically.
|
|
620
|
+
"""
|
|
621
|
+
mark = _CHECK_MARKS.get(status, _CHECK_MARKS["warn"])
|
|
622
|
+
for i, line in enumerate(message.splitlines()):
|
|
623
|
+
prefix = f" {mark} " if i == 0 else " "
|
|
624
|
+
print(f"{prefix}{line}")
|
|
625
|
+
|
|
626
|
+
|
|
627
|
+
def doctor(config_path: str | None = None) -> int:
|
|
628
|
+
"""Run diagnostic checks and report system status. Always returns 0 (informational)."""
|
|
629
|
+
print("\nmuxplex-deck doctor\n")
|
|
630
|
+
|
|
631
|
+
checks: list[tuple[str, str]] = []
|
|
632
|
+
checks.append(check_python_version())
|
|
633
|
+
checks.append(check_install_and_update())
|
|
634
|
+
checks.append(check_config_file(config_path))
|
|
635
|
+
|
|
636
|
+
try:
|
|
637
|
+
cfg = config_mod.load_config(config_path)
|
|
638
|
+
except ConfigError:
|
|
639
|
+
cfg = None
|
|
640
|
+
|
|
641
|
+
raw = config_mod.load_raw_config(config_path)
|
|
642
|
+
key_file = config_mod._expand(raw.get("key_file", config_mod.DEFAULT_KEY_FILE))
|
|
643
|
+
checks.append(check_federation_key(key_file))
|
|
644
|
+
|
|
645
|
+
ca_file = cfg.ca_file if cfg is not None else None
|
|
646
|
+
if cfg is None and raw.get("ca_file"):
|
|
647
|
+
ca_file = config_mod._expand(raw["ca_file"])
|
|
648
|
+
checks.append(check_ca_file(ca_file))
|
|
649
|
+
|
|
650
|
+
checks.append(check_deck_detected(config_path))
|
|
651
|
+
checks.append(check_hid_openable())
|
|
652
|
+
|
|
653
|
+
server_url = cfg.server_url if cfg is not None else raw.get("server_url", "")
|
|
654
|
+
checks.append(check_server_reachable(server_url, ca_file))
|
|
655
|
+
|
|
656
|
+
checks.append(check_service_status())
|
|
657
|
+
|
|
658
|
+
for status, message in checks:
|
|
659
|
+
print_check(status, message)
|
|
660
|
+
|
|
661
|
+
print()
|
|
662
|
+
return 0
|
|
663
|
+
|
|
664
|
+
|
|
665
|
+
# ---------------------------------------------------------------------------
|
|
666
|
+
# status -- hardware + connection view, read from the running sidecar's
|
|
667
|
+
# published status file (see .statusfile). Never contends with the sidecar
|
|
668
|
+
# for the exclusive HID handle: `doctor`'s check_hid_openable() probes the
|
|
669
|
+
# device directly and can produce an expected false failure once the
|
|
670
|
+
# service holds it (see that function's docstring); `status` avoids the
|
|
671
|
+
# problem entirely by reading what the running process already knows.
|
|
672
|
+
# ---------------------------------------------------------------------------
|
|
673
|
+
|
|
674
|
+
# A "small multiple" of the sidecar's default 2s poll interval -- generous
|
|
675
|
+
# enough to absorb scheduling jitter, but tight enough that a genuinely
|
|
676
|
+
# stuck/dead sidecar is flagged well before a human would otherwise notice.
|
|
677
|
+
_STATUS_STALE_THRESHOLD_SECONDS = 15.0
|
|
678
|
+
|
|
679
|
+
|
|
680
|
+
def _format_device_line(device: dict[str, Any]) -> tuple[str, str]:
|
|
681
|
+
if not device.get("connected"):
|
|
682
|
+
return "warn", "Device: not connected"
|
|
683
|
+
caps = device.get("capabilities") or {}
|
|
684
|
+
if not caps:
|
|
685
|
+
return "ok", "Device: connected (capabilities unavailable)"
|
|
686
|
+
touchscreen = "yes" if caps.get("has_touchscreen") else "no"
|
|
687
|
+
return "ok", (
|
|
688
|
+
f"Device: {caps.get('model', '?')} -- {caps.get('key_count', '?')} keys "
|
|
689
|
+
f"({caps.get('key_rows', '?')}x{caps.get('key_cols', '?')}), "
|
|
690
|
+
f"{caps.get('dial_count', '?')} dials, touchscreen={touchscreen}"
|
|
691
|
+
)
|
|
692
|
+
|
|
693
|
+
|
|
694
|
+
def _format_server_line(server: dict[str, Any]) -> tuple[str, str]:
|
|
695
|
+
url = server.get("url") or "(not configured)"
|
|
696
|
+
if server.get("connected"):
|
|
697
|
+
return "ok", f"Server: {url} (reachable)"
|
|
698
|
+
err = server.get("last_error")
|
|
699
|
+
message = f"Server: {url} (unreachable)"
|
|
700
|
+
if err:
|
|
701
|
+
message += f" -- {err}"
|
|
702
|
+
return "warn", message
|
|
703
|
+
|
|
704
|
+
|
|
705
|
+
def _format_state_line(state: dict[str, Any]) -> str:
|
|
706
|
+
session = state.get("active_session") or "none"
|
|
707
|
+
view = state.get("active_view") or "all"
|
|
708
|
+
page = state.get("page")
|
|
709
|
+
page_text = str(page) if page is not None else "-"
|
|
710
|
+
return f"Active session: {session} | view: {view} | page: {page_text}"
|
|
711
|
+
|
|
712
|
+
|
|
713
|
+
def _print_direct_probe(config_path: str | None) -> None:
|
|
714
|
+
"""Fallback when the service isn't running -- nothing holds the device."""
|
|
715
|
+
print_check(*check_deck_detected(config_path))
|
|
716
|
+
|
|
717
|
+
|
|
718
|
+
def status(config_path: str | None = None, *, as_json: bool = False) -> int:
|
|
719
|
+
"""Print the sidecar's hardware + connection status.
|
|
720
|
+
|
|
721
|
+
Reads the status file the running sidecar publishes (see
|
|
722
|
+
`.statusfile`) rather than probing the device directly -- HID access is
|
|
723
|
+
exclusive, so a direct probe would produce a false "could not open"
|
|
724
|
+
failure whenever the service is healthy and already holds the device
|
|
725
|
+
(exactly the false-failure `check_hid_openable` now also avoids). When
|
|
726
|
+
the service is NOT running, nothing holds the device, so it's safe to
|
|
727
|
+
fall back to a direct probe -- this keeps `status` useful even before
|
|
728
|
+
the service has ever been installed.
|
|
729
|
+
"""
|
|
730
|
+
from .service import service_is_active
|
|
731
|
+
from .statusfile import read_status
|
|
732
|
+
|
|
733
|
+
running = service_is_active()
|
|
734
|
+
data = read_status()
|
|
735
|
+
|
|
736
|
+
if as_json:
|
|
737
|
+
payload: dict[str, Any] = {"service_running": running, "status": data}
|
|
738
|
+
print(json.dumps(payload, indent=2))
|
|
739
|
+
return 0 if data is not None else 1
|
|
740
|
+
|
|
741
|
+
print("\nmuxplex-deck status\n")
|
|
742
|
+
print_check(
|
|
743
|
+
"ok" if running else "warn",
|
|
744
|
+
f"Service: {'running' if running else 'not running'}",
|
|
745
|
+
)
|
|
746
|
+
|
|
747
|
+
if not running:
|
|
748
|
+
if data is None:
|
|
749
|
+
print_check(
|
|
750
|
+
"warn",
|
|
751
|
+
"No status file found -- probing the device directly instead "
|
|
752
|
+
"(safe: nothing holds it while the service is stopped).",
|
|
753
|
+
)
|
|
754
|
+
else:
|
|
755
|
+
print_check(
|
|
756
|
+
"warn",
|
|
757
|
+
"Service not running -- probing the device directly instead "
|
|
758
|
+
"of trusting the (possibly stale) status file.",
|
|
759
|
+
)
|
|
760
|
+
_print_direct_probe(config_path)
|
|
761
|
+
print()
|
|
762
|
+
return 0
|
|
763
|
+
|
|
764
|
+
if data is None:
|
|
765
|
+
print_check(
|
|
766
|
+
"warn",
|
|
767
|
+
"No status file found even though the service is running -- it "
|
|
768
|
+
"may have just started. Try: muxplex-deck service logs",
|
|
769
|
+
)
|
|
770
|
+
print()
|
|
771
|
+
return 0
|
|
772
|
+
|
|
773
|
+
age = time.time() - data.get("updated_at", 0)
|
|
774
|
+
if age > _STATUS_STALE_THRESHOLD_SECONDS:
|
|
775
|
+
print_check(
|
|
776
|
+
"warn",
|
|
777
|
+
f"Status file is stale (last updated {age:.0f}s ago) -- the "
|
|
778
|
+
"sidecar may be stuck. Try: muxplex-deck service logs",
|
|
779
|
+
)
|
|
780
|
+
else:
|
|
781
|
+
print_check("ok", f"Status updated {age:.0f}s ago (pid {data.get('pid', '?')})")
|
|
782
|
+
|
|
783
|
+
print_check(*_format_device_line(data.get("device", {})))
|
|
784
|
+
print_check(*_format_server_line(data.get("server", {})))
|
|
785
|
+
print_check("ok", _format_state_line(data.get("state", {})))
|
|
786
|
+
|
|
787
|
+
print()
|
|
788
|
+
return 0
|
|
789
|
+
|
|
790
|
+
|
|
791
|
+
# ---------------------------------------------------------------------------
|
|
792
|
+
# update / upgrade
|
|
793
|
+
# ---------------------------------------------------------------------------
|
|
794
|
+
|
|
795
|
+
_REPO_URL = "https://github.com/bkrabach/muxplex-deck"
|
|
796
|
+
|
|
797
|
+
|
|
798
|
+
def _find_uv() -> str | None:
|
|
799
|
+
"""Locate `uv`, checking PATH first then well-known install locations."""
|
|
800
|
+
import shutil
|
|
801
|
+
|
|
802
|
+
found = shutil.which("uv")
|
|
803
|
+
if found:
|
|
804
|
+
return found
|
|
805
|
+
candidates = [
|
|
806
|
+
str(Path.home() / ".local" / "bin" / "uv"),
|
|
807
|
+
"/opt/homebrew/bin/uv",
|
|
808
|
+
"/usr/local/bin/uv",
|
|
809
|
+
"/snap/bin/uv",
|
|
810
|
+
"/root/.local/bin/uv",
|
|
811
|
+
]
|
|
812
|
+
for path in candidates:
|
|
813
|
+
if Path(path).exists() and Path(path).is_file() and _is_executable(path):
|
|
814
|
+
return path
|
|
815
|
+
return None
|
|
816
|
+
|
|
817
|
+
|
|
818
|
+
def _is_executable(path: str) -> bool:
|
|
819
|
+
import os
|
|
820
|
+
|
|
821
|
+
return os.access(path, os.X_OK)
|
|
822
|
+
|
|
823
|
+
|
|
824
|
+
def _find_pip() -> str | None:
|
|
825
|
+
import shutil
|
|
826
|
+
|
|
827
|
+
for name in ("pip", "pip3"):
|
|
828
|
+
found = shutil.which(name)
|
|
829
|
+
if found:
|
|
830
|
+
return found
|
|
831
|
+
candidates = [
|
|
832
|
+
str(Path.home() / ".local" / "bin" / "pip"),
|
|
833
|
+
str(Path.home() / ".local" / "bin" / "pip3"),
|
|
834
|
+
"/opt/homebrew/bin/pip3",
|
|
835
|
+
"/usr/local/bin/pip3",
|
|
836
|
+
]
|
|
837
|
+
for path in candidates:
|
|
838
|
+
if Path(path).exists() and _is_executable(path):
|
|
839
|
+
return path
|
|
840
|
+
return None
|
|
841
|
+
|
|
842
|
+
|
|
843
|
+
def _service_is_active() -> bool:
|
|
844
|
+
"""Best-effort: is the muxplex-deck service currently running?
|
|
845
|
+
|
|
846
|
+
Thin wrapper around `service.service_is_active` -- the real
|
|
847
|
+
implementation now lives there (shared with `check_hid_openable`, which
|
|
848
|
+
needs it to tell "our own service holds the device" apart from a
|
|
849
|
+
genuine HID-permission failure). Kept as a module-level name here so
|
|
850
|
+
existing call sites/tests can monkeypatch `cli._service_is_active`
|
|
851
|
+
directly without needing to know it delegates.
|
|
852
|
+
"""
|
|
853
|
+
from .service import service_is_active
|
|
854
|
+
|
|
855
|
+
return service_is_active()
|
|
856
|
+
|
|
857
|
+
|
|
858
|
+
def update() -> None:
|
|
859
|
+
"""Update muxplex-deck to the latest version and restart the service (if installed).
|
|
860
|
+
|
|
861
|
+
Simplified relative to muxplex's own `upgrade()`: muxplex-deck has no
|
|
862
|
+
PyPI release, so there is no "already up to date, use --force" version
|
|
863
|
+
gate -- it always reinstalls from git HEAD (matching `uv tool install
|
|
864
|
+
--force`'s own semantics). Reporting style (plain ` ERROR: ...` on
|
|
865
|
+
stderr equivalent) matches muxplex's.
|
|
866
|
+
"""
|
|
867
|
+
from .service import service_install
|
|
868
|
+
|
|
869
|
+
print("\nmuxplex-deck update\n")
|
|
870
|
+
|
|
871
|
+
was_active = _service_is_active()
|
|
872
|
+
if was_active:
|
|
873
|
+
print(" Stopping service...")
|
|
874
|
+
if sys.platform == "darwin":
|
|
875
|
+
import os
|
|
876
|
+
|
|
877
|
+
uid = os.getuid()
|
|
878
|
+
subprocess.run(
|
|
879
|
+
["launchctl", "bootout", f"gui/{uid}/com.muxplex-deck"],
|
|
880
|
+
capture_output=True,
|
|
881
|
+
check=False,
|
|
882
|
+
)
|
|
883
|
+
else:
|
|
884
|
+
subprocess.run(
|
|
885
|
+
["systemctl", "--user", "stop", "muxplex-deck"],
|
|
886
|
+
capture_output=True,
|
|
887
|
+
check=False,
|
|
888
|
+
)
|
|
889
|
+
else:
|
|
890
|
+
print(" No active service found (skipping stop)")
|
|
891
|
+
|
|
892
|
+
install_failed = False
|
|
893
|
+
restart_failed = False
|
|
894
|
+
print(" Installing latest version...")
|
|
895
|
+
try:
|
|
896
|
+
uv_path = _find_uv()
|
|
897
|
+
if uv_path:
|
|
898
|
+
result = subprocess.run(
|
|
899
|
+
[uv_path, "tool", "install", "--force", f"git+{_REPO_URL}"],
|
|
900
|
+
capture_output=True,
|
|
901
|
+
text=True,
|
|
902
|
+
check=False,
|
|
903
|
+
)
|
|
904
|
+
if result.returncode != 0:
|
|
905
|
+
print(f" ERROR: uv tool install failed:\n{result.stderr}")
|
|
906
|
+
install_failed = True
|
|
907
|
+
else:
|
|
908
|
+
print(" Installed successfully")
|
|
909
|
+
else:
|
|
910
|
+
pip_path = _find_pip()
|
|
911
|
+
if pip_path:
|
|
912
|
+
result = subprocess.run(
|
|
913
|
+
[pip_path, "install", "--upgrade", f"git+{_REPO_URL}"],
|
|
914
|
+
capture_output=True,
|
|
915
|
+
text=True,
|
|
916
|
+
check=False,
|
|
917
|
+
)
|
|
918
|
+
if result.returncode != 0:
|
|
919
|
+
print(f" ERROR: pip install failed:\n{result.stderr}")
|
|
920
|
+
install_failed = True
|
|
921
|
+
else:
|
|
922
|
+
print(" Installed successfully")
|
|
923
|
+
else:
|
|
924
|
+
print(" ERROR: neither uv nor pip found -- cannot update")
|
|
925
|
+
install_failed = True
|
|
926
|
+
|
|
927
|
+
if not install_failed and was_active:
|
|
928
|
+
print(" Regenerating service file...")
|
|
929
|
+
try:
|
|
930
|
+
service_install()
|
|
931
|
+
except Exception as exc: # noqa: BLE001 -- report and continue; update must still finish
|
|
932
|
+
print(f" ERROR: service file regeneration failed: {exc}")
|
|
933
|
+
finally:
|
|
934
|
+
if was_active:
|
|
935
|
+
print(" Restarting service...")
|
|
936
|
+
try:
|
|
937
|
+
from .service import service_restart
|
|
938
|
+
|
|
939
|
+
service_restart()
|
|
940
|
+
except Exception as exc: # noqa: BLE001 -- report and continue; update must still finish
|
|
941
|
+
print(f" ERROR: service restart failed: {exc}")
|
|
942
|
+
restart_failed = True
|
|
943
|
+
else:
|
|
944
|
+
if not _service_is_active():
|
|
945
|
+
restart_failed = True
|
|
946
|
+
|
|
947
|
+
if install_failed:
|
|
948
|
+
print("\n ERROR: update failed -- service has been restarted (best-effort).\n")
|
|
949
|
+
sys.exit(1)
|
|
950
|
+
|
|
951
|
+
if restart_failed:
|
|
952
|
+
print(
|
|
953
|
+
"\n ERROR: update installed successfully but the service failed to "
|
|
954
|
+
"restart.\n The new version is installed but the service is NOT "
|
|
955
|
+
"running.\n Run: muxplex-deck service start\n"
|
|
956
|
+
)
|
|
957
|
+
sys.exit(1)
|
|
958
|
+
|
|
959
|
+
print("\n Verifying...")
|
|
960
|
+
doctor()
|
|
961
|
+
|
|
962
|
+
|
|
963
|
+
# ---------------------------------------------------------------------------
|
|
964
|
+
# main
|
|
965
|
+
# ---------------------------------------------------------------------------
|
|
966
|
+
|
|
967
|
+
|
|
968
|
+
def main() -> None:
|
|
969
|
+
"""CLI entry point."""
|
|
970
|
+
parser = argparse.ArgumentParser(
|
|
971
|
+
prog="muxplex-deck",
|
|
972
|
+
description="Drive an Elgato Stream Deck against a muxplex server.",
|
|
973
|
+
)
|
|
974
|
+
_add_run_flags(parser)
|
|
975
|
+
|
|
976
|
+
sub = parser.add_subparsers(dest="command")
|
|
977
|
+
|
|
978
|
+
run_parser = sub.add_parser("run", help="Run the sidecar (default)")
|
|
979
|
+
_add_run_flags(run_parser)
|
|
980
|
+
|
|
981
|
+
sub.add_parser("version", help="Show the muxplex-deck version")
|
|
982
|
+
|
|
983
|
+
sub.add_parser("doctor", help="Check dependencies and system status")
|
|
984
|
+
|
|
985
|
+
status_parser = sub.add_parser(
|
|
986
|
+
"status", help="Show connected hardware + connection state"
|
|
987
|
+
)
|
|
988
|
+
status_parser.add_argument(
|
|
989
|
+
"--json", action="store_true", help="Emit raw status as JSON"
|
|
990
|
+
)
|
|
991
|
+
|
|
992
|
+
sub.add_parser(
|
|
993
|
+
"update",
|
|
994
|
+
aliases=["upgrade"],
|
|
995
|
+
help="Update muxplex-deck to the latest version and restart the service",
|
|
996
|
+
)
|
|
997
|
+
|
|
998
|
+
init_parser = sub.add_parser(
|
|
999
|
+
"init",
|
|
1000
|
+
help="Interactive setup wizard: server URL, CA certificate, federation key",
|
|
1001
|
+
)
|
|
1002
|
+
init_parser.add_argument(
|
|
1003
|
+
"server_url",
|
|
1004
|
+
nargs="?",
|
|
1005
|
+
default=None,
|
|
1006
|
+
help="muxplex server URL, e.g. https://your-server:8088 (prompted if omitted)",
|
|
1007
|
+
)
|
|
1008
|
+
init_parser.add_argument(
|
|
1009
|
+
"--non-interactive",
|
|
1010
|
+
action="store_true",
|
|
1011
|
+
help="Fail with a clear message instead of prompting when input is needed (for scripting)",
|
|
1012
|
+
)
|
|
1013
|
+
|
|
1014
|
+
config_parser = sub.add_parser("config", help="View and manage config")
|
|
1015
|
+
config_sub = config_parser.add_subparsers(dest="config_command")
|
|
1016
|
+
config_sub.add_parser("list", help="Show all config keys (default)")
|
|
1017
|
+
config_get_parser = config_sub.add_parser("get", help="Show one config value")
|
|
1018
|
+
config_get_parser.add_argument("key", help="Config key")
|
|
1019
|
+
config_set_parser = config_sub.add_parser("set", help="Set a config value")
|
|
1020
|
+
config_set_parser.add_argument("key", help="Config key")
|
|
1021
|
+
config_set_parser.add_argument("value", help="New value")
|
|
1022
|
+
config_reset_parser = config_sub.add_parser("reset", help="Reset to defaults")
|
|
1023
|
+
config_reset_parser.add_argument(
|
|
1024
|
+
"key", nargs="?", help="Config key (omit to reset all)"
|
|
1025
|
+
)
|
|
1026
|
+
|
|
1027
|
+
service_parser = sub.add_parser(
|
|
1028
|
+
"service", help="Manage the muxplex-deck background service"
|
|
1029
|
+
)
|
|
1030
|
+
service_sub = service_parser.add_subparsers(dest="service_command")
|
|
1031
|
+
service_sub.add_parser("install", help="Install + enable + start the service")
|
|
1032
|
+
service_sub.add_parser("uninstall", help="Stop + disable + remove the service")
|
|
1033
|
+
service_sub.add_parser("start", help="Start the service")
|
|
1034
|
+
service_sub.add_parser("stop", help="Stop the service")
|
|
1035
|
+
service_sub.add_parser("restart", help="Stop + start the service")
|
|
1036
|
+
service_sub.add_parser("status", help="Show service status")
|
|
1037
|
+
service_sub.add_parser("logs", help="Tail service logs")
|
|
1038
|
+
|
|
1039
|
+
parser.add_argument(
|
|
1040
|
+
"--version",
|
|
1041
|
+
action="store_true",
|
|
1042
|
+
help="Show the muxplex-deck version and exit",
|
|
1043
|
+
)
|
|
1044
|
+
|
|
1045
|
+
args = parser.parse_args()
|
|
1046
|
+
|
|
1047
|
+
if getattr(args, "version", False) and args.command is None:
|
|
1048
|
+
print_version()
|
|
1049
|
+
return
|
|
1050
|
+
|
|
1051
|
+
if args.command == "version":
|
|
1052
|
+
print_version()
|
|
1053
|
+
elif args.command == "doctor":
|
|
1054
|
+
doctor(getattr(args, "config", None))
|
|
1055
|
+
elif args.command == "status":
|
|
1056
|
+
sys.exit(
|
|
1057
|
+
status(getattr(args, "config", None), as_json=getattr(args, "json", False))
|
|
1058
|
+
)
|
|
1059
|
+
elif args.command in ("update", "upgrade"):
|
|
1060
|
+
update()
|
|
1061
|
+
elif args.command == "init":
|
|
1062
|
+
from .init_wizard import run_init
|
|
1063
|
+
|
|
1064
|
+
sys.exit(
|
|
1065
|
+
run_init(
|
|
1066
|
+
getattr(args, "config", None),
|
|
1067
|
+
getattr(args, "server_url", None),
|
|
1068
|
+
non_interactive=getattr(args, "non_interactive", False),
|
|
1069
|
+
)
|
|
1070
|
+
)
|
|
1071
|
+
elif args.command == "config":
|
|
1072
|
+
config_path = getattr(args, "config", None)
|
|
1073
|
+
cmd = getattr(args, "config_command", None)
|
|
1074
|
+
if cmd == "get":
|
|
1075
|
+
config_get(args.key, config_path)
|
|
1076
|
+
elif cmd == "set":
|
|
1077
|
+
config_set(args.key, args.value, config_path)
|
|
1078
|
+
elif cmd == "reset":
|
|
1079
|
+
config_reset(getattr(args, "key", None), config_path)
|
|
1080
|
+
else:
|
|
1081
|
+
config_list(config_path)
|
|
1082
|
+
elif args.command == "service":
|
|
1083
|
+
from .service import (
|
|
1084
|
+
service_install,
|
|
1085
|
+
service_logs,
|
|
1086
|
+
service_restart,
|
|
1087
|
+
service_start,
|
|
1088
|
+
service_status,
|
|
1089
|
+
service_stop,
|
|
1090
|
+
service_uninstall,
|
|
1091
|
+
)
|
|
1092
|
+
|
|
1093
|
+
cmd = getattr(args, "service_command", None)
|
|
1094
|
+
if cmd == "install":
|
|
1095
|
+
service_install()
|
|
1096
|
+
elif cmd == "uninstall":
|
|
1097
|
+
service_uninstall()
|
|
1098
|
+
elif cmd == "start":
|
|
1099
|
+
service_start()
|
|
1100
|
+
elif cmd == "stop":
|
|
1101
|
+
service_stop()
|
|
1102
|
+
elif cmd == "restart":
|
|
1103
|
+
service_restart()
|
|
1104
|
+
elif cmd == "status":
|
|
1105
|
+
service_status()
|
|
1106
|
+
elif cmd == "logs":
|
|
1107
|
+
service_logs()
|
|
1108
|
+
else:
|
|
1109
|
+
service_parser.print_help()
|
|
1110
|
+
else:
|
|
1111
|
+
# No subcommand (or "run"): the default action.
|
|
1112
|
+
sys.exit(
|
|
1113
|
+
run(
|
|
1114
|
+
args.config,
|
|
1115
|
+
emulator=args.emulator,
|
|
1116
|
+
emulator_port=args.emulator_port,
|
|
1117
|
+
)
|
|
1118
|
+
)
|
|
1119
|
+
|
|
1120
|
+
|
|
1121
|
+
if __name__ == "__main__":
|
|
1122
|
+
main()
|