android-emu-agent 0.1.3__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.
- android_emu_agent/__init__.py +3 -0
- android_emu_agent/actions/__init__.py +1 -0
- android_emu_agent/actions/executor.py +288 -0
- android_emu_agent/actions/selector.py +122 -0
- android_emu_agent/actions/wait.py +193 -0
- android_emu_agent/artifacts/__init__.py +1 -0
- android_emu_agent/artifacts/manager.py +125 -0
- android_emu_agent/artifacts/py.typed +0 -0
- android_emu_agent/cli/__init__.py +1 -0
- android_emu_agent/cli/commands/__init__.py +1 -0
- android_emu_agent/cli/commands/action.py +158 -0
- android_emu_agent/cli/commands/app_cmd.py +95 -0
- android_emu_agent/cli/commands/artifact.py +81 -0
- android_emu_agent/cli/commands/daemon.py +62 -0
- android_emu_agent/cli/commands/device.py +122 -0
- android_emu_agent/cli/commands/emulator.py +46 -0
- android_emu_agent/cli/commands/file.py +139 -0
- android_emu_agent/cli/commands/reliability.py +310 -0
- android_emu_agent/cli/commands/session.py +65 -0
- android_emu_agent/cli/commands/ui.py +112 -0
- android_emu_agent/cli/commands/wait.py +132 -0
- android_emu_agent/cli/daemon_client.py +185 -0
- android_emu_agent/cli/main.py +52 -0
- android_emu_agent/cli/utils.py +171 -0
- android_emu_agent/daemon/__init__.py +1 -0
- android_emu_agent/daemon/core.py +62 -0
- android_emu_agent/daemon/health.py +177 -0
- android_emu_agent/daemon/models.py +244 -0
- android_emu_agent/daemon/server.py +1644 -0
- android_emu_agent/db/__init__.py +1 -0
- android_emu_agent/db/models.py +229 -0
- android_emu_agent/device/__init__.py +1 -0
- android_emu_agent/device/manager.py +522 -0
- android_emu_agent/device/session.py +129 -0
- android_emu_agent/errors.py +232 -0
- android_emu_agent/files/__init__.py +1 -0
- android_emu_agent/files/manager.py +311 -0
- android_emu_agent/py.typed +0 -0
- android_emu_agent/reliability/__init__.py +1 -0
- android_emu_agent/reliability/manager.py +244 -0
- android_emu_agent/ui/__init__.py +1 -0
- android_emu_agent/ui/context.py +169 -0
- android_emu_agent/ui/ref_resolver.py +149 -0
- android_emu_agent/ui/snapshotter.py +236 -0
- android_emu_agent/validation.py +59 -0
- android_emu_agent-0.1.3.dist-info/METADATA +375 -0
- android_emu_agent-0.1.3.dist-info/RECORD +50 -0
- android_emu_agent-0.1.3.dist-info/WHEEL +4 -0
- android_emu_agent-0.1.3.dist-info/entry_points.txt +2 -0
- android_emu_agent-0.1.3.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
"""Health monitoring - device connectivity checks and stale connection eviction."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import contextlib
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from datetime import datetime
|
|
9
|
+
from typing import TYPE_CHECKING, Any
|
|
10
|
+
|
|
11
|
+
import structlog
|
|
12
|
+
|
|
13
|
+
if TYPE_CHECKING:
|
|
14
|
+
from android_emu_agent.device.manager import DeviceManager
|
|
15
|
+
from android_emu_agent.device.session import SessionManager
|
|
16
|
+
|
|
17
|
+
logger = structlog.get_logger()
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass
|
|
21
|
+
class DeviceHealth:
|
|
22
|
+
"""Health status for a single device."""
|
|
23
|
+
|
|
24
|
+
serial: str
|
|
25
|
+
adb_ok: bool
|
|
26
|
+
u2_ok: bool
|
|
27
|
+
last_check: datetime
|
|
28
|
+
error: str | None = None
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class HealthMonitor:
|
|
32
|
+
"""Monitors device health and evicts stale connections."""
|
|
33
|
+
|
|
34
|
+
def __init__(
|
|
35
|
+
self,
|
|
36
|
+
device_manager: DeviceManager,
|
|
37
|
+
session_manager: SessionManager,
|
|
38
|
+
) -> None:
|
|
39
|
+
self._device_manager = device_manager
|
|
40
|
+
self._session_manager = session_manager
|
|
41
|
+
self._device_health: dict[str, DeviceHealth] = {}
|
|
42
|
+
self._task: asyncio.Task[None] | None = None
|
|
43
|
+
self._running = False
|
|
44
|
+
|
|
45
|
+
async def check_device(self, serial: str, timeout: float = 3.0) -> DeviceHealth:
|
|
46
|
+
"""Check health of a single device.
|
|
47
|
+
|
|
48
|
+
Args:
|
|
49
|
+
serial: Device serial to check
|
|
50
|
+
timeout: Timeout in seconds for each check
|
|
51
|
+
|
|
52
|
+
Returns:
|
|
53
|
+
DeviceHealth with check results
|
|
54
|
+
"""
|
|
55
|
+
now = datetime.now()
|
|
56
|
+
|
|
57
|
+
# Step 1: ADB check
|
|
58
|
+
adb_ok = await self._check_adb(serial, timeout)
|
|
59
|
+
if not adb_ok:
|
|
60
|
+
health = DeviceHealth(
|
|
61
|
+
serial=serial,
|
|
62
|
+
adb_ok=False,
|
|
63
|
+
u2_ok=False,
|
|
64
|
+
last_check=now,
|
|
65
|
+
error="ADB connection failed",
|
|
66
|
+
)
|
|
67
|
+
self._device_health[serial] = health
|
|
68
|
+
return health
|
|
69
|
+
|
|
70
|
+
# Step 2: u2 check (only if ADB passed)
|
|
71
|
+
u2_ok, u2_error = await self._check_u2(serial, timeout)
|
|
72
|
+
health = DeviceHealth(
|
|
73
|
+
serial=serial,
|
|
74
|
+
adb_ok=True,
|
|
75
|
+
u2_ok=u2_ok,
|
|
76
|
+
last_check=now,
|
|
77
|
+
error=u2_error,
|
|
78
|
+
)
|
|
79
|
+
self._device_health[serial] = health
|
|
80
|
+
return health
|
|
81
|
+
|
|
82
|
+
async def _check_adb(self, serial: str, timeout: float) -> bool:
|
|
83
|
+
"""Check ADB connectivity."""
|
|
84
|
+
device = self._device_manager._adb_devices.get(serial)
|
|
85
|
+
if not device:
|
|
86
|
+
return False
|
|
87
|
+
try:
|
|
88
|
+
result = await asyncio.wait_for(
|
|
89
|
+
asyncio.to_thread(device.shell, "echo ok"),
|
|
90
|
+
timeout=timeout,
|
|
91
|
+
)
|
|
92
|
+
return "ok" in str(result)
|
|
93
|
+
except Exception:
|
|
94
|
+
return False
|
|
95
|
+
|
|
96
|
+
async def _check_u2(self, serial: str, timeout: float) -> tuple[bool, str | None]:
|
|
97
|
+
"""Check u2/ATX server connectivity."""
|
|
98
|
+
device = self._device_manager._u2_devices.get(serial)
|
|
99
|
+
if not device:
|
|
100
|
+
# No cached u2 connection = nothing to verify, will connect fresh
|
|
101
|
+
return True, None
|
|
102
|
+
try:
|
|
103
|
+
await asyncio.wait_for(
|
|
104
|
+
asyncio.to_thread(lambda: device.info),
|
|
105
|
+
timeout=timeout,
|
|
106
|
+
)
|
|
107
|
+
return True, None
|
|
108
|
+
except Exception as e:
|
|
109
|
+
return False, f"ATX server unresponsive: {e}"
|
|
110
|
+
|
|
111
|
+
async def start(self) -> None:
|
|
112
|
+
"""Start the health monitor heartbeat loop."""
|
|
113
|
+
logger.info("health_monitor_starting")
|
|
114
|
+
self._running = True
|
|
115
|
+
self._task = asyncio.create_task(self._heartbeat_loop())
|
|
116
|
+
logger.info("health_monitor_started")
|
|
117
|
+
|
|
118
|
+
async def stop(self) -> None:
|
|
119
|
+
"""Stop the health monitor."""
|
|
120
|
+
logger.info("health_monitor_stopping")
|
|
121
|
+
self._running = False
|
|
122
|
+
if self._task:
|
|
123
|
+
self._task.cancel()
|
|
124
|
+
with contextlib.suppress(asyncio.CancelledError):
|
|
125
|
+
await self._task
|
|
126
|
+
self._task = None
|
|
127
|
+
logger.info("health_monitor_stopped")
|
|
128
|
+
|
|
129
|
+
def get_status(self) -> dict[str, Any]:
|
|
130
|
+
"""Get current health status for all monitored devices.
|
|
131
|
+
|
|
132
|
+
Returns:
|
|
133
|
+
Dict with device health information
|
|
134
|
+
"""
|
|
135
|
+
devices: dict[str, dict[str, Any]] = {}
|
|
136
|
+
for serial, health in self._device_health.items():
|
|
137
|
+
devices[serial] = {
|
|
138
|
+
"adb_ok": health.adb_ok,
|
|
139
|
+
"u2_ok": health.u2_ok,
|
|
140
|
+
"last_check": health.last_check.isoformat(),
|
|
141
|
+
"error": health.error,
|
|
142
|
+
}
|
|
143
|
+
return {"devices": devices}
|
|
144
|
+
|
|
145
|
+
async def _heartbeat_loop(self) -> None:
|
|
146
|
+
"""Periodic heartbeat to check device health."""
|
|
147
|
+
while self._running:
|
|
148
|
+
await asyncio.sleep(15)
|
|
149
|
+
try:
|
|
150
|
+
await self._run_health_checks()
|
|
151
|
+
except Exception:
|
|
152
|
+
logger.exception("health_check_error")
|
|
153
|
+
|
|
154
|
+
async def _run_health_checks(self) -> None:
|
|
155
|
+
"""Run health checks on all relevant devices."""
|
|
156
|
+
# Get devices with active sessions (prioritize)
|
|
157
|
+
sessions = await self._session_manager.list_sessions()
|
|
158
|
+
session_devices = {s.device_serial for s in sessions}
|
|
159
|
+
|
|
160
|
+
# Check session devices first, then other known devices
|
|
161
|
+
all_devices = list(session_devices) + [
|
|
162
|
+
s for s in self._device_health if s not in session_devices
|
|
163
|
+
]
|
|
164
|
+
|
|
165
|
+
for serial in all_devices:
|
|
166
|
+
health = await self.check_device(serial, timeout=3.0)
|
|
167
|
+
|
|
168
|
+
if not health.adb_ok or not health.u2_ok:
|
|
169
|
+
logger.warning(
|
|
170
|
+
"device_unhealthy",
|
|
171
|
+
serial=serial,
|
|
172
|
+
adb_ok=health.adb_ok,
|
|
173
|
+
u2_ok=health.u2_ok,
|
|
174
|
+
error=health.error,
|
|
175
|
+
)
|
|
176
|
+
# Evict stale connections
|
|
177
|
+
await self._device_manager.evict_device(serial)
|
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
"""Pydantic request models for daemon endpoints."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Literal
|
|
6
|
+
|
|
7
|
+
from pydantic import BaseModel, model_validator
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class SessionStartRequest(BaseModel):
|
|
11
|
+
device_serial: str
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class SessionStopRequest(BaseModel):
|
|
15
|
+
session_id: str
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class SnapshotRequest(BaseModel):
|
|
19
|
+
session_id: str
|
|
20
|
+
mode: Literal["compact", "full", "raw"] = "compact"
|
|
21
|
+
full: bool = False # Deprecated: kept for backward compatibility
|
|
22
|
+
|
|
23
|
+
@model_validator(mode="after")
|
|
24
|
+
def migrate_full_to_mode(self) -> SnapshotRequest:
|
|
25
|
+
"""Migrate legacy 'full' field to 'mode' field.
|
|
26
|
+
|
|
27
|
+
If 'full=True' is set and mode is still the default 'compact',
|
|
28
|
+
upgrade mode to 'full' for backward compatibility.
|
|
29
|
+
"""
|
|
30
|
+
if self.full and self.mode == "compact":
|
|
31
|
+
object.__setattr__(self, "mode", "full")
|
|
32
|
+
return self
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class SessionRequest(BaseModel):
|
|
36
|
+
session_id: str
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class ActionRequest(BaseModel):
|
|
40
|
+
session_id: str
|
|
41
|
+
ref: str # Can be @ref, text:"...", id:..., desc:"...", or coords:x,y
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class SetTextRequest(BaseModel):
|
|
45
|
+
session_id: str
|
|
46
|
+
ref: str
|
|
47
|
+
text: str
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class WaitIdleRequest(BaseModel):
|
|
51
|
+
session_id: str
|
|
52
|
+
timeout_ms: int | None = None
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class WaitActivityRequest(BaseModel):
|
|
56
|
+
session_id: str
|
|
57
|
+
activity: str
|
|
58
|
+
timeout_ms: int | None = None
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class WaitTextRequest(BaseModel):
|
|
62
|
+
session_id: str
|
|
63
|
+
text: str
|
|
64
|
+
timeout_ms: int | None = None
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class WaitSelectorRequest(BaseModel):
|
|
68
|
+
session_id: str
|
|
69
|
+
ref: str | None = None
|
|
70
|
+
selector: dict[str, str] | None = None
|
|
71
|
+
timeout_ms: int | None = None
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class DeviceSettingRequest(BaseModel):
|
|
75
|
+
serial: str
|
|
76
|
+
state: str
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class AppResetRequest(BaseModel):
|
|
80
|
+
session_id: str
|
|
81
|
+
package: str
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
class RotationRequest(BaseModel):
|
|
85
|
+
serial: str
|
|
86
|
+
orientation: str # portrait, landscape, reverse-portrait, reverse-landscape, auto
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
class WifiRequest(BaseModel):
|
|
90
|
+
serial: str
|
|
91
|
+
enabled: bool
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
class MobileRequest(BaseModel):
|
|
95
|
+
serial: str
|
|
96
|
+
enabled: bool
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
class DozeRequest(BaseModel):
|
|
100
|
+
serial: str
|
|
101
|
+
enabled: bool
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
class AppLaunchRequest(BaseModel):
|
|
105
|
+
session_id: str
|
|
106
|
+
package: str
|
|
107
|
+
activity: str | None = None
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
class AppForceStopRequest(BaseModel):
|
|
111
|
+
session_id: str
|
|
112
|
+
package: str
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
class AppDeeplinkRequest(BaseModel):
|
|
116
|
+
session_id: str
|
|
117
|
+
uri: str
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
class EmulatorSnapshotRequest(BaseModel):
|
|
121
|
+
serial: str
|
|
122
|
+
name: str
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
class ArtifactLogsRequest(BaseModel):
|
|
126
|
+
session_id: str
|
|
127
|
+
since: str | None = None
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
class SwipeRequest(BaseModel):
|
|
131
|
+
"""Request for swipe action."""
|
|
132
|
+
|
|
133
|
+
session_id: str
|
|
134
|
+
direction: str # up, down, left, right
|
|
135
|
+
container: str | None = None # Optional @ref or selector
|
|
136
|
+
distance: float = 0.8
|
|
137
|
+
duration_ms: int = 300
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
class DeviceTargetRequest(BaseModel):
|
|
141
|
+
session_id: str | None = None
|
|
142
|
+
serial: str | None = None
|
|
143
|
+
|
|
144
|
+
@model_validator(mode="after")
|
|
145
|
+
def validate_target(self) -> DeviceTargetRequest:
|
|
146
|
+
if bool(self.session_id) == bool(self.serial):
|
|
147
|
+
raise ValueError("Provide exactly one of session_id or serial")
|
|
148
|
+
return self
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
class AppListRequest(DeviceTargetRequest):
|
|
152
|
+
scope: str = "all"
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
class ReliabilityPackageRequest(DeviceTargetRequest):
|
|
156
|
+
package: str
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
class ReliabilityExitInfoRequest(ReliabilityPackageRequest):
|
|
160
|
+
list_only: bool = False
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
class ReliabilityEventsRequest(DeviceTargetRequest):
|
|
164
|
+
pattern: str | None = None
|
|
165
|
+
since: str | None = None
|
|
166
|
+
package: str | None = None
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
class ReliabilityDropboxListRequest(DeviceTargetRequest):
|
|
170
|
+
package: str | None = None
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
class ReliabilityDropboxPrintRequest(DeviceTargetRequest):
|
|
174
|
+
tag: str
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
class ReliabilityBugreportRequest(DeviceTargetRequest):
|
|
178
|
+
filename: str | None = None
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
class ReliabilityBackgroundRequest(ReliabilityPackageRequest):
|
|
182
|
+
pass
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
class ReliabilityTrimMemoryRequest(ReliabilityPackageRequest):
|
|
186
|
+
level: str = "RUNNING_CRITICAL"
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
class ReliabilityOomAdjRequest(ReliabilityPackageRequest):
|
|
190
|
+
score: int = 1000
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
class ReliabilityCompileRequest(ReliabilityPackageRequest):
|
|
194
|
+
mode: Literal["reset", "speed"]
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
class ReliabilityToggleRequest(DeviceTargetRequest):
|
|
198
|
+
state: str # on|off
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
class ReliabilityRunAsRequest(ReliabilityPackageRequest):
|
|
202
|
+
path: str = "files/"
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
class ReliabilityDumpheapRequest(ReliabilityPackageRequest):
|
|
206
|
+
keep_remote: bool = False
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
class ReliabilitySigquitRequest(ReliabilityPackageRequest):
|
|
210
|
+
pass
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
class FilePushRequest(DeviceTargetRequest):
|
|
214
|
+
local_path: str
|
|
215
|
+
remote_path: str | None = None
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
class FilePullRequest(DeviceTargetRequest):
|
|
219
|
+
remote_path: str
|
|
220
|
+
local_path: str | None = None
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
class FileAppPushRequest(DeviceTargetRequest):
|
|
224
|
+
package: str
|
|
225
|
+
local_path: str
|
|
226
|
+
remote_path: str | None = None
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
class FileAppPullRequest(DeviceTargetRequest):
|
|
230
|
+
package: str
|
|
231
|
+
remote_path: str
|
|
232
|
+
local_path: str | None = None
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
class FileFindRequest(DeviceTargetRequest):
|
|
236
|
+
path: str
|
|
237
|
+
name: str
|
|
238
|
+
kind: Literal["file", "dir", "any"] = "any"
|
|
239
|
+
max_depth: int = 4
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
class FileListRequest(DeviceTargetRequest):
|
|
243
|
+
path: str
|
|
244
|
+
kind: Literal["file", "dir", "any"] = "any"
|