muxplex-deck 0.4.1__tar.gz → 0.5.0__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.4.1 → muxplex_deck-0.5.0}/PKG-INFO +18 -19
  2. {muxplex_deck-0.4.1 → muxplex_deck-0.5.0}/README.md +17 -18
  3. {muxplex_deck-0.4.1 → muxplex_deck-0.5.0}/pyproject.toml +1 -1
  4. {muxplex_deck-0.4.1 → muxplex_deck-0.5.0}/src/muxplex_deck/cli.py +157 -13
  5. muxplex_deck-0.5.0/src/muxplex_deck/hidhelp.py +390 -0
  6. {muxplex_deck-0.4.1 → muxplex_deck-0.5.0}/src/muxplex_deck/init_wizard.py +27 -3
  7. {muxplex_deck-0.4.1 → muxplex_deck-0.5.0}/src/muxplex_deck/main.py +106 -7
  8. {muxplex_deck-0.4.1 → muxplex_deck-0.5.0}/src/muxplex_deck/service.py +47 -20
  9. {muxplex_deck-0.4.1 → muxplex_deck-0.5.0}/src/muxplex_deck/statusfile.py +11 -0
  10. muxplex_deck-0.5.0/src/muxplex_deck/usbnode.py +109 -0
  11. muxplex_deck-0.5.0/src/muxplex_deck/wsl.py +221 -0
  12. {muxplex_deck-0.4.1 → muxplex_deck-0.5.0}/src/deck_probe/__init__.py +0 -0
  13. {muxplex_deck-0.4.1 → muxplex_deck-0.5.0}/src/deck_probe/__main__.py +0 -0
  14. {muxplex_deck-0.4.1 → muxplex_deck-0.5.0}/src/deck_probe/capabilities.py +0 -0
  15. {muxplex_deck-0.4.1 → muxplex_deck-0.5.0}/src/deck_probe/events.py +0 -0
  16. {muxplex_deck-0.4.1 → muxplex_deck-0.5.0}/src/deck_probe/main.py +0 -0
  17. {muxplex_deck-0.4.1 → muxplex_deck-0.5.0}/src/deck_probe/rendering.py +0 -0
  18. {muxplex_deck-0.4.1 → muxplex_deck-0.5.0}/src/muxplex_deck/__init__.py +0 -0
  19. {muxplex_deck-0.4.1 → muxplex_deck-0.5.0}/src/muxplex_deck/__main__.py +0 -0
  20. {muxplex_deck-0.4.1 → muxplex_deck-0.5.0}/src/muxplex_deck/attention.py +0 -0
  21. {muxplex_deck-0.4.1 → muxplex_deck-0.5.0}/src/muxplex_deck/config.py +0 -0
  22. {muxplex_deck-0.4.1 → muxplex_deck-0.5.0}/src/muxplex_deck/device.py +0 -0
  23. {muxplex_deck-0.4.1 → muxplex_deck-0.5.0}/src/muxplex_deck/device_real.py +0 -0
  24. {muxplex_deck-0.4.1 → muxplex_deck-0.5.0}/src/muxplex_deck/emulator.py +0 -0
  25. {muxplex_deck-0.4.1 → muxplex_deck-0.5.0}/src/muxplex_deck/focus.py +0 -0
  26. {muxplex_deck-0.4.1 → muxplex_deck-0.5.0}/src/muxplex_deck/interaction.py +0 -0
  27. {muxplex_deck-0.4.1 → muxplex_deck-0.5.0}/src/muxplex_deck/layout.py +0 -0
  28. {muxplex_deck-0.4.1 → muxplex_deck-0.5.0}/src/muxplex_deck/rendering.py +0 -0
  29. {muxplex_deck-0.4.1 → muxplex_deck-0.5.0}/src/muxplex_deck/views.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: muxplex-deck
