muxplex-deck 0.6.0__tar.gz → 0.7.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.
- {muxplex_deck-0.6.0 → muxplex_deck-0.7.0}/PKG-INFO +1 -1
- {muxplex_deck-0.6.0 → muxplex_deck-0.7.0}/pyproject.toml +1 -1
- {muxplex_deck-0.6.0 → muxplex_deck-0.7.0}/src/muxplex_deck/cli.py +363 -100
- {muxplex_deck-0.6.0 → muxplex_deck-0.7.0}/src/muxplex_deck/init_wizard.py +133 -44
- {muxplex_deck-0.6.0 → muxplex_deck-0.7.0}/src/muxplex_deck/layout.py +52 -17
- muxplex_deck-0.7.0/src/muxplex_deck/report.py +416 -0
- {muxplex_deck-0.6.0 → muxplex_deck-0.7.0}/src/muxplex_deck/service.py +38 -8
- {muxplex_deck-0.6.0 → muxplex_deck-0.7.0}/src/muxplex_deck/statusfile.py +1 -1
- {muxplex_deck-0.6.0 → muxplex_deck-0.7.0}/README.md +0 -0
- {muxplex_deck-0.6.0 → muxplex_deck-0.7.0}/src/deck_probe/__init__.py +0 -0
- {muxplex_deck-0.6.0 → muxplex_deck-0.7.0}/src/deck_probe/__main__.py +0 -0
- {muxplex_deck-0.6.0 → muxplex_deck-0.7.0}/src/deck_probe/capabilities.py +0 -0
- {muxplex_deck-0.6.0 → muxplex_deck-0.7.0}/src/deck_probe/events.py +0 -0
- {muxplex_deck-0.6.0 → muxplex_deck-0.7.0}/src/deck_probe/main.py +0 -0
- {muxplex_deck-0.6.0 → muxplex_deck-0.7.0}/src/deck_probe/rendering.py +0 -0
- {muxplex_deck-0.6.0 → muxplex_deck-0.7.0}/src/muxplex_deck/__init__.py +0 -0
- {muxplex_deck-0.6.0 → muxplex_deck-0.7.0}/src/muxplex_deck/__main__.py +0 -0
- {muxplex_deck-0.6.0 → muxplex_deck-0.7.0}/src/muxplex_deck/_vendor/hidapi/LICENSE-bsd.txt +0 -0
- {muxplex_deck-0.6.0 → muxplex_deck-0.7.0}/src/muxplex_deck/_vendor/hidapi/VERSION +0 -0
- {muxplex_deck-0.6.0 → muxplex_deck-0.7.0}/src/muxplex_deck/_vendor/hidapi/x64/hidapi.dll +0 -0
- {muxplex_deck-0.6.0 → muxplex_deck-0.7.0}/src/muxplex_deck/attention.py +0 -0
- {muxplex_deck-0.6.0 → muxplex_deck-0.7.0}/src/muxplex_deck/config.py +0 -0
- {muxplex_deck-0.6.0 → muxplex_deck-0.7.0}/src/muxplex_deck/device.py +0 -0
- {muxplex_deck-0.6.0 → muxplex_deck-0.7.0}/src/muxplex_deck/device_real.py +0 -0
- {muxplex_deck-0.6.0 → muxplex_deck-0.7.0}/src/muxplex_deck/emulator.py +0 -0
- {muxplex_deck-0.6.0 → muxplex_deck-0.7.0}/src/muxplex_deck/focus.py +0 -0
- {muxplex_deck-0.6.0 → muxplex_deck-0.7.0}/src/muxplex_deck/hidapi_win.py +0 -0
- {muxplex_deck-0.6.0 → muxplex_deck-0.7.0}/src/muxplex_deck/hidhelp.py +0 -0
- {muxplex_deck-0.6.0 → muxplex_deck-0.7.0}/src/muxplex_deck/interaction.py +0 -0
- {muxplex_deck-0.6.0 → muxplex_deck-0.7.0}/src/muxplex_deck/main.py +0 -0
- {muxplex_deck-0.6.0 → muxplex_deck-0.7.0}/src/muxplex_deck/rendering.py +0 -0
- {muxplex_deck-0.6.0 → muxplex_deck-0.7.0}/src/muxplex_deck/usbnode.py +0 -0
- {muxplex_deck-0.6.0 → muxplex_deck-0.7.0}/src/muxplex_deck/views.py +0 -0
- {muxplex_deck-0.6.0 → muxplex_deck-0.7.0}/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.
|
|
3
|
+
Version: 0.7.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>
|
|
@@ -389,6 +389,21 @@ def check_ca_file(ca_file: Path | None) -> tuple[str, str]:
|
|
|
389
389
|
check=False,
|
|
390
390
|
)
|
|
391
391
|
except FileNotFoundError:
|
|
392
|
+
if sys.platform == "win32":
|
|
393
|
+
# Windows doesn't ship openssl by default -- unlike Linux/macOS,
|
|
394
|
+
# where its absence is unusual and worth a "warn", here it's the
|
|
395
|
+
# norm. Flagging this as something the user needs to fix would
|
|
396
|
+
# violate the repo's own rule (never print guidance that treats
|
|
397
|
+
# an environment gap as the user's problem when it isn't one).
|
|
398
|
+
# `ok`, with an actionable-if-they-want-it alternative -- never
|
|
399
|
+
# invent an X.509 parser dependency for this one check.
|
|
400
|
+
return "ok", (
|
|
401
|
+
"ca_file: could not verify via openssl (not bundled with "
|
|
402
|
+
"Windows) -- compare the SHA-256 fingerprint printed "
|
|
403
|
+
"during `init` against the server's, or install openssl "
|
|
404
|
+
"(winget install ShiningLight.OpenSSL.Light, or use Git "
|
|
405
|
+
"for Windows' bundled openssl.exe) to enable this check"
|
|
406
|
+
)
|
|
392
407
|
return "warn", "openssl not found -- cannot verify ca_file is a CA"
|
|
393
408
|
except Exception as exc: # noqa: BLE001 -- doctor check must degrade to warn, never raise
|
|
394
409
|
return "warn", f"Could not inspect ca_file {ca_file}: {exc}"
|
|
@@ -685,11 +700,27 @@ def check_federation_key_auth(
|
|
|
685
700
|
callers must not print a false success, but also
|
|
686
701
|
must not treat an unrelated network hiccup as a
|
|
687
702
|
rejected key.
|
|
703
|
+
|
|
704
|
+
**Must send `Accept: application/json`.** This is the root cause of a
|
|
705
|
+
real regression: without it, muxplex's auth middleware treats the
|
|
706
|
+
request as a browser navigation and answers an unauthenticated
|
|
707
|
+
request with a 307 redirect to `/login` instead of a 401 -- which this
|
|
708
|
+
client (deliberately, like `muxplex_client.MuxplexClient`, see its
|
|
709
|
+
docstring) does not follow, so the redirect surfaced as an
|
|
710
|
+
"unexpected response HTTP 307" that read as "can't verify" and let a
|
|
711
|
+
bad key through unvalidated. `follow_redirects` is NOT the fix --
|
|
712
|
+
following the redirect would land on the login page and return a
|
|
713
|
+
misleading 200. Sending the header the server's auth layer actually
|
|
714
|
+
branches on is what makes this request exercise the credential at
|
|
715
|
+
all, exactly like the sidecar's own `MuxplexClient` already does.
|
|
688
716
|
"""
|
|
689
717
|
import httpx
|
|
690
718
|
|
|
691
719
|
url = f"{server_url.rstrip('/')}/api/sessions"
|
|
692
|
-
headers = {
|
|
720
|
+
headers = {
|
|
721
|
+
"Accept": "application/json",
|
|
722
|
+
"Authorization": f"Bearer {federation_key}",
|
|
723
|
+
}
|
|
693
724
|
try:
|
|
694
725
|
with httpx.Client(verify=verify, timeout=5.0) as client:
|
|
695
726
|
resp = client.get(url, headers=headers)
|
|
@@ -726,6 +757,16 @@ def check_service_status() -> tuple[str, str]:
|
|
|
726
757
|
|
|
727
758
|
if sys.platform == "darwin":
|
|
728
759
|
manager, tool = "launchd", "launchctl"
|
|
760
|
+
elif sys.platform == "win32":
|
|
761
|
+
# Accurate-but-misleading is still misleading: "systemctl not
|
|
762
|
+
# found" frames this as a missing Linux tool, when the real story
|
|
763
|
+
# is that background-service support (Task Scheduler) is a
|
|
764
|
+
# not-yet-built increment on this platform -- not something the
|
|
765
|
+
# user is missing or can install their way out of.
|
|
766
|
+
return "warn", (
|
|
767
|
+
"Service: background-service install isn't supported on "
|
|
768
|
+
"Windows yet -- run: muxplex-deck"
|
|
769
|
+
)
|
|
729
770
|
else:
|
|
730
771
|
manager, tool = "systemd", "systemctl"
|
|
731
772
|
|
|
@@ -771,15 +812,118 @@ def print_check(status: str, message: str) -> None:
|
|
|
771
812
|
print(f"{prefix}{line}")
|
|
772
813
|
|
|
773
814
|
|
|
774
|
-
def
|
|
815
|
+
def _status_glyph(status: str) -> str:
|
|
816
|
+
"""Map a check helper's ("ok"|"warn"|"fail") status to a report glyph.
|
|
817
|
+
|
|
818
|
+
"ok" -> fine. "fail" (a definitive rejection) and "warn" (almost always
|
|
819
|
+
paired with a "run: ..." remediation already in the message) both ->
|
|
820
|
+
act-now. Checks that are genuinely blocked on an upstream item (e.g.
|
|
821
|
+
server/service before config exists) are never routed through this
|
|
822
|
+
mapper -- callers construct those `Check`s directly with
|
|
823
|
+
`report.BLOCKED` instead.
|
|
824
|
+
"""
|
|
825
|
+
from . import report
|
|
826
|
+
|
|
827
|
+
return report.FINE if status == "ok" else report.ACT
|
|
828
|
+
|
|
829
|
+
|
|
830
|
+
def _hid_tail(hid_message: str) -> str:
|
|
831
|
+
"""Short phrase summarizing a successful HID-open, for the merged device line."""
|
|
832
|
+
text = hid_message
|
|
833
|
+
text = text.removeprefix("HID: ")
|
|
834
|
+
if text.startswith("device opened successfully"):
|
|
835
|
+
return "HID opens"
|
|
836
|
+
if text.startswith("device in use by the muxplex-deck service"):
|
|
837
|
+
return "HID in use by service"
|
|
838
|
+
return text if text.startswith("HID") else f"HID: {text}"
|
|
839
|
+
|
|
840
|
+
|
|
841
|
+
def _device_hid_checks(
|
|
842
|
+
deck_status: str, deck_message: str, hid_status: str, hid_message: str
|
|
843
|
+
) -> list[Any]:
|
|
844
|
+
"""Merge device-detection + HID-open into one line when they agree.
|
|
845
|
+
|
|
846
|
+
When both share a glyph there's nothing to hide by combining them --
|
|
847
|
+
one readable "device" line beats two boring ones. When they diverge
|
|
848
|
+
(e.g. the device is visible on Windows but WSL can't open it yet),
|
|
849
|
+
collapsing to a single worst-member line would silently drop whichever
|
|
850
|
+
fact ACTION needs to reference (like a busid) -- so they render as two
|
|
851
|
+
independent, dependency-ordered lines instead: "device" (is it
|
|
852
|
+
physically present) then "hid" (can this process open it).
|
|
853
|
+
"""
|
|
854
|
+
from . import report
|
|
855
|
+
|
|
856
|
+
deck_glyph = _status_glyph(deck_status)
|
|
857
|
+
hid_glyph = _status_glyph(hid_status)
|
|
858
|
+
if deck_glyph == hid_glyph:
|
|
859
|
+
tail = _hid_tail(hid_message)
|
|
860
|
+
value = f"{deck_message}, {tail}" if tail else deck_message
|
|
861
|
+
return [report.Check("device", deck_glyph, value)]
|
|
862
|
+
return [
|
|
863
|
+
report.Check("device", deck_glyph, deck_message),
|
|
864
|
+
report.Check("hid", hid_glyph, hid_message),
|
|
865
|
+
]
|
|
866
|
+
|
|
867
|
+
|
|
868
|
+
def _doctor_decision(check: Any) -> Any:
|
|
869
|
+
"""Build the ACTION decision for the first act-now check, in dependency order."""
|
|
870
|
+
from . import report
|
|
871
|
+
|
|
872
|
+
if check.subject == "config":
|
|
873
|
+
return report.Decision(
|
|
874
|
+
commands=["muxplex-deck init"],
|
|
875
|
+
prose=(
|
|
876
|
+
"Creates the config, fetches and fingerprints the server CA, "
|
|
877
|
+
"stores the federation key, and offers to install the service."
|
|
878
|
+
),
|
|
879
|
+
)
|
|
880
|
+
command = report.extract_run_command(check.value)
|
|
881
|
+
if command:
|
|
882
|
+
return report.Decision(commands=[command])
|
|
883
|
+
return report.Decision(commands=[], prose=check.value)
|
|
884
|
+
|
|
885
|
+
|
|
886
|
+
def _build_doctor_action(collapsed: list[Any]) -> list[str] | None:
|
|
887
|
+
"""The ACTION band: the first act-now item's decision, plus an overflow
|
|
888
|
+
|
|
889
|
+
note when more than one independent act-now item exists (see
|
|
890
|
+
report.Action.overflow_note) -- never dump every fix at once.
|
|
891
|
+
"""
|
|
892
|
+
from . import report
|
|
893
|
+
|
|
894
|
+
act_items = [c for c in collapsed if c.glyph == report.ACT]
|
|
895
|
+
if not act_items:
|
|
896
|
+
return None
|
|
897
|
+
overflow = None
|
|
898
|
+
if len(act_items) > 1:
|
|
899
|
+
overflow = f"{len(act_items) - 1} more after this -- rerun doctor."
|
|
900
|
+
action = report.Action(
|
|
901
|
+
decision=_doctor_decision(act_items[0]), overflow_note=overflow
|
|
902
|
+
)
|
|
903
|
+
return report.render_action(action)
|
|
904
|
+
|
|
905
|
+
|
|
906
|
+
def doctor(config_path: str | None = None, *, show_all: bool = False) -> int:
|
|
775
907
|
"""Run diagnostic checks and report system status. Always returns 0 (informational)."""
|
|
776
|
-
|
|
908
|
+
from . import hidhelp, report
|
|
777
909
|
|
|
778
|
-
|
|
779
|
-
checks.append(check_python_version())
|
|
780
|
-
checks.append(check_install_and_update())
|
|
781
|
-
checks.append(check_config_file(config_path))
|
|
910
|
+
utf8 = report.utf8_capable()
|
|
782
911
|
|
|
912
|
+
py_status, py_message = check_python_version()
|
|
913
|
+
inst_status, inst_message = check_install_and_update()
|
|
914
|
+
env_members = [
|
|
915
|
+
report.Check("python", _status_glyph(py_status), py_message),
|
|
916
|
+
report.Check("install", _status_glyph(inst_status), inst_message),
|
|
917
|
+
]
|
|
918
|
+
hidapi_check = check_hidapi_dll() # Windows-only; None everywhere else.
|
|
919
|
+
if hidapi_check is not None:
|
|
920
|
+
hidapi_status, hidapi_message = hidapi_check
|
|
921
|
+
env_members.append(
|
|
922
|
+
report.Check("hidapi", _status_glyph(hidapi_status), hidapi_message)
|
|
923
|
+
)
|
|
924
|
+
environment_group = report.Group("environment", env_members)
|
|
925
|
+
|
|
926
|
+
cfg_status, cfg_message = check_config_file(config_path)
|
|
783
927
|
try:
|
|
784
928
|
cfg = config_mod.load_config(config_path)
|
|
785
929
|
except ConfigError:
|
|
@@ -787,29 +931,44 @@ def doctor(config_path: str | None = None) -> int:
|
|
|
787
931
|
|
|
788
932
|
raw = config_mod.load_raw_config(config_path)
|
|
789
933
|
key_file = config_mod._expand(raw.get("key_file", config_mod.DEFAULT_KEY_FILE))
|
|
790
|
-
|
|
934
|
+
key_status, key_message = check_federation_key(key_file)
|
|
791
935
|
|
|
792
936
|
ca_file = cfg.ca_file if cfg is not None else None
|
|
793
937
|
if cfg is None and raw.get("ca_file"):
|
|
794
938
|
ca_file = config_mod._expand(raw["ca_file"])
|
|
795
|
-
|
|
939
|
+
ca_status, ca_message = check_ca_file(ca_file)
|
|
940
|
+
|
|
941
|
+
config_group = report.Group(
|
|
942
|
+
"config",
|
|
943
|
+
[
|
|
944
|
+
report.Check("file", _status_glyph(cfg_status), cfg_message),
|
|
945
|
+
report.Check("key", _status_glyph(key_status), key_message),
|
|
946
|
+
report.Check("ca", _status_glyph(ca_status), ca_message),
|
|
947
|
+
],
|
|
948
|
+
)
|
|
949
|
+
config_created = cfg_status == "ok"
|
|
796
950
|
|
|
797
|
-
|
|
798
|
-
hidapi_check = check_hidapi_dll()
|
|
799
|
-
if hidapi_check is not None:
|
|
800
|
-
checks.append(hidapi_check)
|
|
951
|
+
items: list[Any] = [environment_group, config_group]
|
|
801
952
|
|
|
802
953
|
# Environment guidance (WSL/usbipd/udev-liveness) BEFORE the device
|
|
803
954
|
# checks -- it explains why the next line is about to warn. Returns []
|
|
804
955
|
# on a healthy platform (macOS, or native Linux with udev running), so
|
|
805
956
|
# this adds no output there -- see hidhelp.explain_environment().
|
|
806
|
-
from . import hidhelp
|
|
807
|
-
|
|
808
957
|
env_guidances = hidhelp.explain_environment()
|
|
809
958
|
for guidance in env_guidances:
|
|
810
|
-
|
|
959
|
+
items.append(
|
|
960
|
+
report.Check("device", _status_glyph(guidance.status), guidance.message)
|
|
961
|
+
)
|
|
811
962
|
env_states = {g.state for g in env_guidances}
|
|
812
963
|
|
|
964
|
+
if not config_created:
|
|
965
|
+
# Server/service structurally need server_url/config -- blocked,
|
|
966
|
+
# not independently evaluated. Device/HID don't depend on config
|
|
967
|
+
# at all (a bare USB probe works with no config file), so they are
|
|
968
|
+
# still evaluated for real below.
|
|
969
|
+
items.append(report.Check("server", report.BLOCKED, "waiting on config"))
|
|
970
|
+
items.append(report.Check("service", report.BLOCKED, "waiting on config"))
|
|
971
|
+
|
|
813
972
|
if "W7" in env_states:
|
|
814
973
|
# The W7 guidance just appended above already says the device is
|
|
815
974
|
# attached but can't be opened, plus the proven per-attach `chown`
|
|
@@ -833,22 +992,27 @@ def doctor(config_path: str | None = None) -> int:
|
|
|
833
992
|
# yet) -- the generic "check the cable" guidance would
|
|
834
993
|
# flatly contradict it (see bug report: located @ BUSID 1-4,
|
|
835
994
|
# immediately followed by "check your cable").
|
|
836
|
-
deck_message =
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
)
|
|
840
|
-
|
|
841
|
-
checks.append(check_hid_openable())
|
|
995
|
+
deck_message = "not detected on this OS yet -- see the WSL guidance above."
|
|
996
|
+
hid_status, hid_message = check_hid_openable()
|
|
997
|
+
items.extend(
|
|
998
|
+
_device_hid_checks(deck_status, deck_message, hid_status, hid_message)
|
|
999
|
+
)
|
|
842
1000
|
|
|
843
|
-
|
|
844
|
-
|
|
1001
|
+
if config_created:
|
|
1002
|
+
server_url = cfg.server_url if cfg is not None else raw.get("server_url", "")
|
|
1003
|
+
srv_status, srv_message = check_server_reachable(server_url, ca_file)
|
|
1004
|
+
items.append(report.Check("server", _status_glyph(srv_status), srv_message))
|
|
845
1005
|
|
|
846
|
-
|
|
1006
|
+
svc_status, svc_message = check_service_status()
|
|
1007
|
+
items.append(report.Check("service", _status_glyph(svc_status), svc_message))
|
|
847
1008
|
|
|
848
|
-
|
|
849
|
-
|
|
1009
|
+
collapsed = report.collapsed_checks(items)
|
|
1010
|
+
action_count = report.count_actions([c.glyph for c in collapsed])
|
|
1011
|
+
verdict = report.verdict_readiness(action_count)
|
|
1012
|
+
state_lines = report.render_items(items, show_all=show_all, utf8=utf8)
|
|
1013
|
+
action_lines = _build_doctor_action(collapsed)
|
|
850
1014
|
|
|
851
|
-
|
|
1015
|
+
sys.stdout.write(report.render(verdict, state_lines, action_lines))
|
|
852
1016
|
return 0
|
|
853
1017
|
|
|
854
1018
|
|
|
@@ -867,48 +1031,62 @@ def doctor(config_path: str | None = None) -> int:
|
|
|
867
1031
|
_STATUS_STALE_THRESHOLD_SECONDS = 15.0
|
|
868
1032
|
|
|
869
1033
|
|
|
870
|
-
def
|
|
1034
|
+
def _status_device_item(device: dict[str, Any]) -> Any:
|
|
1035
|
+
"""Build the "device" Check from the sidecar's published status snapshot.
|
|
1036
|
+
|
|
1037
|
+
"-" is the correct rendering for "I could not determine this" -- during
|
|
1038
|
+
a restart race the caller returns before this is ever called (see the
|
|
1039
|
+
pid-freshness guard in `status()`), so this function only ever runs
|
|
1040
|
+
when the snapshot is genuinely current.
|
|
1041
|
+
"""
|
|
1042
|
+
from . import report
|
|
1043
|
+
|
|
871
1044
|
if not device.get("connected"):
|
|
872
|
-
return "
|
|
1045
|
+
return report.Check("device", report.ACT, "not connected")
|
|
873
1046
|
caps = device.get("capabilities") or {}
|
|
874
1047
|
hint = device.get("hint")
|
|
875
|
-
if not caps and hint:
|
|
876
|
-
return "warn", "Device: connected (capabilities unavailable)"
|
|
877
1048
|
if not caps:
|
|
878
|
-
|
|
1049
|
+
glyph = report.ACT if hint else report.FINE
|
|
1050
|
+
return report.Check("device", glyph, "connected (capabilities unavailable)")
|
|
879
1051
|
touchscreen = "yes" if caps.get("has_touchscreen") else "no"
|
|
880
|
-
|
|
881
|
-
f"
|
|
1052
|
+
value = (
|
|
1053
|
+
f"{caps.get('model', '?')} -- {caps.get('key_count', '?')} keys "
|
|
882
1054
|
f"({caps.get('key_rows', '?')}x{caps.get('key_cols', '?')}), "
|
|
883
1055
|
f"{caps.get('dial_count', '?')} dials, touchscreen={touchscreen}"
|
|
884
1056
|
)
|
|
1057
|
+
return report.Check("device", report.FINE, value)
|
|
1058
|
+
|
|
885
1059
|
|
|
1060
|
+
def _status_server_item(server: dict[str, Any]) -> Any:
|
|
1061
|
+
from . import report
|
|
886
1062
|
|
|
887
|
-
def _format_server_line(server: dict[str, Any]) -> tuple[str, str]:
|
|
888
1063
|
url = server.get("url") or "(not configured)"
|
|
889
1064
|
if server.get("connected"):
|
|
890
|
-
return "
|
|
1065
|
+
return report.Check("server", report.FINE, f"{url} (reachable)")
|
|
891
1066
|
err = server.get("last_error")
|
|
892
|
-
|
|
1067
|
+
value = f"{url} (unreachable)"
|
|
893
1068
|
if err:
|
|
894
|
-
|
|
895
|
-
return "
|
|
1069
|
+
value += f" -- {err}"
|
|
1070
|
+
return report.Check("server", report.ACT, value)
|
|
896
1071
|
|
|
897
1072
|
|
|
898
|
-
def
|
|
899
|
-
|
|
900
|
-
view = state.get("active_view") or "all"
|
|
1073
|
+
def _status_view_value(state: dict[str, Any]) -> str:
|
|
1074
|
+
view_name = state.get("active_view") or "all"
|
|
901
1075
|
page = state.get("page")
|
|
902
|
-
|
|
903
|
-
return f"Active session: {session} | view: {view} | page: {page_text}"
|
|
1076
|
+
return f"{view_name} (page {page})" if page is not None else view_name
|
|
904
1077
|
|
|
905
1078
|
|
|
906
|
-
def
|
|
1079
|
+
def _direct_probe_item(config_path: str | None) -> Any:
|
|
907
1080
|
"""Fallback when the service isn't running -- nothing holds the device."""
|
|
908
|
-
|
|
1081
|
+
from . import report
|
|
1082
|
+
|
|
1083
|
+
deck_status, deck_message = check_deck_detected(config_path)
|
|
1084
|
+
return report.Check("device", _status_glyph(deck_status), deck_message)
|
|
909
1085
|
|
|
910
1086
|
|
|
911
|
-
def status(
|
|
1087
|
+
def status(
|
|
1088
|
+
config_path: str | None = None, *, as_json: bool = False, show_all: bool = False
|
|
1089
|
+
) -> int:
|
|
912
1090
|
"""Print the sidecar's hardware + connection status.
|
|
913
1091
|
|
|
914
1092
|
Reads the status file the running sidecar publishes (see
|
|
@@ -920,6 +1098,7 @@ def status(config_path: str | None = None, *, as_json: bool = False) -> int:
|
|
|
920
1098
|
fall back to a direct probe -- this keeps `status` useful even before
|
|
921
1099
|
the service has ever been installed.
|
|
922
1100
|
"""
|
|
1101
|
+
from . import report
|
|
923
1102
|
from .service import service_is_active, service_main_pid
|
|
924
1103
|
from .statusfile import read_status
|
|
925
1104
|
|
|
@@ -931,36 +1110,45 @@ def status(config_path: str | None = None, *, as_json: bool = False) -> int:
|
|
|
931
1110
|
print(json.dumps(payload, indent=2))
|
|
932
1111
|
return 0 if data is not None else 1
|
|
933
1112
|
|
|
934
|
-
|
|
935
|
-
print_check(
|
|
936
|
-
"ok" if running else "warn",
|
|
937
|
-
f"Service: {'running' if running else 'not running'}",
|
|
938
|
-
)
|
|
1113
|
+
utf8 = report.utf8_capable()
|
|
939
1114
|
|
|
940
1115
|
if not running:
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
1116
|
+
probe_note = (
|
|
1117
|
+
"no status file -- probing the device directly instead (safe: "
|
|
1118
|
+
"nothing holds it while the service is stopped)"
|
|
1119
|
+
if data is None
|
|
1120
|
+
else "not trusting the (possibly stale) status file -- probing "
|
|
1121
|
+
"the device directly instead"
|
|
1122
|
+
)
|
|
1123
|
+
items: list[Any] = [
|
|
1124
|
+
report.Check("service", report.BLOCKED, "not running"),
|
|
1125
|
+
report.Check("probe", report.BLOCKED, probe_note),
|
|
1126
|
+
_direct_probe_item(config_path),
|
|
1127
|
+
]
|
|
1128
|
+
collapsed = report.collapsed_checks(items)
|
|
1129
|
+
action_count = report.count_actions([c.glyph for c in collapsed])
|
|
1130
|
+
verdict = (
|
|
1131
|
+
"Not connected -- service not running."
|
|
1132
|
+
if action_count == 0
|
|
1133
|
+
else report.verdict_readiness(action_count)
|
|
1134
|
+
)
|
|
1135
|
+
state_lines = report.render_items(items, show_all=show_all, utf8=utf8)
|
|
1136
|
+
sys.stdout.write(report.render(verdict, state_lines, None))
|
|
955
1137
|
return 0
|
|
956
1138
|
|
|
957
1139
|
if data is None:
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
1140
|
+
items = [
|
|
1141
|
+
report.Check(
|
|
1142
|
+
"status",
|
|
1143
|
+
report.BLOCKED,
|
|
1144
|
+
"no status file yet -- the sidecar may have just started. "
|
|
1145
|
+
"Try: muxplex-deck service logs",
|
|
1146
|
+
)
|
|
1147
|
+
]
|
|
1148
|
+
state_lines = report.render_items(items, show_all=show_all, utf8=utf8)
|
|
1149
|
+
sys.stdout.write(
|
|
1150
|
+
report.render("Not connected -- starting up.", state_lines, None)
|
|
962
1151
|
)
|
|
963
|
-
print()
|
|
964
1152
|
return 0
|
|
965
1153
|
|
|
966
1154
|
# Is this snapshot from the process running RIGHT NOW, or from a
|
|
@@ -983,40 +1171,94 @@ def status(config_path: str | None = None, *, as_json: bool = False) -> int:
|
|
|
983
1171
|
) <= _STATUS_STALE_THRESHOLD_SECONDS
|
|
984
1172
|
|
|
985
1173
|
if not is_current:
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
1174
|
+
# "-" (BLOCKED), never "!" (act-now): we did not actually observe
|
|
1175
|
+
# this instant, so nothing here has earned a verdict yet -- see the
|
|
1176
|
+
# restart-race incident this guards against (AGENTS.md).
|
|
1177
|
+
items = [
|
|
1178
|
+
report.Check(
|
|
1179
|
+
"status",
|
|
1180
|
+
report.BLOCKED,
|
|
1181
|
+
f"not yet available for the running process (pid "
|
|
1182
|
+
f"{current_pid if current_pid is not None else '?'}) -- the "
|
|
1183
|
+
f"published status is from a previous run (pid {recorded_pid}). "
|
|
1184
|
+
"This is expected right after (re)starting; try again in a "
|
|
1185
|
+
"moment, or: muxplex-deck service logs",
|
|
1186
|
+
)
|
|
1187
|
+
]
|
|
1188
|
+
state_lines = report.render_items(items, show_all=show_all, utf8=utf8)
|
|
1189
|
+
sys.stdout.write(
|
|
1190
|
+
report.render("Not connected -- previous run's data.", state_lines, None)
|
|
993
1191
|
)
|
|
994
|
-
print()
|
|
995
1192
|
return 0
|
|
996
1193
|
|
|
997
1194
|
age = time.time() - data.get("updated_at", 0)
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1195
|
+
stale = age > _STATUS_STALE_THRESHOLD_SECONDS
|
|
1196
|
+
device_item = _status_device_item(data.get("device", {}))
|
|
1197
|
+
server_item = _status_server_item(data.get("server", {}))
|
|
1198
|
+
state = data.get("state", {})
|
|
1199
|
+
|
|
1200
|
+
session_readout = report.Readout("session", state.get("active_session") or "none")
|
|
1201
|
+
view_readout = report.Readout("view", _status_view_value(state))
|
|
1202
|
+
pid_readout = report.Readout(
|
|
1203
|
+
"pid", f"{data.get('pid', '?')} (updated {age:.0f}s ago)"
|
|
1204
|
+
)
|
|
1205
|
+
|
|
1206
|
+
all_fine = (
|
|
1207
|
+
not stale
|
|
1208
|
+
and device_item.glyph == report.FINE
|
|
1209
|
+
and server_item.glyph == report.FINE
|
|
1210
|
+
)
|
|
1211
|
+
|
|
1212
|
+
if all_fine:
|
|
1213
|
+
readouts = [
|
|
1214
|
+
session_readout,
|
|
1215
|
+
view_readout,
|
|
1216
|
+
report.Readout("device", device_item.value),
|
|
1217
|
+
report.Readout("server", server_item.value),
|
|
1218
|
+
pid_readout,
|
|
1219
|
+
]
|
|
1220
|
+
state_lines = report.render_readouts(readouts)
|
|
1221
|
+
sys.stdout.write(report.render("Running.", state_lines, None))
|
|
1222
|
+
return 0
|
|
1006
1223
|
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1224
|
+
lines: list[str] = [
|
|
1225
|
+
report.format_readout_line(session_readout.name, session_readout.value),
|
|
1226
|
+
report.format_readout_line(view_readout.name, view_readout.value),
|
|
1227
|
+
report.format_check_line(
|
|
1228
|
+
device_item.glyph, device_item.subject, device_item.value, utf8=utf8
|
|
1229
|
+
),
|
|
1230
|
+
]
|
|
1231
|
+
hint = data.get("device", {}).get("hint")
|
|
1232
|
+
if hint and device_item.glyph != report.FINE:
|
|
1011
1233
|
# Populated by the sidecar's open-failure branch (see main.py /
|
|
1012
1234
|
# hidhelp.explain_open_failure) -- this is what turns a stale
|
|
1013
1235
|
# status file into the primary teaching surface instead of a
|
|
1014
1236
|
# bare "not connected" with no explanation.
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1237
|
+
lines.append(report.format_check_line(report.ACT, "hint", hint, utf8=utf8))
|
|
1238
|
+
lines.append(
|
|
1239
|
+
report.format_check_line(
|
|
1240
|
+
server_item.glyph, server_item.subject, server_item.value, utf8=utf8
|
|
1241
|
+
)
|
|
1242
|
+
)
|
|
1243
|
+
if stale:
|
|
1244
|
+
lines.append(
|
|
1245
|
+
report.format_check_line(
|
|
1246
|
+
report.ACT,
|
|
1247
|
+
"status",
|
|
1248
|
+
f"stale (last updated {age:.0f}s ago) -- the sidecar may be "
|
|
1249
|
+
"stuck. Try: muxplex-deck service logs",
|
|
1250
|
+
utf8=utf8,
|
|
1251
|
+
)
|
|
1252
|
+
)
|
|
1253
|
+
lines.append(report.format_readout_line(pid_readout.name, pid_readout.value))
|
|
1254
|
+
|
|
1255
|
+
glyphs = [device_item.glyph, server_item.glyph]
|
|
1256
|
+
if hint and device_item.glyph != report.FINE:
|
|
1257
|
+
glyphs.append(report.ACT)
|
|
1258
|
+
if stale:
|
|
1259
|
+
glyphs.append(report.ACT)
|
|
1260
|
+
verdict = report.verdict_readiness(report.count_actions(glyphs))
|
|
1261
|
+
sys.stdout.write(report.render(verdict, lines, None))
|
|
1020
1262
|
return 0
|
|
1021
1263
|
|
|
1022
1264
|
|
|
@@ -1387,7 +1629,17 @@ def main() -> None:
|
|
|
1387
1629
|
|
|
1388
1630
|
sub.add_parser("version", help="Show the muxplex-deck version")
|
|
1389
1631
|
|
|
1390
|
-
sub.add_parser(
|
|
1632
|
+
doctor_parser = sub.add_parser(
|
|
1633
|
+
"doctor", help="Check dependencies and system status"
|
|
1634
|
+
)
|
|
1635
|
+
doctor_parser.add_argument(
|
|
1636
|
+
"--all",
|
|
1637
|
+
"--verbose",
|
|
1638
|
+
dest="show_all",
|
|
1639
|
+
action="store_true",
|
|
1640
|
+
help="Show every underlying check (VID:PID, full paths, etc.) instead "
|
|
1641
|
+
"of the collapsed summary -- a strict superset, never a different answer",
|
|
1642
|
+
)
|
|
1391
1643
|
|
|
1392
1644
|
status_parser = sub.add_parser(
|
|
1393
1645
|
"status", help="Show connected hardware + connection state"
|
|
@@ -1395,6 +1647,13 @@ def main() -> None:
|
|
|
1395
1647
|
status_parser.add_argument(
|
|
1396
1648
|
"--json", action="store_true", help="Emit raw status as JSON"
|
|
1397
1649
|
)
|
|
1650
|
+
status_parser.add_argument(
|
|
1651
|
+
"--all",
|
|
1652
|
+
"--verbose",
|
|
1653
|
+
dest="show_all",
|
|
1654
|
+
action="store_true",
|
|
1655
|
+
help="Show every underlying check instead of the collapsed summary",
|
|
1656
|
+
)
|
|
1398
1657
|
|
|
1399
1658
|
update_parser = sub.add_parser(
|
|
1400
1659
|
"update",
|
|
@@ -1470,10 +1729,14 @@ def main() -> None:
|
|
|
1470
1729
|
if args.command == "version":
|
|
1471
1730
|
print_version()
|
|
1472
1731
|
elif args.command == "doctor":
|
|
1473
|
-
doctor(getattr(args, "config", None))
|
|
1732
|
+
doctor(getattr(args, "config", None), show_all=getattr(args, "show_all", False))
|
|
1474
1733
|
elif args.command == "status":
|
|
1475
1734
|
sys.exit(
|
|
1476
|
-
status(
|
|
1735
|
+
status(
|
|
1736
|
+
getattr(args, "config", None),
|
|
1737
|
+
as_json=getattr(args, "json", False),
|
|
1738
|
+
show_all=getattr(args, "show_all", False),
|
|
1739
|
+
)
|
|
1477
1740
|
)
|
|
1478
1741
|
elif args.command in ("update", "upgrade"):
|
|
1479
1742
|
update(force=getattr(args, "force", False))
|