kotonebot 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.
Files changed (70) hide show
  1. kotonebot/__init__.py +40 -0
  2. kotonebot/backend/__init__.py +0 -0
  3. kotonebot/backend/bot.py +302 -0
  4. kotonebot/backend/color.py +525 -0
  5. kotonebot/backend/context/__init__.py +3 -0
  6. kotonebot/backend/context/context.py +1001 -0
  7. kotonebot/backend/context/task_action.py +176 -0
  8. kotonebot/backend/core.py +126 -0
  9. kotonebot/backend/debug/__init__.py +1 -0
  10. kotonebot/backend/debug/entry.py +89 -0
  11. kotonebot/backend/debug/mock.py +79 -0
  12. kotonebot/backend/debug/server.py +223 -0
  13. kotonebot/backend/debug/vars.py +346 -0
  14. kotonebot/backend/dispatch.py +228 -0
  15. kotonebot/backend/flow_controller.py +197 -0
  16. kotonebot/backend/image.py +748 -0
  17. kotonebot/backend/loop.py +277 -0
  18. kotonebot/backend/ocr.py +511 -0
  19. kotonebot/backend/preprocessor.py +103 -0
  20. kotonebot/client/__init__.py +10 -0
  21. kotonebot/client/device.py +500 -0
  22. kotonebot/client/fast_screenshot.py +378 -0
  23. kotonebot/client/host/__init__.py +12 -0
  24. kotonebot/client/host/adb_common.py +94 -0
  25. kotonebot/client/host/custom.py +114 -0
  26. kotonebot/client/host/leidian_host.py +202 -0
  27. kotonebot/client/host/mumu12_host.py +245 -0
  28. kotonebot/client/host/protocol.py +213 -0
  29. kotonebot/client/host/windows_common.py +55 -0
  30. kotonebot/client/implements/__init__.py +7 -0
  31. kotonebot/client/implements/adb.py +85 -0
  32. kotonebot/client/implements/adb_raw.py +159 -0
  33. kotonebot/client/implements/nemu_ipc/__init__.py +8 -0
  34. kotonebot/client/implements/nemu_ipc/external_renderer_ipc.py +280 -0
  35. kotonebot/client/implements/nemu_ipc/nemu_ipc.py +327 -0
  36. kotonebot/client/implements/remote_windows.py +193 -0
  37. kotonebot/client/implements/uiautomator2.py +82 -0
  38. kotonebot/client/implements/windows.py +168 -0
  39. kotonebot/client/protocol.py +69 -0
  40. kotonebot/client/registration.py +24 -0
  41. kotonebot/config/__init__.py +1 -0
  42. kotonebot/config/base_config.py +96 -0
  43. kotonebot/config/manager.py +36 -0
  44. kotonebot/errors.py +72 -0
  45. kotonebot/interop/win/__init__.py +0 -0
  46. kotonebot/interop/win/message_box.py +314 -0
  47. kotonebot/interop/win/reg.py +37 -0
  48. kotonebot/interop/win/shortcut.py +43 -0
  49. kotonebot/interop/win/task_dialog.py +469 -0
  50. kotonebot/logging/__init__.py +2 -0
  51. kotonebot/logging/log.py +18 -0
  52. kotonebot/primitives/__init__.py +17 -0
  53. kotonebot/primitives/geometry.py +290 -0
  54. kotonebot/primitives/visual.py +63 -0
  55. kotonebot/tools/__init__.py +0 -0
  56. kotonebot/tools/mirror.py +354 -0
  57. kotonebot/ui/__init__.py +0 -0
  58. kotonebot/ui/file_host/sensio.py +36 -0
  59. kotonebot/ui/file_host/tmp_send.py +54 -0
  60. kotonebot/ui/pushkit/__init__.py +3 -0
  61. kotonebot/ui/pushkit/image_host.py +87 -0
  62. kotonebot/ui/pushkit/protocol.py +13 -0
  63. kotonebot/ui/pushkit/wxpusher.py +53 -0
  64. kotonebot/ui/user.py +144 -0
  65. kotonebot/util.py +409 -0
  66. kotonebot-0.1.0.dist-info/METADATA +204 -0
  67. kotonebot-0.1.0.dist-info/RECORD +70 -0
  68. kotonebot-0.1.0.dist-info/WHEEL +5 -0
  69. kotonebot-0.1.0.dist-info/licenses/LICENSE +674 -0
  70. kotonebot-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,202 @@
