vadgr-computer-use 0.1.0__py3-none-any.whl
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.
- computer_use/__init__.py +50 -0
- computer_use/__main__.py +134 -0
- computer_use/bridge/__init__.py +0 -0
- computer_use/bridge/actions.py +76 -0
- computer_use/bridge/capture.py +70 -0
- computer_use/bridge/client.py +187 -0
- computer_use/bridge/daemon.py +949 -0
- computer_use/bridge/deployer.py +300 -0
- computer_use/bridge/protocol.py +40 -0
- computer_use/bridge/supervisor.py +277 -0
- computer_use/core/__init__.py +0 -0
- computer_use/core/actions.py +94 -0
- computer_use/core/engine.py +220 -0
- computer_use/core/errors.py +29 -0
- computer_use/core/loop.py +183 -0
- computer_use/core/screenshot.py +43 -0
- computer_use/core/smooth_move.py +259 -0
- computer_use/core/types.py +92 -0
- computer_use/mcp_server.py +389 -0
- computer_use/platform/__init__.py +0 -0
- computer_use/platform/base.py +51 -0
- computer_use/platform/detect.py +74 -0
- computer_use/platform/linux.py +1352 -0
- computer_use/platform/macos.py +407 -0
- computer_use/platform/windows.py +407 -0
- computer_use/platform/wsl2.py +824 -0
- computer_use/providers/__init__.py +0 -0
- computer_use/providers/anthropic.py +262 -0
- computer_use/providers/base.py +57 -0
- computer_use/providers/openai.py +250 -0
- computer_use/providers/registry.py +69 -0
- vadgr_computer_use-0.1.0.dist-info/METADATA +156 -0
- vadgr_computer_use-0.1.0.dist-info/RECORD +38 -0
- vadgr_computer_use-0.1.0.dist-info/WHEEL +5 -0
- vadgr_computer_use-0.1.0.dist-info/entry_points.txt +2 -0
- vadgr_computer_use-0.1.0.dist-info/licenses/LICENSE +190 -0
- vadgr_computer_use-0.1.0.dist-info/licenses/NOTICE +11 -0
- vadgr_computer_use-0.1.0.dist-info/top_level.txt +1 -0
computer_use/__init__.py
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"""Computer Use Engine -- gives LLM agents eyes and hands.
|
|
2
|
+
|
|
3
|
+
Library mode (agent calls engine):
|
|
4
|
+
from computer_use import ComputerUseEngine
|
|
5
|
+
engine = ComputerUseEngine()
|
|
6
|
+
screen = engine.screenshot()
|
|
7
|
+
engine.click(500, 300)
|
|
8
|
+
engine.type_text("hello")
|
|
9
|
+
|
|
10
|
+
Autonomous mode (engine calls LLM):
|
|
11
|
+
from computer_use import ComputerUseEngine
|
|
12
|
+
engine = ComputerUseEngine(provider="anthropic")
|
|
13
|
+
results = engine.run_task("Open Notepad and type hello")
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from computer_use.core.engine import ComputerUseEngine
|
|
17
|
+
from computer_use.core.errors import (
|
|
18
|
+
ActionError,
|
|
19
|
+
ActionTimeoutError,
|
|
20
|
+
ComputerUseError,
|
|
21
|
+
ConfigError,
|
|
22
|
+
PlatformNotSupportedError,
|
|
23
|
+
ProviderError,
|
|
24
|
+
ScreenCaptureError,
|
|
25
|
+
)
|
|
26
|
+
from computer_use.core.types import (
|
|
27
|
+
Action,
|
|
28
|
+
ActionType,
|
|
29
|
+
Platform,
|
|
30
|
+
Region,
|
|
31
|
+
ScreenState,
|
|
32
|
+
StepResult,
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
__all__ = [
|
|
36
|
+
"ComputerUseEngine",
|
|
37
|
+
"Action",
|
|
38
|
+
"ActionType",
|
|
39
|
+
"Platform",
|
|
40
|
+
"Region",
|
|
41
|
+
"ScreenState",
|
|
42
|
+
"StepResult",
|
|
43
|
+
"ComputerUseError",
|
|
44
|
+
"ScreenCaptureError",
|
|
45
|
+
"ActionError",
|
|
46
|
+
"ActionTimeoutError",
|
|
47
|
+
"ProviderError",
|
|
48
|
+
"ConfigError",
|
|
49
|
+
"PlatformNotSupportedError",
|
|
50
|
+
]
|
computer_use/__main__.py
ADDED
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
"""CLI entry point for autonomous mode.
|
|
2
|
+
|
|
3
|
+
Usage:
|
|
4
|
+
python -m computer_use "Open Chrome and search for Vadgr"
|
|
5
|
+
python -m computer_use --provider anthropic "Open Notepad"
|
|
6
|
+
python -m computer_use --config custom.yaml "Click the Start menu"
|
|
7
|
+
python -m computer_use --info # Show platform info
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import argparse
|
|
11
|
+
import logging
|
|
12
|
+
import sys
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def main():
|
|
16
|
+
parser = argparse.ArgumentParser(
|
|
17
|
+
description="Computer Use Engine -- autonomous desktop control."
|
|
18
|
+
)
|
|
19
|
+
parser.add_argument(
|
|
20
|
+
"task",
|
|
21
|
+
nargs="?",
|
|
22
|
+
help="Natural-language task description.",
|
|
23
|
+
)
|
|
24
|
+
parser.add_argument(
|
|
25
|
+
"--provider",
|
|
26
|
+
default=None,
|
|
27
|
+
help="LLM provider (anthropic, openai).",
|
|
28
|
+
)
|
|
29
|
+
parser.add_argument(
|
|
30
|
+
"--config",
|
|
31
|
+
default=None,
|
|
32
|
+
help="Path to config.yaml.",
|
|
33
|
+
)
|
|
34
|
+
parser.add_argument(
|
|
35
|
+
"--max-steps",
|
|
36
|
+
type=int,
|
|
37
|
+
default=50,
|
|
38
|
+
help="Maximum steps before stopping.",
|
|
39
|
+
)
|
|
40
|
+
parser.add_argument(
|
|
41
|
+
"--no-verify",
|
|
42
|
+
action="store_true",
|
|
43
|
+
help="Skip action verification.",
|
|
44
|
+
)
|
|
45
|
+
parser.add_argument(
|
|
46
|
+
"--info",
|
|
47
|
+
action="store_true",
|
|
48
|
+
help="Show platform info and exit.",
|
|
49
|
+
)
|
|
50
|
+
parser.add_argument(
|
|
51
|
+
"--screenshot",
|
|
52
|
+
type=str,
|
|
53
|
+
default=None,
|
|
54
|
+
metavar="PATH",
|
|
55
|
+
help="Take a screenshot and save to PATH.",
|
|
56
|
+
)
|
|
57
|
+
parser.add_argument(
|
|
58
|
+
"--verbose", "-v",
|
|
59
|
+
action="store_true",
|
|
60
|
+
help="Enable debug logging.",
|
|
61
|
+
)
|
|
62
|
+
args = parser.parse_args()
|
|
63
|
+
|
|
64
|
+
logging.basicConfig(
|
|
65
|
+
level=logging.DEBUG if args.verbose else logging.INFO,
|
|
66
|
+
format="%(asctime)s [%(name)s] %(levelname)s: %(message)s",
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
from computer_use.core.engine import ComputerUseEngine
|
|
70
|
+
|
|
71
|
+
# Info mode
|
|
72
|
+
if args.info:
|
|
73
|
+
engine = ComputerUseEngine(config_path=args.config)
|
|
74
|
+
info = engine.get_platform_info()
|
|
75
|
+
print(f"Platform: {info['platform']}")
|
|
76
|
+
print(f"Backend available: {info['backend_available']}")
|
|
77
|
+
print(f"Accessibility API: {info['accessibility']['api_name']}")
|
|
78
|
+
print(f"Accessibility available: {info['accessibility']['available']}")
|
|
79
|
+
if info["accessibility"].get("notes"):
|
|
80
|
+
print(f"Notes: {info['accessibility']['notes']}")
|
|
81
|
+
screen_w, screen_h = engine.get_screen_size()
|
|
82
|
+
print(f"Screen size: {screen_w}x{screen_h}")
|
|
83
|
+
return
|
|
84
|
+
|
|
85
|
+
# Screenshot mode
|
|
86
|
+
if args.screenshot:
|
|
87
|
+
engine = ComputerUseEngine(config_path=args.config)
|
|
88
|
+
screen = engine.screenshot()
|
|
89
|
+
with open(args.screenshot, "wb") as f:
|
|
90
|
+
f.write(screen.image_bytes)
|
|
91
|
+
print(
|
|
92
|
+
f"Screenshot saved to {args.screenshot} "
|
|
93
|
+
f"({screen.width}x{screen.height})"
|
|
94
|
+
)
|
|
95
|
+
return
|
|
96
|
+
|
|
97
|
+
# Task mode (autonomous)
|
|
98
|
+
if not args.task:
|
|
99
|
+
parser.error("A task description is required (or use --info / --screenshot)")
|
|
100
|
+
|
|
101
|
+
provider = args.provider or "anthropic"
|
|
102
|
+
engine = ComputerUseEngine(
|
|
103
|
+
config_path=args.config,
|
|
104
|
+
provider=provider,
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
print(f"Platform: {engine.get_platform().value}")
|
|
108
|
+
print(f"Provider: {provider}")
|
|
109
|
+
print(f"Task: {args.task}")
|
|
110
|
+
print(f"Max steps: {args.max_steps}")
|
|
111
|
+
print("---")
|
|
112
|
+
|
|
113
|
+
results = engine.run_task(
|
|
114
|
+
task=args.task,
|
|
115
|
+
max_steps=args.max_steps,
|
|
116
|
+
verify=not args.no_verify,
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
print(f"\nCompleted {len(results)} steps.")
|
|
120
|
+
for i, r in enumerate(results, 1):
|
|
121
|
+
status = "OK" if r.success else "FAILED"
|
|
122
|
+
action = r.action_taken.action_type.value
|
|
123
|
+
reason = r.reasoning[:80] if r.reasoning else ""
|
|
124
|
+
print(f" Step {i}: [{status}] {action} -- {reason}")
|
|
125
|
+
if r.error:
|
|
126
|
+
print(f" Error: {r.error}")
|
|
127
|
+
|
|
128
|
+
success_count = sum(1 for r in results if r.success)
|
|
129
|
+
print(f"\n{success_count}/{len(results)} steps succeeded.")
|
|
130
|
+
sys.exit(0 if all(r.success for r in results) else 1)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
if __name__ == "__main__":
|
|
134
|
+
main()
|
|
File without changes
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
"""Action execution via the bridge daemon."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
|
|
5
|
+
from computer_use.bridge.client import BridgeClient, BridgeError
|
|
6
|
+
from computer_use.core.actions import ActionExecutor
|
|
7
|
+
from computer_use.core.errors import ActionError
|
|
8
|
+
|
|
9
|
+
logger = logging.getLogger("computer_use.bridge.actions")
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class BridgeActionExecutor(ActionExecutor):
|
|
13
|
+
"""Implements ActionExecutor by delegating to the bridge daemon over TCP.
|
|
14
|
+
|
|
15
|
+
Accepts an optional *fallback* ActionExecutor (typically WSL2ActionExecutor).
|
|
16
|
+
When the bridge raises an error for text-input methods (type_text, key_press),
|
|
17
|
+
the fallback executor is tried before propagating the failure.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
def __init__(self, client: BridgeClient, fallback: ActionExecutor | None = None):
|
|
21
|
+
self._client = client
|
|
22
|
+
self._fallback = fallback
|
|
23
|
+
|
|
24
|
+
def _act(self, method: str, params: dict) -> None:
|
|
25
|
+
try:
|
|
26
|
+
self._client.call(method, params, timeout=10.0)
|
|
27
|
+
except BridgeError as e:
|
|
28
|
+
raise ActionError(f"Bridge {method} failed: {e}") from e
|
|
29
|
+
|
|
30
|
+
def move_mouse(self, x: int, y: int) -> None:
|
|
31
|
+
self._act("move_mouse", {"x": x, "y": y})
|
|
32
|
+
|
|
33
|
+
def click(self, x: int, y: int, button: str = "left") -> None:
|
|
34
|
+
self._act("click", {"x": x, "y": y, "button": button})
|
|
35
|
+
|
|
36
|
+
def double_click(self, x: int, y: int) -> None:
|
|
37
|
+
self._act("double_click", {"x": x, "y": y})
|
|
38
|
+
|
|
39
|
+
def type_text(self, text: str) -> None:
|
|
40
|
+
try:
|
|
41
|
+
self._act("type_text", {"text": text})
|
|
42
|
+
except ActionError:
|
|
43
|
+
if self._fallback is not None:
|
|
44
|
+
logger.warning("Bridge type_text failed, using PowerShell fallback")
|
|
45
|
+
self._fallback.type_text(text)
|
|
46
|
+
else:
|
|
47
|
+
raise
|
|
48
|
+
|
|
49
|
+
def key_press(self, keys: list[str]) -> None:
|
|
50
|
+
try:
|
|
51
|
+
self._act("key_press", {"keys": keys})
|
|
52
|
+
except ActionError:
|
|
53
|
+
if self._fallback is not None:
|
|
54
|
+
logger.warning("Bridge key_press failed, using PowerShell fallback")
|
|
55
|
+
self._fallback.key_press(keys)
|
|
56
|
+
else:
|
|
57
|
+
raise
|
|
58
|
+
|
|
59
|
+
def scroll(self, x: int, y: int, amount: int) -> None:
|
|
60
|
+
self._act("scroll", {"x": x, "y": y, "amount": amount})
|
|
61
|
+
|
|
62
|
+
def drag(
|
|
63
|
+
self,
|
|
64
|
+
start_x: int,
|
|
65
|
+
start_y: int,
|
|
66
|
+
end_x: int,
|
|
67
|
+
end_y: int,
|
|
68
|
+
duration: float = 0.5,
|
|
69
|
+
) -> None:
|
|
70
|
+
self._act("drag", {
|
|
71
|
+
"start_x": start_x,
|
|
72
|
+
"start_y": start_y,
|
|
73
|
+
"end_x": end_x,
|
|
74
|
+
"end_y": end_y,
|
|
75
|
+
"duration": duration,
|
|
76
|
+
})
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
"""Screenshot capture via the bridge daemon."""
|
|
2
|
+
|
|
3
|
+
import base64
|
|
4
|
+
|
|
5
|
+
from computer_use.bridge.client import BridgeClient, BridgeError
|
|
6
|
+
from computer_use.core.errors import ScreenCaptureError
|
|
7
|
+
from computer_use.core.screenshot import ScreenCapture
|
|
8
|
+
from computer_use.core.types import Region, ScreenState
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class BridgeScreenCapture(ScreenCapture):
|
|
12
|
+
"""Implements ScreenCapture by delegating to the bridge daemon over TCP."""
|
|
13
|
+
|
|
14
|
+
def __init__(self, client: BridgeClient, quality: int = 85):
|
|
15
|
+
self._client = client
|
|
16
|
+
self._quality = quality
|
|
17
|
+
|
|
18
|
+
def capture_full(self) -> ScreenState:
|
|
19
|
+
try:
|
|
20
|
+
result = self._client.call(
|
|
21
|
+
"screenshot_full", {"quality": self._quality}, timeout=5.0
|
|
22
|
+
)
|
|
23
|
+
except BridgeError as e:
|
|
24
|
+
raise ScreenCaptureError(f"Bridge screenshot failed: {e}") from e
|
|
25
|
+
|
|
26
|
+
return ScreenState(
|
|
27
|
+
image_bytes=base64.b64decode(result["image_b64"]),
|
|
28
|
+
width=result["width"],
|
|
29
|
+
height=result["height"],
|
|
30
|
+
scale_factor=result.get("scale_factor", 1.0),
|
|
31
|
+
offset_x=result.get("offset_x", 0),
|
|
32
|
+
offset_y=result.get("offset_y", 0),
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
def capture_region(self, region: Region) -> ScreenState:
|
|
36
|
+
try:
|
|
37
|
+
result = self._client.call(
|
|
38
|
+
"screenshot_region",
|
|
39
|
+
{
|
|
40
|
+
"x": region.x,
|
|
41
|
+
"y": region.y,
|
|
42
|
+
"width": region.width,
|
|
43
|
+
"height": region.height,
|
|
44
|
+
"quality": self._quality,
|
|
45
|
+
},
|
|
46
|
+
timeout=5.0,
|
|
47
|
+
)
|
|
48
|
+
except BridgeError as e:
|
|
49
|
+
raise ScreenCaptureError(f"Bridge region screenshot failed: {e}") from e
|
|
50
|
+
|
|
51
|
+
return ScreenState(
|
|
52
|
+
image_bytes=base64.b64decode(result["image_b64"]),
|
|
53
|
+
width=result["width"],
|
|
54
|
+
height=result["height"],
|
|
55
|
+
scale_factor=result.get("scale_factor", 1.0),
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
def get_screen_size(self) -> tuple[int, int]:
|
|
59
|
+
try:
|
|
60
|
+
result = self._client.call("screen_size", timeout=3.0)
|
|
61
|
+
return (result["width"], result["height"])
|
|
62
|
+
except BridgeError as e:
|
|
63
|
+
raise ScreenCaptureError(f"Bridge screen_size failed: {e}") from e
|
|
64
|
+
|
|
65
|
+
def get_scale_factor(self) -> float:
|
|
66
|
+
try:
|
|
67
|
+
result = self._client.call("scale_factor", timeout=3.0)
|
|
68
|
+
return result.get("factor", 1.0)
|
|
69
|
+
except BridgeError:
|
|
70
|
+
return 1.0
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
"""TCP client for the Windows bridge daemon."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import logging
|
|
5
|
+
import os
|
|
6
|
+
import platform
|
|
7
|
+
import socket
|
|
8
|
+
import subprocess
|
|
9
|
+
import threading
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
from computer_use.bridge.protocol import (
|
|
13
|
+
HEADER_SIZE,
|
|
14
|
+
decode_header,
|
|
15
|
+
encode_message,
|
|
16
|
+
get_port,
|
|
17
|
+
make_request,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
logger = logging.getLogger("computer_use.bridge.client")
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _is_wsl2_mirrored() -> bool:
|
|
24
|
+
"""Check if WSL2 is using mirrored networking mode.
|
|
25
|
+
|
|
26
|
+
With mirrored networking, WSL2 shares the host's network stack so
|
|
27
|
+
localhost reaches Windows directly -- no need for the gateway IP.
|
|
28
|
+
"""
|
|
29
|
+
try:
|
|
30
|
+
wslconfig = Path("/mnt/c/Users") / os.environ.get("USER", "") / ".wslconfig"
|
|
31
|
+
if not wslconfig.exists():
|
|
32
|
+
# Try via powershell
|
|
33
|
+
result = subprocess.run(
|
|
34
|
+
["powershell.exe", "-NoProfile", "-Command", "$env:USERPROFILE"],
|
|
35
|
+
capture_output=True, text=True, timeout=3,
|
|
36
|
+
)
|
|
37
|
+
if result.returncode == 0:
|
|
38
|
+
win_profile = result.stdout.strip()
|
|
39
|
+
wsl_path = subprocess.run(
|
|
40
|
+
["wslpath", "-u", win_profile],
|
|
41
|
+
capture_output=True, text=True,
|
|
42
|
+
).stdout.strip()
|
|
43
|
+
wslconfig = Path(wsl_path) / ".wslconfig"
|
|
44
|
+
|
|
45
|
+
if wslconfig.exists():
|
|
46
|
+
content = wslconfig.read_text().lower()
|
|
47
|
+
return "networkingmode=mirrored" in content.replace(" ", "")
|
|
48
|
+
except Exception:
|
|
49
|
+
pass
|
|
50
|
+
return False
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _detect_windows_host() -> str:
|
|
54
|
+
"""Auto-detect the Windows host IP when running inside WSL2.
|
|
55
|
+
|
|
56
|
+
On standard WSL2, 127.0.0.1 points to the Linux VM, not Windows.
|
|
57
|
+
The Windows host IP is the default gateway (WSL2 vEthernet adapter).
|
|
58
|
+
|
|
59
|
+
On mirrored networking (networkingMode=mirrored in .wslconfig),
|
|
60
|
+
localhost reaches Windows directly, so 127.0.0.1 works.
|
|
61
|
+
|
|
62
|
+
Returns '127.0.0.1' on non-WSL2 platforms.
|
|
63
|
+
"""
|
|
64
|
+
try:
|
|
65
|
+
if "microsoft" in platform.release().lower():
|
|
66
|
+
if _is_wsl2_mirrored():
|
|
67
|
+
logger.debug("WSL2 mirrored networking, using 127.0.0.1")
|
|
68
|
+
return "127.0.0.1"
|
|
69
|
+
|
|
70
|
+
# Default gateway is the Windows host on the WSL2 virtual network
|
|
71
|
+
with open("/proc/net/route") as f:
|
|
72
|
+
for line in f:
|
|
73
|
+
fields = line.strip().split()
|
|
74
|
+
if fields[1] == "00000000": # default route
|
|
75
|
+
# Gateway is in hex, little-endian
|
|
76
|
+
gw_hex = fields[2]
|
|
77
|
+
gw_bytes = bytes.fromhex(gw_hex)
|
|
78
|
+
ip = f"{gw_bytes[3]}.{gw_bytes[2]}.{gw_bytes[1]}.{gw_bytes[0]}"
|
|
79
|
+
logger.debug("WSL2 detected, Windows host IP: %s", ip)
|
|
80
|
+
return ip
|
|
81
|
+
except Exception:
|
|
82
|
+
pass
|
|
83
|
+
return "127.0.0.1"
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
class BridgeError(Exception):
|
|
87
|
+
pass
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
class BridgeClient:
|
|
91
|
+
"""Connects to the Windows bridge daemon over TCP.
|
|
92
|
+
|
|
93
|
+
On WSL2, auto-discovers the Windows host IP.
|
|
94
|
+
Thread-safe. Reconnects automatically on socket errors (one retry).
|
|
95
|
+
"""
|
|
96
|
+
|
|
97
|
+
def __init__(self, host: str | None = None, port: int | None = None):
|
|
98
|
+
self._host = host or _detect_windows_host()
|
|
99
|
+
self._port = port or get_port()
|
|
100
|
+
self._sock: socket.socket | None = None
|
|
101
|
+
self._lock = threading.Lock()
|
|
102
|
+
|
|
103
|
+
def is_available(self) -> bool:
|
|
104
|
+
try:
|
|
105
|
+
result = self.call("ping", timeout=2.0)
|
|
106
|
+
return result.get("pong", False)
|
|
107
|
+
except Exception:
|
|
108
|
+
return False
|
|
109
|
+
|
|
110
|
+
def handshake(self) -> dict | None:
|
|
111
|
+
"""Return the raw `ping` response dict, or None if the daemon is unreachable.
|
|
112
|
+
|
|
113
|
+
Separate from `is_available()` because callers (supervisor) need the
|
|
114
|
+
full response to read `version_hash` for drift detection. A running
|
|
115
|
+
daemon returns at least `{"pong": True}`; absent fields are treated
|
|
116
|
+
as drift by the supervisor.
|
|
117
|
+
"""
|
|
118
|
+
try:
|
|
119
|
+
return self.call("ping", timeout=2.0)
|
|
120
|
+
except Exception:
|
|
121
|
+
return None
|
|
122
|
+
|
|
123
|
+
def call(self, method: str, params: dict | None = None, timeout: float = 10.0) -> dict:
|
|
124
|
+
with self._lock:
|
|
125
|
+
return self._call_locked(method, params, timeout, retry=True)
|
|
126
|
+
|
|
127
|
+
def _call_locked(
|
|
128
|
+
self, method: str, params: dict | None, timeout: float, retry: bool
|
|
129
|
+
) -> dict:
|
|
130
|
+
try:
|
|
131
|
+
self._ensure_connected(timeout)
|
|
132
|
+
request = make_request(method, params)
|
|
133
|
+
self._send(encode_message(request))
|
|
134
|
+
response = self._receive(timeout)
|
|
135
|
+
if not response.get("ok", False):
|
|
136
|
+
raise BridgeError(response.get("error", "Unknown daemon error"))
|
|
137
|
+
return response.get("result", {})
|
|
138
|
+
except (socket.error, OSError, ConnectionError) as e:
|
|
139
|
+
self._close_socket()
|
|
140
|
+
if retry:
|
|
141
|
+
logger.debug("Connection lost, retrying: %s", e)
|
|
142
|
+
return self._call_locked(method, params, timeout, retry=False)
|
|
143
|
+
raise BridgeError(f"Bridge connection failed: {e}") from e
|
|
144
|
+
|
|
145
|
+
def _ensure_connected(self, timeout: float) -> None:
|
|
146
|
+
if self._sock is not None:
|
|
147
|
+
return
|
|
148
|
+
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
|
149
|
+
sock.settimeout(timeout)
|
|
150
|
+
sock.connect((self._host, self._port))
|
|
151
|
+
sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1)
|
|
152
|
+
self._sock = sock
|
|
153
|
+
logger.debug("Connected to bridge at %s:%d", self._host, self._port)
|
|
154
|
+
|
|
155
|
+
def _send(self, data: bytes) -> None:
|
|
156
|
+
self._sock.sendall(data)
|
|
157
|
+
|
|
158
|
+
def _receive(self, timeout: float) -> dict:
|
|
159
|
+
self._sock.settimeout(timeout)
|
|
160
|
+
header = self._recv_exact(HEADER_SIZE)
|
|
161
|
+
length = decode_header(header)
|
|
162
|
+
payload = self._recv_exact(length)
|
|
163
|
+
return json.loads(payload)
|
|
164
|
+
|
|
165
|
+
def _recv_exact(self, n: int) -> bytes:
|
|
166
|
+
buf = bytearray()
|
|
167
|
+
while len(buf) < n:
|
|
168
|
+
chunk = self._sock.recv(n - len(buf))
|
|
169
|
+
if not chunk:
|
|
170
|
+
raise ConnectionError("Bridge daemon closed connection")
|
|
171
|
+
buf.extend(chunk)
|
|
172
|
+
return bytes(buf)
|
|
173
|
+
|
|
174
|
+
def _close_socket(self) -> None:
|
|
175
|
+
if self._sock is not None:
|
|
176
|
+
try:
|
|
177
|
+
self._sock.close()
|
|
178
|
+
except OSError:
|
|
179
|
+
pass
|
|
180
|
+
self._sock = None
|
|
181
|
+
|
|
182
|
+
def close(self) -> None:
|
|
183
|
+
with self._lock:
|
|
184
|
+
self._close_socket()
|
|
185
|
+
|
|
186
|
+
def __del__(self):
|
|
187
|
+
self._close_socket()
|