muxplex-deck 0.5.0__tar.gz → 0.5.2__tar.gz

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.
Files changed (29) hide show
  1. {muxplex_deck-0.5.0 → muxplex_deck-0.5.2}/PKG-INFO +1 -1
  2. {muxplex_deck-0.5.0 → muxplex_deck-0.5.2}/pyproject.toml +1 -1
  3. {muxplex_deck-0.5.0 → muxplex_deck-0.5.2}/src/muxplex_deck/cli.py +97 -35
  4. {muxplex_deck-0.5.0 → muxplex_deck-0.5.2}/src/muxplex_deck/config.py +3 -3
  5. {muxplex_deck-0.5.0 → muxplex_deck-0.5.2}/src/muxplex_deck/hidhelp.py +23 -4
  6. {muxplex_deck-0.5.0 → muxplex_deck-0.5.2}/src/muxplex_deck/service.py +82 -7
  7. {muxplex_deck-0.5.0 → muxplex_deck-0.5.2}/README.md +0 -0
  8. {muxplex_deck-0.5.0 → muxplex_deck-0.5.2}/src/deck_probe/__init__.py +0 -0
  9. {muxplex_deck-0.5.0 → muxplex_deck-0.5.2}/src/deck_probe/__main__.py +0 -0
  10. {muxplex_deck-0.5.0 → muxplex_deck-0.5.2}/src/deck_probe/capabilities.py +0 -0
  11. {muxplex_deck-0.5.0 → muxplex_deck-0.5.2}/src/deck_probe/events.py +0 -0
  12. {muxplex_deck-0.5.0 → muxplex_deck-0.5.2}/src/deck_probe/main.py +0 -0
  13. {muxplex_deck-0.5.0 → muxplex_deck-0.5.2}/src/deck_probe/rendering.py +0 -0
  14. {muxplex_deck-0.5.0 → muxplex_deck-0.5.2}/src/muxplex_deck/__init__.py +0 -0
  15. {muxplex_deck-0.5.0 → muxplex_deck-0.5.2}/src/muxplex_deck/__main__.py +0 -0
  16. {muxplex_deck-0.5.0 → muxplex_deck-0.5.2}/src/muxplex_deck/attention.py +0 -0
  17. {muxplex_deck-0.5.0 → muxplex_deck-0.5.2}/src/muxplex_deck/device.py +0 -0
  18. {muxplex_deck-0.5.0 → muxplex_deck-0.5.2}/src/muxplex_deck/device_real.py +0 -0
  19. {muxplex_deck-0.5.0 → muxplex_deck-0.5.2}/src/muxplex_deck/emulator.py +0 -0
  20. {muxplex_deck-0.5.0 → muxplex_deck-0.5.2}/src/muxplex_deck/focus.py +0 -0
  21. {muxplex_deck-0.5.0 → muxplex_deck-0.5.2}/src/muxplex_deck/init_wizard.py +0 -0
  22. {muxplex_deck-0.5.0 → muxplex_deck-0.5.2}/src/muxplex_deck/interaction.py +0 -0
  23. {muxplex_deck-0.5.0 → muxplex_deck-0.5.2}/src/muxplex_deck/layout.py +0 -0
  24. {muxplex_deck-0.5.0 → muxplex_deck-0.5.2}/src/muxplex_deck/main.py +0 -0
  25. {muxplex_deck-0.5.0 → muxplex_deck-0.5.2}/src/muxplex_deck/rendering.py +0 -0
  26. {muxplex_deck-0.5.0 → muxplex_deck-0.5.2}/src/muxplex_deck/statusfile.py +0 -0
  27. {muxplex_deck-0.5.0 → muxplex_deck-0.5.2}/src/muxplex_deck/usbnode.py +0 -0
  28. {muxplex_deck-0.5.0 → muxplex_deck-0.5.2}/src/muxplex_deck/views.py +0 -0
  29. {muxplex_deck-0.5.0 → muxplex_deck-0.5.2}/src/muxplex_deck/wsl.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: muxplex-deck