1
+ import os
2
+ import subprocess
3
+ from typing import Literal
4
+ from functools import lru_cache
5
+ from typing_extensions import override
6
+
7
+ from kotonebot import logging
8
+ from kotonebot.client import Device
9
+ from kotonebot.util import Countdown, Interval
10
+ from .protocol import HostProtocol, Instance, copy_type, AdbHostConfig
11
+ from .adb_common import AdbRecipes, CommonAdbCreateDeviceMixin
12
+
13
+ logger = logging.getLogger(__name__)
14
+ LeidianRecipes = AdbRecipes
15
+
16
+ if os.name == 'nt':
17
+ from ...interop.win.reg import read_reg
18
+ else:
19
+ def read_reg(key, subkey, name, *, default=None, **kwargs):
20
+ """Stub for read_reg on non-Windows platforms."""
21
+ return default
22
+
23
+ class LeidianInstance(CommonAdbCreateDeviceMixin, Instance[AdbHostConfig]):
24
+ @copy_type(Instance.__init__)
25
+ def __init__(self, *args, **kwargs):
26
+ super().__init__(*args, **kwargs)
27
+ self._args = args
28
+ self.index: int | None = None
29
+ self.is_running: bool = False
30
+
31
+ @override
32
+ def refresh(self):
33
+ ins = LeidianHost.query(id=self.id)
34
+ assert isinstance(ins, LeidianInstance), f'Expected LeidianInstance, got {type(ins)}'
35
+ if ins is not None:
36
+ self.adb_port = ins.adb_port
37
+ self.adb_ip = ins.adb_ip
38
+ self.adb_name = ins.adb_name
39
+ self.is_running = ins.is_running
40
+ logger.debug('Refreshed Leidian instance: %s', repr(ins))
41
+
42
+ @override
43
+ def start(self):
44
+ if self.running():
45
+ logger.warning('Instance is already running.')
46
+ return
47
+ logger.info('Starting Leidian instance %s', self)
48
+ LeidianHost._invoke_manager(['launch', '--index', str(self.index)])
49
+ self.refresh()
50
+
51
+ @override
52
+ def stop(self):
53
+ if not self.running():
54
+ logger.warning('Instance is not running.')
55
+ return
56
+ logger.info('Stopping Leidian instance id=%s name=%s...', self.id, self.name)
57
+ LeidianHost._invoke_manager(['quit', '--index', str(self.index)])
58
+ self.refresh()
59
+
60
+ @override
61
+ def wait_available(self, timeout: float = 180):
62
+ cd = Countdown(timeout)
63
+ it = Interval(5)
64
+ while not cd.expired() and not self.running():
65
+ it.wait()
66
+ self.refresh()
67
+ if not self.running():
68
+ raise TimeoutError(f'Leidian instance "{self.name}" is not available.')
69
+
70
+ @override
71
+ def running(self) -> bool:
72
+ return self.is_running
73
+
74
+ @override
75
+ def create_device(self, impl: LeidianRecipes, host_config: AdbHostConfig) -> Device:
76
+ """为雷电模拟器实例创建 Device。"""
77
+ if self.adb_port is None:
78
+ raise ValueError("ADB port is not set and is required.")
79
+
80
+ return super().create_device(impl, host_config)
81
+
82
+ class LeidianHost(HostProtocol[LeidianRecipes]):
83
+ @staticmethod
84
+ @lru_cache(maxsize=1)
85
+ def _read_install_path() -> str | None:
86
+ """
87
+ 从注册表中读取雷电模拟器的安装路径。
88
+
89
+ :return: 安装路径,如果未找到则返回 None。
90
+ """
91
+ if os.name != 'nt':
92
+ return None
93
+
94
+ try:
95
+ icon_path = read_reg('HKCU', r'Software\leidian\LDPlayer9', 'DisplayIcon', default=None)
96
+ if icon_path and isinstance(icon_path, str):
97
+ icon_path = icon_path.replace('"', '')
98
+ path = os.path.dirname(icon_path)
99
+ logger.debug('Leidian installation path (from DisplayIcon): %s', path)
100
+ return path
101
+ install_dir = read_reg('HKCU', r'Software\leidian\LDPlayer9', 'InstallDir', default=None)
102
+ if install_dir and isinstance(install_dir, str):
103
+ install_dir = install_dir.replace('"', '')
104
+ logger.debug('Leidian installation path (from InstallDir): %s', install_dir)
105
+ return install_dir
106
+ except Exception as e:
107
+ logger.error(f'Failed to read Leidian installation path from registry: {e}')
108
+
109
+ return None
110
+
111
+ @staticmethod
112
+ def _invoke_manager(args: list[str]) -> str:
113
+ """
114
+ 调用 ldconsole.exe。
115
+
116
+ 参考文档:https://www.ldmnq.com/forum/30.html,以及命令行帮助。
117
+ 另外还有个 ld.exe,封装了 adb.exe,可以直接执行 adb 命令。(https://www.ldmnq.com/forum/9178.html)
118
+
119
+ :param args: 命令行参数列表。
120
+ :return: 命令执行的输出。
121
+ """
122
+ install_path = LeidianHost._read_install_path()
123
+ if install_path is None:
124
+ raise RuntimeError('Leidian is not installed.')
125
+ manager_path = os.path.join(install_path, 'ldconsole.exe')
126
+ logger.debug('ldconsole execute: %s', repr(args))
127
+ output = subprocess.run(
128
+ [manager_path] + args,
129
+ capture_output=True,
130
+ text=True,
131
+ # encoding='utf-8', # 居然不是 utf-8 编码
132
+ # https://stackoverflow.com/questions/6011235/run-a-program-from-python-and-have-it-continue-to-run-after-the-script-is-kille
133
+ creationflags=subprocess.DETACHED_PROCESS | subprocess.CREATE_NEW_PROCESS_GROUP
134
+ )
135
+ if output.returncode != 0:
136
+ raise RuntimeError(f'Failed to invoke ldconsole: {output.stderr}')
137
+ return output.stdout
138
+
139
+ @staticmethod
140
+ def installed() -> bool:
141
+ return LeidianHost._read_install_path() is not None
142
+
143
+ @staticmethod
144
+ def list() -> list[Instance]:
145
+ output = LeidianHost._invoke_manager(['list2'])
146
+ instances = []
147
+
148
+ # 解析 list2 命令的输出
149
+ # 格式: 索引,标题,顶层窗口句柄,绑定窗口句柄,是否进入android,进程PID,VBox进程PID
150
+ for line in output.strip().split('\n'):
151
+ if not line:
152
+ continue
153
+
154
+ parts = line.split(',')
155
+ if len(parts) < 5:
156
+ logger.warning(f'Invalid list2 output line: {line}')
157
+ continue
158
+
159
+ index = parts[0]
160
+ name = parts[1]
161
+ is_android_started = parts[4] == '1'
162
+ # 端口号规则 https://help.ldmnq.com/docs/LD9adbserver#a67730c2e7e2e0400d40bcab37d0e0cf
163
+ adb_port = 5554 + (int(index) * 2)
164
+
165
+ instance = LeidianInstance(
166
+ id=index,
167
+ name=name,
168
+ adb_port=adb_port,
169
+ adb_ip='127.0.0.1',
170
+ adb_name=f'emulator-{adb_port}'
171
+ )
172
+ instance.index = int(index)
173
+ instance.is_running = is_android_started
174
+ logger.debug('Leidian instance: %s', repr(instance))
175
+ instances.append(instance)
176
+
177
+ return instances
178
+
179
+ @staticmethod
180
+ def query(*, id: str) -> Instance | None:
181
+ instances = LeidianHost.list()
182
+ for instance in instances:
183
+ if instance.id == id:
184
+ return instance
185
+ return None
186
+
187
+ @staticmethod
188
+ def recipes() -> 'list[LeidianRecipes]':
189
+ return ['adb', 'adb_raw', 'uiautomator2']
190
+
191
+ if __name__ == '__main__':
192
+ logging.basicConfig(level=logging.DEBUG, format='[%(asctime)s] [%(levelname)s] [%(name)s] [%(funcName)s] [%(lineno)d] %(message)s')
193
+ print(LeidianHost._read_install_path())
194
+ print(LeidianHost.installed())
195
+ print(LeidianHost.list())
196
+ print(ins:=LeidianHost.query(id='0'))
197
+ assert isinstance(ins, LeidianInstance)
198
+ ins.start()
199
+ ins.wait_available()
200
+ print('status', ins.running(), ins.adb_port, ins.adb_ip)
201
+ # ins.stop()
202
+ # print('status', ins.running(), ins.adb_port, ins.adb_ip)
@@ -0,0 +1,245 @@
1
+ from dataclasses import dataclass
2
+ import os
3
+ import json
4
+ import subprocess
5
+ from functools import lru_cache
6
+ from typing import Any, Literal, overload
7
+ from typing_extensions import override
8
+
9
+ from kotonebot import logging
10
+ from kotonebot.client import Device
11
+ from kotonebot.client.device import AndroidDevice
12
+ from kotonebot.client.implements.adb import AdbImpl
13
+ from kotonebot.client.implements.nemu_ipc import NemuIpcImpl, NemuIpcImplConfig
14
+ from kotonebot.util import Countdown, Interval
15
+ from .protocol import HostProtocol, Instance, copy_type, AdbHostConfig
16
+ from .adb_common import AdbRecipes, CommonAdbCreateDeviceMixin, connect_adb, is_adb_recipe
17
+
18
+ if os.name == 'nt':
19
+ from ...interop.win.reg import read_reg
20
+ else:
21
+ def read_reg(key, subkey, name, *, default=None, **kwargs):
22
+ """Stub for read_reg on non-Windows platforms."""
23
+ return default
24
+
25
+ logger = logging.getLogger(__name__)
26
+ MuMu12Recipes = AdbRecipes | Literal['nemu_ipc']
27
+
28
+ @dataclass
29
+ class MuMu12HostConfig(AdbHostConfig):
30
+ """nemu_ipc 能力的配置模型。"""
31
+ display_id: int | None = 0
32
+ """目标显示器 ID,默认为 0(主显示器)。若为 None 且设置了 target_package_name,则自动获取对应的 display_id。"""
33
+ target_package_name: str | None = None
34
+ """目标应用包名,用于自动获取 display_id。"""
35
+ app_index: int = 0
36
+ """多开应用索引,传给 get_display_id 方法。"""
37
+
38
+
39
+ class Mumu12Instance(CommonAdbCreateDeviceMixin, Instance[MuMu12HostConfig]):
40
+ @copy_type(Instance.__init__)
41
+ def __init__(self, *args, **kwargs):
42
+ super().__init__(*args, **kwargs)
43
+ self._args = args
44
+ self.index: int | None = None
45
+ self.is_android_started: bool = False
46
+
47
+ @override
48
+ def refresh(self):
49
+ ins = Mumu12Host.query(id=self.id)
50
+ assert isinstance(ins, Mumu12Instance), f'Expected Mumu12Instance, got {type(ins)}'
51
+ if ins is not None:
52
+ self.adb_port = ins.adb_port
53
+ self.adb_ip = ins.adb_ip
54
+ self.adb_name = ins.adb_name
55
+ self.is_android_started = ins.is_android_started
56
+ logger.debug('Refreshed MuMu12 instance: %s', repr(ins))
57
+
58
+ @override
59
+ def start(self):
60
+ if self.running():
61
+ logger.warning('Instance is already running.')
62
+ return
63
+ logger.info('Starting MuMu12 instance %s', self)
64
+ Mumu12Host._invoke_manager(['control', '-v', self.id, 'launch'])
65
+ self.refresh()
66
+
67
+ @override
68
+ def stop(self):
69
+ if not self.running():
70
+ logger.warning('Instance is not running.')
71
+ return
72
+ logger.info('Stopping MuMu12 instance id=%s name=%s...', self.id, self.name)
73
+ Mumu12Host._invoke_manager(['control', '-v', self.id, 'shutdown'])
74
+ self.refresh()
75
+
76
+ @override
77
+ def wait_available(self, timeout: float = 180):
78
+ cd = Countdown(timeout)
79
+ it = Interval(5)
80
+ while not cd.expired() and not self.running():
81
+ it.wait()
82
+ self.refresh()
83
+ if not self.running():
84
+ raise TimeoutError(f'MuMu12 instance "{self.name}" is not available.')
85
+
86
+ @override
87
+ def running(self) -> bool:
88
+ return self.is_android_started
89
+
90
+ @overload
91
+ def create_device(self, recipe: Literal['nemu_ipc'], host_config: MuMu12HostConfig) -> Device: ...
92
+ @overload
93
+ def create_device(self, recipe: AdbRecipes, host_config: AdbHostConfig) -> Device: ...
94
+
95
+ @override
96
+ def create_device(self, recipe: MuMu12Recipes, host_config: MuMu12HostConfig | AdbHostConfig) -> Device:
97
+ """为MuMu12模拟器实例创建 Device。"""
98
+ if self.adb_port is None:
99
+ raise ValueError("ADB port is not set and is required.")
100
+
101
+ if recipe == 'nemu_ipc' and isinstance(host_config, MuMu12HostConfig):
102
+ # NemuImpl
103
+ nemu_path = Mumu12Host._read_install_path()
104
+ if not nemu_path:
105
+ raise RuntimeError("无法找到 MuMu12 的安装路径。")
106
+ nemu_config = NemuIpcImplConfig(
107
+ nemu_folder=nemu_path,
108
+ instance_id=int(self.id),
109
+ display_id=host_config.display_id,
110
+ target_package_name=host_config.target_package_name,
111
+ app_index=host_config.app_index
112
+ )
113
+ nemu_impl = NemuIpcImpl(nemu_config)
114
+ # AdbImpl
115
+ adb_impl = AdbImpl(connect_adb(
116
+ self.adb_ip,
117
+ self.adb_port,
118
+ timeout=host_config.timeout,
119
+ device_serial=self.adb_name
120
+ ))
121
+ device = AndroidDevice()
122
+ device._screenshot = nemu_impl
123
+ device._touch = nemu_impl
124
+ device.commands = adb_impl
125
+
126
+ return device
127
+ elif isinstance(host_config, AdbHostConfig) and is_adb_recipe(recipe):
128
+ return super().create_device(recipe, host_config)
129
+ else:
130
+ raise ValueError(f'Unknown recipe: {recipe}')
131
+
132
+ class Mumu12Host(HostProtocol[MuMu12Recipes]):
133
+ @staticmethod
134
+ @lru_cache(maxsize=1)
135
+ def _read_install_path() -> str | None:
136
+ r"""
137
+ 从注册表中读取 MuMu Player 12 的安装路径。
138
+
139
+ 返回的路径为根目录。如 `F:\Apps\Netease\MuMuPlayer-12.0`。
140
+
141
+ :return: 若找到,则返回安装路径;否则返回 None。
142
+ """
143
+ if os.name != 'nt':
144
+ return None
145
+
146
+ uninstall_subkeys = [
147
+ r'SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\MuMuPlayer-12.0',
148
+ # TODO: 支持国际版 MuMu
149
+ # r'SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\MuMuPlayerGlobal-12.0'
150
+ ]
151
+
152
+ for subkey in uninstall_subkeys:
153
+ icon_path = read_reg('HKLM', subkey, 'DisplayIcon', default=None)
154
+ if icon_path and isinstance(icon_path, str):
155
+ icon_path = icon_path.replace('"', '')
156
+ path = os.path.dirname(icon_path)
157
+ logger.debug('MuMu Player 12 installation path: %s', path)
158
+ # 返回根目录(去掉 shell 子目录)
159
+ if os.path.basename(path).lower() == 'shell':
160
+ path = os.path.dirname(path)
161
+ return path
162
+ return None
163
+
164
+ @staticmethod
165
+ def _invoke_manager(args: list[str]) -> str:
166
+ """
167
+ 调用 MuMuManager.exe。
168
+
169
+ :param args: 命令行参数列表。
170
+ :return: 命令执行的输出。
171
+ """
172
+ install_path = Mumu12Host._read_install_path()
173
+ if install_path is None:
174
+ raise RuntimeError('MuMu Player 12 is not installed.')
175
+ manager_path = os.path.join(install_path, 'shell', 'MuMuManager.exe')
176
+ logger.debug('MuMuManager execute: %s', repr(args))
177
+ output = subprocess.run(
178
+ [manager_path] + args,
179
+ capture_output=True,
180
+ text=True,
181
+ encoding='utf-8',
182
+ # https://stackoverflow.com/questions/6011235/run-a-program-from-python-and-have-it-continue-to-run-after-the-script-is-kille
183
+ creationflags=subprocess.DETACHED_PROCESS | subprocess.CREATE_NEW_PROCESS_GROUP
184
+ )
185
+ if output.returncode != 0:
186
+ # raise RuntimeError(f'Failed to invoke MuMuManager: {output.stderr}')
187
+ logger.warning('Failed to invoke MuMuManager: %s', output.stderr)
188
+ return output.stdout
189
+
190
+ @staticmethod
191
+ def installed() -> bool:
192
+ return Mumu12Host._read_install_path() is not None
193
+
194
+ @staticmethod
195
+ def list() -> list[Instance]:
196
+ output = Mumu12Host._invoke_manager(['info', '-v', 'all'])
197
+ logger.debug('MuMuManager.exe output: %s', output)
198
+
199
+ try:
200
+ data: dict[str, dict[str, Any]] = json.loads(output)
201
+ if 'name' in data.keys():
202
+ # 这里有个坑:
203
+ # 如果只有一个实例,返回的 JSON 结构是单个对象而不是数组
204
+ data = { '0': data }
205
+ instances = []
206
+ for index, instance_data in data.items():
207
+ instance = Mumu12Instance(
208
+ id=index,
209
+ name=instance_data['name'],
210
+ adb_port=instance_data.get('adb_port'),
211
+ adb_ip=instance_data.get('adb_host_ip', '127.0.0.1'),
212
+ adb_name=None
213
+ )
214
+ instance.index = int(index)
215
+ instance.is_android_started = instance_data.get('is_android_started', False)
216
+ logger.debug('Mumu12 instance: %s', repr(instance))
217
+ instances.append(instance)
218
+ return instances
219
+ except json.JSONDecodeError as e:
220
+ raise RuntimeError(f'Failed to parse output: {e}')
221
+
222
+ @staticmethod
223
+ def query(*, id: str) -> Instance | None:
224
+ instances = Mumu12Host.list()
225
+ for instance in instances:
226
+ if instance.id == id:
227
+ return instance
228
+ return None
229
+
230
+ @staticmethod
231
+ def recipes() -> 'list[MuMu12Recipes]':
232
+ return ['adb', 'adb_raw', 'uiautomator2', 'nemu_ipc']
233
+
234
+ if __name__ == '__main__':
235
+ logging.basicConfig(level=logging.DEBUG, format='[%(asctime)s] [%(levelname)s] [%(name)s] [%(funcName)s] [%(lineno)d] %(message)s')
236
+ print(Mumu12Host._read_install_path())
237
+ print(Mumu12Host.installed())
238
+ print(Mumu12Host.list())
239
+ print(ins:=Mumu12Host.query(id='2'))
240
+ assert isinstance(ins, Mumu12Instance)
241
+ ins.start()
242
+ ins.wait_available()
243
+ print('status', ins.running(), ins.adb_port, ins.adb_ip)
244
+ ins.stop()
245
+ print('status', ins.running(), ins.adb_port, ins.adb_ip)
@@ -0,0 +1,213 @@
1
+ import time
2
+ import socket
3
+ from abc import ABC, abstractmethod
4
+ from typing import Callable, TypeVar, Protocol, Any, Generic
5
+ from dataclasses import dataclass
6
+
7
+ from adbutils import adb, AdbTimeout, AdbError
8
+ from adbutils._device import AdbDevice
9
+
10
+ from kotonebot import logging
11
+ from kotonebot.client import Device, DeviceImpl
12
+
13
+ from kotonebot.util import Countdown, Interval
14
+
15
+ logger = logging.getLogger(__name__)
16
+ # https://github.com/python/typing/issues/769#issuecomment-903760354
17
+ _T = TypeVar("_T")
18
+ def copy_type(_: _T) -> Callable[[Any], _T]:
19
+ return lambda x: x
20
+
21
+ # --- 定义专用的 HostConfig 数据类 ---
22
+ @dataclass
23
+ class AdbHostConfig:
24
+ """由外部为基于 ADB 的主机提供的配置。"""
25
+ timeout: float = 180
26
+
27
+ @dataclass
28
+ class WindowsHostConfig:
29
+ """由外部为 Windows 实现提供配置。"""
30
+ window_title: str
31
+ ahk_exe_path: str
32
+
33
+ @dataclass
34
+ class RemoteWindowsHostConfig:
35
+ """由外部为远程 Windows 实现提供配置。"""
36
+ windows_host_config: WindowsHostConfig
37
+ host: str
38
+ port: int
39
+
40
+ # --- 使用泛型改造 Instance 协议 ---
41
+ T_HostConfig = TypeVar("T_HostConfig")
42
+
43
+ def tcp_ping(host: str, port: int, timeout: float = 1.0) -> bool:
44
+ """
45
+ 通过 TCP ping 检查主机和端口是否可达。
46
+
47
+ :param host: 主机名或 IP 地址
48
+ :param port: 端口号
49
+ :param timeout: 超时时间(秒)
50
+ :return: 如果主机和端口可达,则返回 True,否则返回 False
51
+ """
52
+ logger.debug('TCP ping %s:%d...', host, port)
53
+ try:
54
+ with socket.create_connection((host, port), timeout):
55
+ logger.debug('TCP ping %s:%d success.', host, port)
56
+ return True
57
+ except (socket.timeout, ConnectionRefusedError, OSError):
58
+ logger.debug('TCP ping %s:%d failed.', host, port)
59
+ return False
60
+
61
+
62
+ class Instance(Generic[T_HostConfig], ABC):
63
+ """
64
+ 代表一个可运行环境的实例(如一个模拟器)。
65
+ 使用泛型来约束 create_device 方法的配置参数类型。
66
+ """
67
+ def __init__(self,
68
+ id: str,
69
+ name: str,
70
+ adb_port: int | None = None,
71
+ adb_ip: str = '127.0.0.1',
72
+ adb_name: str | None = None
73
+ ):
74
+ self.id: str = id
75
+ self.name: str = name
76
+ self.adb_port: int | None = adb_port
77
+ self.adb_ip: str = adb_ip
78
+ self.adb_name: str | None = adb_name
79
+
80
+ def require_adb_port(self) -> int:
81
+ if self.adb_port is None:
82
+ raise ValueError("ADB port is not set and is required.")
83
+ return self.adb_port
84
+
85
+ @abstractmethod
86
+ def refresh(self):
87
+ """
88
+ 刷新实例信息,如 ADB 端口号等。
89
+ """
90
+ raise NotImplementedError()
91
+
92
+ @abstractmethod
93
+ def start(self):
94
+ """
95
+ 启动模拟器实例。
96
+ """
97
+ raise NotImplementedError()
98
+
99
+ @abstractmethod
100
+ def stop(self):
101
+ """
102
+ 停止模拟器实例。
103
+ """
104
+ raise NotImplementedError()
105
+
106
+ @abstractmethod
107
+ def running(self) -> bool:
108
+ raise NotImplementedError()
109
+
110
+ @abstractmethod
111
+ def create_device(self, impl: DeviceImpl, host_config: T_HostConfig) -> Device:
112
+ """
113
+ 根据实现名称和类型化的主机配置创建设备。
114
+
115
+ :param impl: 设备实现的名称。
116
+ :param host_config: 一个类型化的数据对象,包含创建所需的所有外部配置。
117
+ :return: 配置好的 Device 实例。
118
+ """
119
+ raise NotImplementedError()
120
+
121
+ def wait_available(self, timeout: float = 180):
122
+ logger.info('Starting to wait for emulator %s(127.0.0.1:%d) to be available...', self.name, self.adb_port)
123
+ state = 0
124
+ port = self.require_adb_port()
125
+ emulator_name = self.adb_name
126
+ cd = Countdown(timeout)
127
+ it = Interval(1)
128
+ d: AdbDevice | None = None
129
+ while True:
130
+ if cd.expired():
131
+ raise TimeoutError(f'Emulator "{self.name}" is not available.')
132
+ it.wait()
133
+ try:
134
+ match state:
135
+ case 0:
136
+ logger.debug('Ping emulator %s(127.0.0.1:%d)...', self.name, port)
137
+ if tcp_ping('127.0.0.1', port):
138
+ logger.debug('Ping emulator %s(127.0.0.1:%d) success.', self.name, port)
139
+ state = 1
140
+ case 1:
141
+ logger.debug('Connecting to emulator %s(127.0.0.1:%d)...', self.name, port)
142
+ if adb.connect(f'127.0.0.1:{port}', timeout=0.5):
143
+ logger.debug('Connect to emulator %s(127.0.0.1:%d) success.', self.name, port)
144
+ state = 2
145
+ case 2:
146
+ logger.debug('Getting device list...')
147
+ if devices := adb.device_list():
148
+ logger.debug('Get device list success. devices=%s', devices)
149
+ # emulator_name 用于适配雷电模拟器
150
+ # 雷电模拟器启动后,在上方的列表中并不会出现 127.0.0.1:5555,而是 emulator-5554
151
+ d = next(
152
+ (d for d in devices if d.serial == f'127.0.0.1:{port}' or d.serial == emulator_name),
153
+ None
154
+ )
155
+ if d:
156
+ logger.debug('Get target device success. d=%s', d)
157
+ state = 3
158
+ case 3:
159
+ if not d:
160
+ logger.warning('Device is None.')
161
+ state = 0
162
+ continue
163
+ logger.debug('Waiting for device state...')
164
+ if d.get_state() == 'device':
165
+ logger.debug('Device state ready. state=%s', d.get_state())
166
+ state = 4
167
+ case 4:
168
+ logger.debug('Waiting for device boot completed...')
169
+ if not d:
170
+ logger.warning('Device is None.')
171
+ state = 0
172
+ continue
173
+ ret = d.shell('getprop sys.boot_completed')
174
+ if isinstance(ret, str) and ret.strip() == '1':
175
+ logger.debug('Device boot completed. ret=%s', ret)
176
+ state = 5
177
+ case 5:
178
+ if not d:
179
+ logger.warning('Device is None.')
180
+ state = 0
181
+ continue
182
+ app = d.app_current()
183
+ logger.debug('Waiting for launcher... (current=%s)', app)
184
+ if app and 'launcher' in app.package:
185
+ logger.info('Emulator %s(127.0.0.1:%d) now is available.', self.name, self.adb_port)
186
+ state = 6
187
+ case 6:
188
+ break
189
+ except (AdbError, AdbTimeout):
190
+ state = 1
191
+ continue
192
+ time.sleep(1)
193
+ logger.info('Emulator %s(127.0.0.1:%d) now is available.', self.name, self.adb_port)
194
+
195
+ def __repr__(self) -> str:
196
+ return f'{self.__class__.__name__}(name="{self.name}", id="{self.id}", adb="{self.adb_ip}:{self.adb_port}"({self.adb_name}))'
197
+
198
+ Recipe = TypeVar('Recipe', bound=str)
199
+ class HostProtocol(Generic[Recipe], Protocol):
200
+ @staticmethod
201
+ def installed() -> bool: ...
202
+
203
+ @staticmethod
204
+ def list() -> list[Instance]: ...
205
+
206
+ @staticmethod
207
+ def query(*, id: str) -> Instance | None: ...
208
+
209
+ @staticmethod
210
+ def recipes() -> 'list[Recipe]': ...
211
+
212
+ if __name__ == '__main__':
213
+ pass