3
- Version: 0.4.1
3
+ Version: 0.5.0
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>
@@ -108,10 +108,14 @@ without a udev rule granting your user access to the device (vendor id
108
108
  prints a copy-pasteable remediation block (the same rule shown in
109
109
  "Permissions" under the hardware probe section above) instead of silently
110
110
  installing a service that won't work. It never writes to `/etc` itself --
111
- only detects and reports. Run the printed `sudo tee ... && sudo udevadm
111
+ only detects and reports. This remediation is only ever printed when udev
112
+ is actually running (`/run/udev/control` present) -- on WSL without
113
+ systemd, or in a container, it never fires anyway, so `muxplex-deck`
114
+ detects that and prints a different, WSL/container-appropriate fix instead
115
+ (see `docs/WSL.md`). Run the printed `sudo tee ... && sudo udevadm
112
116
  control --reload-rules && sudo udevadm trigger` commands, then unplug and
113
- replug the deck (or re-`usbipd attach` under WSL) and `muxplex-deck service
114
- restart`.
117
+ replug the deck (or `muxplex-deck wsl attach` under WSL) and `muxplex-deck
118
+ service restart`.
115
119
 
116
120
  On Linux, `service install` also attempts `loginctl enable-linger
117
121
  <you>` (best-effort, non-fatal) so the service keeps running after you log
@@ -159,25 +163,20 @@ uv run deck-probe
159
163
 
160
164
  #### Windows + WSL
161
165
 
162
- USB devices are not visible inside WSL until attached from the Windows
163
- side. In an **admin PowerShell**:
164
-
165
- ```powershell
166
- usbipd list # find the Stream Deck's BUSID (VID 0fd9)
167
- usbipd bind --busid <BUSID> # first time only
168
- usbipd attach --wsl --busid <BUSID>
169
- ```
170
-
171
- Then inside WSL, grant HID access via a udev rule (first time only):
166
+ USB devices are invisible to WSL until Windows hands them over. Rather
167
+ than driving `usbipd`/udev rules by hand for this PoC probe, install and
168
+ use the actual product instead -- it handles this for you:
172
169
 
173
170
  ```sh
174
- sudo tee /etc/udev/rules.d/70-streamdeck.rules >/dev/null \
175
- <<< 'SUBSYSTEM=="hidraw", ATTRS{idVendor}=="0fd9", MODE="0666"'
176
- sudo udevadm control --reload-rules
177
- # then detach/re-attach the device (usbipd detach + attach)
178
- sudo apt install libhidapi-libusb0 # native HIDAPI, if not present
171
+ uv tool install muxplex-deck
172
+ muxplex-deck wsl attach # finds the deck, attaches it, tells you what's left
173
+ muxplex-deck doctor # diagnoses every remaining step, with real values
179
174
  ```
180
175
 
176
+ Neither needs the details -- but see `docs/WSL.md` if you want them (BUSID/
177
+ device-node churn, the `usbipd` vs `usbipd.exe` trap, durable vs per-attach
178
+ permission options).
179
+
181
180
  **Close the Elgato Stream Deck app on Windows first** -- it holds
182
181
  exclusive HID access. The probe prints this same guidance when it finds
183
182
  no device.
@@ -95,10 +95,14 @@ without a udev rule granting your user access to the device (vendor id
95
95
  prints a copy-pasteable remediation block (the same rule shown in
96
96
  "Permissions" under the hardware probe section above) instead of silently
97
97
  installing a service that won't work. It never writes to `/etc` itself --
98
- only detects and reports. Run the printed `sudo tee ... && sudo udevadm
98
+ only detects and reports. This remediation is only ever printed when udev
99
+ is actually running (`/run/udev/control` present) -- on WSL without
100
+ systemd, or in a container, it never fires anyway, so `muxplex-deck`
101
+ detects that and prints a different, WSL/container-appropriate fix instead
102
+ (see `docs/WSL.md`). Run the printed `sudo tee ... && sudo udevadm
99
103
  control --reload-rules && sudo udevadm trigger` commands, then unplug and
100
- replug the deck (or re-`usbipd attach` under WSL) and `muxplex-deck service
101
- restart`.
104
+ replug the deck (or `muxplex-deck wsl attach` under WSL) and `muxplex-deck
105
+ service restart`.
102
106
 
103
107
  On Linux, `service install` also attempts `loginctl enable-linger
104
108
  <you>` (best-effort, non-fatal) so the service keeps running after you log
@@ -146,25 +150,20 @@ uv run deck-probe
146
150
 
147
151
  #### Windows + WSL
148
152
 
149
- USB devices are not visible inside WSL until attached from the Windows
150
- side. In an **admin PowerShell**:
151
-
152
- ```powershell
153
- usbipd list # find the Stream Deck's BUSID (VID 0fd9)
154
- usbipd bind --busid <BUSID> # first time only
155
- usbipd attach --wsl --busid <BUSID>
156
- ```
157
-
158
- Then inside WSL, grant HID access via a udev rule (first time only):
153
+ USB devices are invisible to WSL until Windows hands them over. Rather
154
+ than driving `usbipd`/udev rules by hand for this PoC probe, install and
155
+ use the actual product instead -- it handles this for you:
159
156
 
160
157
  ```sh
161
- sudo tee /etc/udev/rules.d/70-streamdeck.rules >/dev/null \
162
- <<< 'SUBSYSTEM=="hidraw", ATTRS{idVendor}=="0fd9", MODE="0666"'
163
- sudo udevadm control --reload-rules
164
- # then detach/re-attach the device (usbipd detach + attach)
165
- sudo apt install libhidapi-libusb0 # native HIDAPI, if not present
158
+ uv tool install muxplex-deck
159
+ muxplex-deck wsl attach # finds the deck, attaches it, tells you what's left
160
+ muxplex-deck doctor # diagnoses every remaining step, with real values
166
161
  ```
167
162
 
163
+ Neither needs the details -- but see `docs/WSL.md` if you want them (BUSID/
164
+ device-node churn, the `usbipd` vs `usbipd.exe` trap, durable vs per-attach
165
+ permission options).
166
+
168
167
  **Close the Elgato Stream Deck app on Windows first** -- it holds
169
168
  exclusive HID access. The probe prints this same guidance when it finds
170
169
  no device.
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "muxplex-deck"
3
- version = "0.4.1"
3
+ version = "0.5.0"
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 = [
@@ -421,16 +421,9 @@ def probe_deck_status(manager: Any) -> dict:
421
421
 
422
422
  _NO_DEVICE_GUIDANCE = (
423
423
  "No Stream Deck found. Things to check:\n"
424
- " - All platforms: close the official Elgato Stream Deck app -- it holds\n"
425
- " exclusive HID access, so muxplex-deck cannot open the device while it runs.\n"
426
- " - Linux (incl. WSL): a udev rule must grant access to Elgato devices\n"
427
- " (vendor id 0x0fd9). Run 'muxplex-deck service install' for the exact\n"
428
- " remediation block, or see AGENTS.md.\n"
429
- " - WSL specifically: USB devices are not visible until attached from\n"
430
- " Windows via usbipd -- in an admin PowerShell:\n"
431
- " usbipd list (find the Stream Deck's BUSID)\n"
432
- " usbipd bind --busid <BUSID> (first time only)\n"
433
- " usbipd attach --wsl --busid <BUSID>\n"
424
+ " - Close the official Elgato Stream Deck app -- it holds exclusive HID access,\n"
425
+ " so muxplex-deck cannot open the device while it is running.\n"
426
+ " - Check the USB cable and try a different port.\n"
434
427
  )
435
428
 
436
429
 
@@ -502,11 +495,13 @@ def check_hid_openable() -> tuple[str, str]:
502
495
  if service_is_active():
503
496
  return "ok", "HID: device in use by the muxplex-deck service (expected)"
504
497
 
498
+ from . import hidhelp
499
+
505
500
  hint = ""
506
501
  if sys.platform not in ("darwin", "win32") and not udev_rule_exists():
507
- hint = " Run: muxplex-deck service install (prints the udev remediation)."
502
+ hint = hidhelp.HID_HINT_RUN_SERVICE_INSTALL
508
503
  elif sys.platform not in ("darwin", "win32"):
509
- hint = " A udev rule exists but the device still could not be opened."
504
+ hint = hidhelp.HID_HINT_RULE_EXISTS_BUT_STILL_FAILED
510
505
  return "warn", f"HID: could not open device ({status['error']}).{hint}"
511
506
 
512
507
 
@@ -668,6 +663,15 @@ def doctor(config_path: str | None = None) -> int:
668
663
  ca_file = config_mod._expand(raw["ca_file"])
669
664
  checks.append(check_ca_file(ca_file))
670
665
 
666
+ # Environment guidance (WSL/usbipd/udev-liveness) BEFORE the device
667
+ # checks -- it explains why the next line is about to warn. Returns []
668
+ # on a healthy platform (macOS, or native Linux with udev running), so
669
+ # this adds no output there -- see hidhelp.explain_environment().
670
+ from . import hidhelp
671
+
672
+ for guidance in hidhelp.explain_environment():
673
+ checks.append((guidance.status, guidance.message))
674
+
671
675
  checks.append(check_deck_detected(config_path))
672
676
  checks.append(check_hid_openable())
673
677
 
@@ -702,6 +706,9 @@ def _format_device_line(device: dict[str, Any]) -> tuple[str, str]:
702
706
  if not device.get("connected"):
703
707
  return "warn", "Device: not connected"
704
708
  caps = device.get("capabilities") or {}
709
+ hint = device.get("hint")
710
+ if not caps and hint:
711
+ return "warn", "Device: connected (capabilities unavailable)"
705
712
  if not caps:
706
713
  return "ok", "Device: connected (capabilities unavailable)"
707
714
  touchscreen = "yes" if caps.get("has_touchscreen") else "no"
@@ -801,7 +808,15 @@ def status(config_path: str | None = None, *, as_json: bool = False) -> int:
801
808
  else:
802
809
  print_check("ok", f"Status updated {age:.0f}s ago (pid {data.get('pid', '?')})")
803
810
 
804
- print_check(*_format_device_line(data.get("device", {})))
811
+ device_data = data.get("device", {})
812
+ print_check(*_format_device_line(device_data))
813
+ hint = device_data.get("hint")
814
+ if hint and not device_data.get("connected"):
815
+ # Populated by the sidecar's open-failure branch (see main.py /
816
+ # hidhelp.explain_open_failure) -- this is what turns a stale
817
+ # status file into the primary teaching surface instead of a
818
+ # bare "not connected" with no explanation.
819
+ print_check("warn", hint)
805
820
  print_check(*_format_server_line(data.get("server", {})))
806
821
  print_check("ok", _format_state_line(data.get("state", {})))
807
822
 
@@ -1016,6 +1031,122 @@ def update(*, force: bool = False) -> None:
1016
1031
  doctor()
1017
1032
 
1018
1033
 
1034
+ # ---------------------------------------------------------------------------
1035
+ # wsl attach -- find, share-check, and attach the Stream Deck under WSL2.
1036
+ #
1037
+ # This is the ONLY command whose entire purpose is to mutate host state
1038
+ # (WSL_COLD_START_SPEC.md section 6.1): it's the sole caller of
1039
+ # `wsl.attach()`. Its deciding rationale isn't convenience -- it removes
1040
+ # the `usbipd` vs `usbipd.exe` trap from the recurring path entirely (a
1041
+ # user who never types either name cannot get the wrong binary; see
1042
+ # section 6.4). Never invokes `sudo` (P3) -- when a permission fix is
1043
+ # still needed after attaching, it prints the command, it doesn't run it.
1044
+ # ---------------------------------------------------------------------------
1045
+
1046
+
1047
+ def wsl_attach(*, vendor_id: str = "0fd9") -> int:
1048
+ """`muxplex-deck wsl attach` -- attach the Stream Deck to this WSL2 distro.
1049
+
1050
+ Exit 0 on a successful attach (even if a follow-up permission fix is
1051
+ still needed -- the attach itself succeeded). Exit 1 for every state
1052
+ that stops short of attaching (not WSL, WSL1, usbipd.exe missing, not
1053
+ found, not shared) so scripts can branch on it.
1054
+ """
1055
+ from . import hidhelp
1056
+ from . import usbnode as usbnode_mod
1057
+ from . import wsl as wsl_mod
1058
+
1059
+ print("\nmuxplex-deck wsl attach\n")
1060
+
1061
+ info = wsl_mod.detect()
1062
+ if not info.is_wsl:
1063
+ print_check(
1064
+ "fail", "Not running under WSL -- this command only makes sense there."
1065
+ )
1066
+ print()
1067
+ return 1
1068
+ print_check("ok", f"WSL{info.version} detected ({info.kernel})")
1069
+
1070
+ if info.version == 1:
1071
+ print_check("fail", hidhelp.W1_MESSAGE)
1072
+ print()
1073
+ return 1
1074
+
1075
+ paths = wsl_mod.find_usbipd()
1076
+ if paths.linux_impostor is not None and paths.windows is not None:
1077
+ print_check("warn", hidhelp.impostor_message(paths))
1078
+
1079
+ if paths.windows is None:
1080
+ print_check("warn", hidhelp.W2_MESSAGE)
1081
+ print()
1082
+ return 1
1083
+ print_check("ok", f"usbipd.exe: {paths.windows}")
1084
+
1085
+ devices = wsl_mod.list_devices(paths.windows, vendor_id=vendor_id)
1086
+ if devices is None:
1087
+ print_check("warn", hidhelp.W2_MESSAGE)
1088
+ print()
1089
+ return 1
1090
+ if not devices:
1091
+ print_check("warn", hidhelp.NO_MATCHING_WINDOWS_DEVICE_MESSAGE)
1092
+ print()
1093
+ return 1
1094
+
1095
+ device = devices[0]
1096
+ print_check(
1097
+ "ok",
1098
+ f"Found on Windows: BUSID {device.busid} {device.vid_pid} {device.description}",
1099
+ )
1100
+
1101
+ if device.state == "not_shared":
1102
+ print_check("warn", hidhelp.w4_message(device))
1103
+ print()
1104
+ return 1
1105
+
1106
+ if device.state == "unknown":
1107
+ print_check("warn", hidhelp.w3_unknown_state_message(device))
1108
+ print()
1109
+ return 1
1110
+
1111
+ if device.state == "shared":
1112
+ print_check("ok", "Shared -- attaching...")
1113
+ success, message = wsl_mod.attach(paths.windows, device.busid)
1114
+ if not success:
1115
+ print_check("fail", f"Attach failed: {message}")
1116
+ print()
1117
+ return 1
1118
+ print_check("ok", "Attached")
1119
+ else:
1120
+ print_check("ok", f"Already attached (BUSID {device.busid})")
1121
+
1122
+ node = usbnode_mod.find_usb_node(vendor_id)
1123
+ if node is None:
1124
+ print_check("warn", hidhelp.w6_message(device))
1125
+ print()
1126
+ return 1
1127
+ print_check("ok", f"Visible to Linux: {node.path}")
1128
+
1129
+ if not node.readable_writable:
1130
+ print_check("warn", hidhelp.w7_message(node))
1131
+ if not usbnode_mod.udev_is_live():
1132
+ print_check(
1133
+ "warn",
1134
+ hidhelp.u_dead_wsl_message(wsl_mod.wsl_conf_systemd_state()),
1135
+ )
1136
+
1137
+ print()
1138
+ print(" Next:")
1139
+ print(" muxplex-deck service restart")
1140
+ print(" muxplex-deck status")
1141
+ print()
1142
+ print(
1143
+ " The device number changes on every attach -- re-run "
1144
+ "`muxplex-deck wsl attach`\n after any unplug or Windows reboot."
1145
+ )
1146
+ print()
1147
+ return 0
1148
+
1149
+
1019
1150
  # ---------------------------------------------------------------------------
1020
1151
  # main
1021
1152
  # ---------------------------------------------------------------------------
@@ -1085,6 +1216,13 @@ def main() -> None:
1085
1216
  "key", nargs="?", help="Config key (omit to reset all)"
1086
1217
  )
1087
1218
 
1219
+ wsl_parser = sub.add_parser("wsl", help="WSL2 USB/IP helpers")
1220
+ wsl_sub = wsl_parser.add_subparsers(dest="wsl_command")
1221
+ wsl_sub.add_parser(
1222
+ "attach",
1223
+ help="Find, share-check, and attach the Stream Deck to this WSL2 distro",
1224
+ )
1225
+
1088
1226
  service_parser = sub.add_parser(
1089
1227
  "service", help="Manage the muxplex-deck background service"
1090
1228
  )
@@ -1129,6 +1267,12 @@ def main() -> None:
1129
1267
  non_interactive=getattr(args, "non_interactive", False),
1130
1268
  )
1131
1269
  )
1270
+ elif args.command == "wsl":
1271
+ cmd = getattr(args, "wsl_command", None)
1272
+ if cmd == "attach":
1273
+ sys.exit(wsl_attach())
1274
+ else:
1275
+ wsl_parser.print_help()
1132
1276
  elif args.command == "config":
1133
1277
  config_path = getattr(args, "config", None)
1134
1278
  cmd = getattr(args, "config_command", None)