Skip to content

Commit 3b35b47

Browse files
committed
Add AC_usb_* executor commands for headless USB passthrough control
Every GUI action now has an executor command so JSON action files, the socket server, and the scheduler can drive USB passthrough without a GUI: feature flag, ACL CRUD + export/import, local loopback list/open, and remote (live WebRTC) list/open. Each returns a JSON-able dict; the descriptor probe summarises the device. Stubs added to actions.pyi.
1 parent 216c021 commit 3b35b47

5 files changed

Lines changed: 338 additions & 0 deletions

File tree

docs/source/Eng/doc/operations_layer/usb_passthrough_operator_guide.rst

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -270,6 +270,38 @@ Audit chain shows ``broken_at_id`` Someone edited ``audit.db`` directly
270270
========================================== =====================================================
271271

272272

273+
Headless control (``AC_usb_*`` commands)
274+
========================================
275+
276+
Everything the GUI does is also an executor command, so JSON action
277+
files, the socket server, and the scheduler can drive USB passthrough
278+
with no GUI:
279+
280+
================================ ============================================
281+
Command Purpose
282+
================================ ============================================
283+
``AC_usb_passthrough_enable`` Toggle the feature flag (``enabled`` bool)
284+
``AC_usb_passthrough_status`` Report whether passthrough is enabled
285+
``AC_usb_acl_list`` List ACL rules + default + integrity state
286+
``AC_usb_acl_add`` Add a per-device rule
287+
``AC_usb_acl_remove`` Remove a rule
288+
``AC_usb_acl_set_default`` Set the default policy (allow/deny)
289+
``AC_usb_acl_export`` / ``_import`` Back up / restore the ACL as JSON
290+
``AC_usb_loopback_list`` List ACL-visible devices over loopback
291+
``AC_usb_loopback_open`` Claim a local device + read its descriptor
292+
``AC_usb_remote_list`` List a remote host's devices (live WebRTC)
293+
``AC_usb_remote_open`` Claim a remote device + read its descriptor
294+
================================ ============================================
295+
296+
Example JSON action::
297+
298+
[
299+
["AC_usb_passthrough_enable", {"enabled": true}],
300+
["AC_usb_acl_add", {"vendor_id": "1050", "product_id": "0407"}],
301+
["AC_usb_loopback_open", {"vendor_id": "1050", "product_id": "0407"}]
302+
]
303+
304+
273305
What is *not* shipped yet
274306
=========================
275307

docs/source/Zh/doc/operations_layer/usb_passthrough_operator_guide.rst

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -251,6 +251,37 @@ OPEN 後 host 鍵盤停止運作 Linux:HID 裝置被 claim
251251
========================================== =====================================================
252252

253253

254+
無 GUI 控制(``AC_usb_*`` 指令)
255+
================================
256+
257+
GUI 能做的事都有對應的 executor 指令,所以 JSON action 檔、socket
258+
server 與排程器都能在沒有 GUI 的情況下驅動 USB passthrough:
259+
260+
================================ ============================================
261+
指令 用途
262+
================================ ============================================
263+
``AC_usb_passthrough_enable`` 切換 feature flag(``enabled`` 布林)
264+
``AC_usb_passthrough_status`` 回報是否已啟用
265+
``AC_usb_acl_list`` 列出 ACL 規則 + 預設 + 完整性狀態
266+
``AC_usb_acl_add`` 新增 per-device 規則
267+
``AC_usb_acl_remove`` 移除規則
268+
``AC_usb_acl_set_default`` 設定預設政策(allow/deny)
269+
``AC_usb_acl_export`` / ``_import`` 以 JSON 備份/還原 ACL
270+
``AC_usb_loopback_list`` 經 loopback 列出 ACL 可見裝置
271+
``AC_usb_loopback_open`` claim 本機裝置並讀描述元
272+
``AC_usb_remote_list`` 列出遠端主機裝置(live WebRTC)
273+
``AC_usb_remote_open`` claim 遠端裝置並讀描述元
274+
================================ ============================================
275+
276+
JSON action 範例::
277+
278+
[
279+
["AC_usb_passthrough_enable", {"enabled": true}],
280+
["AC_usb_acl_add", {"vendor_id": "1050", "product_id": "0407"}],
281+
["AC_usb_loopback_open", {"vendor_id": "1050", "product_id": "0407"}]
282+
]
283+
284+
254285
尚未發布的部分
255286
==============
256287

je_auto_control/actions.pyi

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -468,6 +468,42 @@ def AC_usb_watch_start(poll_interval_s: float = ...) -> Dict[str, Any]:
468468
def AC_usb_watch_stop() -> Dict[str, Any]:
469469
...
470470