3
- Version: 0.5.0
3
+ Version: 0.5.2
4
4
  Summary: Hardware probe/diagnostic app for the Elgato Stream Deck+ (seed of the muxplex sidecar)
5
5
  Author: Amplifier
6
6
  Author-email: Amplifier <240397093+microsoft-amplifier@users.noreply.github.com>
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "muxplex-deck"
3
- version = "0.5.0"
3
+ version = "0.5.2"
4
4
  description = "Hardware probe/diagnostic app for the Elgato Stream Deck+ (seed of the muxplex sidecar)"
5
5
  readme = "README.md"
6
6
  authors = [
@@ -16,6 +16,7 @@ import argparse
16
16
  import contextlib
17
17
  import json
18
18
  import platform
19
+ import shutil
19
20
  import subprocess
20
21
  import sys
21
22
  import time
@@ -323,7 +324,7 @@ def check_config_file(config_path: str | None = None) -> tuple[str, str]:
323
324
  resolved = config_mod._resolve_config_path(config_path)
324
325
  if resolved.exists():
325
326
  return "ok", f"Config: {resolved}"
326
- return "warn", f"Config: {resolved} (not yet created -- see README.md)"
327
+ return "warn", f"Config: {resolved} (not yet created -- run: muxplex-deck init)"
327
328
 
328
329
 
329
330
  def check_federation_key(key_file: Path) -> tuple[str, str]:
@@ -496,9 +497,16 @@ def check_hid_openable() -> tuple[str, str]:
496
497
  return "ok", "HID: device in use by the muxplex-deck service (expected)"
497
498
 
498
499
  from . import hidhelp
500
+ from . import wsl as wsl_mod
499
501
 
500
502
  hint = ""
501
- if sys.platform not in ("darwin", "win32") and not udev_rule_exists():
503
+ if wsl_mod.detect().is_wsl:
504
+ # The udev-rule hints below don't apply on WSL -- see
505
+ # hidhelp.udev_guidance()'s WSL gate. explain_environment()'s W7
506
+ # guidance (the proven per-attach chown), already surfaced above
507
+ # this line in `doctor()`, covers this instead.
508
+ pass
509
+ elif sys.platform not in ("darwin", "win32") and not udev_rule_exists():
502
510
  hint = hidhelp.HID_HINT_RUN_SERVICE_INSTALL
503
511
  elif sys.platform not in ("darwin", "win32"):
504
512
  hint = hidhelp.HID_HINT_RULE_EXISTS_BUT_STILL_FAILED
@@ -586,38 +594,35 @@ def check_server_reachable(server_url: str, ca_file: Path | None) -> tuple[str,
586
594
 
587
595
 
588
596
  def check_service_status() -> tuple[str, str]:
589
- """Best-effort service-installed/running check (systemd or launchd)."""
597
+ """Service-state check: not installed / installed but not running / running.
598
+
599
+ Three distinct states, not two -- `service_is_installed()` (unit/plist
600
+ file exists) and `service_is_active()` (currently running) are checked
601
+ independently, so an installed-but-crash-looping service is reported
602
+ honestly instead of as "not installed" (which used to tell the user to
603
+ re-run `service install` for a service that was already installed and
604
+ failing -- see AGENTS.md for the incident this fixes).
605
+ """
606
+ from .service import service_is_active, service_is_installed
607
+
590
608
  if sys.platform == "darwin":
591
- import os as _os
609
+ manager, tool = "launchd", "launchctl"
610
+ else:
611
+ manager, tool = "systemd", "systemctl"
592
612
 
593
- uid = _os.getuid()
594
- try:
595
- result = subprocess.run(
596
- ["launchctl", "print", f"gui/{uid}/com.muxplex-deck"],
597
- capture_output=True,
598
- text=True,
599
- timeout=5,
600
- check=False,
601
- )
602
- except FileNotFoundError:
603
- return "warn", "Service: launchctl not found"
604
- if result.returncode == 0:
605
- return "ok", "Service: installed (launchd)"
613
+ if shutil.which(tool) is None:
614
+ return "warn", f"Service: {tool} not found -- run muxplex-deck directly"
615
+
616
+ if not service_is_installed():
606
617
  return "warn", "Service: not installed -- run: muxplex-deck service install"
607
618
 
608
- try:
609
- result = subprocess.run(
610
- ["systemctl", "--user", "is-active", "muxplex-deck"],
611
- capture_output=True,
612
- text=True,
613
- timeout=5,
614
- check=False,
615
- )
616
- except FileNotFoundError:
617
- return "warn", "Service: systemctl not found -- run muxplex-deck directly"
618
- if result.returncode == 0:
619
- return "ok", f"Service: {result.stdout.strip()} (systemd)"
620
- return "warn", "Service: not installed -- run: muxplex-deck service install"
619
+ if service_is_active():
620
+ return "ok", f"Service: installed and running ({manager})"
621
+
622
+ return "warn", (
623
+ f"Service: installed ({manager}) but not running -- check: "
624
+ "muxplex-deck service logs"
625
+ )
621
626
 
622
627
 
623
628
  _CHECK_MARKS: dict[str, str] = {
@@ -669,11 +674,40 @@ def doctor(config_path: str | None = None) -> int:
669
674
  # this adds no output there -- see hidhelp.explain_environment().
670
675
  from . import hidhelp
671
676
 
672
- for guidance in hidhelp.explain_environment():
677
+ env_guidances = hidhelp.explain_environment()
678
+ for guidance in env_guidances:
673
679
  checks.append((guidance.status, guidance.message))
674
-
675
- checks.append(check_deck_detected(config_path))
676
- checks.append(check_hid_openable())
680
+ env_states = {g.state for g in env_guidances}
681
+
682
+ if "W7" in env_states:
683
+ # The W7 guidance just appended above already says the device is
684
+ # attached but can't be opened, plus the proven per-attach `chown`
685
+ # fix -- check_deck_detected()/check_hid_openable() would only
686
+ # restate the same fact across two more (partly contradictory)
687
+ # lines. See the WSL cold-start bug report: "3 lines describe one
688
+ # problem". Skip both; nothing more to add.
689
+ pass
690
+ else:
691
+ deck_status, deck_message = check_deck_detected(config_path)
692
+ if deck_message == _NO_DEVICE_GUIDANCE and env_states & {
693
+ "W1",
694
+ "W2",
695
+ "W3",
696
+ "W4",
697
+ "W5",
698
+ "W6",
699
+ }:
700
+ # A WSL-specific state above already explains exactly where
701
+ # the device is (or precisely why it isn't visible to this OS
702
+ # yet) -- the generic "check the cable" guidance would
703
+ # flatly contradict it (see bug report: located @ BUSID 1-4,
704
+ # immediately followed by "check your cable").
705
+ deck_message = (
706
+ "Stream Deck: not detected on this OS yet -- see the WSL "
707
+ "guidance above."
708
+ )
709
+ checks.append((deck_status, deck_message))
710
+ checks.append(check_hid_openable())
677
711
 
678
712
  server_url = cfg.server_url if cfg is not None else raw.get("server_url", "")
679
713
  checks.append(check_server_reachable(server_url, ca_file))
@@ -1044,6 +1078,34 @@ def update(*, force: bool = False) -> None:
1044
1078
  # ---------------------------------------------------------------------------
1045
1079
 
1046
1080
 
1081
+ # A settled-enough bound for sysfs to enumerate a just-attached device --
1082
+ # generous enough to absorb the usbip attach -> udev/sysfs lag that
1083
+ # produced a false W6 ("not visible yet") immediately followed by a
1084
+ # `doctor` run that found the device fine, but tight enough that a
1085
+ # genuinely-stuck attach still fails promptly.
1086
+ _ATTACH_SETTLE_ATTEMPTS = 5
1087
+ _ATTACH_SETTLE_DELAY_SECONDS = 0.2
1088
+
1089
+
1090
+ def _find_usb_node_with_settle(usbnode_mod: Any, vendor_id: str) -> Any:
1091
+ """Poll for the USB node with a bounded settle window after an attach.
1092
+
1093
+ A successful `wsl.attach()` can return before sysfs has finished
1094
+ enumerating the device on this side -- checking `find_usb_node` only
1095
+ once produced a false negative (W6: "attached but Linux doesn't see it
1096
+ yet") moments before the very next `doctor` run found the device
1097
+ working. Retries a few times with a short delay instead of declaring
1098
+ failure on the very first miss.
1099
+ """
1100
+ node = usbnode_mod.find_usb_node(vendor_id)
1101
+ for _ in range(_ATTACH_SETTLE_ATTEMPTS - 1):
1102
+ if node is not None:
1103
+ return node
1104
+ time.sleep(_ATTACH_SETTLE_DELAY_SECONDS)
1105
+ node = usbnode_mod.find_usb_node(vendor_id)
1106
+ return node
1107
+
1108
+
1047
1109
  def wsl_attach(*, vendor_id: str = "0fd9") -> int:
1048
1110
  """`muxplex-deck wsl attach` -- attach the Stream Deck to this WSL2 distro.
1049
1111
 
@@ -1119,7 +1181,7 @@ def wsl_attach(*, vendor_id: str = "0fd9") -> int:
1119
1181
  else:
1120
1182
  print_check("ok", f"Already attached (BUSID {device.busid})")
1121
1183
 
1122
- node = usbnode_mod.find_usb_node(vendor_id)
1184
+ node = _find_usb_node_with_settle(usbnode_mod, vendor_id)
1123
1185
  if node is None:
1124
1186
  print_check("warn", hidhelp.w6_message(device))
1125
1187
  print()
@@ -145,9 +145,9 @@ def load_config(config_path: str | None = None) -> Config:
145
145
  if not path.exists():
146
146
  raise ConfigError(
147
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."
148
+ "Run: muxplex-deck init\n"
149
+ "(or create it by hand with at least a 'server_url' field, e.g.:\n"
150
+ ' {"server_url": "https://<your-server>:8088"})'
151
151
  )
152
152
 
153
153
  try:
@@ -52,7 +52,10 @@ HID_HINT_RULE_EXISTS_BUT_STILL_FAILED = (
52
52
  " A udev rule exists but the device still could not be opened."
53
53
  )
54
54
 
55
- _UDEV_RULE_CONTENT = 'SUBSYSTEM=="usb", ATTRS{idVendor}=="0fd9", MODE="0660", GROUP="plugdev", TAG+="uaccess"\n'
55
+ _UDEV_RULE_CONTENT = (
56
+ 'SUBSYSTEM=="usb", ATTRS{idVendor}=="0fd9", MODE="0660", '
57
+ 'GROUP="plugdev", TAG+="uaccess"'
58
+ )
56
59
 
57
60
 
58
61
  @dataclass(frozen=True)
@@ -369,8 +372,24 @@ def udev_guidance() -> Guidance | None:
369
372
 
370
373
  Returns `None` when udev is not live (P4: never print a command we
371
374
  know will fail here) -- `explain_environment()`'s U-DEAD guidance
372
- takes its place in that case instead.
375
+ takes its place in that case instead. Also returns `None` on WSL:
376
+ `udevadm control --reload-rules` can report success there while the
377
+ rule still never fires for a usbip-attached device (see AGENTS.md
378
+ "U7" -- a real WSL user followed this exact block and lost ~40
379
+ minutes to a rule that never took effect). On WSL the only proven
380
+ remediation is the per-attach `sudo chown` in `w7_message()`, already
381
+ surfaced by `explain_environment()` -- callers show that instead (see
382
+ `service._warn_if_no_udev_rule`).
383
+
384
+ The install command is a single `echo | sudo tee` line rather than a
385
+ `<<'EOF' ... EOF` heredoc: this text gets copy-pasted into a terminal,
386
+ and a heredoc terminator that isn't at column 0 (as it wasn't here,
387
+ once the surrounding block got indented for display) is silently
388
+ swallowed by the shell, leaving the user stuck at a `>` continuation
389
+ prompt. A single line has no terminator to misplace.
373
390
  """
391
+ if wsl.detect().is_wsl:
392
+ return None
374
393
  if not usbnode.udev_is_live():
375
394
  return None
376
395
  return Guidance(
@@ -379,8 +398,8 @@ def udev_guidance() -> Guidance | None:
379
398
  f"No udev rule found for the Stream Deck (vendor id {_ELGATO_VENDOR_ID}).\n"
380
399
  "Without it, the service (running as your user, not root) will fail to\n"
381
400
  "open the device. Install a rule once:\n\n"
382
- " sudo tee /etc/udev/rules.d/70-streamdeck.rules >/dev/null <<'EOF'\n"
383
- f"{_UDEV_RULE_CONTENT}EOF\n"
401
+ f" echo '{_UDEV_RULE_CONTENT}' | sudo tee "
402
+ "/etc/udev/rules.d/70-streamdeck.rules >/dev/null\n"
384
403
  " sudo udevadm control --reload-rules && sudo udevadm trigger\n\n"
385
404
  "Then unplug and replug the Stream Deck (or re-run `muxplex-deck wsl attach`\n"
386
405
  "under WSL), and make sure you're in the `plugdev` group:\n"
@@ -44,6 +44,8 @@ import sys
44
44
  import time
45
45
  from pathlib import Path
46
46
 
47
+ from . import config as config_mod
48
+
47
49
  # ---------------------------------------------------------------------------
48
50
  # Constants
49
51
  # ---------------------------------------------------------------------------
@@ -232,20 +234,28 @@ def _warn_if_no_udev_rule() -> None:
232
234
 
233
235
  Delegates the actual guidance text to `hidhelp.udev_guidance()` -- the
234
236
  single home for this string (see WSL_COLD_START_SPEC.md P6). That
235
- function returns `None` when udev isn't actually running (P4: never
236
- print a command known to fail here); in that case this prints
237
- nothing, and the U-DEAD guidance from `hidhelp.explain_environment()`
238
- (surfaced separately by `_print_environment_guidance()` below) takes
239
- its place instead.
237
+ function now returns `None` both when udev isn't actually running
238
+ (P4: never print a command known to fail here) and when this is WSL
239
+ (the reload can report success there while the rule still never
240
+ fires for a usbip-attached device -- see AGENTS.md "U7"; a real WSL
241
+ user followed this exact block and lost ~40 minutes). On a healthy
242
+ non-WSL platform this prints nothing, exactly as before. On WSL,
243
+ show the environment-appropriate guidance instead (the proven
244
+ per-attach `chown`) rather than leaving the user with nothing.
240
245
  """
241
246
  if udev_rule_exists():
242
247
  return
243
248
  from . import hidhelp
244
249
 
245
250
  guidance = hidhelp.udev_guidance()
246
- if guidance is None:
251
+ if guidance is not None:
252
+ _print_guidance_block(guidance.message)
247
253
  return
248
- _print_guidance_block(guidance.message)
254
+
255
+ from . import wsl as wsl_mod
256
+
257
+ if wsl_mod.detect().is_wsl:
258
+ _print_environment_guidance()
249
259
 
250
260
 
251
261
  def _print_environment_guidance() -> None:
@@ -299,6 +309,22 @@ def service_is_active() -> bool:
299
309
  return result.returncode == 0
300
310
 
301
311
 
312
+ def service_is_installed() -> bool:
313
+ """Is the muxplex-deck unit/plist file present on disk?
314
+
315
+ Independent of `service_is_active()` -- a service that is installed but
316
+ crash-looping (or simply stopped) is still installed. `doctor`'s
317
+ `check_service_status` used to conflate "not active" with "not
318
+ installed" and recommend `service install` for a service that was
319
+ already installed and failing; this is the file-existence half of the
320
+ fix (see AGENTS.md for the incident: 1113 restarts, then told to
321
+ "install" a service that was already there).
322
+ """
323
+ if _is_darwin():
324
+ return _LAUNCHD_PLIST_PATH.exists()
325
+ return _SYSTEMD_UNIT_PATH.exists()
326
+
327
+
302
328
  def _enable_linger() -> None:
303
329
  """Best-effort `loginctl enable-linger` so the service survives logout.
304
330
 
@@ -338,6 +364,37 @@ def _print_next_steps() -> None:
338
364
  print()
339
365
 
340
366
 
367
+ def _config_ready() -> tuple[bool, str | None]:
368
+ """Return (True, None) if config is loadable, else (False, ConfigError message).
369
+
370
+ Calls the exact same `config.load_config()` the installed unit's own
371
+ `ExecStart` (`... run`, no `--config` flag) will call at startup, so
372
+ "ready to install" and "ready to run" can never diverge. A config that
373
+ fails this check would make the service crash immediately once
374
+ started, and `Restart=always` (`KeepAlive` on launchd) would then
375
+ restart it every few seconds forever -- see the `service install`
376
+ crash-loop incident in AGENTS.md (1113 restarts on a fresh machine
377
+ with no config yet).
378
+ """
379
+ try:
380
+ config_mod.load_config(None)
381
+ except config_mod.ConfigError as exc:
382
+ return False, str(exc)
383
+ return True, None
384
+
385
+
386
+ def _print_config_not_ready(config_error: str | None) -> None:
387
+ """Explain why install is stopping short of enabling/starting the service."""
388
+ _print_guidance_block(
389
+ "Not enabling or starting yet -- configuration is incomplete:\n"
390
+ + (config_error or "unknown configuration error")
391
+ )
392
+ print()
393
+ print(" Run muxplex-deck init to create it, then run this command again:")
394
+ print(" muxplex-deck service install")
395
+ print()
396
+
397
+
341
398
  def _systemd_install() -> None:
342
399
  print("\nmuxplex-deck service install (systemd --user)\n")
343
400
 
@@ -354,6 +411,15 @@ def _systemd_install() -> None:
354
411
  subprocess.run(["systemctl", "--user", "daemon-reload"], check=True)
355
412
  _step_ok("Reloaded the systemd user daemon")
356
413
 
414
+ # Never enable/start a unit whose config we already know will make it
415
+ # crash on launch -- see `_config_ready()`. The unit file above is
416
+ # harmless to have on disk either way; only the "make it run" steps are
417
+ # gated.
418
+ config_ready, config_error = _config_ready()
419
+ if not config_ready:
420
+ _print_config_not_ready(config_error)
421
+ return
422
+
357
423
  subprocess.run(
358
424
  ["systemctl", "--user", "enable", "--now", "muxplex-deck"], check=True
359
425
  )
@@ -559,6 +625,15 @@ def _launchd_install() -> None:
559
625
  _LAUNCHD_PLIST_PATH.write_text(plist_content)
560
626
  _step_ok(f"Wrote plist: {_LAUNCHD_PLIST_PATH}")
561
627
 
628
+ # Never bootstrap (load + start) a job whose config we already know will
629
+ # make it crash on launch -- see `_config_ready()`. `KeepAlive` would
630
+ # then have launchd relaunch it forever. The plist above is harmless to
631
+ # have on disk either way; only the "make it run" step is gated.
632
+ config_ready, config_error = _config_ready()
633
+ if not config_ready:
634
+ _print_config_not_ready(config_error)
635
+ return
636
+
562
637
  result = _launchd_bootstrap()
563
638
  if result.returncode == 0:
564
639
  _step_ok("Loaded + started the service (launchctl bootstrap)")
File without changes