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/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()