471+
def AC_usb_passthrough_enable(enabled: bool = ...) -> Dict[str, Any]:
472+
"""Toggle the USB passthrough feature flag (default off)."""
473+
474+
def AC_usb_passthrough_status() -> Dict[str, Any]:
475+
...
476+
477+
def AC_usb_acl_list() -> Dict[str, Any]:
478+
...
479+
480+
def AC_usb_acl_add(vendor_id: str, product_id: str, serial: str | None = ..., allow: bool = ..., prompt_on_open: bool = ..., label: str = ...) -> Dict[str, Any]:
481+
"""Add a per-device rule to the USB ACL."""
482+
483+
def AC_usb_acl_remove(vendor_id: str, product_id: str, serial: str | None = ...) -> Dict[str, Any]:
484+
...
485+
486+
def AC_usb_acl_set_default(policy: str) -> Dict[str, Any]:
487+
...
488+
489+
def AC_usb_acl_export(path: str) -> Dict[str, Any]:
490+
...
491+
492+
def AC_usb_acl_import(path: str, replace: bool = ...) -> Dict[str, Any]:
493+
...
494+
495+
def AC_usb_loopback_list() -> Dict[str, Any]:
496+
"""List ACL-visible devices over the in-process loopback channel."""
497+
498+
def AC_usb_loopback_open(vendor_id: str, product_id: str, serial: str | None = ...) -> Dict[str, Any]:
499+
"""Claim a local device over loopback and read its descriptor."""
500+
501+
def AC_usb_remote_list() -> Dict[str, Any]:
502+
"""List the remote host's devices over the live WebRTC usb channel."""
503+
504+
def AC_usb_remote_open(vendor_id: str, product_id: str, serial: str | None = ...) -> Dict[str, Any]:
505+
"""Claim a remote device over the live WebRTC usb channel and probe it."""
506+
471507
def AC_vlm_click(description: str, screen_region: List[int] | None = ..., model: str | None = ..., backend: Any = ...) -> bool:
472508
"""Locate by description, then click the center of the match."""
473509

je_auto_control/utils/executor/action_executor.py

Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -957,6 +957,136 @@ def _usb_recent_events(since: int = 0,
957957
)
958958

959959

