muxplex-deck 0.6.1__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.1 → muxplex_deck-0.7.0}/PKG-INFO +1 -1
- {muxplex_deck-0.6.1 → muxplex_deck-0.7.0}/pyproject.toml +1 -1
- {muxplex_deck-0.6.1 → muxplex_deck-0.7.0}/src/muxplex_deck/cli.py +321 -99
- {muxplex_deck-0.6.1 → 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.1 → muxplex_deck-0.7.0}/src/muxplex_deck/service.py +20 -8
- {muxplex_deck-0.6.1 → muxplex_deck-0.7.0}/src/muxplex_deck/statusfile.py +1 -1
- {muxplex_deck-0.6.1 → muxplex_deck-0.7.0}/README.md +0 -0
- {muxplex_deck-0.6.1 → muxplex_deck-0.7.0}/src/deck_probe/__init__.py +0 -0
- {muxplex_deck-0.6.1 → muxplex_deck-0.7.0}/src/deck_probe/__main__.py +0 -0
- {muxplex_deck-0.6.1 → muxplex_deck-0.7.0}/src/deck_probe/capabilities.py +0 -0
- {muxplex_deck-0.6.1 → muxplex_deck-0.7.0}/src/deck_probe/events.py +0 -0
- {muxplex_deck-0.6.1 → muxplex_deck-0.7.0}/src/deck_probe/main.py +0 -0
- {muxplex_deck-0.6.1 → muxplex_deck-0.7.0}/src/deck_probe/rendering.py +0 -0
- {muxplex_deck-0.6.1 → muxplex_deck-0.7.0}/src/muxplex_deck/__init__.py +0 -0
- {muxplex_deck-0.6.1 → muxplex_deck-0.7.0}/src/muxplex_deck/__main__.py +0 -0
- {muxplex_deck-0.6.1 → muxplex_deck-0.7.0}/src/muxplex_deck/_vendor/hidapi/LICENSE-bsd.txt +0 -0
- {muxplex_deck-0.6.1 → muxplex_deck-0.7.0}/src/muxplex_deck/_vendor/hidapi/VERSION +0 -0
- {muxplex_deck-0.6.1 → muxplex_deck-0.7.0}/src/muxplex_deck/_vendor/hidapi/x64/hidapi.dll +0 -0
- {muxplex_deck-0.6.1 → muxplex_deck-0.7.0}/src/muxplex_deck/attention.py +0 -0
- {muxplex_deck-0.6.1 → muxplex_deck-0.7.0}/src/muxplex_deck/config.py +0 -0
- {muxplex_deck-0.6.1 → muxplex_deck-0.7.0}/src/muxplex_deck/device.py +0 -0
- {muxplex_deck-0.6.1 → muxplex_deck-0.7.0}/src/muxplex_deck/device_real.py +0 -0
- {muxplex_deck-0.6.1 → muxplex_deck-0.7.0}/src/muxplex_deck/emulator.py +0 -0
- {muxplex_deck-0.6.1 → muxplex_deck-0.7.0}/src/muxplex_deck/focus.py +0 -0
- {muxplex_deck-0.6.1 → muxplex_deck-0.7.0}/src/muxplex_deck/hidapi_win.py +0 -0
- {muxplex_deck-0.6.1 → muxplex_deck-0.7.0}/src/muxplex_deck/hidhelp.py +0 -0
- {muxplex_deck-0.6.1 → muxplex_deck-0.7.0}/src/muxplex_deck/init_wizard.py +0 -0
- {muxplex_deck-0.6.1 → muxplex_deck-0.7.0}/src/muxplex_deck/interaction.py +0 -0
- {muxplex_deck-0.6.1 → muxplex_deck-0.7.0}/src/muxplex_deck/main.py +0 -0
- {muxplex_deck-0.6.1 → muxplex_deck-0.7.0}/src/muxplex_deck/rendering.py +0 -0
- {muxplex_deck-0.6.1 → muxplex_deck-0.7.0}/src/muxplex_deck/usbnode.py +0 -0
- {muxplex_deck-0.6.1 → muxplex_deck-0.7.0}/src/muxplex_deck/views.py +0 -0
- {muxplex_deck-0.6.1 → 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>
|
|
@@ -812,15 +812,118 @@ def print_check(status: str, message: str) -> None:
|
|
|
812
812
|
print(f"{prefix}{line}")
|
|
813
813
|
|
|
814
814
|
|
|
815
|
-
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:
|
|
816
907
|
"""Run diagnostic checks and report system status. Always returns 0 (informational)."""
|
|
817
|
-
|
|
908
|
+
from . import hidhelp, report
|
|
909
|
+
|
|
910
|
+
utf8 = report.utf8_capable()
|
|
818
911
|
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
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)
|
|
823
925
|
|
|
926
|
+
cfg_status, cfg_message = check_config_file(config_path)
|
|
824
927
|
try:
|
|
825
928
|
cfg = config_mod.load_config(config_path)
|
|
826
929
|
except ConfigError:
|
|
@@ -828,29 +931,44 @@ def doctor(config_path: str | None = None) -> int:
|
|
|
828
931
|
|
|
829
932
|
raw = config_mod.load_raw_config(config_path)
|
|
830
933
|
key_file = config_mod._expand(raw.get("key_file", config_mod.DEFAULT_KEY_FILE))
|
|
831
|
-
|
|
934
|
+
key_status, key_message = check_federation_key(key_file)
|
|
832
935
|
|
|
833
936
|
ca_file = cfg.ca_file if cfg is not None else None
|
|
834
937
|
if cfg is None and raw.get("ca_file"):
|
|
835
938
|
ca_file = config_mod._expand(raw["ca_file"])
|
|
836
|
-
|
|
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"
|
|
837
950
|
|
|
838
|
-
|
|
839
|
-
hidapi_check = check_hidapi_dll()
|
|
840
|
-
if hidapi_check is not None:
|
|
841
|
-
checks.append(hidapi_check)
|
|
951
|
+
items: list[Any] = [environment_group, config_group]
|
|
842
952
|
|
|
843
953
|
# Environment guidance (WSL/usbipd/udev-liveness) BEFORE the device
|
|
844
954
|
# checks -- it explains why the next line is about to warn. Returns []
|
|
845
955
|
# on a healthy platform (macOS, or native Linux with udev running), so
|
|
846
956
|
# this adds no output there -- see hidhelp.explain_environment().
|
|
847
|
-
from . import hidhelp
|
|
848
|
-
|
|
849
957
|
env_guidances = hidhelp.explain_environment()
|
|
850
958
|
for guidance in env_guidances:
|
|
851
|
-
|
|
959
|
+
items.append(
|
|
960
|
+
report.Check("device", _status_glyph(guidance.status), guidance.message)
|
|
961
|
+
)
|
|
852
962
|
env_states = {g.state for g in env_guidances}
|
|
853
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
|
+
|
|
854
972
|
if "W7" in env_states:
|
|
855
973
|
# The W7 guidance just appended above already says the device is
|
|
856
974
|
# attached but can't be opened, plus the proven per-attach `chown`
|
|
@@ -874,22 +992,27 @@ def doctor(config_path: str | None = None) -> int:
|
|
|
874
992
|
# yet) -- the generic "check the cable" guidance would
|
|
875
993
|
# flatly contradict it (see bug report: located @ BUSID 1-4,
|
|
876
994
|
# immediately followed by "check your cable").
|
|
877
|
-
deck_message =
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
)
|
|
881
|
-
|
|
882
|
-
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
|
+
)
|
|
883
1000
|
|
|
884
|
-
|
|
885
|
-
|
|
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))
|
|
886
1005
|
|
|
887
|
-
|
|
1006
|
+
svc_status, svc_message = check_service_status()
|
|
1007
|
+
items.append(report.Check("service", _status_glyph(svc_status), svc_message))
|
|
888
1008
|
|
|
889
|
-
|
|
890
|
-
|
|
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)
|
|
891
1014
|
|
|
892
|
-
|
|
1015
|
+
sys.stdout.write(report.render(verdict, state_lines, action_lines))
|
|
893
1016
|
return 0
|
|
894
1017
|
|
|
895
1018
|
|
|
@@ -908,48 +1031,62 @@ def doctor(config_path: str | None = None) -> int:
|
|
|
908
1031
|
_STATUS_STALE_THRESHOLD_SECONDS = 15.0
|
|
909
1032
|
|
|
910
1033
|
|
|
911
|
-
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
|
+
|
|
912
1044
|
if not device.get("connected"):
|
|
913
|
-
return "
|
|
1045
|
+
return report.Check("device", report.ACT, "not connected")
|
|
914
1046
|
caps = device.get("capabilities") or {}
|
|
915
1047
|
hint = device.get("hint")
|
|
916
|
-
if not caps and hint:
|
|
917
|
-
return "warn", "Device: connected (capabilities unavailable)"
|
|
918
1048
|
if not caps:
|
|
919
|
-
|
|
1049
|
+
glyph = report.ACT if hint else report.FINE
|
|
1050
|
+
return report.Check("device", glyph, "connected (capabilities unavailable)")
|
|
920
1051
|
touchscreen = "yes" if caps.get("has_touchscreen") else "no"
|
|
921
|
-
|
|
922
|
-
f"
|
|
1052
|
+
value = (
|
|
1053
|
+
f"{caps.get('model', '?')} -- {caps.get('key_count', '?')} keys "
|
|
923
1054
|
f"({caps.get('key_rows', '?')}x{caps.get('key_cols', '?')}), "
|
|
924
1055
|
f"{caps.get('dial_count', '?')} dials, touchscreen={touchscreen}"
|
|
925
1056
|
)
|
|
1057
|
+
return report.Check("device", report.FINE, value)
|
|
926
1058
|
|
|
927
1059
|
|
|
928
|
-
def
|
|
1060
|
+
def _status_server_item(server: dict[str, Any]) -> Any:
|
|
1061
|
+
from . import report
|
|
1062
|
+
|
|
929
1063
|
url = server.get("url") or "(not configured)"
|
|
930
1064
|
if server.get("connected"):
|
|
931
|
-
return "
|
|
1065
|
+
return report.Check("server", report.FINE, f"{url} (reachable)")
|
|
932
1066
|
err = server.get("last_error")
|
|
933
|
-
|
|
1067
|
+
value = f"{url} (unreachable)"
|
|
934
1068
|
if err:
|
|
935
|
-
|
|
936
|
-
return "
|
|
1069
|
+
value += f" -- {err}"
|
|
1070
|
+
return report.Check("server", report.ACT, value)
|
|
937
1071
|
|
|
938
1072
|
|
|
939
|
-
def
|
|
940
|
-
|
|
941
|
-
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"
|
|
942
1075
|
page = state.get("page")
|
|
943
|
-
|
|
944
|
-
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
|
|
945
1077
|
|
|
946
1078
|
|
|
947
|
-
def
|
|
1079
|
+
def _direct_probe_item(config_path: str | None) -> Any:
|
|
948
1080
|
"""Fallback when the service isn't running -- nothing holds the device."""
|
|
949
|
-
|
|
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)
|
|
950
1085
|
|
|
951
1086
|
|
|
952
|
-
def status(
|
|
1087
|
+
def status(
|
|
1088
|
+
config_path: str | None = None, *, as_json: bool = False, show_all: bool = False
|
|
1089
|
+
) -> int:
|
|
953
1090
|
"""Print the sidecar's hardware + connection status.
|
|
954
1091
|
|
|
955
1092
|
Reads the status file the running sidecar publishes (see
|
|
@@ -961,6 +1098,7 @@ def status(config_path: str | None = None, *, as_json: bool = False) -> int:
|
|
|
961
1098
|
fall back to a direct probe -- this keeps `status` useful even before
|
|
962
1099
|
the service has ever been installed.
|
|
963
1100
|
"""
|
|
1101
|
+
from . import report
|
|
964
1102
|
from .service import service_is_active, service_main_pid
|
|
965
1103
|
from .statusfile import read_status
|
|
966
1104
|
|
|
@@ -972,36 +1110,45 @@ def status(config_path: str | None = None, *, as_json: bool = False) -> int:
|
|
|
972
1110
|
print(json.dumps(payload, indent=2))
|
|
973
1111
|
return 0 if data is not None else 1
|
|
974
1112
|
|
|
975
|
-
|
|
976
|
-
print_check(
|
|
977
|
-
"ok" if running else "warn",
|
|
978
|
-
f"Service: {'running' if running else 'not running'}",
|
|
979
|
-
)
|
|
1113
|
+
utf8 = report.utf8_capable()
|
|
980
1114
|
|
|
981
1115
|
if not running:
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
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))
|
|
996
1137
|
return 0
|
|
997
1138
|
|
|
998
1139
|
if data is None:
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
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)
|
|
1003
1151
|
)
|
|
1004
|
-
print()
|
|
1005
1152
|
return 0
|
|
1006
1153
|
|
|
1007
1154
|
# Is this snapshot from the process running RIGHT NOW, or from a
|
|
@@ -1024,40 +1171,94 @@ def status(config_path: str | None = None, *, as_json: bool = False) -> int:
|
|
|
1024
1171
|
) <= _STATUS_STALE_THRESHOLD_SECONDS
|
|
1025
1172
|
|
|
1026
1173
|
if not is_current:
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
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)
|
|
1034
1191
|
)
|
|
1035
|
-
print()
|
|
1036
1192
|
return 0
|
|
1037
1193
|
|
|
1038
1194
|
age = time.time() - data.get("updated_at", 0)
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
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
|
+
)
|
|
1047
1205
|
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
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
|
|
1223
|
+
|
|
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:
|
|
1052
1233
|
# Populated by the sidecar's open-failure branch (see main.py /
|
|
1053
1234
|
# hidhelp.explain_open_failure) -- this is what turns a stale
|
|
1054
1235
|
# status file into the primary teaching surface instead of a
|
|
1055
1236
|
# bare "not connected" with no explanation.
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
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))
|
|
1061
1262
|
return 0
|
|
1062
1263
|
|
|
1063
1264
|
|
|
@@ -1428,7 +1629,17 @@ def main() -> None:
|
|
|
1428
1629
|
|
|
1429
1630
|
sub.add_parser("version", help="Show the muxplex-deck version")
|
|
1430
1631
|
|
|
1431
|
-
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
|
+
)
|
|
1432
1643
|
|
|
1433
1644
|
status_parser = sub.add_parser(
|
|
1434
1645
|
"status", help="Show connected hardware + connection state"
|
|
@@ -1436,6 +1647,13 @@ def main() -> None:
|
|
|
1436
1647
|
status_parser.add_argument(
|
|
1437
1648
|
"--json", action="store_true", help="Emit raw status as JSON"
|
|
1438
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
|
+
)
|
|
1439
1657
|
|
|
1440
1658
|
update_parser = sub.add_parser(
|
|
1441
1659
|
"update",
|
|
@@ -1511,10 +1729,14 @@ def main() -> None:
|
|
|
1511
1729
|
if args.command == "version":
|
|
1512
1730
|
print_version()
|
|
1513
1731
|
elif args.command == "doctor":
|
|
1514
|
-
doctor(getattr(args, "config", None))
|
|
1732
|
+
doctor(getattr(args, "config", None), show_all=getattr(args, "show_all", False))
|
|
1515
1733
|
elif args.command == "status":
|
|
1516
1734
|
sys.exit(
|
|
1517
|
-
status(
|
|
1735
|
+
status(
|
|
1736
|
+
getattr(args, "config", None),
|
|
1737
|
+
as_json=getattr(args, "json", False),
|
|
1738
|
+
show_all=getattr(args, "show_all", False),
|
|
1739
|
+
)
|
|
1518
1740
|
)
|
|
1519
1741
|
elif args.command in ("update", "upgrade"):
|
|
1520
1742
|
update(force=getattr(args, "force", False))
|
|
@@ -13,18 +13,34 @@ Two layout modes:
|
|
|
13
13
|
Deck+). Every LCD key is a session tile; dial 0 cycles views, dial 1
|
|
14
14
|
pages, and the touch strip carries the server/view/status headline.
|
|
15
15
|
This is the pre-existing Stream Deck+ behavior, unchanged.
|
|
16
|
-
- REDUCED -- everything else (Original/MK2 3x5, XL 4x8, Mini
|
|
16
|
+
- REDUCED -- everything else (Original/MK2 3x5, XL 4x8, Mini 3x2, ...).
|
|
17
17
|
With no dials or strip to lean on, three keys are reserved *by grid
|
|
18
18
|
position* (computed from `key_layout` rows x cols, never hardcoded)
|
|
19
|
-
for the roles the dials/strip played
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
19
|
+
for the roles the dials/strip played. Which three positions depends on
|
|
20
|
+
grid shape alone (see `_reserved_control_keys`), never on model name:
|
|
21
|
+
|
|
22
|
+
* Exactly 3 columns, 2+ rows (e.g. the Stream Deck Mini, 3 cols x 2
|
|
23
|
+
rows): 3 columns is exactly enough to dedicate the *entire bottom
|
|
24
|
+
row* to controls, reading left to right as PREV, VIEW, NEXT --
|
|
25
|
+
every row above it is entirely session tiles. This was chosen over
|
|
26
|
+
reusing the corner layout below because on a 3-wide grid the
|
|
27
|
+
corner scheme leaves session tiles awkwardly split around a lone
|
|
28
|
+
reserved key in the bottom row (e.g. keys 1, 2, 4 on a 3x2 grid --
|
|
29
|
+
a gap where key 3 (PREV) sits), whereas a full control row leaves
|
|
30
|
+
every session tile contiguous on the row(s) above.
|
|
31
|
+
* Everything else (any other column count): reserve the three
|
|
32
|
+
corners -- VIEW top-left (index 0), PREV bottom-left
|
|
33
|
+
(index (rows-1)*cols), NEXT bottom-right (index rows*cols-1) --
|
|
34
|
+
the original REDUCED geometry, ported from dial-0/dial-1's roles
|
|
35
|
+
on the Stream Deck+. Unchanged for the 15-key Original (3x5) and
|
|
36
|
+
XL (4x8).
|
|
37
|
+
|
|
38
|
+
In both cases VIEW shows the current view name + server label (what
|
|
39
|
+
the strip showed); a tap opens a paged view picker on the session-slot
|
|
40
|
+
keys (dial-0's picker role) -- VIEW becomes BACK, PREV/NEXT page the
|
|
41
|
+
list, tapping a view selects it. Every remaining key is a session tile
|
|
42
|
+
in reading order, so sessions_per_page = key_count - 3 (12 on a 15-key
|
|
43
|
+
deck, 3 on a 6-key Mini).
|
|
28
44
|
|
|
29
45
|
Edge cases (degrade gracefully, never crash):
|
|
30
46
|
|
|
@@ -33,10 +49,10 @@ Edge cases (degrade gracefully, never crash):
|
|
|
33
49
|
- Touchscreen but fewer than two dials: REDUCED for the keys, but the
|
|
34
50
|
strip is still used for the status headline (`use_strip` stays True) --
|
|
35
51
|
free extra signal, no behavior depends on it.
|
|
36
|
-
- Fewer than 4 keys, or a grid so degenerate the three reserved
|
|
37
|
-
collide (e.g. a single row, where top-left == bottom-left): every
|
|
38
|
-
becomes a session tile with no view/page controls -- a plain
|
|
39
|
-
switcher beats three controls with no sessions.
|
|
52
|
+
- Fewer than 4 keys, or a grid so degenerate the three reserved control
|
|
53
|
+
keys collide (e.g. a single row, where top-left == bottom-left): every
|
|
54
|
+
key becomes a session tile with no view/page controls -- a plain
|
|
55
|
+
session switcher beats three controls with no sessions.
|
|
40
56
|
|
|
41
57
|
Everything here is pure (no device I/O, no threads), so both layout modes
|
|
42
58
|
are unit-testable with fake capability dicts.
|
|
@@ -102,6 +118,27 @@ class LayoutPlan:
|
|
|
102
118
|
use_strip: bool # paint the touch strip (status headline)?
|
|
103
119
|
|
|
104
120
|
|
|
121
|
+
def _reserved_control_keys(rows: int, cols: int) -> tuple[int, int, int]:
|
|
122
|
+
"""Compute `(view_key, prev_key, next_key)` physical key indices.
|
|
123
|
+
|
|
124
|
+
Chosen by grid shape alone (`key_rows` x `key_cols`) -- never by model
|
|
125
|
+
name. Two geometries:
|
|
126
|
+
|
|
127
|
+
- Exactly 3 columns, 2+ rows: the bottom row exactly fits all three
|
|
128
|
+
controls, reading left to right as PREV, VIEW, NEXT. Every row above
|
|
129
|
+
it is entirely session tiles. A 3-wide grid is the one shape where a
|
|
130
|
+
full control row is both possible (no leftover) and better than the
|
|
131
|
+
corner scheme below, which would otherwise strand session tiles
|
|
132
|
+
around a lone reserved key mid-row.
|
|
133
|
+
- Everything else: reserve the three corners -- VIEW top-left, PREV
|
|
134
|
+
bottom-left, NEXT bottom-right -- the original REDUCED geometry.
|
|
135
|
+
"""
|
|
136
|
+
if cols == 3 and rows >= 2:
|
|
137
|
+
bottom_row_start = (rows - 1) * cols
|
|
138
|
+
return bottom_row_start + 1, bottom_row_start, bottom_row_start + 2
|
|
139
|
+
return 0, (rows - 1) * cols, rows * cols - 1
|
|
140
|
+
|
|
141
|
+
|
|
105
142
|
def plan_layout(caps: Mapping[str, Any]) -> LayoutPlan:
|
|
106
143
|
"""Choose the layout mode and key assignments for a deck's capabilities.
|
|
107
144
|
|
|
@@ -131,9 +168,7 @@ def plan_layout(caps: Mapping[str, Any]) -> LayoutPlan:
|
|
|
131
168
|
use_strip=True,
|
|
132
169
|
)
|
|
133
170
|
|
|
134
|
-
view_key =
|
|
135
|
-
prev_key = (rows - 1) * cols
|
|
136
|
-
next_key = rows * cols - 1
|
|
171
|
+
view_key, prev_key, next_key = _reserved_control_keys(rows, cols)
|
|
137
172
|
reserved = (view_key, prev_key, next_key)
|
|
138
173
|
degenerate = (
|
|
139
174
|
key_count < _REDUCED_MIN_KEYS
|
|
@@ -0,0 +1,416 @@
|
|
|
1
|
+
"""Terminal-output rendering for `doctor` / `status` / `service` -- pure text.
|
|
2
|
+
|
|
3
|
+
Three bands, always this order, separated by exactly ONE blank line, with
|
|
4
|
+
leading/trailing blank lines as the only frame::
|
|
5
|
+
|
|
6
|
+
VERDICT one line, column 0
|
|
7
|
+
STATE the checks or readouts, indented
|
|
8
|
+
ACTION "Do this:" + exactly one decision (omitted when nothing to do)
|
|
9
|
+
|
|
10
|
+
No ANSI color, no box drawing, no tree connectors -- the acid test is the
|
|
11
|
+
paste: the user's primary act is selecting this output and pasting it as
|
|
12
|
+
plain text, so every emphasis device must survive that. Output is
|
|
13
|
+
byte-identical whether printed to a TTY or piped to a file; the only thing
|
|
14
|
+
that varies with environment is the `+` (fine) glyph, which becomes a
|
|
15
|
+
Unicode checkmark when the environment is UTF-8-capable (see
|
|
16
|
+
`utf8_capable`). This module does no I/O itself -- callers decide what to
|
|
17
|
+
check and how to phrase it; this module only lays it out.
|
|
18
|
+
|
|
19
|
+
Column ladder (exact, see the layout-architect review this implements)::
|
|
20
|
+
|
|
21
|
+
check line 2sp + glyph + 2sp + subject padded to 14 + value (col 19)
|
|
22
|
+
continuation 19sp + text (hangs under the value column)
|
|
23
|
+
readout line 5sp + name padded to 14 + value -- gutter left empty
|
|
24
|
+
group label column 0, lowercase, blank line above (--all only)
|
|
25
|
+
command to run indented +4 from its governing text
|
|
26
|
+
prose in ACTION column 2, max 2 lines
|
|
27
|
+
|
|
28
|
+
Only VERDICT, "Do this:", and `--all` group labels ever touch column 0.
|
|
29
|
+
Below 60 columns, subject padding drops to a single space (no other
|
|
30
|
+
reflow); above 80, nothing changes -- fields are never stretched to fill
|
|
31
|
+
a wide terminal, and wrapping always happens at column 78.
|
|
32
|
+
|
|
33
|
+
The gutter law: the gutter is empty if and only if there is nothing to do.
|
|
34
|
+
A fully healthy report has no glyphs at all -- see `Readout` -- and `+`
|
|
35
|
+
appears only for contrast alongside a failure in the same report, or under
|
|
36
|
+
`--all`.
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
from __future__ import annotations
|
|
40
|
+
|
|
41
|
+
import os
|
|
42
|
+
import re
|
|
43
|
+
import textwrap
|
|
44
|
+
from dataclasses import dataclass, field
|
|
45
|
+
|
|
46
|
+
# ---------------------------------------------------------------------------
|
|
47
|
+
# Glyphs -- exactly three, ASCII by design (see module docstring on UTF-8).
|
|
48
|
+
# ---------------------------------------------------------------------------
|
|
49
|
+
|
|
50
|
+
ACT = "!" # act now -- the root cause; a command exists
|
|
51
|
+
FINE = "+" # nothing to do
|
|
52
|
+
BLOCKED = "-" # cannot be done or evaluated until an upstream item resolves
|
|
53
|
+
|
|
54
|
+
# Rollup severity for grouped checks: "!" > "-" > "+" (higher = worse).
|
|
55
|
+
_SEVERITY = {ACT: 2, BLOCKED: 1, FINE: 0}
|
|
56
|
+
|
|
57
|
+
# ---------------------------------------------------------------------------
|
|
58
|
+
# Column geometry
|
|
59
|
+
# ---------------------------------------------------------------------------
|
|
60
|
+
|
|
61
|
+
_GUTTER_WIDTH = 5 # " " + glyph/space + " "
|
|
62
|
+
_SUBJECT_WIDTH = 14
|
|
63
|
+
_NARROW_SUBJECT_WIDTH = 1 # below _NARROW_THRESHOLD, drop padding to 1 space
|
|
64
|
+
_NARROW_THRESHOLD = 60
|
|
65
|
+
_DEFAULT_WIDTH = 78
|
|
66
|
+
_ACTION_PROSE_COLUMN = 2
|
|
67
|
+
_ACTION_COMMAND_INDENT = 4 # "+4 from its governing text"
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _subject_field(subject: str, width: int) -> str:
|
|
71
|
+
"""The subject column's text, including its trailing separator.
|
|
72
|
+
|
|
73
|
+
At normal widths this is `subject` left-justified to 14 columns. Below
|
|
74
|
+
`_NARROW_THRESHOLD` columns, padding drops to a single separating space
|
|
75
|
+
instead (the spec's "drop subject padding to a single space") -- the
|
|
76
|
+
column ladder is no longer aligned at that width, which is expected.
|
|
77
|
+
"""
|
|
78
|
+
if width < _NARROW_THRESHOLD:
|
|
79
|
+
return f"{subject} "
|
|
80
|
+
return subject.ljust(_SUBJECT_WIDTH)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
# ---------------------------------------------------------------------------
|
|
84
|
+
# UTF-8 glyph substitution -- "+"/"✓" only. "!" and "-" never change: a
|
|
85
|
+
# diagnostic tool must not emit output that can be corrupted by the very
|
|
86
|
+
# environment it is diagnosing, so the ASCII forms are always valid; the
|
|
87
|
+
# checkmark is a cosmetic upgrade applied only when it is safe.
|
|
88
|
+
# ---------------------------------------------------------------------------
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def utf8_capable(env: dict[str, str] | None = None) -> bool:
|
|
92
|
+
"""Best-effort: can this environment render a UTF-8 checkmark safely?
|
|
93
|
+
|
|
94
|
+
Checks LC_ALL, LC_CTYPE, LANG in that order (the standard POSIX locale
|
|
95
|
+
precedence) for a "UTF-8"/"utf8" marker. Defaults to `os.environ`;
|
|
96
|
+
callers (and tests) may pass an explicit mapping instead.
|
|
97
|
+
"""
|
|
98
|
+
source = os.environ if env is None else env
|
|
99
|
+
for key in ("LC_ALL", "LC_CTYPE", "LANG"):
|
|
100
|
+
value = source.get(key)
|
|
101
|
+
if not value:
|
|
102
|
+
continue
|
|
103
|
+
normalized = value.lower()
|
|
104
|
+
if "utf-8" in normalized or "utf8" in normalized:
|
|
105
|
+
return True
|
|
106
|
+
return False
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _glyph_char(glyph: str, *, utf8: bool) -> str:
|
|
110
|
+
if glyph == FINE and utf8:
|
|
111
|
+
return "\u2713"
|
|
112
|
+
return glyph
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
# ---------------------------------------------------------------------------
|
|
116
|
+
# Data model
|
|
117
|
+
# ---------------------------------------------------------------------------
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
@dataclass
|
|
121
|
+
class Check:
|
|
122
|
+
"""One line of STATE: a glyph, a subject, and a value."""
|
|
123
|
+
|
|
124
|
+
subject: str
|
|
125
|
+
glyph: str
|
|
126
|
+
value: str
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
@dataclass
|
|
130
|
+
class Group:
|
|
131
|
+
"""A named cluster of `Check`s that collapses to one line by default.
|
|
132
|
+
|
|
133
|
+
Default mode renders `rollup()` (glyph = worst member, value = that
|
|
134
|
+
member's value). `--all` renders every member instead, under a
|
|
135
|
+
column-0 group label -- a strict superset, never a different answer.
|
|
136
|
+
"""
|
|
137
|
+
|
|
138
|
+
subject: str
|
|
139
|
+
members: list[Check]
|
|
140
|
+
|
|
141
|
+
def rollup(self) -> Check:
|
|
142
|
+
worst = max(self.members, key=lambda c: _SEVERITY[c.glyph])
|
|
143
|
+
return Check(subject=self.subject, glyph=worst.glyph, value=worst.value)
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
@dataclass
|
|
147
|
+
class Readout:
|
|
148
|
+
"""One line of STATE with no glyph at all -- the all-fine gutter-empty form."""
|
|
149
|
+
|
|
150
|
+
name: str
|
|
151
|
+
value: str
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
Item = Check | Group
|
|
155
|
+
|
|
156
|
+
# ---------------------------------------------------------------------------
|
|
157
|
+
# Wrapping -- hard-wrap at column 78 (or the given width), never soft-wrap.
|
|
158
|
+
# Lines that already fit are left byte-for-byte alone (so hand-formatted
|
|
159
|
+
# multi-line guidance blocks with their own bullet indentation survive);
|
|
160
|
+
# only a line that genuinely overflows gets reflowed, and only that line.
|
|
161
|
+
# ---------------------------------------------------------------------------
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def _wrap_logical_line(line: str, avail: int) -> list[str]:
|
|
165
|
+
avail = max(10, avail)
|
|
166
|
+
if len(line) <= avail:
|
|
167
|
+
return [line]
|
|
168
|
+
wrapped = textwrap.wrap(
|
|
169
|
+
line, width=avail, break_long_words=False, break_on_hyphens=False
|
|
170
|
+
)
|
|
171
|
+
return wrapped or [""]
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def _render_value_block(
|
|
175
|
+
prefix: str, value: str, *, value_column: int, width: int
|
|
176
|
+
) -> str:
|
|
177
|
+
"""Render `prefix` + `value`, wrapping/hanging continuations at `value_column`."""
|
|
178
|
+
avail = max(10, width - value_column)
|
|
179
|
+
logical_lines = value.splitlines() or [""]
|
|
180
|
+
out: list[str] = []
|
|
181
|
+
first = True
|
|
182
|
+
for logical in logical_lines:
|
|
183
|
+
for fragment in _wrap_logical_line(logical, avail):
|
|
184
|
+
if first:
|
|
185
|
+
out.append(prefix + fragment)
|
|
186
|
+
first = False
|
|
187
|
+
else:
|
|
188
|
+
out.append(" " * value_column + fragment)
|
|
189
|
+
return "\n".join(out)
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def format_check_line(
|
|
193
|
+
glyph: str, subject: str, value: str, *, utf8: bool, width: int = _DEFAULT_WIDTH
|
|
194
|
+
) -> str:
|
|
195
|
+
"""Render one STATE check line: ` <glyph> <subject><value>` (+ wraps)."""
|
|
196
|
+
display_glyph = _glyph_char(glyph, utf8=utf8)
|
|
197
|
+
prefix = f" {display_glyph} {_subject_field(subject, width)}"
|
|
198
|
+
return _render_value_block(prefix, value, value_column=len(prefix), width=width)
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def format_readout_line(name: str, value: str, *, width: int = _DEFAULT_WIDTH) -> str:
|
|
202
|
+
"""Render one STATE readout line: gutter left empty (the all-fine form)."""
|
|
203
|
+
prefix = " " * _GUTTER_WIDTH + _subject_field(name, width)
|
|
204
|
+
return _render_value_block(prefix, value, value_column=len(prefix), width=width)
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def group_label(subject: str) -> str:
|
|
208
|
+
"""The `--all`-only column-0 group heading (lowercase, blank line above it)."""
|
|
209
|
+
return subject.lower()
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
# ---------------------------------------------------------------------------
|
|
213
|
+
# Rendering a list of Check/Group items into STATE lines
|
|
214
|
+
# ---------------------------------------------------------------------------
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def collapsed_checks(items: list[Item]) -> list[Check]:
|
|
218
|
+
"""Flatten groups to their rollup -- the representation the VERDICT/ACTION
|
|
219
|
+
|
|
220
|
+
bands are always computed from, so both bands stay identical between
|
|
221
|
+
default and `--all` mode (the superset property: only the middle
|
|
222
|
+
expands).
|
|
223
|
+
"""
|
|
224
|
+
return [item.rollup() if isinstance(item, Group) else item for item in items]
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def render_items(
|
|
228
|
+
items: list[Item], *, show_all: bool, utf8: bool, width: int = _DEFAULT_WIDTH
|
|
229
|
+
) -> list[str]:
|
|
230
|
+
"""Render STATE lines for a list of Check/Group items.
|
|
231
|
+
|
|
232
|
+
Default mode: each Group renders as its single rolled-up line. `--all`:
|
|
233
|
+
each Group expands into a column-0 lowercase label followed by every
|
|
234
|
+
member as its own check line; ungrouped Checks render identically in
|
|
235
|
+
both modes (nothing to expand).
|
|
236
|
+
"""
|
|
237
|
+
lines: list[str] = []
|
|
238
|
+
need_blank_before_next_group = False
|
|
239
|
+
for item in items:
|
|
240
|
+
if isinstance(item, Group):
|
|
241
|
+
if show_all:
|
|
242
|
+
if need_blank_before_next_group:
|
|
243
|
+
lines.append("")
|
|
244
|
+
lines.append(group_label(item.subject))
|
|
245
|
+
for member in item.members:
|
|
246
|
+
lines.append(
|
|
247
|
+
format_check_line(
|
|
248
|
+
member.glyph,
|
|
249
|
+
member.subject,
|
|
250
|
+
member.value,
|
|
251
|
+
utf8=utf8,
|
|
252
|
+
width=width,
|
|
253
|
+
)
|
|
254
|
+
)
|
|
255
|
+
need_blank_before_next_group = True
|
|
256
|
+
continue
|
|
257
|
+
rolled = item.rollup()
|
|
258
|
+
lines.append(
|
|
259
|
+
format_check_line(
|
|
260
|
+
rolled.glyph, rolled.subject, rolled.value, utf8=utf8, width=width
|
|
261
|
+
)
|
|
262
|
+
)
|
|
263
|
+
else:
|
|
264
|
+
lines.append(
|
|
265
|
+
format_check_line(
|
|
266
|
+
item.glyph, item.subject, item.value, utf8=utf8, width=width
|
|
267
|
+
)
|
|
268
|
+
)
|
|
269
|
+
need_blank_before_next_group = False
|
|
270
|
+
return lines
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def render_readouts(
|
|
274
|
+
readouts: list[Readout], *, width: int = _DEFAULT_WIDTH
|
|
275
|
+
) -> list[str]:
|
|
276
|
+
return [format_readout_line(r.name, r.value, width=width) for r in readouts]
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
# ---------------------------------------------------------------------------
|
|
280
|
+
# VERDICT -- counts ACTIONS, not problems.
|
|
281
|
+
# ---------------------------------------------------------------------------
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
def count_actions(glyphs: list[str]) -> int:
|
|
285
|
+
return sum(1 for g in glyphs if g == ACT)
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
def verdict_readiness(action_count: int, *, note: str | None = None) -> str:
|
|
289
|
+
""" "Ready."/"Not ready -- N thing(s) to do[, note]." -- the doctor-style verdict."""
|
|
290
|
+
if action_count <= 0:
|
|
291
|
+
return "Ready."
|
|
292
|
+
word = "thing" if action_count == 1 else "things"
|
|
293
|
+
base = f"Not ready -- {action_count} {word} to do"
|
|
294
|
+
if note:
|
|
295
|
+
base = f"{base}, {note}"
|
|
296
|
+
return base + "."
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
# ---------------------------------------------------------------------------
|
|
300
|
+
# ACTION band
|
|
301
|
+
# ---------------------------------------------------------------------------
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
def prose_lines(text: str, *, width: int = _DEFAULT_WIDTH) -> list[str]:
|
|
305
|
+
"""Prose in ACTION: column 2, wrapped at `width`."""
|
|
306
|
+
avail = max(10, width - _ACTION_PROSE_COLUMN)
|
|
307
|
+
out: list[str] = []
|
|
308
|
+
for logical in text.splitlines() or [text]:
|
|
309
|
+
out.extend(_wrap_logical_line(logical, avail))
|
|
310
|
+
return [" " * _ACTION_PROSE_COLUMN + line for line in out]
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
@dataclass
|
|
314
|
+
class Decision:
|
|
315
|
+
"""A single ACTION decision: a contiguous command sequence, plus prose."""
|
|
316
|
+
|
|
317
|
+
commands: list[str]
|
|
318
|
+
prose: str | None = None # max 2 lines
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
@dataclass
|
|
322
|
+
class Step:
|
|
323
|
+
"""One numbered step of a multi-step, cross-machine ACTION."""
|
|
324
|
+
|
|
325
|
+
number: int
|
|
326
|
+
intro: str # step body text, e.g. "On Windows, in an Administrator PowerShell:"
|
|
327
|
+
commands: list[str] = field(default_factory=list)
|
|
328
|
+
note: str | None = None # continuation prose specific to this step
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
@dataclass
|
|
332
|
+
class Action:
|
|
333
|
+
decision: Decision | None = None
|
|
334
|
+
steps: list[Step] = field(default_factory=list)
|
|
335
|
+
overflow_note: str | None = None # e.g. "2 more after this -- rerun doctor."
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
def _render_decision(decision: Decision, *, width: int) -> list[str]:
|
|
339
|
+
lines: list[str] = []
|
|
340
|
+
if decision.commands:
|
|
341
|
+
lines.append("")
|
|
342
|
+
for cmd in decision.commands:
|
|
343
|
+
lines.append(" " * _ACTION_COMMAND_INDENT + cmd)
|
|
344
|
+
if decision.prose:
|
|
345
|
+
lines.append("")
|
|
346
|
+
lines.extend(prose_lines(decision.prose, width=width))
|
|
347
|
+
return lines
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
def _render_step(step: Step, *, width: int) -> list[str]:
|
|
351
|
+
marker = f"{step.number}. "
|
|
352
|
+
body_column = _ACTION_PROSE_COLUMN + len(marker)
|
|
353
|
+
command_column = body_column + _ACTION_COMMAND_INDENT
|
|
354
|
+
lines = [" " * _ACTION_PROSE_COLUMN + marker + step.intro]
|
|
355
|
+
if step.commands:
|
|
356
|
+
lines.append("")
|
|
357
|
+
for cmd in step.commands:
|
|
358
|
+
lines.append(" " * command_column + cmd)
|
|
359
|
+
if step.note:
|
|
360
|
+
lines.append("")
|
|
361
|
+
for logical in step.note.splitlines() or [step.note]:
|
|
362
|
+
lines.append(" " * body_column + logical)
|
|
363
|
+
return lines
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
def render_action(action: Action, *, width: int = _DEFAULT_WIDTH) -> list[str]:
|
|
367
|
+
"""Render the full ACTION band, including its "Do this:" header line."""
|
|
368
|
+
lines: list[str] = ["Do this:"]
|
|
369
|
+
if action.decision is not None:
|
|
370
|
+
lines.extend(_render_decision(action.decision, width=width))
|
|
371
|
+
else:
|
|
372
|
+
for index, step in enumerate(action.steps):
|
|
373
|
+
if index > 0:
|
|
374
|
+
lines.append("")
|
|
375
|
+
lines.extend(_render_step(step, width=width))
|
|
376
|
+
if action.overflow_note:
|
|
377
|
+
lines.append("")
|
|
378
|
+
lines.extend(prose_lines(action.overflow_note, width=width))
|
|
379
|
+
return lines
|
|
380
|
+
|
|
381
|
+
|
|
382
|
+
# ---------------------------------------------------------------------------
|
|
383
|
+
# Top-level assembly
|
|
384
|
+
# ---------------------------------------------------------------------------
|
|
385
|
+
|
|
386
|
+
|
|
387
|
+
def render(
|
|
388
|
+
verdict: str, state_lines: list[str], action_lines: list[str] | None = None
|
|
389
|
+
) -> str:
|
|
390
|
+
"""Assemble VERDICT / STATE / ACTION with exactly one blank-line separator
|
|
391
|
+
|
|
392
|
+
between bands and a leading/trailing blank line as the only frame.
|
|
393
|
+
Returns the full text -- callers should write it verbatim (e.g.
|
|
394
|
+
`sys.stdout.write(...)`), not `print()` it (which would add a second
|
|
395
|
+
trailing newline).
|
|
396
|
+
"""
|
|
397
|
+
lines: list[str] = ["", verdict, "", *state_lines]
|
|
398
|
+
if action_lines:
|
|
399
|
+
lines += ["", *action_lines]
|
|
400
|
+
return "\n".join(lines) + "\n\n"
|
|
401
|
+
|
|
402
|
+
|
|
403
|
+
# ---------------------------------------------------------------------------
|
|
404
|
+
# Helper for callers extracting an embedded "run: <command>" suggestion out
|
|
405
|
+
# of an existing check message -- lets ACTION reuse guidance text that
|
|
406
|
+
# already exists in check functions instead of duplicating it.
|
|
407
|
+
# ---------------------------------------------------------------------------
|
|
408
|
+
|
|
409
|
+
_RUN_COMMAND_RE = re.compile(r"run:\s*(muxplex-deck[^\n.]*)")
|
|
410
|
+
|
|
411
|
+
|
|
412
|
+
def extract_run_command(message: str) -> str | None:
|
|
413
|
+
match = _RUN_COMMAND_RE.search(message)
|
|
414
|
+
if not match:
|
|
415
|
+
return None
|
|
416
|
+
return match.group(1).strip()
|
|
@@ -139,19 +139,29 @@ _LAUNCHD_PLIST_TEMPLATE = """\
|
|
|
139
139
|
</plist>
|
|
140
140
|
"""
|
|
141
141
|
|
|
142
|
-
|
|
143
|
-
#
|
|
144
|
-
#
|
|
145
|
-
|
|
146
|
-
|
|
142
|
+
|
|
143
|
+
# Same glyph vocabulary as `report.py` (see that module's docstring): "+"
|
|
144
|
+
# for fine, becoming a UTF-8 checkmark only when the environment can render
|
|
145
|
+
# one. This install/uninstall narration is step-by-step progress output,
|
|
146
|
+
# not a VERDICT/STATE/ACTION report -- it intentionally keeps its own
|
|
147
|
+
# simple 2-space-indent style rather than adopting the full column ladder,
|
|
148
|
+
# but shares the same glyph-substitution rule so the visual language is
|
|
149
|
+
# consistent across `doctor`/`status`/`service`. No ANSI color: plain text
|
|
150
|
+
# only, so this stays byte-identical whether printed to a TTY or piped.
|
|
151
|
+
def _mark_ok() -> str:
|
|
152
|
+
from . import report
|
|
153
|
+
|
|
154
|
+
return report._glyph_char(report.FINE, utf8=report.utf8_capable())
|
|
147
155
|
|
|
148
156
|
|
|
149
157
|
def _step_ok(message: str) -> None:
|
|
150
|
-
print(f" {
|
|
158
|
+
print(f" {_mark_ok()} {message}")
|
|
151
159
|
|
|
152
160
|
|
|
153
161
|
def _step_warn(message: str) -> None:
|
|
154
|
-
|
|
162
|
+
from . import report
|
|
163
|
+
|
|
164
|
+
print(f" {report.ACT} {message}")
|
|
155
165
|
|
|
156
166
|
|
|
157
167
|
# ---------------------------------------------------------------------------
|
|
@@ -256,8 +266,10 @@ def _print_guidance_block(message: str) -> None:
|
|
|
256
266
|
uses (kept as a small local duplicate here to avoid a circular import
|
|
257
267
|
with `cli.py`).
|
|
258
268
|
"""
|
|
269
|
+
from . import report
|
|
270
|
+
|
|
259
271
|
for i, line in enumerate(message.splitlines()):
|
|
260
|
-
prefix = f" {
|
|
272
|
+
prefix = f" {report.ACT} " if i == 0 else " "
|
|
261
273
|
print(f"{prefix}{line}")
|
|
262
274
|
|
|
263
275
|
|
|
@@ -122,7 +122,7 @@ def build_status(
|
|
|
122
122
|
`hidhelp.explain_open_failure`), published so `muxplex-deck status`
|
|
123
123
|
can show it instead of a stale/misleading status. Older readers that
|
|
124
124
|
don't know this field simply don't see it (`.get()` is used to read
|
|
125
|
-
it back -- see `cli.
|
|
125
|
+
it back -- see `cli._status_device_item`).
|
|
126
126
|
"""
|
|
127
127
|
device: dict[str, Any] = {"connected": device_connected}
|
|
128
128
|
if device_connected and device_caps is not None:
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|