androidctl 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.
- androidctl/__init__.py +5 -0
- androidctl/__main__.py +4 -0
- androidctl/_version.py +1 -0
- androidctl/app.py +73 -0
- androidctl/cli_options.py +27 -0
- androidctl/command_payloads.py +264 -0
- androidctl/command_views.py +157 -0
- androidctl/commands/__init__.py +1 -0
- androidctl/commands/actions.py +236 -0
- androidctl/commands/adb_wireless.py +157 -0
- androidctl/commands/close.py +30 -0
- androidctl/commands/connect.py +69 -0
- androidctl/commands/execute.py +179 -0
- androidctl/commands/list_apps.py +26 -0
- androidctl/commands/observe.py +26 -0
- androidctl/commands/open.py +41 -0
- androidctl/commands/plumbing.py +58 -0
- androidctl/commands/run_pipeline.py +307 -0
- androidctl/commands/screenshot.py +29 -0
- androidctl/commands/setup.py +301 -0
- androidctl/commands/wait.py +60 -0
- androidctl/daemon/__init__.py +1 -0
- androidctl/daemon/client.py +348 -0
- androidctl/daemon/discovery.py +190 -0
- androidctl/daemon/launcher.py +26 -0
- androidctl/daemon/owner.py +349 -0
- androidctl/errors/__init__.py +1 -0
- androidctl/errors/mapping.py +149 -0
- androidctl/errors/models.py +16 -0
- androidctl/exit_codes.py +8 -0
- androidctl/output.py +147 -0
- androidctl/parsing/__init__.py +1 -0
- androidctl/parsing/duration.py +17 -0
- androidctl/parsing/open_target.py +51 -0
- androidctl/parsing/refs.py +12 -0
- androidctl/parsing/screen_id.py +10 -0
- androidctl/parsing/wait.py +70 -0
- androidctl/renderers/__init__.py +110 -0
- androidctl/renderers/_paths.py +109 -0
- androidctl/renderers/xml.py +234 -0
- androidctl/renderers/xml_projection.py +732 -0
- androidctl/resources/__init__.py +1 -0
- androidctl/resources/androidctl-agent-0.1.0-release.apk +0 -0
- androidctl/setup/__init__.py +1 -0
- androidctl/setup/accessibility.py +159 -0
- androidctl/setup/adb.py +586 -0
- androidctl/setup/apk_resource.py +29 -0
- androidctl/setup/pairing.py +70 -0
- androidctl/setup/verify.py +175 -0
- androidctl/workspace/__init__.py +3 -0
- androidctl/workspace/resolve.py +27 -0
- androidctl-0.1.0.dist-info/METADATA +217 -0
- androidctl-0.1.0.dist-info/RECORD +187 -0
- androidctl-0.1.0.dist-info/WHEEL +5 -0
- androidctl-0.1.0.dist-info/entry_points.txt +3 -0
- androidctl-0.1.0.dist-info/licenses/LICENSE +674 -0
- androidctl-0.1.0.dist-info/top_level.txt +3 -0
- androidctl_contracts/__init__.py +55 -0
- androidctl_contracts/_version.py +1 -0
- androidctl_contracts/_wire_helpers.py +31 -0
- androidctl_contracts/base.py +142 -0
- androidctl_contracts/command_catalog.py +414 -0
- androidctl_contracts/command_results.py +630 -0
- androidctl_contracts/daemon_api.py +335 -0
- androidctl_contracts/errors.py +44 -0
- androidctl_contracts/paths.py +5 -0
- androidctl_contracts/public_screen.py +579 -0
- androidctl_contracts/user_state.py +23 -0
- androidctl_contracts/vocabulary.py +82 -0
- androidctld/__init__.py +5 -0
- androidctld/__main__.py +63 -0
- androidctld/_version.py +1 -0
- androidctld/actions/__init__.py +1 -0
- androidctld/actions/action_target.py +142 -0
- androidctld/actions/capabilities.py +539 -0
- androidctld/actions/executor.py +894 -0
- androidctld/actions/focus_confirmation.py +177 -0
- androidctld/actions/focused_input_admissibility.py +120 -0
- androidctld/actions/fresh_current.py +176 -0
- androidctld/actions/postconditions.py +473 -0
- androidctld/actions/repair.py +101 -0
- androidctld/actions/request_builder.py +204 -0
- androidctld/actions/settle.py +146 -0
- androidctld/actions/submit_confirmation.py +211 -0
- androidctld/actions/submit_routing.py +311 -0
- androidctld/actions/type_confirmation.py +257 -0
- androidctld/app_targets.py +71 -0
- androidctld/artifacts/__init__.py +1 -0
- androidctld/artifacts/models.py +26 -0
- androidctld/artifacts/screen_lookup.py +241 -0
- androidctld/artifacts/screen_payloads.py +109 -0
- androidctld/artifacts/writer.py +286 -0
- androidctld/auth/__init__.py +1 -0
- androidctld/auth/active_registry.py +266 -0
- androidctld/auth/secret_files.py +52 -0
- androidctld/auth/token_store.py +59 -0
- androidctld/commands/__init__.py +1 -0
- androidctld/commands/assembly.py +231 -0
- androidctld/commands/command_models.py +254 -0
- androidctld/commands/dispatch.py +99 -0
- androidctld/commands/executor.py +31 -0
- androidctld/commands/from_boundary.py +175 -0
- androidctld/commands/handlers/__init__.py +15 -0
- androidctld/commands/handlers/action.py +439 -0
- androidctld/commands/handlers/connect.py +94 -0
- androidctld/commands/handlers/list_apps.py +215 -0
- androidctld/commands/handlers/observe.py +121 -0
- androidctld/commands/handlers/screenshot.py +105 -0
- androidctld/commands/handlers/wait.py +286 -0
- androidctld/commands/models.py +65 -0
- androidctld/commands/open_targets.py +56 -0
- androidctld/commands/orchestration.py +353 -0
- androidctld/commands/registry.py +116 -0
- androidctld/commands/result_builders.py +40 -0
- androidctld/commands/result_models.py +555 -0
- androidctld/commands/results.py +108 -0
- androidctld/commands/semantic_command_names.py +17 -0
- androidctld/commands/semantic_error_mapping.py +93 -0
- androidctld/commands/semantic_truth.py +135 -0
- androidctld/commands/service.py +67 -0
- androidctld/config.py +75 -0
- androidctld/daemon/__init__.py +1 -0
- androidctld/daemon/active_slot.py +326 -0
- androidctld/daemon/envelope.py +30 -0
- androidctld/daemon/http_host.py +123 -0
- androidctld/daemon/ingress.py +112 -0
- androidctld/daemon/ownership_probe.py +204 -0
- androidctld/daemon/server.py +286 -0
- androidctld/daemon/service.py +99 -0
- androidctld/device/__init__.py +1 -0
- androidctld/device/action_models.py +154 -0
- androidctld/device/action_serialization.py +121 -0
- androidctld/device/adapters.py +220 -0
- androidctld/device/bootstrap.py +153 -0
- androidctld/device/connectors.py +231 -0
- androidctld/device/errors.py +100 -0
- androidctld/device/interfaces.py +58 -0
- androidctld/device/parsing.py +320 -0
- androidctld/device/rpc.py +483 -0
- androidctld/device/schema.py +114 -0
- androidctld/device/types.py +161 -0
- androidctld/errors/__init__.py +94 -0
- androidctld/logging/__init__.py +22 -0
- androidctld/observation.py +98 -0
- androidctld/protocol.py +53 -0
- androidctld/refs/__init__.py +1 -0
- androidctld/refs/models.py +54 -0
- androidctld/refs/repair.py +284 -0
- androidctld/refs/service.py +422 -0
- androidctld/rendering/__init__.py +1 -0
- androidctld/rendering/screen_xml.py +256 -0
- androidctld/runtime/__init__.py +21 -0
- androidctld/runtime/kernel.py +548 -0
- androidctld/runtime/lifecycle.py +19 -0
- androidctld/runtime/models.py +48 -0
- androidctld/runtime/screen_state.py +117 -0
- androidctld/runtime/state_repo.py +70 -0
- androidctld/runtime/store.py +76 -0
- androidctld/runtime_policy.py +127 -0
- androidctld/schema/__init__.py +5 -0
- androidctld/schema/base.py +132 -0
- androidctld/schema/core.py +35 -0
- androidctld/schema/daemon_api.py +108 -0
- androidctld/schema/persistence.py +161 -0
- androidctld/schema/persistence_io.py +41 -0
- androidctld/schema/validation_errors.py +309 -0
- androidctld/semantics/__init__.py +1 -0
- androidctld/semantics/compiler.py +610 -0
- androidctld/semantics/continuity.py +107 -0
- androidctld/semantics/labels.py +252 -0
- androidctld/semantics/models.py +25 -0
- androidctld/semantics/policy.py +23 -0
- androidctld/semantics/public_models.py +123 -0
- androidctld/semantics/registries.py +13 -0
- androidctld/semantics/submit_refs.py +417 -0
- androidctld/semantics/surface.py +254 -0
- androidctld/semantics/targets.py +167 -0
- androidctld/snapshots/__init__.py +1 -0
- androidctld/snapshots/models.py +219 -0
- androidctld/snapshots/refresh.py +273 -0
- androidctld/snapshots/schema.py +74 -0
- androidctld/snapshots/service.py +138 -0
- androidctld/text_equivalence.py +67 -0
- androidctld/waits/__init__.py +1 -0
- androidctld/waits/evaluators.py +216 -0
- androidctld/waits/loop.py +305 -0
- androidctld/waits/matcher.py +41 -0
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
"""Shared device-facing types."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Callable
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from enum import Enum
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from androidctld.config import DEFAULT_HOST
|
|
11
|
+
from androidctld.protocol import ConnectionMode
|
|
12
|
+
from androidctld.refs.models import NodeHandle
|
|
13
|
+
from androidctld.runtime_policy import DEFAULT_DEVICE_PORT
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass(frozen=True)
|
|
17
|
+
class ConnectionConfig:
|
|
18
|
+
mode: ConnectionMode
|
|
19
|
+
token: str
|
|
20
|
+
serial: str | None = None
|
|
21
|
+
host: str | None = None
|
|
22
|
+
port: int = DEFAULT_DEVICE_PORT
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass(frozen=True)
|
|
26
|
+
class ConnectionSpec:
|
|
27
|
+
mode: ConnectionMode
|
|
28
|
+
port: int = DEFAULT_DEVICE_PORT
|
|
29
|
+
serial: str | None = None
|
|
30
|
+
host: str | None = None
|
|
31
|
+
|
|
32
|
+
@classmethod
|
|
33
|
+
def from_config(cls, config: ConnectionConfig) -> ConnectionSpec:
|
|
34
|
+
if config.mode is ConnectionMode.ADB:
|
|
35
|
+
return cls(
|
|
36
|
+
mode=config.mode,
|
|
37
|
+
port=config.port,
|
|
38
|
+
serial=config.serial,
|
|
39
|
+
host=None,
|
|
40
|
+
)
|
|
41
|
+
return cls(
|
|
42
|
+
mode=config.mode,
|
|
43
|
+
port=config.port,
|
|
44
|
+
serial=config.serial,
|
|
45
|
+
host=config.host,
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
def to_connection_config(self, token: str) -> ConnectionConfig:
|
|
49
|
+
return ConnectionConfig(
|
|
50
|
+
mode=self.mode,
|
|
51
|
+
token=token,
|
|
52
|
+
serial=self.serial,
|
|
53
|
+
host=self.host,
|
|
54
|
+
port=self.port,
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@dataclass(frozen=True)
|
|
59
|
+
class DeviceEndpoint:
|
|
60
|
+
host: str = DEFAULT_HOST
|
|
61
|
+
port: int = DEFAULT_DEVICE_PORT
|
|
62
|
+
|
|
63
|
+
@property
|
|
64
|
+
def base_url(self) -> str:
|
|
65
|
+
return f"http://{self.host}:{self.port}"
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@dataclass
|
|
69
|
+
class DeviceCapabilities:
|
|
70
|
+
supports_events_poll: bool
|
|
71
|
+
supports_screenshot: bool
|
|
72
|
+
action_kinds: list[str] = field(default_factory=list)
|
|
73
|
+
|
|
74
|
+
def supports_action(self, action_kind: str) -> bool:
|
|
75
|
+
return action_kind in self.action_kinds
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
@dataclass
|
|
79
|
+
class MetaInfo:
|
|
80
|
+
service: str
|
|
81
|
+
version: str
|
|
82
|
+
capabilities: DeviceCapabilities
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
class ActionStatus(str, Enum):
|
|
86
|
+
DONE = "done"
|
|
87
|
+
PARTIAL = "partial"
|
|
88
|
+
TIMEOUT = "timeout"
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
@dataclass(frozen=True)
|
|
92
|
+
class ObservedApp:
|
|
93
|
+
package_name: str | None = None
|
|
94
|
+
activity_name: str | None = None
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
@dataclass(frozen=True)
|
|
98
|
+
class ResolvedHandleTarget:
|
|
99
|
+
handle: NodeHandle
|
|
100
|
+
kind: str = "handle"
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
@dataclass(frozen=True)
|
|
104
|
+
class ResolvedCoordinatesTarget:
|
|
105
|
+
x: float
|
|
106
|
+
y: float
|
|
107
|
+
kind: str = "coordinates"
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
@dataclass(frozen=True)
|
|
111
|
+
class ResolvedNoneTarget:
|
|
112
|
+
kind: str = "none"
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
ResolvedTarget = ResolvedHandleTarget | ResolvedCoordinatesTarget | ResolvedNoneTarget
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
@dataclass(frozen=True)
|
|
119
|
+
class ActionPerformResult:
|
|
120
|
+
action_id: str
|
|
121
|
+
status: ActionStatus
|
|
122
|
+
duration_ms: int | None = None
|
|
123
|
+
resolved_target: ResolvedTarget | None = None
|
|
124
|
+
observed: ObservedApp | None = None
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
@dataclass(frozen=True)
|
|
128
|
+
class DeviceEvent:
|
|
129
|
+
seq: int
|
|
130
|
+
type: str
|
|
131
|
+
timestamp: str
|
|
132
|
+
data: dict[str, Any]
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
@dataclass(frozen=True)
|
|
136
|
+
class EventsPollResult:
|
|
137
|
+
events: tuple[DeviceEvent, ...]
|
|
138
|
+
latest_seq: int
|
|
139
|
+
need_resync: bool
|
|
140
|
+
timed_out: bool
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
@dataclass(frozen=True)
|
|
144
|
+
class ScreenshotCaptureResult:
|
|
145
|
+
content_type: str
|
|
146
|
+
width_px: int
|
|
147
|
+
height_px: int
|
|
148
|
+
body_base64: str
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
@dataclass
|
|
152
|
+
class RuntimeTransport:
|
|
153
|
+
endpoint: DeviceEndpoint
|
|
154
|
+
close: Callable[[], None]
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
@dataclass
|
|
158
|
+
class BootstrapResult:
|
|
159
|
+
connection: ConnectionSpec
|
|
160
|
+
transport: RuntimeTransport
|
|
161
|
+
meta: MetaInfo
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
"""Error models and helpers for androidctld."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from enum import Enum
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from androidctl_contracts.errors import (
|
|
10
|
+
DaemonError as ContractDaemonError,
|
|
11
|
+
)
|
|
12
|
+
from androidctl_contracts.errors import DaemonErrorCode as ContractDaemonErrorCode
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class DaemonErrorCode(str, Enum):
|
|
16
|
+
DAEMON_BAD_REQUEST = "DAEMON_BAD_REQUEST"
|
|
17
|
+
DAEMON_UNAUTHORIZED = "DAEMON_UNAUTHORIZED"
|
|
18
|
+
WORKSPACE_BUSY = "WORKSPACE_BUSY"
|
|
19
|
+
RUNTIME_BUSY = "RUNTIME_BUSY"
|
|
20
|
+
RUNTIME_NOT_CONNECTED = "RUNTIME_NOT_CONNECTED"
|
|
21
|
+
SCREEN_NOT_READY = "SCREEN_NOT_READY"
|
|
22
|
+
COMMAND_NOT_FOUND = "COMMAND_NOT_FOUND"
|
|
23
|
+
COMMAND_CANCELLED = "COMMAND_CANCELLED"
|
|
24
|
+
REF_RESOLUTION_FAILED = "REF_RESOLUTION_FAILED"
|
|
25
|
+
REF_STALE = "REF_STALE"
|
|
26
|
+
TARGET_BLOCKED = "TARGET_BLOCKED"
|
|
27
|
+
TARGET_NOT_ACTIONABLE = "TARGET_NOT_ACTIONABLE"
|
|
28
|
+
WAIT_TIMEOUT = "WAIT_TIMEOUT"
|
|
29
|
+
DEVICE_RPC_FAILED = "DEVICE_RPC_FAILED"
|
|
30
|
+
DEVICE_DISCONNECTED = "DEVICE_DISCONNECTED"
|
|
31
|
+
DEVICE_AGENT_UNAVAILABLE = "DEVICE_AGENT_UNAVAILABLE"
|
|
32
|
+
DEVICE_AGENT_UNAUTHORIZED = "DEVICE_AGENT_UNAUTHORIZED"
|
|
33
|
+
DEVICE_AGENT_VERSION_MISMATCH = "DEVICE_AGENT_VERSION_MISMATCH"
|
|
34
|
+
DEVICE_AGENT_CAPABILITY_MISMATCH = "DEVICE_AGENT_CAPABILITY_MISMATCH"
|
|
35
|
+
ACCESSIBILITY_NOT_READY = "ACCESSIBILITY_NOT_READY"
|
|
36
|
+
OPEN_FAILED = "OPEN_FAILED"
|
|
37
|
+
ACTION_NOT_CONFIRMED = "ACTION_NOT_CONFIRMED"
|
|
38
|
+
TYPE_NOT_CONFIRMED = "TYPE_NOT_CONFIRMED"
|
|
39
|
+
SUBMIT_NOT_CONFIRMED = "SUBMIT_NOT_CONFIRMED"
|
|
40
|
+
DEVICE_RPC_TRANSPORT_RESET = "DEVICE_RPC_TRANSPORT_RESET"
|
|
41
|
+
INTERNAL_COMMAND_FAILURE = "INTERNAL_COMMAND_FAILURE"
|
|
42
|
+
WORKSPACE_UNAVAILABLE = "WORKSPACE_UNAVAILABLE"
|
|
43
|
+
ARTIFACT_ROOT_UNWRITABLE = "ARTIFACT_ROOT_UNWRITABLE"
|
|
44
|
+
ARTIFACT_WRITE_FAILED = "ARTIFACT_WRITE_FAILED"
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
_WIRE_CODE_BY_VALUE = {code.value: code for code in ContractDaemonErrorCode}
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@dataclass
|
|
51
|
+
class DaemonError(Exception):
|
|
52
|
+
code: DaemonErrorCode
|
|
53
|
+
message: str
|
|
54
|
+
retryable: bool = False
|
|
55
|
+
details: dict[str, Any] = field(default_factory=dict)
|
|
56
|
+
http_status: int = 200
|
|
57
|
+
|
|
58
|
+
def __post_init__(self) -> None:
|
|
59
|
+
self.code = DaemonErrorCode(self.code)
|
|
60
|
+
|
|
61
|
+
def to_contract_error(self) -> ContractDaemonError:
|
|
62
|
+
return ContractDaemonError(
|
|
63
|
+
code=_to_contract_code(self.code),
|
|
64
|
+
message=self.message,
|
|
65
|
+
retryable=self.retryable,
|
|
66
|
+
details=dict(self.details),
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def bad_request(message: str, details: dict[str, Any] | None = None) -> DaemonError:
|
|
71
|
+
return DaemonError(
|
|
72
|
+
code=DaemonErrorCode.DAEMON_BAD_REQUEST,
|
|
73
|
+
message=message,
|
|
74
|
+
retryable=False,
|
|
75
|
+
details=details or {},
|
|
76
|
+
http_status=400,
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def unauthorized(message: str = "missing or invalid daemon token") -> DaemonError:
|
|
81
|
+
return DaemonError(
|
|
82
|
+
code=DaemonErrorCode.DAEMON_UNAUTHORIZED,
|
|
83
|
+
message=message,
|
|
84
|
+
retryable=False,
|
|
85
|
+
details={},
|
|
86
|
+
http_status=401,
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _to_contract_code(code: DaemonErrorCode) -> ContractDaemonErrorCode:
|
|
91
|
+
try:
|
|
92
|
+
return _WIRE_CODE_BY_VALUE[code.value]
|
|
93
|
+
except KeyError as error:
|
|
94
|
+
raise ValueError(f"{code.value} is not legal in DaemonErrorEnvelope") from error
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"""Logging configuration for androidctld."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
|
|
7
|
+
LOGGER_NAME = "androidctld"
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def configure_logging(level: int = logging.INFO) -> logging.Logger:
|
|
11
|
+
logger = logging.getLogger(LOGGER_NAME)
|
|
12
|
+
if not logger.handlers:
|
|
13
|
+
handler = logging.StreamHandler()
|
|
14
|
+
formatter = logging.Formatter(
|
|
15
|
+
fmt="%(asctime)s %(levelname)s %(name)s %(message)s",
|
|
16
|
+
datefmt="%Y-%m-%dT%H:%M:%S",
|
|
17
|
+
)
|
|
18
|
+
handler.setFormatter(formatter)
|
|
19
|
+
logger.addHandler(handler)
|
|
20
|
+
logger.setLevel(level)
|
|
21
|
+
logger.propagate = False
|
|
22
|
+
return logger
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
"""Shared observation-loop timing and cursor bookkeeping."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
|
|
7
|
+
from androidctld.device.types import EventsPollResult
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@dataclass(frozen=True)
|
|
11
|
+
class ObservationPolicy:
|
|
12
|
+
min_grace_ms: int
|
|
13
|
+
snapshot_max_interval_ms: int
|
|
14
|
+
stable_window_ms: int
|
|
15
|
+
max_total_ms: int
|
|
16
|
+
poll_slice_ms: int
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass(frozen=True)
|
|
20
|
+
class ObservationPollOutcome:
|
|
21
|
+
saw_events: bool
|
|
22
|
+
need_resync: bool
|
|
23
|
+
latest_seq: int
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass
|
|
27
|
+
class ObservationLoop:
|
|
28
|
+
policy: ObservationPolicy
|
|
29
|
+
started_at: float
|
|
30
|
+
after_seq: int = 0
|
|
31
|
+
last_refresh_at: float | None = None
|
|
32
|
+
stable_since: float | None = None
|
|
33
|
+
|
|
34
|
+
@classmethod
|
|
35
|
+
def begin(cls, policy: ObservationPolicy, started_at: float) -> ObservationLoop:
|
|
36
|
+
return cls(policy=policy, started_at=started_at)
|
|
37
|
+
|
|
38
|
+
@property
|
|
39
|
+
def grace_deadline_at(self) -> float:
|
|
40
|
+
return self.started_at + (self.policy.min_grace_ms / 1000.0)
|
|
41
|
+
|
|
42
|
+
@property
|
|
43
|
+
def deadline_at(self) -> float:
|
|
44
|
+
return self.started_at + (self.policy.max_total_ms / 1000.0)
|
|
45
|
+
|
|
46
|
+
def timed_out(self, now: float) -> bool:
|
|
47
|
+
return now >= self.deadline_at
|
|
48
|
+
|
|
49
|
+
def remaining_ms(self, now: float) -> int:
|
|
50
|
+
return max(int((self.deadline_at - now) * 1000), 0)
|
|
51
|
+
|
|
52
|
+
def poll_wait_ms(self, now: float) -> int:
|
|
53
|
+
return min(self.policy.poll_slice_ms, self.remaining_ms(now))
|
|
54
|
+
|
|
55
|
+
def grace_elapsed(self, now: float) -> bool:
|
|
56
|
+
return now >= self.grace_deadline_at
|
|
57
|
+
|
|
58
|
+
def apply_poll_result(self, result: EventsPollResult) -> ObservationPollOutcome:
|
|
59
|
+
saw_events = bool(result.events)
|
|
60
|
+
need_resync = bool(result.need_resync)
|
|
61
|
+
latest_seq = int(result.latest_seq)
|
|
62
|
+
if need_resync:
|
|
63
|
+
self.after_seq = 0
|
|
64
|
+
self.stable_since = None
|
|
65
|
+
self.last_refresh_at = None
|
|
66
|
+
else:
|
|
67
|
+
self.after_seq = latest_seq
|
|
68
|
+
return ObservationPollOutcome(
|
|
69
|
+
saw_events=saw_events,
|
|
70
|
+
need_resync=need_resync,
|
|
71
|
+
latest_seq=latest_seq,
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
def should_refresh(
|
|
75
|
+
self, now: float, *, saw_events: bool, need_resync: bool
|
|
76
|
+
) -> bool:
|
|
77
|
+
if need_resync:
|
|
78
|
+
return True
|
|
79
|
+
if self.last_refresh_at is None:
|
|
80
|
+
return True
|
|
81
|
+
if saw_events:
|
|
82
|
+
return True
|
|
83
|
+
return (
|
|
84
|
+
now - self.last_refresh_at
|
|
85
|
+
) * 1000 >= self.policy.snapshot_max_interval_ms
|
|
86
|
+
|
|
87
|
+
def mark_refreshed(self, now: float) -> None:
|
|
88
|
+
self.last_refresh_at = now
|
|
89
|
+
|
|
90
|
+
def observe_stability(self, now: float, *, changed: bool) -> bool:
|
|
91
|
+
if changed:
|
|
92
|
+
self.stable_since = None
|
|
93
|
+
return False
|
|
94
|
+
if self.stable_since is None:
|
|
95
|
+
self.stable_since = now
|
|
96
|
+
if not self.grace_elapsed(now):
|
|
97
|
+
return False
|
|
98
|
+
return (now - self.stable_since) * 1000 >= self.policy.stable_window_ms
|
androidctld/protocol.py
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
"""Centralized protocol enums for androidctld runtime."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from enum import Enum
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class RuntimeStatus(str, Enum):
|
|
9
|
+
NEW = "new"
|
|
10
|
+
BOOTSTRAPPING = "bootstrapping"
|
|
11
|
+
CONNECTED = "connected"
|
|
12
|
+
READY = "ready"
|
|
13
|
+
BROKEN = "broken"
|
|
14
|
+
CLOSED = "closed"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class ConnectionMode(str, Enum):
|
|
18
|
+
ADB = "adb"
|
|
19
|
+
LAN = "lan"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class CommandKind(str, Enum):
|
|
23
|
+
CONNECT = "connect"
|
|
24
|
+
OBSERVE = "observe"
|
|
25
|
+
LIST_APPS = "listApps"
|
|
26
|
+
OPEN = "open"
|
|
27
|
+
TAP = "tap"
|
|
28
|
+
LONG_TAP = "longTap"
|
|
29
|
+
TYPE = "type"
|
|
30
|
+
FOCUS = "focus"
|
|
31
|
+
SUBMIT = "submit"
|
|
32
|
+
SCROLL = "scroll"
|
|
33
|
+
GLOBAL = "global"
|
|
34
|
+
WAIT = "wait"
|
|
35
|
+
SCREENSHOT = "screenshot"
|
|
36
|
+
CLOSE = "close"
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class DeviceRpcMethod(str, Enum):
|
|
40
|
+
META_GET = "meta.get"
|
|
41
|
+
SNAPSHOT_GET = "snapshot.get"
|
|
42
|
+
EVENTS_POLL = "events.poll"
|
|
43
|
+
ACTION_PERFORM = "action.perform"
|
|
44
|
+
SCREENSHOT_CAPTURE = "screenshot.capture"
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class DeviceRpcErrorCode(str, Enum):
|
|
48
|
+
STALE_TARGET = "STALE_TARGET"
|
|
49
|
+
TARGET_NOT_ACTIONABLE = "TARGET_NOT_ACTIONABLE"
|
|
50
|
+
ACTION_FAILED = "ACTION_FAILED"
|
|
51
|
+
ACTION_TIMEOUT = "ACTION_TIMEOUT"
|
|
52
|
+
RUNTIME_NOT_READY = "RUNTIME_NOT_READY"
|
|
53
|
+
ACCESSIBILITY_DISABLED = "ACCESSIBILITY_DISABLED"
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Ref helpers for semantic targets."""
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"""Runtime ref registry models."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@dataclass(frozen=True)
|
|
9
|
+
class RefFingerprint:
|
|
10
|
+
role: str
|
|
11
|
+
normalized_label: str
|
|
12
|
+
resource_id: str
|
|
13
|
+
class_name: str
|
|
14
|
+
parent_role: str
|
|
15
|
+
parent_label: str
|
|
16
|
+
sibling_labels: tuple[str, ...]
|
|
17
|
+
relative_bounds: tuple[int, int, int, int]
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass(frozen=True)
|
|
21
|
+
class NodeHandle:
|
|
22
|
+
snapshot_id: int
|
|
23
|
+
rid: str
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass(frozen=True)
|
|
27
|
+
class SemanticProfile:
|
|
28
|
+
state: tuple[str, ...]
|
|
29
|
+
actions: tuple[str, ...]
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass(frozen=True)
|
|
33
|
+
class RefRepairSourceSignature:
|
|
34
|
+
ref: str
|
|
35
|
+
fingerprint: RefFingerprint
|
|
36
|
+
state: tuple[str, ...]
|
|
37
|
+
actions: tuple[str, ...]
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@dataclass
|
|
41
|
+
class RefBinding:
|
|
42
|
+
ref: str
|
|
43
|
+
handle: NodeHandle
|
|
44
|
+
fingerprint: RefFingerprint
|
|
45
|
+
semantic_profile: SemanticProfile
|
|
46
|
+
reused: bool = False
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@dataclass
|
|
50
|
+
class RefRegistry:
|
|
51
|
+
bindings: dict[str, RefBinding] = field(default_factory=dict)
|
|
52
|
+
|
|
53
|
+
def get(self, ref: str) -> RefBinding | None:
|
|
54
|
+
return self.bindings.get(ref)
|