960+
# --- USB passthrough (Phase 2) ------------------------------------------
961+
962+
963+
def _usb_passthrough_enable(enabled: bool = True) -> Dict[str, Any]:
964+
"""Toggle the USB passthrough feature flag (default off)."""
965+
from je_auto_control.utils.usb.passthrough import (
966+
enable_usb_passthrough, is_usb_passthrough_enabled,
967+
)
968+
enable_usb_passthrough(bool(enabled))
969+
return {"enabled": is_usb_passthrough_enabled()}
970+
971+
972+
def _usb_passthrough_status() -> Dict[str, Any]:
973+
from je_auto_control.utils.usb.passthrough import is_usb_passthrough_enabled
974+
return {"enabled": is_usb_passthrough_enabled()}
975+
976+
977+
def _usb_acl_list() -> Dict[str, Any]:
978+
from je_auto_control.utils.usb.passthrough import UsbAcl
979+
acl = UsbAcl()
980+
return {
981+
"default": acl.default_policy,
982+
"integrity_ok": acl.integrity_ok,
983+
"rules": [r.to_dict() for r in acl.list_rules()],
984+
}
985+
986+
987+
def _usb_acl_add(vendor_id: str, product_id: str,
988+
serial: Optional[str] = None, allow: bool = True,
989+
prompt_on_open: bool = False, label: str = "") -> Dict[str, Any]:
990+
from je_auto_control.utils.usb.passthrough import AclRule, UsbAcl
991+
acl = UsbAcl()
992+
acl.add_rule(AclRule(
993+
vendor_id=str(vendor_id), product_id=str(product_id),
994+
serial=(None if serial is None else str(serial)),
995+
label=str(label), allow=bool(allow),
996+
prompt_on_open=bool(prompt_on_open),
997+
))
998+
return {"added": True, "rules": len(acl.list_rules())}
999+
1000+
1001+
def _usb_acl_remove(vendor_id: str, product_id: str,
1002+
serial: Optional[str] = None) -> Dict[str, Any]:
1003+
from je_auto_control.utils.usb.passthrough import UsbAcl
1004+
removed = UsbAcl().remove_rule(
1005+
vendor_id=str(vendor_id), product_id=str(product_id),
1006+
serial=(None if serial is None else str(serial)),
1007+
)
1008+
return {"removed": removed}
1009+
1010+
1011+
def _usb_acl_set_default(policy: str) -> Dict[str, Any]:
1012+
from je_auto_control.utils.usb.passthrough import UsbAcl
1013+
acl = UsbAcl()
1014+
acl.set_default_policy(str(policy))
1015+
return {"default": acl.default_policy}
1016+
1017+
1018+
def _usb_acl_export(path: str) -> Dict[str, Any]:
1019+
from je_auto_control.utils.usb.passthrough import UsbAcl, export_acl_to_file
1020+
from pathlib import Path
1021+
export_acl_to_file(UsbAcl(), Path(path))
1022+
return {"exported": True, "path": str(path)}
1023+
1024+
1025+
def _usb_acl_import(path: str, replace: bool = False) -> Dict[str, Any]:
1026+
from je_auto_control.utils.usb.passthrough import UsbAcl, import_acl_from_file
1027+
from pathlib import Path
1028+
count = import_acl_from_file(UsbAcl(), Path(path), replace=bool(replace))
1029+
return {"imported": count}
1030+
1031+
1032+
def _usb_descriptor_probe(client: Any, vendor_id: str, product_id: str,
1033+
serial: Optional[str]) -> Dict[str, Any]:
1034+
"""Open a claim, read the device descriptor, close — return a summary."""
1035+
from je_auto_control.utils.usb.passthrough import describe_descriptor
1036+
handle = client.open(
1037+
vendor_id=str(vendor_id), product_id=str(product_id),
1038+
serial=(None if serial is None else str(serial)),
1039+
)
1040+
try:
1041+
descriptor = handle.control_transfer(
1042+
bm_request_type=0x80, b_request=0x06, w_value=0x0100, length=18,
1043+
)
1044+
finally:
1045+
handle.close()
1046+
return {
1047+
"ok": True, "vendor_id": str(vendor_id), "product_id": str(product_id),
1048+
"descriptor_hex": descriptor.hex(),
1049+
"descriptor": describe_descriptor(descriptor),
1050+
}
1051+
1052+
1053+
def _usb_loopback_list() -> Dict[str, Any]:
1054+
"""List ACL-visible devices over the in-process loopback channel."""
1055+
from je_auto_control.utils.usb.passthrough import UsbAcl, UsbLoopback
1056+
with UsbLoopback(acl=UsbAcl(), viewer_id="executor") as loop:
1057+
return {"devices": loop.list_devices()}
1058+
1059+
1060+
def _usb_loopback_open(vendor_id: str, product_id: str,
1061+
serial: Optional[str] = None) -> Dict[str, Any]:
1062+
"""Claim a local device over loopback and read its descriptor."""
1063+
from je_auto_control.utils.usb.passthrough import UsbAcl, UsbLoopback
1064+
with UsbLoopback(acl=UsbAcl(), viewer_id="executor") as loop:
1065+
return _usb_descriptor_probe(loop, vendor_id, product_id, serial)
1066+
1067+
1068+
def _usb_remote_client() -> Any:
1069+
"""Return the live WebRTC viewer's USB client or raise a clear error."""
1070+
from je_auto_control.utils.remote_desktop.registry import registry
1071+
client = registry.webrtc_usb_client()
1072+
if client is None:
1073+
raise RuntimeError("no live WebRTC viewer with a usb channel")
1074+
return client
1075+
1076+
1077+
def _usb_remote_list() -> Dict[str, Any]:
1078+
"""List the remote host's devices over the live WebRTC usb channel."""
1079+
return {"devices": _usb_remote_client().list_devices()}
1080+
1081+
1082+
def _usb_remote_open(vendor_id: str, product_id: str,
1083+
serial: Optional[str] = None) -> Dict[str, Any]:
1084+
"""Claim a remote device over the live WebRTC usb channel + probe."""
1085+
return _usb_descriptor_probe(
1086+
_usb_remote_client(), vendor_id, product_id, serial,
1087+
)
1088+
1089+
9601090
def _ac_web_run(action: Optional[Dict[str, Any]] = None,
9611091
**action_kwargs: Any) -> Any:
9621092
"""Bridge one WR_* action into the WebRunner executor (Phase 7.7).
@@ -1858,6 +1988,20 @@ def __init__(self):
18581988
"AC_usb_watch_stop": _usb_watch_stop,
18591989
"AC_usb_recent_events": _usb_recent_events,
18601990

1991+
# USB passthrough (Phase 2) — flag, ACL, local + remote use
1992+
"AC_usb_passthrough_enable": _usb_passthrough_enable,
1993+
"AC_usb_passthrough_status": _usb_passthrough_status,
1994+
"AC_usb_acl_list": _usb_acl_list,
1995+
"AC_usb_acl_add": _usb_acl_add,
1996+
"AC_usb_acl_remove": _usb_acl_remove,
1997+
"AC_usb_acl_set_default": _usb_acl_set_default,
1998+
"AC_usb_acl_export": _usb_acl_export,
1999+
"AC_usb_acl_import": _usb_acl_import,
2000+
"AC_usb_loopback_list": _usb_loopback_list,
2001+
"AC_usb_loopback_open": _usb_loopback_open,
2002+
"AC_usb_remote_list": _usb_remote_list,
2003+
"AC_usb_remote_open": _usb_remote_open,
2004+
18612005
# System diagnostics
18622006
"AC_diagnose": _diagnose,
18632007

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
"""Tests for the AC_usb_* passthrough executor commands."""
2+
import pytest
3+
4+
from je_auto_control.utils.executor import action_executor as ax
5+
from je_auto_control.utils.executor.action_executor import executor
6+
from je_auto_control.utils.usb.passthrough.backend import (
7+
BackendDevice, FakeUsbBackend,
8+
)
9+
10+
_SAMPLE = BackendDevice(vendor_id="1050", product_id="0407", serial="ABC123")
11+
12+
_NEW_COMMANDS = [
13+
"AC_usb_passthrough_enable", "AC_usb_passthrough_status",
14+
"AC_usb_acl_list", "AC_usb_acl_add", "AC_usb_acl_remove",
15+
"AC_usb_acl_set_default", "AC_usb_acl_export", "AC_usb_acl_import",
16+
"AC_usb_loopback_list", "AC_usb_loopback_open",
17+
"AC_usb_remote_list", "AC_usb_remote_open",
18+
]
19+
20+
21+
@pytest.fixture()
22+
def temp_acl(monkeypatch, tmp_path):
23+
"""Point UsbAcl at a temp path so the user's real ACL is untouched."""
24+
monkeypatch.setattr(
25+
"je_auto_control.utils.usb.passthrough.acl.default_acl_path",
26+
lambda: tmp_path / "usb_acl.json",
27+
)
28+
monkeypatch.setattr(
29+
"je_auto_control.utils.usb.passthrough.loopback.default_passthrough_backend",
30+
lambda: FakeUsbBackend(devices=[_SAMPLE]),
31+
)
32+
return tmp_path
33+
34+
35+
def test_all_commands_registered():
36+
known = executor.known_commands()
37+
for name in _NEW_COMMANDS:
38+
assert name in known, name
39+
40+
41+
def test_flag_enable_and_status():
42+
try:
43+
assert ax._usb_passthrough_enable(True)["enabled"] is True
44+
assert ax._usb_passthrough_status()["enabled"] is True
45+
assert ax._usb_passthrough_enable(False)["enabled"] is False
46+
finally:
47+
ax._usb_passthrough_enable(False)
48+
49+
50+
def test_acl_add_list_remove(temp_acl):
51+
assert ax._usb_acl_add("1050", "0407", allow=True)["added"] is True
52+
listed = ax._usb_acl_list()
53+
assert listed["default"] == "deny"
54+
assert len(listed["rules"]) == 1
55+
assert listed["rules"][0]["vendor_id"] == "1050"
56+
assert ax._usb_acl_remove("1050", "0407")["removed"] is True
57+
assert ax._usb_acl_list()["rules"] == []
58+
59+
60+
def test_acl_set_default(temp_acl):
61+
assert ax._usb_acl_set_default("allow")["default"] == "allow"
62+
assert ax._usb_acl_list()["default"] == "allow"
63+
64+
65+
def test_acl_export_import(temp_acl):
66+
ax._usb_acl_add("1050", "0407", allow=True)
67+
out = temp_acl / "exp.json"
68+
assert ax._usb_acl_export(str(out))["exported"] is True
69+
ax._usb_acl_remove("1050", "0407")
70+
assert ax._usb_acl_import(str(out))["imported"] == 1
71+
assert len(ax._usb_acl_list()["rules"]) == 1
72+
73+
74+
def test_loopback_list_and_open(temp_acl):
75+
ax._usb_acl_add("1050", "0407", allow=True)
76+
devices = ax._usb_loopback_list()["devices"]
77+
assert [d["vendor_id"] for d in devices] == ["1050"]
78+
opened = ax._usb_loopback_open("1050", "0407", serial="ABC123")
79+
assert opened["ok"] is True
80+
assert "descriptor" in opened
81+
assert isinstance(opened["descriptor_hex"], str)
82+
83+
84+
def test_loopback_open_denied_without_rule(temp_acl):
85+
from je_auto_control.utils.usb.passthrough import UsbClientError
86+
# No allow rule → default deny → open fails closed.
87+
with pytest.raises(UsbClientError):
88+
ax._usb_loopback_open("1050", "0407")
89+
90+
91+
def test_remote_list_without_session_raises():
92+
from je_auto_control.utils.remote_desktop.registry import registry
93+
registry._webrtc_viewer = None # noqa: SLF001 test setup
94+
with pytest.raises(RuntimeError):
95+
ax._usb_remote_list()

0 commit comments

Comments
 (0)