qirabot 0.1.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.
- qirabot-0.1.0/PKG-INFO +24 -0
- qirabot-0.1.0/pyproject.toml +48 -0
- qirabot-0.1.0/setup.cfg +4 -0
- qirabot-0.1.0/src/qirabot/__init__.py +31 -0
- qirabot-0.1.0/src/qirabot/_transport.py +172 -0
- qirabot-0.1.0/src/qirabot/actions.py +156 -0
- qirabot-0.1.0/src/qirabot/client.py +231 -0
- qirabot-0.1.0/src/qirabot/exceptions.py +90 -0
- qirabot-0.1.0/src/qirabot/task_context.py +377 -0
- qirabot-0.1.0/src/qirabot.egg-info/PKG-INFO +24 -0
- qirabot-0.1.0/src/qirabot.egg-info/SOURCES.txt +12 -0
- qirabot-0.1.0/src/qirabot.egg-info/dependency_links.txt +1 -0
- qirabot-0.1.0/src/qirabot.egg-info/requires.txt +8 -0
- qirabot-0.1.0/src/qirabot.egg-info/top_level.txt +1 -0
qirabot-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: qirabot
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Official Python SDK for Qirabot - AI-powered device automation
|
|
5
|
+
Author-email: Qirabot Team <support@qirabot.com>
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Keywords: qirabot,automation,testing,ai,sdk,rpa
|
|
8
|
+
Classifier: Development Status :: 4 - Beta
|
|
9
|
+
Classifier: Intended Audience :: Developers
|
|
10
|
+
Classifier: Programming Language :: Python :: 3
|
|
11
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
15
|
+
Classifier: Typing :: Typed
|
|
16
|
+
Requires-Python: >=3.10
|
|
17
|
+
Description-Content-Type: text/markdown
|
|
18
|
+
Requires-Dist: httpx>=0.27.0
|
|
19
|
+
Requires-Dist: websockets>=13.0
|
|
20
|
+
Provides-Extra: dev
|
|
21
|
+
Requires-Dist: pytest>=7.0.0; extra == "dev"
|
|
22
|
+
Requires-Dist: pytest-cov>=4.0.0; extra == "dev"
|
|
23
|
+
Requires-Dist: mypy>=1.0.0; extra == "dev"
|
|
24
|
+
Requires-Dist: ruff>=0.1.0; extra == "dev"
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=61.0", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "qirabot"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Official Python SDK for Qirabot - AI-powered device automation"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = "MIT"
|
|
11
|
+
requires-python = ">=3.10"
|
|
12
|
+
authors = [
|
|
13
|
+
{ name = "Qirabot Team", email = "support@qirabot.com" }
|
|
14
|
+
]
|
|
15
|
+
keywords = ["qirabot", "automation", "testing", "ai", "sdk", "rpa"]
|
|
16
|
+
classifiers = [
|
|
17
|
+
"Development Status :: 4 - Beta",
|
|
18
|
+
"Intended Audience :: Developers",
|
|
19
|
+
"Programming Language :: Python :: 3",
|
|
20
|
+
"Programming Language :: Python :: 3.10",
|
|
21
|
+
"Programming Language :: Python :: 3.11",
|
|
22
|
+
"Programming Language :: Python :: 3.12",
|
|
23
|
+
"Programming Language :: Python :: 3.13",
|
|
24
|
+
"Typing :: Typed",
|
|
25
|
+
]
|
|
26
|
+
dependencies = [
|
|
27
|
+
"httpx>=0.27.0",
|
|
28
|
+
"websockets>=13.0",
|
|
29
|
+
]
|
|
30
|
+
|
|
31
|
+
[project.optional-dependencies]
|
|
32
|
+
dev = [
|
|
33
|
+
"pytest>=7.0.0",
|
|
34
|
+
"pytest-cov>=4.0.0",
|
|
35
|
+
"mypy>=1.0.0",
|
|
36
|
+
"ruff>=0.1.0",
|
|
37
|
+
]
|
|
38
|
+
|
|
39
|
+
[tool.setuptools.packages.find]
|
|
40
|
+
where = ["src"]
|
|
41
|
+
|
|
42
|
+
[tool.mypy]
|
|
43
|
+
python_version = "3.10"
|
|
44
|
+
strict = true
|
|
45
|
+
|
|
46
|
+
[tool.ruff]
|
|
47
|
+
target-version = "py310"
|
|
48
|
+
line-length = 100
|
qirabot-0.1.0/setup.cfg
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"""Qirabot - AI-powered device automation SDK."""
|
|
2
|
+
|
|
3
|
+
from qirabot.actions import Action
|
|
4
|
+
from qirabot.client import DeviceInfo, Qirabot, TaskResult
|
|
5
|
+
from qirabot.exceptions import (
|
|
6
|
+
ActionError,
|
|
7
|
+
AuthenticationError,
|
|
8
|
+
DeviceBusyError,
|
|
9
|
+
DeviceOfflineError,
|
|
10
|
+
LeaseExpiredError,
|
|
11
|
+
QirabotError,
|
|
12
|
+
QirabotTimeoutError,
|
|
13
|
+
)
|
|
14
|
+
from qirabot.task_context import ScreenshotEvent, StepEvent, TaskContext
|
|
15
|
+
|
|
16
|
+
__all__ = [
|
|
17
|
+
"Action",
|
|
18
|
+
"ActionError",
|
|
19
|
+
"AuthenticationError",
|
|
20
|
+
"DeviceBusyError",
|
|
21
|
+
"DeviceInfo",
|
|
22
|
+
"DeviceOfflineError",
|
|
23
|
+
"LeaseExpiredError",
|
|
24
|
+
"Qirabot",
|
|
25
|
+
"QirabotError",
|
|
26
|
+
"QirabotTimeoutError",
|
|
27
|
+
"ScreenshotEvent",
|
|
28
|
+
"StepEvent",
|
|
29
|
+
"TaskContext",
|
|
30
|
+
"TaskResult",
|
|
31
|
+
]
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
"""HTTP + WebSocket transport layer for Qirabot SDK."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import logging
|
|
7
|
+
import struct
|
|
8
|
+
from dataclasses import dataclass, field
|
|
9
|
+
from typing import Any, Iterator
|
|
10
|
+
from urllib.parse import urlparse
|
|
11
|
+
|
|
12
|
+
import httpx
|
|
13
|
+
import websockets.sync.client as ws_sync
|
|
14
|
+
|
|
15
|
+
from qirabot.exceptions import raise_for_error, QirabotTimeoutError
|
|
16
|
+
|
|
17
|
+
logger = logging.getLogger("qirabot")
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass
|
|
21
|
+
class StepMessage:
|
|
22
|
+
"""A step event received from WebSocket."""
|
|
23
|
+
|
|
24
|
+
data: dict[str, Any]
|
|
25
|
+
screenshot: bytes | None = None
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class Transport:
|
|
29
|
+
"""HTTP client with WebSocket support."""
|
|
30
|
+
|
|
31
|
+
def __init__(
|
|
32
|
+
self,
|
|
33
|
+
base_url: str,
|
|
34
|
+
api_key: str,
|
|
35
|
+
timeout: float = 30.0,
|
|
36
|
+
verify_ssl: bool = True,
|
|
37
|
+
):
|
|
38
|
+
self._base_url = base_url.rstrip("/")
|
|
39
|
+
self._api_url = f"{self._base_url}/api/v1"
|
|
40
|
+
self._api_key = api_key
|
|
41
|
+
self._headers = {"X-API-Key": api_key}
|
|
42
|
+
self._timeout = timeout
|
|
43
|
+
self._verify_ssl = verify_ssl
|
|
44
|
+
self._client = httpx.Client(
|
|
45
|
+
base_url=self._api_url,
|
|
46
|
+
headers=self._headers,
|
|
47
|
+
timeout=timeout,
|
|
48
|
+
verify=verify_ssl,
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
def request(self, method: str, path: str, json_data: dict[str, Any] | None = None) -> Any:
|
|
52
|
+
"""Send an HTTP request and return parsed JSON response."""
|
|
53
|
+
response = self._client.request(method, path, json=json_data)
|
|
54
|
+
if response.status_code >= 400:
|
|
55
|
+
try:
|
|
56
|
+
data = response.json()
|
|
57
|
+
except Exception:
|
|
58
|
+
data = {"error": {"message": response.text or "Unknown error"}}
|
|
59
|
+
raise_for_error(response.status_code, data)
|
|
60
|
+
if response.status_code == 204:
|
|
61
|
+
return {}
|
|
62
|
+
try:
|
|
63
|
+
return response.json()
|
|
64
|
+
except Exception:
|
|
65
|
+
return {}
|
|
66
|
+
|
|
67
|
+
def post(self, path: str, json_data: dict[str, Any] | None = None) -> dict[str, Any]:
|
|
68
|
+
"""Send a POST request."""
|
|
69
|
+
return self.request("POST", path, json_data)
|
|
70
|
+
|
|
71
|
+
def delete(self, path: str) -> dict[str, Any]:
|
|
72
|
+
"""Send a DELETE request."""
|
|
73
|
+
return self.request("DELETE", path)
|
|
74
|
+
|
|
75
|
+
def ws_connect(self, path: str, timeout: float | None = None) -> WSConnection:
|
|
76
|
+
"""Open a WebSocket connection.
|
|
77
|
+
|
|
78
|
+
Args:
|
|
79
|
+
path: API path (e.g. "/sdk/tasks/{id}/ws").
|
|
80
|
+
timeout: Optional timeout override.
|
|
81
|
+
|
|
82
|
+
Returns:
|
|
83
|
+
A WSConnection wrapper.
|
|
84
|
+
"""
|
|
85
|
+
connect_timeout = timeout or self._timeout
|
|
86
|
+
ws_url = self._build_ws_url(path)
|
|
87
|
+
additional_headers = {"X-API-Key": self._api_key}
|
|
88
|
+
conn = ws_sync.connect(
|
|
89
|
+
ws_url,
|
|
90
|
+
additional_headers=additional_headers,
|
|
91
|
+
open_timeout=connect_timeout,
|
|
92
|
+
close_timeout=10,
|
|
93
|
+
ping_interval=None,
|
|
94
|
+
)
|
|
95
|
+
return WSConnection(conn)
|
|
96
|
+
|
|
97
|
+
def get_bytes(self, path: str) -> bytes:
|
|
98
|
+
"""Send a GET request and return raw bytes (for screenshot download)."""
|
|
99
|
+
response = self._client.get(path)
|
|
100
|
+
if response.status_code >= 400:
|
|
101
|
+
try:
|
|
102
|
+
data = response.json()
|
|
103
|
+
except Exception:
|
|
104
|
+
data = {"error": {"message": response.text or "Unknown error"}}
|
|
105
|
+
raise_for_error(response.status_code, data)
|
|
106
|
+
return response.content
|
|
107
|
+
|
|
108
|
+
def close(self) -> None:
|
|
109
|
+
"""Close the HTTP client."""
|
|
110
|
+
self._client.close()
|
|
111
|
+
|
|
112
|
+
def _build_ws_url(self, path: str) -> str:
|
|
113
|
+
"""Convert HTTP base URL to WebSocket URL."""
|
|
114
|
+
parsed = urlparse(self._api_url)
|
|
115
|
+
scheme = "wss" if parsed.scheme == "https" else "ws"
|
|
116
|
+
return f"{scheme}://{parsed.netloc}{parsed.path}{path}"
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
class WSConnection:
|
|
120
|
+
"""Wrapper around a WebSocket connection for SDK task action.
|
|
121
|
+
|
|
122
|
+
Server protocol:
|
|
123
|
+
- Text frame: JSON message (step event, result event, or error)
|
|
124
|
+
- Binary frame: composite [2-byte JSON length (big-endian)] [JSON] [PNG]
|
|
125
|
+
Used for step events with inline screenshots.
|
|
126
|
+
"""
|
|
127
|
+
|
|
128
|
+
def __init__(self, conn: ws_sync.ClientConnection):
|
|
129
|
+
self._conn = conn
|
|
130
|
+
|
|
131
|
+
def send_action(self, action: dict[str, Any]) -> None:
|
|
132
|
+
"""Send an execute_action request."""
|
|
133
|
+
msg = json.dumps({"type": "execute_action", "action": action})
|
|
134
|
+
self._conn.send(msg)
|
|
135
|
+
|
|
136
|
+
def receive(self, timeout: float | None = None) -> StepMessage:
|
|
137
|
+
"""Receive the next message from the server.
|
|
138
|
+
|
|
139
|
+
Returns a StepMessage. For binary frames, the screenshot field is populated.
|
|
140
|
+
"""
|
|
141
|
+
self._conn.socket.settimeout(timeout)
|
|
142
|
+
try:
|
|
143
|
+
frame = self._conn.recv()
|
|
144
|
+
except TimeoutError:
|
|
145
|
+
raise QirabotTimeoutError("WebSocket receive timed out")
|
|
146
|
+
|
|
147
|
+
if isinstance(frame, str):
|
|
148
|
+
# Text frame: pure JSON
|
|
149
|
+
data = json.loads(frame)
|
|
150
|
+
return StepMessage(data=data)
|
|
151
|
+
|
|
152
|
+
# Binary frame: [2-byte JSON length] [JSON] [PNG]
|
|
153
|
+
if len(frame) < 2:
|
|
154
|
+
return StepMessage(data={})
|
|
155
|
+
json_len = struct.unpack("!H", frame[:2])[0]
|
|
156
|
+
json_bytes = frame[2 : 2 + json_len]
|
|
157
|
+
screenshot = frame[2 + json_len :]
|
|
158
|
+
data = json.loads(json_bytes)
|
|
159
|
+
return StepMessage(data=data, screenshot=screenshot if screenshot else None)
|
|
160
|
+
|
|
161
|
+
def close(self) -> None:
|
|
162
|
+
"""Close the WebSocket connection."""
|
|
163
|
+
try:
|
|
164
|
+
self._conn.close()
|
|
165
|
+
except Exception:
|
|
166
|
+
pass
|
|
167
|
+
|
|
168
|
+
def __enter__(self) -> WSConnection:
|
|
169
|
+
return self
|
|
170
|
+
|
|
171
|
+
def __exit__(self, *args: object) -> None:
|
|
172
|
+
self.close()
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
"""Action definitions for Qirabot SDK."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass
|
|
10
|
+
class Action:
|
|
11
|
+
"""A device action to execute.
|
|
12
|
+
|
|
13
|
+
Use the class methods to create actions instead of constructing directly.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
type: str
|
|
17
|
+
params: dict[str, Any] = field(default_factory=dict)
|
|
18
|
+
|
|
19
|
+
def to_dict(self) -> dict[str, Any]:
|
|
20
|
+
"""Serialize for API request."""
|
|
21
|
+
return {"type": self.type, "params": self.params}
|
|
22
|
+
|
|
23
|
+
# ── Click ──
|
|
24
|
+
|
|
25
|
+
@classmethod
|
|
26
|
+
def click(cls, locate: str) -> Action:
|
|
27
|
+
return cls(type="click", params={"locate": locate})
|
|
28
|
+
|
|
29
|
+
@classmethod
|
|
30
|
+
def double_click(cls, locate: str) -> Action:
|
|
31
|
+
return cls(type="double_click", params={"locate": locate})
|
|
32
|
+
|
|
33
|
+
@classmethod
|
|
34
|
+
def right_click(cls, locate: str) -> Action:
|
|
35
|
+
return cls(type="right_click", params={"locate": locate})
|
|
36
|
+
|
|
37
|
+
@classmethod
|
|
38
|
+
def hover(cls, locate: str) -> Action:
|
|
39
|
+
return cls(type="hover", params={"locate": locate})
|
|
40
|
+
|
|
41
|
+
# ── Text Input ──
|
|
42
|
+
|
|
43
|
+
@classmethod
|
|
44
|
+
def type_text(cls, locate: str, content: str) -> Action:
|
|
45
|
+
return cls(type="type_text", params={"locate": locate, "content": content})
|
|
46
|
+
|
|
47
|
+
@classmethod
|
|
48
|
+
def type_direct(cls, content: str) -> Action:
|
|
49
|
+
return cls(type="type_text_direct", params={"content": content})
|
|
50
|
+
|
|
51
|
+
@classmethod
|
|
52
|
+
def clear_text(cls, locate: str) -> Action:
|
|
53
|
+
return cls(type="clear_text", params={"locate": locate})
|
|
54
|
+
|
|
55
|
+
@classmethod
|
|
56
|
+
def press_key(cls, key: str) -> Action:
|
|
57
|
+
return cls(type="press_key", params={"key": key})
|
|
58
|
+
|
|
59
|
+
# ── Navigation ──
|
|
60
|
+
|
|
61
|
+
@classmethod
|
|
62
|
+
def navigate(cls, url: str) -> Action:
|
|
63
|
+
return cls(type="navigate", params={"url": url})
|
|
64
|
+
|
|
65
|
+
@classmethod
|
|
66
|
+
def scroll(cls, direction: str = "down", distance: int | None = None) -> Action:
|
|
67
|
+
params: dict[str, Any] = {"direction": direction}
|
|
68
|
+
if distance is not None:
|
|
69
|
+
params["distance"] = distance
|
|
70
|
+
return cls(type="scroll", params=params)
|
|
71
|
+
|
|
72
|
+
@classmethod
|
|
73
|
+
def scroll_at(cls, locate: str, direction: str = "down", distance: int | None = None) -> Action:
|
|
74
|
+
params: dict[str, Any] = {"locate": locate, "direction": direction}
|
|
75
|
+
if distance is not None:
|
|
76
|
+
params["distance"] = distance
|
|
77
|
+
return cls(type="scroll_at", params=params)
|
|
78
|
+
|
|
79
|
+
@classmethod
|
|
80
|
+
def swipe(
|
|
81
|
+
cls,
|
|
82
|
+
direction: str,
|
|
83
|
+
locate: str | None = None,
|
|
84
|
+
distance: int | None = None,
|
|
85
|
+
duration_ms: int = 500,
|
|
86
|
+
) -> Action:
|
|
87
|
+
params: dict[str, Any] = {"direction": direction, "durationMs": duration_ms}
|
|
88
|
+
if locate is not None:
|
|
89
|
+
params["locate"] = locate
|
|
90
|
+
if distance is not None:
|
|
91
|
+
params["distance"] = distance
|
|
92
|
+
return cls(type="swipe", params=params)
|
|
93
|
+
|
|
94
|
+
# ── Wait ──
|
|
95
|
+
|
|
96
|
+
@classmethod
|
|
97
|
+
def wait(cls, duration_ms: int) -> Action:
|
|
98
|
+
return cls(type="wait", params={"durationMs": duration_ms})
|
|
99
|
+
|
|
100
|
+
@classmethod
|
|
101
|
+
def wait_for(
|
|
102
|
+
cls,
|
|
103
|
+
assertion: str,
|
|
104
|
+
timeout_ms: int,
|
|
105
|
+
check_interval_ms: int = 1000,
|
|
106
|
+
) -> Action:
|
|
107
|
+
return cls(
|
|
108
|
+
type="wait_for",
|
|
109
|
+
params={
|
|
110
|
+
"assertion": assertion,
|
|
111
|
+
"timeoutMs": timeout_ms,
|
|
112
|
+
"checkInterval": check_interval_ms,
|
|
113
|
+
},
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
# ── Screenshot ──
|
|
117
|
+
|
|
118
|
+
@classmethod
|
|
119
|
+
def take_screenshot(cls) -> Action:
|
|
120
|
+
return cls(type="take_screenshot")
|
|
121
|
+
|
|
122
|
+
# ── AI ──
|
|
123
|
+
|
|
124
|
+
@classmethod
|
|
125
|
+
def extract(cls, instruction: str, variable: str = "result", value_type: str = "string") -> Action:
|
|
126
|
+
return cls(
|
|
127
|
+
type="extract",
|
|
128
|
+
params={"instruction": instruction, "variable": variable, "valueType": value_type},
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
@classmethod
|
|
132
|
+
def verify(cls, assertion: str) -> Action:
|
|
133
|
+
return cls(type="assert", params={"assertion": assertion})
|
|
134
|
+
|
|
135
|
+
@classmethod
|
|
136
|
+
def ai(cls, instruction: str, max_steps: int = 10, model_alias: str | None = None, language: str | None = None) -> Action:
|
|
137
|
+
params: dict[str, Any] = {"instruction": instruction, "maxSteps": max_steps}
|
|
138
|
+
if model_alias is not None:
|
|
139
|
+
params["modelAlias"] = model_alias
|
|
140
|
+
if language is not None:
|
|
141
|
+
params["language"] = language
|
|
142
|
+
return cls(type="ai_decision", params=params)
|
|
143
|
+
|
|
144
|
+
@classmethod
|
|
145
|
+
def search(cls, locate: str, content: str) -> Action:
|
|
146
|
+
return cls(type="search", params={"locate": locate, "text": content})
|
|
147
|
+
|
|
148
|
+
# ── App Control ──
|
|
149
|
+
|
|
150
|
+
@classmethod
|
|
151
|
+
def start_app(cls, package: str) -> Action:
|
|
152
|
+
return cls(type="start_app", params={"package": package})
|
|
153
|
+
|
|
154
|
+
@classmethod
|
|
155
|
+
def stop_app(cls, package: str) -> Action:
|
|
156
|
+
return cls(type="stop_app", params={"package": package})
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
"""Main client for Qirabot SDK."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import time
|
|
6
|
+
from contextlib import contextmanager
|
|
7
|
+
from dataclasses import dataclass, field
|
|
8
|
+
from typing import Any, Iterator
|
|
9
|
+
|
|
10
|
+
from qirabot._transport import Transport
|
|
11
|
+
from qirabot.actions import Action
|
|
12
|
+
from qirabot.exceptions import QirabotTimeoutError
|
|
13
|
+
from qirabot.task_context import TaskContext
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass
|
|
17
|
+
class DeviceInfo:
|
|
18
|
+
"""Device information."""
|
|
19
|
+
|
|
20
|
+
id: str
|
|
21
|
+
name: str
|
|
22
|
+
platform: str
|
|
23
|
+
online: bool
|
|
24
|
+
|
|
25
|
+
@classmethod
|
|
26
|
+
def from_dict(cls, data: dict[str, Any]) -> DeviceInfo:
|
|
27
|
+
return cls(
|
|
28
|
+
id=data.get("id", ""),
|
|
29
|
+
name=data.get("name", ""),
|
|
30
|
+
platform=data.get("platform", ""),
|
|
31
|
+
online=data.get("online", False),
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass
|
|
36
|
+
class TaskResult:
|
|
37
|
+
"""Result of a completed task."""
|
|
38
|
+
|
|
39
|
+
id: str
|
|
40
|
+
status: str
|
|
41
|
+
current_step: int = 0
|
|
42
|
+
source: str = ""
|
|
43
|
+
error: str = ""
|
|
44
|
+
steps: list[dict[str, Any]] = field(default_factory=list)
|
|
45
|
+
|
|
46
|
+
@property
|
|
47
|
+
def succeeded(self) -> bool:
|
|
48
|
+
return self.status == "succeeded"
|
|
49
|
+
|
|
50
|
+
@classmethod
|
|
51
|
+
def from_dict(cls, data: dict[str, Any]) -> TaskResult:
|
|
52
|
+
return cls(
|
|
53
|
+
id=data.get("id", ""),
|
|
54
|
+
status=data.get("status", ""),
|
|
55
|
+
current_step=data.get("currentStep", 0),
|
|
56
|
+
source=data.get("source", ""),
|
|
57
|
+
error=data.get("error", ""),
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class Qirabot:
|
|
62
|
+
"""Qirabot SDK client.
|
|
63
|
+
|
|
64
|
+
Usage::
|
|
65
|
+
|
|
66
|
+
bot = Qirabot("qk_xxx", base_url="https://app.qirabot.com")
|
|
67
|
+
with bot.task("device-id") as t:
|
|
68
|
+
t.click("Login button")
|
|
69
|
+
t.type("username", "admin")
|
|
70
|
+
"""
|
|
71
|
+
|
|
72
|
+
def __init__(
|
|
73
|
+
self,
|
|
74
|
+
api_key: str,
|
|
75
|
+
base_url: str = "https://app.qirabot.com",
|
|
76
|
+
timeout: float = 30.0,
|
|
77
|
+
verify_ssl: bool = True,
|
|
78
|
+
):
|
|
79
|
+
self._transport = Transport(
|
|
80
|
+
base_url=base_url,
|
|
81
|
+
api_key=api_key,
|
|
82
|
+
timeout=timeout,
|
|
83
|
+
verify_ssl=verify_ssl,
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
def devices(self) -> list[DeviceInfo]:
|
|
87
|
+
"""List all devices for the current user."""
|
|
88
|
+
resp = self._transport.request("GET", "/devices")
|
|
89
|
+
return [DeviceInfo.from_dict(d) for d in resp] if isinstance(resp, list) else []
|
|
90
|
+
|
|
91
|
+
def active_devices(self) -> list[DeviceInfo]:
|
|
92
|
+
"""List online devices for the current user."""
|
|
93
|
+
resp = self._transport.request("GET", "/devices/active")
|
|
94
|
+
return [DeviceInfo.from_dict(d) for d in resp] if isinstance(resp, list) else []
|
|
95
|
+
|
|
96
|
+
def submit(
|
|
97
|
+
self,
|
|
98
|
+
device_id: str = "",
|
|
99
|
+
*,
|
|
100
|
+
actions: list[Action] | None = None,
|
|
101
|
+
instruction: str = "",
|
|
102
|
+
model_alias: str = "",
|
|
103
|
+
language: str = "",
|
|
104
|
+
max_steps: int = 10,
|
|
105
|
+
screenshot_mode: str = "cloud",
|
|
106
|
+
) -> str:
|
|
107
|
+
"""Submit actions for autonomous execution and return the task ID immediately.
|
|
108
|
+
|
|
109
|
+
Use ``wait()`` to poll for completion.
|
|
110
|
+
|
|
111
|
+
Args:
|
|
112
|
+
device_id: The device to run on (optional if sandbox is configured).
|
|
113
|
+
actions: List of actions to execute sequentially.
|
|
114
|
+
instruction: Shorthand for a single AI action. Mutually exclusive with ``actions``.
|
|
115
|
+
model_alias: Optional AI model alias.
|
|
116
|
+
language: Optional language (e.g. "zh", "en").
|
|
117
|
+
max_steps: Max steps for AI instruction (default 10).
|
|
118
|
+
screenshot_mode: "cloud" (default) or "none".
|
|
119
|
+
|
|
120
|
+
Returns:
|
|
121
|
+
The task ID string.
|
|
122
|
+
"""
|
|
123
|
+
if instruction and actions:
|
|
124
|
+
raise ValueError("Cannot specify both 'actions' and 'instruction'")
|
|
125
|
+
if not instruction and not actions:
|
|
126
|
+
raise ValueError("Must specify either 'actions' or 'instruction'")
|
|
127
|
+
|
|
128
|
+
if instruction:
|
|
129
|
+
steps = [Action.ai(instruction, max_steps=max_steps, model_alias=model_alias or None, language=language or None)]
|
|
130
|
+
else:
|
|
131
|
+
steps = actions # type: ignore[assignment]
|
|
132
|
+
|
|
133
|
+
body: dict[str, Any] = {
|
|
134
|
+
"actions": [a.to_dict() for a in steps],
|
|
135
|
+
}
|
|
136
|
+
if device_id:
|
|
137
|
+
body["deviceId"] = device_id
|
|
138
|
+
if model_alias:
|
|
139
|
+
body["modelAlias"] = model_alias
|
|
140
|
+
if language:
|
|
141
|
+
body["language"] = language
|
|
142
|
+
if screenshot_mode != "cloud":
|
|
143
|
+
body["screenshotMode"] = screenshot_mode
|
|
144
|
+
|
|
145
|
+
resp = self._transport.post("/sdk/tasks/submit", body)
|
|
146
|
+
return resp["taskId"]
|
|
147
|
+
|
|
148
|
+
def wait(
|
|
149
|
+
self,
|
|
150
|
+
task_id: str,
|
|
151
|
+
timeout: float = 120.0,
|
|
152
|
+
poll_interval: float = 3.0,
|
|
153
|
+
) -> TaskResult:
|
|
154
|
+
"""Poll until a submitted task reaches a terminal state.
|
|
155
|
+
|
|
156
|
+
Args:
|
|
157
|
+
task_id: The task ID returned by ``submit()``.
|
|
158
|
+
timeout: Maximum seconds to wait (default 120).
|
|
159
|
+
poll_interval: Seconds between polls (default 3).
|
|
160
|
+
|
|
161
|
+
Returns:
|
|
162
|
+
A :class:`TaskResult` with the final status and steps.
|
|
163
|
+
|
|
164
|
+
Raises:
|
|
165
|
+
QirabotTimeoutError: If the task does not complete within the timeout.
|
|
166
|
+
"""
|
|
167
|
+
deadline = time.monotonic() + timeout
|
|
168
|
+
while True:
|
|
169
|
+
resp = self._transport.request("GET", f"/tasks/{task_id}")
|
|
170
|
+
status = resp.get("status", "")
|
|
171
|
+
if status not in ("pending", "running"):
|
|
172
|
+
result = TaskResult.from_dict(resp)
|
|
173
|
+
steps_resp = self._transport.request("GET", f"/tasks/{task_id}/steps")
|
|
174
|
+
if isinstance(steps_resp, list):
|
|
175
|
+
result.steps = steps_resp
|
|
176
|
+
return result
|
|
177
|
+
if time.monotonic() >= deadline:
|
|
178
|
+
raise QirabotTimeoutError(f"Task {task_id} did not complete within {timeout}s")
|
|
179
|
+
time.sleep(poll_interval)
|
|
180
|
+
|
|
181
|
+
@contextmanager
|
|
182
|
+
def task(
|
|
183
|
+
self,
|
|
184
|
+
device_id: str,
|
|
185
|
+
model_alias: str = "",
|
|
186
|
+
language: str = "",
|
|
187
|
+
screenshot_mode: str = "cloud",
|
|
188
|
+
) -> Iterator[TaskContext]:
|
|
189
|
+
"""Create a task on a device and return an interactive context.
|
|
190
|
+
|
|
191
|
+
Args:
|
|
192
|
+
device_id: The device to connect to.
|
|
193
|
+
model_alias: Optional AI model alias to use (default: server default).
|
|
194
|
+
language: Optional language for AI responses (e.g. "zh", "en", "ja").
|
|
195
|
+
screenshot_mode: Screenshot storage mode:
|
|
196
|
+
- "cloud": store to cloud, return path (default)
|
|
197
|
+
- "inline": no cloud storage, send binary via WebSocket
|
|
198
|
+
- "none": no screenshots stored or returned
|
|
199
|
+
|
|
200
|
+
Yields:
|
|
201
|
+
A TaskContext that auto-completes on exit.
|
|
202
|
+
"""
|
|
203
|
+
body: dict[str, Any] = {"deviceId": device_id}
|
|
204
|
+
if model_alias:
|
|
205
|
+
body["modelAlias"] = model_alias
|
|
206
|
+
if language:
|
|
207
|
+
body["language"] = language
|
|
208
|
+
if screenshot_mode != "cloud":
|
|
209
|
+
body["screenshotMode"] = screenshot_mode
|
|
210
|
+
|
|
211
|
+
resp = self._transport.post("/sdk/tasks", body)
|
|
212
|
+
task_id = resp["taskId"]
|
|
213
|
+
|
|
214
|
+
ctx = TaskContext(
|
|
215
|
+
transport=self._transport,
|
|
216
|
+
task_id=task_id,
|
|
217
|
+
device_id=device_id,
|
|
218
|
+
screenshot_mode=screenshot_mode,
|
|
219
|
+
)
|
|
220
|
+
with ctx:
|
|
221
|
+
yield ctx
|
|
222
|
+
|
|
223
|
+
def close(self) -> None:
|
|
224
|
+
"""Close the underlying HTTP client."""
|
|
225
|
+
self._transport.close()
|
|
226
|
+
|
|
227
|
+
def __enter__(self) -> Qirabot:
|
|
228
|
+
return self
|
|
229
|
+
|
|
230
|
+
def __exit__(self, *args: object) -> None:
|
|
231
|
+
self.close()
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
"""Exceptions for Qirabot SDK."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class QirabotError(Exception):
|
|
9
|
+
"""Base exception for all Qirabot SDK errors."""
|
|
10
|
+
|
|
11
|
+
def __init__(
|
|
12
|
+
self,
|
|
13
|
+
message: str,
|
|
14
|
+
code: str | None = None,
|
|
15
|
+
status_code: int | None = None,
|
|
16
|
+
):
|
|
17
|
+
self.message = message
|
|
18
|
+
self.code = code
|
|
19
|
+
self.status_code = status_code
|
|
20
|
+
super().__init__(message)
|
|
21
|
+
|
|
22
|
+
def __str__(self) -> str:
|
|
23
|
+
if self.code:
|
|
24
|
+
return f"[{self.code}] {self.message}"
|
|
25
|
+
return self.message
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class AuthenticationError(QirabotError):
|
|
29
|
+
"""API key is missing or invalid (401)."""
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class DeviceBusyError(QirabotError):
|
|
33
|
+
"""Device already has a running task (409)."""
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class DeviceOfflineError(QirabotError):
|
|
37
|
+
"""Device is not connected (400)."""
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class LeaseExpiredError(QirabotError):
|
|
41
|
+
"""Task lease has expired (409)."""
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class ActionError(QirabotError):
|
|
45
|
+
"""A device action failed during task."""
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class QirabotTimeoutError(QirabotError):
|
|
49
|
+
"""Operation timed out (client-side)."""
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
# Error code → exception class mapping
|
|
53
|
+
_ERROR_CODE_MAP: dict[str, type[QirabotError]] = {
|
|
54
|
+
"auth.api_key_missing": AuthenticationError,
|
|
55
|
+
"auth.api_key_invalid": AuthenticationError,
|
|
56
|
+
"sdk.device_busy": DeviceBusyError,
|
|
57
|
+
"sdk.device_not_connected": DeviceOfflineError,
|
|
58
|
+
"sdk.task_not_found": QirabotError,
|
|
59
|
+
"sdk.task_not_active": QirabotError,
|
|
60
|
+
"sdk.lease_expired": LeaseExpiredError,
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
_STATUS_CODE_MAP: dict[int, type[QirabotError]] = {
|
|
64
|
+
401: AuthenticationError,
|
|
65
|
+
409: DeviceBusyError,
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def raise_for_error(status_code: int, data: dict[str, Any]) -> None:
|
|
70
|
+
"""Raise the appropriate exception for an error response.
|
|
71
|
+
|
|
72
|
+
Supports both flat format {"code": "...", "message": "..."}
|
|
73
|
+
and nested format {"error": {"code": "...", "message": "..."}}.
|
|
74
|
+
"""
|
|
75
|
+
error = data.get("error", {})
|
|
76
|
+
if isinstance(error, str):
|
|
77
|
+
message = error or data.get("message", f"Request failed with status {status_code}")
|
|
78
|
+
code = data.get("code")
|
|
79
|
+
elif error:
|
|
80
|
+
message = error.get("message", f"Request failed with status {status_code}")
|
|
81
|
+
code = error.get("code")
|
|
82
|
+
else:
|
|
83
|
+
message = data.get("message", f"Request failed with status {status_code}")
|
|
84
|
+
code = data.get("code")
|
|
85
|
+
|
|
86
|
+
if code and code in _ERROR_CODE_MAP:
|
|
87
|
+
raise _ERROR_CODE_MAP[code](message, code=code, status_code=status_code)
|
|
88
|
+
|
|
89
|
+
exc_cls = _STATUS_CODE_MAP.get(status_code, QirabotError)
|
|
90
|
+
raise exc_cls(message, code=code, status_code=status_code)
|
|
@@ -0,0 +1,377 @@
|
|
|
1
|
+
"""Task context for Qirabot SDK."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
import threading
|
|
7
|
+
from dataclasses import dataclass, field
|
|
8
|
+
from typing import Any, Callable, TYPE_CHECKING
|
|
9
|
+
|
|
10
|
+
from qirabot.actions import Action
|
|
11
|
+
from qirabot.exceptions import ActionError
|
|
12
|
+
|
|
13
|
+
if TYPE_CHECKING:
|
|
14
|
+
from qirabot._transport import Transport, WSConnection
|
|
15
|
+
|
|
16
|
+
logger = logging.getLogger("qirabot")
|
|
17
|
+
|
|
18
|
+
_HEARTBEAT_INTERVAL = 15.0 # seconds
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass
|
|
22
|
+
class StepEvent:
|
|
23
|
+
"""Emitted after each step completes."""
|
|
24
|
+
|
|
25
|
+
number: int
|
|
26
|
+
action: str
|
|
27
|
+
status: str
|
|
28
|
+
output: str | None = None
|
|
29
|
+
decision: str | None = None
|
|
30
|
+
error: str | None = None
|
|
31
|
+
action_duration_time_ms: int = 0
|
|
32
|
+
step_duration_ms: int = 0
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass
|
|
36
|
+
class ScreenshotEvent:
|
|
37
|
+
"""Emitted when a step has a screenshot available.
|
|
38
|
+
|
|
39
|
+
For inline mode, ``data`` contains the raw PNG bytes directly.
|
|
40
|
+
For cloud mode, use ``save()`` or ``to_bytes()`` to download from the server.
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
number: int
|
|
44
|
+
task_id: str
|
|
45
|
+
data: bytes | None = None
|
|
46
|
+
_transport: Transport | None = field(default=None, repr=False)
|
|
47
|
+
|
|
48
|
+
def save(self, local_path: str) -> None:
|
|
49
|
+
"""Save the screenshot to a local file."""
|
|
50
|
+
img = self.to_bytes()
|
|
51
|
+
with open(local_path, "wb") as f:
|
|
52
|
+
f.write(img)
|
|
53
|
+
|
|
54
|
+
def to_bytes(self) -> bytes:
|
|
55
|
+
"""Return raw screenshot bytes.
|
|
56
|
+
|
|
57
|
+
For inline mode, returns the data directly.
|
|
58
|
+
For cloud mode, downloads from the server.
|
|
59
|
+
"""
|
|
60
|
+
if self.data is not None:
|
|
61
|
+
return self.data
|
|
62
|
+
if self._transport is None:
|
|
63
|
+
raise RuntimeError("Transport not available")
|
|
64
|
+
return self._transport.get_bytes(
|
|
65
|
+
f"/screenshots?taskId={self.task_id}&step={self.number}"
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
# Type alias for event handlers
|
|
70
|
+
EventHandler = Callable[..., Any]
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class TaskContext:
|
|
74
|
+
"""Interactive task context for step-by-step device automation.
|
|
75
|
+
|
|
76
|
+
Created by ``Qirabot.task()``. Use as a context manager::
|
|
77
|
+
|
|
78
|
+
with bot.task("device-id") as t:
|
|
79
|
+
t.click("Login button")
|
|
80
|
+
t.type("username", "admin")
|
|
81
|
+
"""
|
|
82
|
+
|
|
83
|
+
def __init__(
|
|
84
|
+
self,
|
|
85
|
+
transport: Transport,
|
|
86
|
+
task_id: str,
|
|
87
|
+
device_id: str,
|
|
88
|
+
screenshot_mode: str = "cloud",
|
|
89
|
+
):
|
|
90
|
+
self._transport = transport
|
|
91
|
+
self._task_id = task_id
|
|
92
|
+
self._device_id = device_id
|
|
93
|
+
self._screenshot_mode = screenshot_mode
|
|
94
|
+
self._heartbeat_stop: threading.Event | None = None
|
|
95
|
+
self._heartbeat_thread: threading.Thread | None = None
|
|
96
|
+
self._listeners: dict[str, list[EventHandler]] = {}
|
|
97
|
+
self._ws: WSConnection | None = None
|
|
98
|
+
|
|
99
|
+
@property
|
|
100
|
+
def task_id(self) -> str:
|
|
101
|
+
return self._task_id
|
|
102
|
+
|
|
103
|
+
@property
|
|
104
|
+
def device_id(self) -> str:
|
|
105
|
+
return self._device_id
|
|
106
|
+
|
|
107
|
+
# ── Event system ──
|
|
108
|
+
|
|
109
|
+
def on(self, event: str, handler: EventHandler) -> TaskContext:
|
|
110
|
+
"""Register an event listener.
|
|
111
|
+
|
|
112
|
+
Supported events:
|
|
113
|
+
- ``"step"``: called with :class:`StepEvent` after each step completes.
|
|
114
|
+
- ``"screenshot"``: called with :class:`ScreenshotEvent` when a screenshot is available.
|
|
115
|
+
|
|
116
|
+
Returns self for chaining.
|
|
117
|
+
"""
|
|
118
|
+
self._listeners.setdefault(event, []).append(handler)
|
|
119
|
+
return self
|
|
120
|
+
|
|
121
|
+
def off(self, event: str, handler: EventHandler | None = None) -> TaskContext:
|
|
122
|
+
"""Remove event listener(s).
|
|
123
|
+
|
|
124
|
+
If handler is None, removes all listeners for the event.
|
|
125
|
+
"""
|
|
126
|
+
if handler is None:
|
|
127
|
+
self._listeners.pop(event, None)
|
|
128
|
+
elif event in self._listeners:
|
|
129
|
+
self._listeners[event] = [h for h in self._listeners[event] if h is not handler]
|
|
130
|
+
return self
|
|
131
|
+
|
|
132
|
+
def _emit(self, event: str, data: Any) -> None:
|
|
133
|
+
for handler in self._listeners.get(event, []):
|
|
134
|
+
try:
|
|
135
|
+
handler(data)
|
|
136
|
+
except Exception:
|
|
137
|
+
logger.exception("Error in %s event handler", event)
|
|
138
|
+
|
|
139
|
+
# ── Context manager ──
|
|
140
|
+
|
|
141
|
+
def __enter__(self) -> TaskContext:
|
|
142
|
+
self._start_heartbeat()
|
|
143
|
+
self._ws = self._transport.ws_connect(
|
|
144
|
+
f"/sdk/tasks/{self._task_id}/ws"
|
|
145
|
+
)
|
|
146
|
+
return self
|
|
147
|
+
|
|
148
|
+
def __exit__(self, exc_type: type | None, exc_val: BaseException | None, exc_tb: Any) -> None:
|
|
149
|
+
self._stop_heartbeat()
|
|
150
|
+
try:
|
|
151
|
+
if exc_type is None:
|
|
152
|
+
self.complete()
|
|
153
|
+
elif issubclass(exc_type, KeyboardInterrupt):
|
|
154
|
+
self.cancel()
|
|
155
|
+
else:
|
|
156
|
+
self.complete(status="failed", error_message=str(exc_val) if exc_val else "")
|
|
157
|
+
except Exception:
|
|
158
|
+
logger.exception("Failed to complete task during cleanup")
|
|
159
|
+
if self._ws is not None:
|
|
160
|
+
try:
|
|
161
|
+
self._ws.close()
|
|
162
|
+
except Exception:
|
|
163
|
+
pass
|
|
164
|
+
self._ws = None
|
|
165
|
+
|
|
166
|
+
# ── Device actions ──
|
|
167
|
+
|
|
168
|
+
def click(self, locate: str, **kw: Any) -> None:
|
|
169
|
+
self._act(Action.click(locate), **kw)
|
|
170
|
+
|
|
171
|
+
def double_click(self, locate: str, **kw: Any) -> None:
|
|
172
|
+
self._act(Action.double_click(locate), **kw)
|
|
173
|
+
|
|
174
|
+
def right_click(self, locate: str, **kw: Any) -> None:
|
|
175
|
+
self._act(Action.right_click(locate), **kw)
|
|
176
|
+
|
|
177
|
+
def hover(self, locate: str, **kw: Any) -> None:
|
|
178
|
+
self._act(Action.hover(locate), **kw)
|
|
179
|
+
|
|
180
|
+
def type(self, locate: str, content: str, **kw: Any) -> None:
|
|
181
|
+
self._act(Action.type_text(locate, content), **kw)
|
|
182
|
+
|
|
183
|
+
def type_direct(self, content: str, **kw: Any) -> None:
|
|
184
|
+
self._act(Action.type_direct(content), **kw)
|
|
185
|
+
|
|
186
|
+
def clear_text(self, locate: str, **kw: Any) -> None:
|
|
187
|
+
self._act(Action.clear_text(locate), **kw)
|
|
188
|
+
|
|
189
|
+
def press_key(self, key: str, **kw: Any) -> None:
|
|
190
|
+
self._act(Action.press_key(key), **kw)
|
|
191
|
+
|
|
192
|
+
def navigate(self, url: str, **kw: Any) -> None:
|
|
193
|
+
self._act(Action.navigate(url), **kw)
|
|
194
|
+
|
|
195
|
+
def scroll(self, direction: str = "down", distance: int | None = None, **kw: Any) -> None:
|
|
196
|
+
self._act(Action.scroll(direction, distance), **kw)
|
|
197
|
+
|
|
198
|
+
def scroll_at(self, locate: str, direction: str = "down", distance: int | None = None, **kw: Any) -> None:
|
|
199
|
+
self._act(Action.scroll_at(locate, direction, distance), **kw)
|
|
200
|
+
|
|
201
|
+
def swipe(self, direction: str, locate: str | None = None, distance: int | None = None, duration_ms: int = 500, **kw: Any) -> None:
|
|
202
|
+
self._act(Action.swipe(direction, locate, distance, duration_ms), **kw)
|
|
203
|
+
|
|
204
|
+
def wait(self, duration_ms: int, **kw: Any) -> None:
|
|
205
|
+
self._act(Action.wait(duration_ms), **kw)
|
|
206
|
+
|
|
207
|
+
def wait_for(self, assertion: str, timeout_ms: int, check_interval_ms: int = 1000, **kw: Any) -> None:
|
|
208
|
+
self._act(Action.wait_for(assertion, timeout_ms, check_interval_ms), **kw)
|
|
209
|
+
|
|
210
|
+
def screenshot(self, path: str | None = None, **kw: Any) -> bytes | None:
|
|
211
|
+
"""Take a screenshot.
|
|
212
|
+
|
|
213
|
+
Args:
|
|
214
|
+
path: If provided, save the screenshot to this local file path.
|
|
215
|
+
|
|
216
|
+
Returns:
|
|
217
|
+
Screenshot bytes if path is None, otherwise None.
|
|
218
|
+
"""
|
|
219
|
+
captured: dict[str, Any] = {}
|
|
220
|
+
|
|
221
|
+
def _capture_screenshot(event: ScreenshotEvent) -> None:
|
|
222
|
+
captured["event"] = event
|
|
223
|
+
|
|
224
|
+
self.on("screenshot", _capture_screenshot)
|
|
225
|
+
try:
|
|
226
|
+
self._act(Action.take_screenshot(), **kw)
|
|
227
|
+
finally:
|
|
228
|
+
self.off("screenshot", _capture_screenshot)
|
|
229
|
+
|
|
230
|
+
event = captured.get("event")
|
|
231
|
+
if event is not None:
|
|
232
|
+
img = event.to_bytes()
|
|
233
|
+
if path:
|
|
234
|
+
with open(path, "wb") as f:
|
|
235
|
+
f.write(img)
|
|
236
|
+
return None
|
|
237
|
+
return img
|
|
238
|
+
return None
|
|
239
|
+
|
|
240
|
+
def extract(self, instruction: str, variable: str = "result", value_type: str = "string", **kw: Any) -> str:
|
|
241
|
+
"""Extract data from the screen using AI.
|
|
242
|
+
|
|
243
|
+
Returns:
|
|
244
|
+
The extracted value as a string.
|
|
245
|
+
"""
|
|
246
|
+
result = self._act(Action.extract(instruction, variable, value_type), **kw)
|
|
247
|
+
return result.get("output", "") if result else ""
|
|
248
|
+
|
|
249
|
+
def verify(self, assertion: str, **kw: Any) -> bool:
|
|
250
|
+
"""Verify a condition on the screen using AI.
|
|
251
|
+
|
|
252
|
+
Returns:
|
|
253
|
+
True if the assertion passes, False otherwise.
|
|
254
|
+
"""
|
|
255
|
+
result = self._act(Action.verify(assertion), **kw)
|
|
256
|
+
output = result.get("output", "") if result else ""
|
|
257
|
+
return output.lower() in ("true", "pass", "yes", "1") if output else False
|
|
258
|
+
|
|
259
|
+
def ai(self, instruction: str, max_steps: int = 10, model_alias: str | None = None, language: str | None = None, **kw: Any) -> str:
|
|
260
|
+
"""Let AI autonomously complete a task.
|
|
261
|
+
|
|
262
|
+
Args:
|
|
263
|
+
instruction: Natural language description of the goal.
|
|
264
|
+
max_steps: Maximum number of steps AI can take.
|
|
265
|
+
model_alias: Optional model alias to override the session default.
|
|
266
|
+
language: Optional language to override the session default (e.g. "zh", "en").
|
|
267
|
+
|
|
268
|
+
Returns:
|
|
269
|
+
The output string from the AI task, or empty string if none.
|
|
270
|
+
"""
|
|
271
|
+
result = self._act(Action.ai(instruction, max_steps, model_alias=model_alias, language=language), **kw)
|
|
272
|
+
return result.get("output", "") if result else ""
|
|
273
|
+
|
|
274
|
+
def search(self, locate: str, content: str, **kw: Any) -> None:
|
|
275
|
+
self._act(Action.search(locate, content), **kw)
|
|
276
|
+
|
|
277
|
+
def start_app(self, package: str, **kw: Any) -> None:
|
|
278
|
+
self._act(Action.start_app(package), **kw)
|
|
279
|
+
|
|
280
|
+
def stop_app(self, package: str, **kw: Any) -> None:
|
|
281
|
+
self._act(Action.stop_app(package), **kw)
|
|
282
|
+
|
|
283
|
+
# ── Execution lifecycle ──
|
|
284
|
+
|
|
285
|
+
def complete(self, status: str = "succeeded", error_message: str = "") -> None:
|
|
286
|
+
"""Complete the task and release the device."""
|
|
287
|
+
body: dict[str, Any] = {"status": status}
|
|
288
|
+
if error_message:
|
|
289
|
+
body["errorMessage"] = error_message
|
|
290
|
+
try:
|
|
291
|
+
self._transport.post(f"/sdk/tasks/{self._task_id}/complete", body)
|
|
292
|
+
except Exception:
|
|
293
|
+
logger.exception("Failed to complete task %s", self._task_id)
|
|
294
|
+
|
|
295
|
+
def cancel(self) -> None:
|
|
296
|
+
"""Cancel the task and release the device."""
|
|
297
|
+
try:
|
|
298
|
+
self._transport.delete(f"/sdk/tasks/{self._task_id}")
|
|
299
|
+
except Exception:
|
|
300
|
+
logger.exception("Failed to cancel task %s", self._task_id)
|
|
301
|
+
|
|
302
|
+
# ── Internal ──
|
|
303
|
+
|
|
304
|
+
def _act(self, action: Action, timeout: float = 300.0, on_step: EventHandler | None = None) -> dict[str, Any] | None:
|
|
305
|
+
"""Execute a single action via WebSocket and return the final result data."""
|
|
306
|
+
if self._ws is None:
|
|
307
|
+
raise RuntimeError("Task not started. Use as a context manager.")
|
|
308
|
+
|
|
309
|
+
self._ws.send_action(action.to_dict())
|
|
310
|
+
|
|
311
|
+
last_result: dict[str, Any] = {}
|
|
312
|
+
|
|
313
|
+
while True:
|
|
314
|
+
msg = self._ws.receive(timeout=timeout)
|
|
315
|
+
msg_type = msg.data.get("type", "")
|
|
316
|
+
|
|
317
|
+
if msg_type == "step":
|
|
318
|
+
step_event = StepEvent(
|
|
319
|
+
number=msg.data.get("stepNumber", 0),
|
|
320
|
+
action=msg.data.get("actionType", ""),
|
|
321
|
+
status=msg.data.get("status", ""),
|
|
322
|
+
output=msg.data.get("output"),
|
|
323
|
+
decision=msg.data.get("decision"),
|
|
324
|
+
error=msg.data.get("error"),
|
|
325
|
+
action_duration_time_ms=msg.data.get("actionDurationTimeMs", 0),
|
|
326
|
+
step_duration_ms=msg.data.get("stepDurationMs", 0),
|
|
327
|
+
)
|
|
328
|
+
self._emit("step", step_event)
|
|
329
|
+
if on_step:
|
|
330
|
+
on_step(step_event)
|
|
331
|
+
|
|
332
|
+
# Emit screenshot event if available (inline binary or cloud path)
|
|
333
|
+
has_screenshot = msg.screenshot is not None
|
|
334
|
+
screenshot_path = msg.data.get("screenshotPath")
|
|
335
|
+
if has_screenshot or screenshot_path:
|
|
336
|
+
screenshot_event = ScreenshotEvent(
|
|
337
|
+
number=step_event.number,
|
|
338
|
+
task_id=self._task_id,
|
|
339
|
+
data=msg.screenshot,
|
|
340
|
+
_transport=self._transport if screenshot_path else None,
|
|
341
|
+
)
|
|
342
|
+
self._emit("screenshot", screenshot_event)
|
|
343
|
+
|
|
344
|
+
elif msg_type == "result":
|
|
345
|
+
last_result = msg.data
|
|
346
|
+
if not msg.data.get("success", False) and not msg.data.get("finished", False):
|
|
347
|
+
error_msg = msg.data.get("error", "Action failed")
|
|
348
|
+
raise ActionError(error_msg, code="action.failed")
|
|
349
|
+
break
|
|
350
|
+
|
|
351
|
+
elif msg_type == "error":
|
|
352
|
+
error_msg = msg.data.get("error", "Unknown error")
|
|
353
|
+
raise ActionError(error_msg, code="ws.error")
|
|
354
|
+
|
|
355
|
+
return last_result
|
|
356
|
+
|
|
357
|
+
def _start_heartbeat(self) -> None:
|
|
358
|
+
self._heartbeat_stop = threading.Event()
|
|
359
|
+
self._heartbeat_thread = threading.Thread(
|
|
360
|
+
target=self._heartbeat_loop,
|
|
361
|
+
daemon=True,
|
|
362
|
+
)
|
|
363
|
+
self._heartbeat_thread.start()
|
|
364
|
+
|
|
365
|
+
def _stop_heartbeat(self) -> None:
|
|
366
|
+
if self._heartbeat_stop:
|
|
367
|
+
self._heartbeat_stop.set()
|
|
368
|
+
if self._heartbeat_thread:
|
|
369
|
+
self._heartbeat_thread.join(timeout=5.0)
|
|
370
|
+
|
|
371
|
+
def _heartbeat_loop(self) -> None:
|
|
372
|
+
assert self._heartbeat_stop is not None
|
|
373
|
+
while not self._heartbeat_stop.wait(_HEARTBEAT_INTERVAL):
|
|
374
|
+
try:
|
|
375
|
+
self._transport.post(f"/sdk/tasks/{self._task_id}/heartbeat")
|
|
376
|
+
except Exception:
|
|
377
|
+
logger.warning("Heartbeat failed for task %s", self._task_id)
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: qirabot
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Official Python SDK for Qirabot - AI-powered device automation
|
|
5
|
+
Author-email: Qirabot Team <support@qirabot.com>
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Keywords: qirabot,automation,testing,ai,sdk,rpa
|
|
8
|
+
Classifier: Development Status :: 4 - Beta
|
|
9
|
+
Classifier: Intended Audience :: Developers
|
|
10
|
+
Classifier: Programming Language :: Python :: 3
|
|
11
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
15
|
+
Classifier: Typing :: Typed
|
|
16
|
+
Requires-Python: >=3.10
|
|
17
|
+
Description-Content-Type: text/markdown
|
|
18
|
+
Requires-Dist: httpx>=0.27.0
|
|
19
|
+
Requires-Dist: websockets>=13.0
|
|
20
|
+
Provides-Extra: dev
|
|
21
|
+
Requires-Dist: pytest>=7.0.0; extra == "dev"
|
|
22
|
+
Requires-Dist: pytest-cov>=4.0.0; extra == "dev"
|
|
23
|
+
Requires-Dist: mypy>=1.0.0; extra == "dev"
|
|
24
|
+
Requires-Dist: ruff>=0.1.0; extra == "dev"
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
pyproject.toml
|
|
2
|
+
src/qirabot/__init__.py
|
|
3
|
+
src/qirabot/_transport.py
|
|
4
|
+
src/qirabot/actions.py
|
|
5
|
+
src/qirabot/client.py
|
|
6
|
+
src/qirabot/exceptions.py
|
|
7
|
+
src/qirabot/task_context.py
|
|
8
|
+
src/qirabot.egg-info/PKG-INFO
|
|
9
|
+
src/qirabot.egg-info/SOURCES.txt
|
|
10
|
+
src/qirabot.egg-info/dependency_links.txt
|
|
11
|
+
src/qirabot.egg-info/requires.txt
|
|
12
|
+
src/qirabot.egg-info/top_level.txt
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
qirabot
|