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.
- kotonebot/__init__.py +40 -0
- kotonebot/backend/__init__.py +0 -0
- kotonebot/backend/bot.py +302 -0
- kotonebot/backend/color.py +525 -0
- kotonebot/backend/context/__init__.py +3 -0
- kotonebot/backend/context/context.py +1001 -0
- kotonebot/backend/context/task_action.py +176 -0
- kotonebot/backend/core.py +126 -0
- kotonebot/backend/debug/__init__.py +1 -0
- kotonebot/backend/debug/entry.py +89 -0
- kotonebot/backend/debug/mock.py +79 -0
- kotonebot/backend/debug/server.py +223 -0
- kotonebot/backend/debug/vars.py +346 -0
- kotonebot/backend/dispatch.py +228 -0
- kotonebot/backend/flow_controller.py +197 -0
- kotonebot/backend/image.py +748 -0
- kotonebot/backend/loop.py +277 -0
- kotonebot/backend/ocr.py +511 -0
- kotonebot/backend/preprocessor.py +103 -0
- kotonebot/client/__init__.py +10 -0
- kotonebot/client/device.py +500 -0
- kotonebot/client/fast_screenshot.py +378 -0
- kotonebot/client/host/__init__.py +12 -0
- kotonebot/client/host/adb_common.py +94 -0
- kotonebot/client/host/custom.py +114 -0
- kotonebot/client/host/leidian_host.py +202 -0
- kotonebot/client/host/mumu12_host.py +245 -0
- kotonebot/client/host/protocol.py +213 -0
- kotonebot/client/host/windows_common.py +55 -0
- kotonebot/client/implements/__init__.py +7 -0
- kotonebot/client/implements/adb.py +85 -0
- kotonebot/client/implements/adb_raw.py +159 -0
- kotonebot/client/implements/nemu_ipc/__init__.py +8 -0
- kotonebot/client/implements/nemu_ipc/external_renderer_ipc.py +280 -0
- kotonebot/client/implements/nemu_ipc/nemu_ipc.py +327 -0
- kotonebot/client/implements/remote_windows.py +193 -0
- kotonebot/client/implements/uiautomator2.py +82 -0
- kotonebot/client/implements/windows.py +168 -0
- kotonebot/client/protocol.py +69 -0
- kotonebot/client/registration.py +24 -0
- kotonebot/config/__init__.py +1 -0
- kotonebot/config/base_config.py +96 -0
- kotonebot/config/manager.py +36 -0
- kotonebot/errors.py +72 -0
- kotonebot/interop/win/__init__.py +0 -0
- kotonebot/interop/win/message_box.py +314 -0
- kotonebot/interop/win/reg.py +37 -0
- kotonebot/interop/win/shortcut.py +43 -0
- kotonebot/interop/win/task_dialog.py +469 -0
- kotonebot/logging/__init__.py +2 -0
- kotonebot/logging/log.py +18 -0
- kotonebot/primitives/__init__.py +17 -0
- kotonebot/primitives/geometry.py +290 -0
- kotonebot/primitives/visual.py +63 -0
- kotonebot/tools/__init__.py +0 -0
- kotonebot/tools/mirror.py +354 -0
- kotonebot/ui/__init__.py +0 -0
- kotonebot/ui/file_host/sensio.py +36 -0
- kotonebot/ui/file_host/tmp_send.py +54 -0
- kotonebot/ui/pushkit/__init__.py +3 -0
- kotonebot/ui/pushkit/image_host.py +87 -0
- kotonebot/ui/pushkit/protocol.py +13 -0
- kotonebot/ui/pushkit/wxpusher.py +53 -0
- kotonebot/ui/user.py +144 -0
- kotonebot/util.py +409 -0
- kotonebot-0.1.0.dist-info/METADATA +204 -0
- kotonebot-0.1.0.dist-info/RECORD +70 -0
- kotonebot-0.1.0.dist-info/WHEEL +5 -0
- kotonebot-0.1.0.dist-info/licenses/LICENSE +674 -0
- kotonebot-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
from typing import Callable, ParamSpec, TypeVar, overload, Literal
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
from .context import ContextStackVars, ScreenshotMode
|
|
7
|
+
from ...errors import TaskNotFoundError
|
|
8
|
+
|
|
9
|
+
P = ParamSpec('P')
|
|
10
|
+
R = TypeVar('R')
|
|
11
|
+
logger = logging.getLogger(__name__)
|
|
12
|
+
|
|
13
|
+
TaskRunAtType = Literal['pre', 'post', 'manual', 'regular'] | str
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass
|
|
17
|
+
class Task:
|
|
18
|
+
name: str
|
|
19
|
+
id: str
|
|
20
|
+
description: str
|
|
21
|
+
func: Callable
|
|
22
|
+
priority: int
|
|
23
|
+
"""
|
|
24
|
+
任务优先级,数字越大优先级越高。
|
|
25
|
+
"""
|
|
26
|
+
run_at: TaskRunAtType = 'regular'
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass
|
|
30
|
+
class Action:
|
|
31
|
+
name: str
|
|
32
|
+
description: str
|
|
33
|
+
func: Callable
|
|
34
|
+
priority: int
|
|
35
|
+
"""
|
|
36
|
+
动作优先级,数字越大优先级越高。
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
task_registry: dict[str, Task] = {}
|
|
41
|
+
action_registry: dict[str, Action] = {}
|
|
42
|
+
current_callstack: list[Task|Action] = []
|
|
43
|
+
|
|
44
|
+
def _placeholder():
|
|
45
|
+
raise NotImplementedError('Placeholder function')
|
|
46
|
+
|
|
47
|
+
def task(
|
|
48
|
+
name: str,
|
|
49
|
+
task_id: str|None = None,
|
|
50
|
+
description: str|None = None,
|
|
51
|
+
*,
|
|
52
|
+
pass_through: bool = False,
|
|
53
|
+
priority: int = 0,
|
|
54
|
+
screenshot_mode: ScreenshotMode = 'auto',
|
|
55
|
+
run_at: TaskRunAtType = 'regular'
|
|
56
|
+
):
|
|
57
|
+
"""
|
|
58
|
+
`task` 装饰器,用于标记一个函数为任务函数。
|
|
59
|
+
|
|
60
|
+
:param name: 任务名称
|
|
61
|
+
:param task_id: 任务 ID。如果为 None,则使用函数名称作为 ID。
|
|
62
|
+
:param description: 任务描述。如果为 None,则使用函数的 docstring 作为描述。
|
|
63
|
+
:param pass_through:
|
|
64
|
+
默认情况下, @task 装饰器会包裹任务函数,跟踪其执行情况。
|
|
65
|
+
如果不想跟踪,则设置此参数为 False。
|
|
66
|
+
:param priority: 任务优先级,数字越大优先级越高。
|
|
67
|
+
:param run_at: 任务运行时间。
|
|
68
|
+
"""
|
|
69
|
+
# 设置 ID
|
|
70
|
+
# 获取 caller 信息
|
|
71
|
+
def _task_decorator(func: Callable[P, R]) -> Callable[P, R]:
|
|
72
|
+
nonlocal description, task_id
|
|
73
|
+
description = description or func.__doc__ or ''
|
|
74
|
+
# TODO: task_id 冲突检测
|
|
75
|
+
task_id = task_id or func.__name__
|
|
76
|
+
task = Task(name, task_id, description, _placeholder, priority, run_at)
|
|
77
|
+
task_registry[name] = task
|
|
78
|
+
logger.debug(f'Task "{name}" registered.')
|
|
79
|
+
if pass_through:
|
|
80
|
+
return func
|
|
81
|
+
else:
|
|
82
|
+
def _wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
|
|
83
|
+
current_callstack.append(task)
|
|
84
|
+
vars = ContextStackVars.push(screenshot_mode=screenshot_mode)
|
|
85
|
+
ret = func(*args, **kwargs)
|
|
86
|
+
ContextStackVars.pop()
|
|
87
|
+
current_callstack.pop()
|
|
88
|
+
return ret
|
|
89
|
+
task.func = _wrapper
|
|
90
|
+
return _wrapper
|
|
91
|
+
return _task_decorator
|
|
92
|
+
|
|
93
|
+
@overload
|
|
94
|
+
def action(func: Callable[P, R]) -> Callable[P, R]: ...
|
|
95
|
+
|
|
96
|
+
@overload
|
|
97
|
+
def action(
|
|
98
|
+
name: str,
|
|
99
|
+
*,
|
|
100
|
+
description: str|None = None,
|
|
101
|
+
pass_through: bool = False,
|
|
102
|
+
priority: int = 0,
|
|
103
|
+
screenshot_mode: ScreenshotMode | None = None,
|
|
104
|
+
) -> Callable[[Callable[P, R]], Callable[P, R]]:
|
|
105
|
+
"""
|
|
106
|
+
`action` 装饰器,用于标记一个函数为动作函数。
|
|
107
|
+
|
|
108
|
+
:param name: 动作名称。如果为 None,则使用函数的名称作为名称。
|
|
109
|
+
:param description: 动作描述。如果为 None,则使用函数的 docstring 作为描述。
|
|
110
|
+
:param pass_through:
|
|
111
|
+
默认情况下, @action 装饰器会包裹动作函数,跟踪其执行情况。
|
|
112
|
+
如果不想跟踪,则设置此参数为 False。
|
|
113
|
+
:param priority: 动作优先级,数字越大优先级越高。
|
|
114
|
+
:param screenshot_mode: 截图模式。
|
|
115
|
+
"""
|
|
116
|
+
...
|
|
117
|
+
|
|
118
|
+
# TODO: 需要找个地方统一管理这些属性名
|
|
119
|
+
ATTR_ORIGINAL_FUNC = '_kb_inner'
|
|
120
|
+
ATTR_ACTION_MARK = '__kb_action_mark'
|
|
121
|
+
def action(*args, **kwargs):
|
|
122
|
+
def _register(func: Callable, name: str, description: str|None = None, priority: int = 0) -> Action:
|
|
123
|
+
description = description or func.__doc__ or ''
|
|
124
|
+
action = Action(name, description, func, priority)
|
|
125
|
+
action_registry[name] = action
|
|
126
|
+
logger.debug(f'Action "{name}" registered.')
|
|
127
|
+
return action
|
|
128
|
+
|
|
129
|
+
if len(args) == 1 and isinstance(args[0], Callable):
|
|
130
|
+
func = args[0]
|
|
131
|
+
action = _register(_placeholder, func.__name__, func.__doc__)
|
|
132
|
+
def _wrapper(*args: P.args, **kwargs: P.kwargs):
|
|
133
|
+
current_callstack.append(action)
|
|
134
|
+
vars = ContextStackVars.push()
|
|
135
|
+
ret = func(*args, **kwargs)
|
|
136
|
+
ContextStackVars.pop()
|
|
137
|
+
current_callstack.pop()
|
|
138
|
+
return ret
|
|
139
|
+
setattr(_wrapper, ATTR_ORIGINAL_FUNC, func)
|
|
140
|
+
setattr(_wrapper, ATTR_ACTION_MARK, True)
|
|
141
|
+
action.func = _wrapper
|
|
142
|
+
return _wrapper
|
|
143
|
+
else:
|
|
144
|
+
name = args[0]
|
|
145
|
+
description = kwargs.get('description', None)
|
|
146
|
+
pass_through = kwargs.get('pass_through', False)
|
|
147
|
+
priority = kwargs.get('priority', 0)
|
|
148
|
+
screenshot_mode = kwargs.get('screenshot_mode', None)
|
|
149
|
+
def _action_decorator(func: Callable):
|
|
150
|
+
nonlocal pass_through
|
|
151
|
+
action = _register(_placeholder, name, description)
|
|
152
|
+
pass_through = kwargs.get('pass_through', False)
|
|
153
|
+
if pass_through:
|
|
154
|
+
return func
|
|
155
|
+
else:
|
|
156
|
+
def _wrapper(*args: P.args, **kwargs: P.kwargs):
|
|
157
|
+
current_callstack.append(action)
|
|
158
|
+
vars = ContextStackVars.push(screenshot_mode=screenshot_mode)
|
|
159
|
+
ret = func(*args, **kwargs)
|
|
160
|
+
ContextStackVars.pop()
|
|
161
|
+
current_callstack.pop()
|
|
162
|
+
return ret
|
|
163
|
+
setattr(_wrapper, ATTR_ORIGINAL_FUNC, func)
|
|
164
|
+
setattr(_wrapper, ATTR_ACTION_MARK, True)
|
|
165
|
+
action.func = _wrapper
|
|
166
|
+
return _wrapper
|
|
167
|
+
return _action_decorator
|
|
168
|
+
|
|
169
|
+
def tasks_from_id(task_ids: list[str]) -> list[Task]:
|
|
170
|
+
result = []
|
|
171
|
+
for tid in task_ids:
|
|
172
|
+
target = next(task for task in task_registry.values() if task.id == tid)
|
|
173
|
+
if target is None:
|
|
174
|
+
raise TaskNotFoundError(f'Task "{tid}" not found.')
|
|
175
|
+
result.append(target)
|
|
176
|
+
return result
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
from functools import cache
|
|
3
|
+
from typing import Callable
|
|
4
|
+
|
|
5
|
+
import cv2
|
|
6
|
+
from cv2.typing import MatLike
|
|
7
|
+
|
|
8
|
+
from kotonebot.util import cv2_imread
|
|
9
|
+
from kotonebot.primitives import RectTuple, Rect, Point
|
|
10
|
+
from kotonebot.errors import ResourceFileMissingError
|
|
11
|
+
|
|
12
|
+
class Ocr:
|
|
13
|
+
def __init__(
|
|
14
|
+
self,
|
|
15
|
+
text: str | Callable[[str], bool],
|
|
16
|
+
*,
|
|
17
|
+
language: str = 'jp',
|
|
18
|
+
):
|
|
19
|
+
self.text = text
|
|
20
|
+
self.language = language
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class Image:
|
|
24
|
+
def __init__(
|
|
25
|
+
self,
|
|
26
|
+
*,
|
|
27
|
+
path: str | None = None,
|
|
28
|
+
name: str | None = 'untitled',
|
|
29
|
+
data: MatLike | None = None,
|
|
30
|
+
):
|
|
31
|
+
self.path = path
|
|
32
|
+
self.name = name
|
|
33
|
+
self.__data: MatLike | None = data
|
|
34
|
+
self.__data_with_alpha: MatLike | None = None
|
|
35
|
+
|
|
36
|
+
@cache
|
|
37
|
+
def binary(self) -> 'Image':
|
|
38
|
+
return Image(data=cv2.cvtColor(self.data, cv2.COLOR_BGR2GRAY))
|
|
39
|
+
|
|
40
|
+
@property
|
|
41
|
+
def data(self) -> MatLike:
|
|
42
|
+
if self.__data is None:
|
|
43
|
+
if self.path is None:
|
|
44
|
+
raise ValueError('Either path or data must be provided.')
|
|
45
|
+
self.__data = cv2_imread(self.path)
|
|
46
|
+
if self.__data is None:
|
|
47
|
+
raise ResourceFileMissingError(self.path, 'sprite')
|
|
48
|
+
logger.debug(f'Read image "{self.name}" from {self.path}')
|
|
49
|
+
return self.__data
|
|
50
|
+
|
|
51
|
+
@property
|
|
52
|
+
def data_with_alpha(self) -> MatLike:
|
|
53
|
+
if self.__data_with_alpha is None:
|
|
54
|
+
if self.path is None:
|
|
55
|
+
raise ValueError('Either path or data must be provided.')
|
|
56
|
+
self.__data_with_alpha = cv2_imread(self.path, cv2.IMREAD_UNCHANGED)
|
|
57
|
+
if self.__data_with_alpha is None:
|
|
58
|
+
raise ResourceFileMissingError(self.path, 'sprite with alpha')
|
|
59
|
+
logger.debug(f'Read image "{self.name}" from {self.path}')
|
|
60
|
+
return self.__data_with_alpha
|
|
61
|
+
|
|
62
|
+
def __repr__(self) -> str:
|
|
63
|
+
if self.path is None:
|
|
64
|
+
return f'<Image: memory>'
|
|
65
|
+
else:
|
|
66
|
+
return f'<Image: "{self.name}" at {self.path}>'
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class HintBox(Rect):
|
|
70
|
+
def __init__(
|
|
71
|
+
self,
|
|
72
|
+
x1: int,
|
|
73
|
+
y1: int,
|
|
74
|
+
x2: int,
|
|
75
|
+
y2: int,
|
|
76
|
+
*,
|
|
77
|
+
name: str | None = None,
|
|
78
|
+
description: str | None = None,
|
|
79
|
+
source_resolution: tuple[int, int],
|
|
80
|
+
):
|
|
81
|
+
super().__init__(x1, y1, x2 - x1, y2 - y1, name=name)
|
|
82
|
+
self.description = description
|
|
83
|
+
self.source_resolution = source_resolution
|
|
84
|
+
|
|
85
|
+
@property
|
|
86
|
+
def width(self) -> int:
|
|
87
|
+
return self.x2 - self.x1
|
|
88
|
+
|
|
89
|
+
@property
|
|
90
|
+
def height(self) -> int:
|
|
91
|
+
return self.y2 - self.y1
|
|
92
|
+
|
|
93
|
+
@property
|
|
94
|
+
def rect(self) -> RectTuple:
|
|
95
|
+
return self.x1, self.y1, self.width, self.height
|
|
96
|
+
|
|
97
|
+
class HintPoint(Point):
|
|
98
|
+
def __init__(self, x: int, y: int, *, name: str | None = None, description: str | None = None):
|
|
99
|
+
super().__init__(x, y, name=name)
|
|
100
|
+
self.description = description
|
|
101
|
+
|
|
102
|
+
def __repr__(self) -> str:
|
|
103
|
+
return f'HintPoint<"{self.name}" at ({self.x}, {self.y})>'
|
|
104
|
+
|
|
105
|
+
def unify_image(image: MatLike | str | Image, transparent: bool = False) -> MatLike:
|
|
106
|
+
if isinstance(image, str):
|
|
107
|
+
if not transparent:
|
|
108
|
+
image = cv2_imread(image)
|
|
109
|
+
else:
|
|
110
|
+
image = cv2_imread(image, cv2.IMREAD_UNCHANGED)
|
|
111
|
+
elif isinstance(image, Image):
|
|
112
|
+
if transparent:
|
|
113
|
+
image = image.data_with_alpha
|
|
114
|
+
else:
|
|
115
|
+
image = image.data
|
|
116
|
+
return image
|
|
117
|
+
|
|
118
|
+
logger = logging.getLogger(__name__)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
if __name__ == '__main__':
|
|
122
|
+
hint_box = HintBox(100, 100, 200, 200, source_resolution=(1920, 1080))
|
|
123
|
+
print(hint_box.rect)
|
|
124
|
+
print(hint_box.width)
|
|
125
|
+
print(hint_box.height)
|
|
126
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
from .vars import result, debug, img, color
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import runpy
|
|
3
|
+
import shutil
|
|
4
|
+
import argparse
|
|
5
|
+
import importlib
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from threading import Thread
|
|
8
|
+
|
|
9
|
+
from . import debug
|
|
10
|
+
from kotonebot import logging
|
|
11
|
+
from kotonebot.backend.context import init_context
|
|
12
|
+
|
|
13
|
+
logger = logging.getLogger(__name__)
|
|
14
|
+
|
|
15
|
+
def _task_thread(task_module: str):
|
|
16
|
+
"""任务线程。"""
|
|
17
|
+
runpy.run_module(task_module, run_name="__main__")
|
|
18
|
+
|
|
19
|
+
def _parse_args():
|
|
20
|
+
"""解析命令行参数。"""
|
|
21
|
+
parser = argparse.ArgumentParser(description='KotoneBot visual debug tool')
|
|
22
|
+
parser.add_argument(
|
|
23
|
+
'-s', '--save',
|
|
24
|
+
help='Save dump image and results to the specified folder',
|
|
25
|
+
type=str,
|
|
26
|
+
metavar='PATH'
|
|
27
|
+
)
|
|
28
|
+
parser.add_argument(
|
|
29
|
+
'-c', '--clear',
|
|
30
|
+
help='Clear the dump folder before running',
|
|
31
|
+
action='store_true'
|
|
32
|
+
)
|
|
33
|
+
parser.add_argument(
|
|
34
|
+
'-t', '--config-type',
|
|
35
|
+
help='The full path of the config data type. e.g. `kotonebot.tasks.common.BaseConfig`',
|
|
36
|
+
type=str,
|
|
37
|
+
metavar='TYPE',
|
|
38
|
+
required=True
|
|
39
|
+
)
|
|
40
|
+
parser.add_argument(
|
|
41
|
+
'input_module',
|
|
42
|
+
help='The module to run'
|
|
43
|
+
)
|
|
44
|
+
return parser.parse_args()
|
|
45
|
+
|
|
46
|
+
def _start_task_thread(module: str):
|
|
47
|
+
"""启动任务线程。"""
|
|
48
|
+
thread = Thread(target=_task_thread, args=(module,))
|
|
49
|
+
thread.start()
|
|
50
|
+
|
|
51
|
+
if __name__ == "__main__":
|
|
52
|
+
args = _parse_args()
|
|
53
|
+
debug.enabled = True
|
|
54
|
+
|
|
55
|
+
# 设置保存路径
|
|
56
|
+
if args.save:
|
|
57
|
+
save_path = Path(args.save)
|
|
58
|
+
debug.auto_save_to_folder = str(save_path)
|
|
59
|
+
if not os.path.exists(save_path):
|
|
60
|
+
os.makedirs(save_path)
|
|
61
|
+
if args.clear:
|
|
62
|
+
if debug.auto_save_to_folder:
|
|
63
|
+
try:
|
|
64
|
+
logger.info(f"Removing {debug.auto_save_to_folder}")
|
|
65
|
+
shutil.rmtree(debug.auto_save_to_folder)
|
|
66
|
+
except PermissionError:
|
|
67
|
+
logger.warning(f"Failed to remove {debug.auto_save_to_folder}. Trying to remove all contents instead.")
|
|
68
|
+
for root, dirs, files in os.walk(debug.auto_save_to_folder):
|
|
69
|
+
for file in files:
|
|
70
|
+
try:
|
|
71
|
+
os.remove(os.path.join(root, file))
|
|
72
|
+
except PermissionError:
|
|
73
|
+
raise
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
# 初始化上下文
|
|
77
|
+
module_name, class_name = args.config_type.rsplit('.', 1)
|
|
78
|
+
class_ = importlib.import_module(module_name).__getattribute__(class_name)
|
|
79
|
+
init_context(config_type=class_)
|
|
80
|
+
|
|
81
|
+
# 启动服务器
|
|
82
|
+
from .server import app
|
|
83
|
+
import uvicorn
|
|
84
|
+
|
|
85
|
+
# 启动任务线程
|
|
86
|
+
_start_task_thread(args.input_module)
|
|
87
|
+
|
|
88
|
+
# 启动服务器
|
|
89
|
+
uvicorn.run(app, host="127.0.0.1", port=8000, log_level='critical' if debug.hide_server_log else None)
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import time
|
|
2
|
+
from typing_extensions import override
|
|
3
|
+
|
|
4
|
+
import cv2
|
|
5
|
+
from cv2.typing import MatLike
|
|
6
|
+
|
|
7
|
+
from kotonebot import sleep
|
|
8
|
+
from kotonebot.client.device import Device
|
|
9
|
+
|
|
10
|
+
class Video:
|
|
11
|
+
def __init__(self, path: str, fps: int):
|
|
12
|
+
self.path = path
|
|
13
|
+
self.fps = fps
|
|
14
|
+
self.paused = False
|
|
15
|
+
"""是否暂停"""
|
|
16
|
+
self.__cap = cv2.VideoCapture(path)
|
|
17
|
+
self.__last_frame = None
|
|
18
|
+
self.__last_time = 0
|
|
19
|
+
|
|
20
|
+
def __iter__(self):
|
|
21
|
+
return self
|
|
22
|
+
|
|
23
|
+
def __next__(self):
|
|
24
|
+
if self.paused:
|
|
25
|
+
return self.__last_frame
|
|
26
|
+
ret, frame = self.__cap.read()
|
|
27
|
+
if not ret:
|
|
28
|
+
raise StopIteration
|
|
29
|
+
self.__last_frame = frame
|
|
30
|
+
self.__last_time = time.time()
|
|
31
|
+
if self.__last_time - time.time() < 1 / self.fps:
|
|
32
|
+
sleep(1 / self.fps)
|
|
33
|
+
return frame
|
|
34
|
+
|
|
35
|
+
def pause(self):
|
|
36
|
+
self.paused = True
|
|
37
|
+
|
|
38
|
+
def resume(self):
|
|
39
|
+
self.paused = False
|
|
40
|
+
|
|
41
|
+
class MockDevice(Device):
|
|
42
|
+
def __init__(
|
|
43
|
+
self
|
|
44
|
+
):
|
|
45
|
+
super().__init__()
|
|
46
|
+
self.__video_stream = None
|
|
47
|
+
self.__image = None
|
|
48
|
+
self.__screen_size = None
|
|
49
|
+
|
|
50
|
+
def load_video(self, path: str, fps: int):
|
|
51
|
+
self.__video_stream = Video(path, fps)
|
|
52
|
+
return self.__video_stream
|
|
53
|
+
|
|
54
|
+
def load_image(self, img: str | MatLike):
|
|
55
|
+
if isinstance(img, str):
|
|
56
|
+
self.__image = cv2.imread(img)
|
|
57
|
+
else:
|
|
58
|
+
self.__image = img
|
|
59
|
+
return self.__image
|
|
60
|
+
|
|
61
|
+
def set_screen_size(self, width: int, height: int):
|
|
62
|
+
self.__screen_size = (width, height)
|
|
63
|
+
|
|
64
|
+
@override
|
|
65
|
+
def screenshot(self):
|
|
66
|
+
if self.__image is not None:
|
|
67
|
+
return self.__image
|
|
68
|
+
elif self.__video_stream is not None:
|
|
69
|
+
return next(self.__video_stream)
|
|
70
|
+
else:
|
|
71
|
+
raise RuntimeError('No video stream loaded')
|
|
72
|
+
|
|
73
|
+
@property
|
|
74
|
+
@override
|
|
75
|
+
def screen_size(self):
|
|
76
|
+
if self.__screen_size is not None:
|
|
77
|
+
return self.__screen_size
|
|
78
|
+
else:
|
|
79
|
+
raise RuntimeError('No screen size set')
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
import time
|
|
2
|
+
import asyncio
|
|
3
|
+
import inspect
|
|
4
|
+
import threading
|
|
5
|
+
import traceback
|
|
6
|
+
import subprocess
|
|
7
|
+
from io import StringIO
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Literal
|
|
10
|
+
from collections import deque
|
|
11
|
+
from contextlib import redirect_stdout
|
|
12
|
+
|
|
13
|
+
import cv2
|
|
14
|
+
import uvicorn
|
|
15
|
+
from thefuzz import fuzz
|
|
16
|
+
from pydantic import BaseModel
|
|
17
|
+
from fastapi.responses import FileResponse, Response
|
|
18
|
+
from fastapi import FastAPI, WebSocket, HTTPException
|
|
19
|
+
from fastapi.middleware.cors import CORSMiddleware
|
|
20
|
+
|
|
21
|
+
import kotonebot
|
|
22
|
+
import kotonebot.backend
|
|
23
|
+
import kotonebot.backend.context
|
|
24
|
+
from kotonebot.backend.core import HintBox, Image
|
|
25
|
+
from ..context import manual_context
|
|
26
|
+
from . import vars as debug_vars
|
|
27
|
+
from .vars import WSImage, WSMessageData, WSMessage, WSCallstack
|
|
28
|
+
|
|
29
|
+
app = FastAPI()
|
|
30
|
+
app.add_middleware(CORSMiddleware, allow_origins=["*"], allow_credentials=True, allow_methods=["*"], allow_headers=["*"])
|
|
31
|
+
|
|
32
|
+
# 获取当前文件夹路径
|
|
33
|
+
CURRENT_DIR = Path(__file__).parent
|
|
34
|
+
|
|
35
|
+
APP_DIR = Path.cwd()
|
|
36
|
+
|
|
37
|
+
class File(BaseModel):
|
|
38
|
+
name: str
|
|
39
|
+
full_path: str
|
|
40
|
+
type: Literal["file", "dir"]
|
|
41
|
+
|
|
42
|
+
@app.get("/api/read_file")
|
|
43
|
+
async def read_file(path: str):
|
|
44
|
+
"""读取文件内容"""
|
|
45
|
+
try:
|
|
46
|
+
# 确保路径在当前目录下
|
|
47
|
+
full_path = (APP_DIR / path).resolve()
|
|
48
|
+
if not Path(full_path).is_relative_to(APP_DIR):
|
|
49
|
+
raise HTTPException(status_code=403, detail="Access denied")
|
|
50
|
+
|
|
51
|
+
if not full_path.exists():
|
|
52
|
+
raise HTTPException(status_code=404, detail="File not found")
|
|
53
|
+
# 添加缓存控制头
|
|
54
|
+
headers = {
|
|
55
|
+
"Cache-Control": "public, max-age=3600", # 缓存1小时
|
|
56
|
+
"ETag": f'"{hash(full_path)}"' # 使用full_path的哈希值作为ETag
|
|
57
|
+
}
|
|
58
|
+
return FileResponse(full_path, headers=headers)
|
|
59
|
+
except Exception as e:
|
|
60
|
+
raise HTTPException(status_code=500, detail=str(e))
|
|
61
|
+
|
|
62
|
+
@app.get("/api/read_memory")
|
|
63
|
+
async def read_memory(key: str):
|
|
64
|
+
"""读取内存中的数据"""
|
|
65
|
+
try:
|
|
66
|
+
image = None
|
|
67
|
+
if (image := debug_vars._read_image(key)) is not None:
|
|
68
|
+
pass
|
|
69
|
+
else:
|
|
70
|
+
raise HTTPException(status_code=404, detail="Key not found")
|
|
71
|
+
|
|
72
|
+
# 编码图片
|
|
73
|
+
encode_params = [cv2.IMWRITE_PNG_COMPRESSION, 4]
|
|
74
|
+
_, buffer = cv2.imencode('.png', image, encode_params)
|
|
75
|
+
# 添加缓存控制头
|
|
76
|
+
headers = {
|
|
77
|
+
"Cache-Control": "public, max-age=3600", # 缓存1小时
|
|
78
|
+
"ETag": f'"{hash(key)}"' # 使用key的哈希值作为ETag
|
|
79
|
+
}
|
|
80
|
+
return Response(
|
|
81
|
+
buffer.tobytes(),
|
|
82
|
+
media_type="image/jpeg",
|
|
83
|
+
headers=headers
|
|
84
|
+
)
|
|
85
|
+
except Exception as e:
|
|
86
|
+
raise HTTPException(status_code=500, detail=str(e))
|
|
87
|
+
|
|
88
|
+
@app.get("/api/screenshot")
|
|
89
|
+
def screenshot():
|
|
90
|
+
from ..context import device
|
|
91
|
+
img = device.screenshot()
|
|
92
|
+
buff = cv2.imencode('.png', img)[1].tobytes()
|
|
93
|
+
return Response(buff, media_type="image/png")
|
|
94
|
+
|
|
95
|
+
class RunCodeRequest(BaseModel):
|
|
96
|
+
code: str
|
|
97
|
+
|
|
98
|
+
@app.post("/api/code/run")
|
|
99
|
+
async def run_code(request: RunCodeRequest):
|
|
100
|
+
event = asyncio.Event()
|
|
101
|
+
stdout = StringIO()
|
|
102
|
+
code = f"from kotonebot import *\n" + request.code
|
|
103
|
+
result = {}
|
|
104
|
+
def _runner():
|
|
105
|
+
nonlocal result
|
|
106
|
+
from kotonebot.backend.context import vars as context_vars
|
|
107
|
+
try:
|
|
108
|
+
with manual_context():
|
|
109
|
+
global_vars = dict(vars(kotonebot.backend.context))
|
|
110
|
+
with redirect_stdout(stdout):
|
|
111
|
+
exec(code, global_vars)
|
|
112
|
+
result = {"status": "ok", "result": stdout.getvalue()}
|
|
113
|
+
except (Exception) as e:
|
|
114
|
+
result = {"status": "error", "result": stdout.getvalue(), "message": str(e), "traceback": traceback.format_exc()}
|
|
115
|
+
except KeyboardInterrupt as e:
|
|
116
|
+
result = {"status": "error", "result": stdout.getvalue(), "message": str(e), "traceback": traceback.format_exc()}
|
|
117
|
+
finally:
|
|
118
|
+
context_vars.flow.clear_interrupt()
|
|
119
|
+
event.set()
|
|
120
|
+
threading.Thread(target=_runner, daemon=True).start()
|
|
121
|
+
await event.wait()
|
|
122
|
+
return result
|
|
123
|
+
|
|
124
|
+
@app.get("/api/code/stop")
|
|
125
|
+
async def stop_code():
|
|
126
|
+
from kotonebot.backend.context import vars
|
|
127
|
+
vars.flow.request_interrupt()
|
|
128
|
+
while vars.flow.is_interrupted:
|
|
129
|
+
await asyncio.sleep(0.1)
|
|
130
|
+
return {"status": "ok"}
|
|
131
|
+
|
|
132
|
+
@app.get("/api/fs/list_dir")
|
|
133
|
+
def list_dir(path: str) -> list[File]:
|
|
134
|
+
result = []
|
|
135
|
+
for item in Path(path).iterdir():
|
|
136
|
+
result.append(File(
|
|
137
|
+
name=item.name,
|
|
138
|
+
full_path=str(item),
|
|
139
|
+
type="file" if item.is_file() else "dir"
|
|
140
|
+
))
|
|
141
|
+
return result
|
|
142
|
+
|
|
143
|
+
@app.get("/api/resources/autocomplete")
|
|
144
|
+
def autocomplete(class_path: str) -> list[str]:
|
|
145
|
+
from kotonebot.kaa.tasks import R
|
|
146
|
+
class_names = class_path.split(".")[:-1]
|
|
147
|
+
target_class = R
|
|
148
|
+
# 定位到目标类
|
|
149
|
+
for name in class_names:
|
|
150
|
+
target_class = getattr(target_class, name, None)
|
|
151
|
+
if target_class is None:
|
|
152
|
+
return []
|
|
153
|
+
# 获取目标类的所有属性
|
|
154
|
+
attrs = [attr for attr in dir(target_class) if not attr.startswith("_")]
|
|
155
|
+
filtered_attrs = []
|
|
156
|
+
for attr in attrs:
|
|
157
|
+
if inspect.isclass(getattr(target_class, attr)):
|
|
158
|
+
filtered_attrs.append(attr)
|
|
159
|
+
elif isinstance(getattr(target_class, attr), (Image, HintBox)):
|
|
160
|
+
filtered_attrs.append(attr)
|
|
161
|
+
attrs = filtered_attrs
|
|
162
|
+
# 排序
|
|
163
|
+
attrs.sort(key=lambda x: fuzz.ratio(x, class_path), reverse=True)
|
|
164
|
+
return attrs
|
|
165
|
+
|
|
166
|
+
@app.get("/api/ping")
|
|
167
|
+
async def ping():
|
|
168
|
+
return {"status": "ok"}
|
|
169
|
+
|
|
170
|
+
message_queue = deque()
|
|
171
|
+
@app.websocket("/ws")
|
|
172
|
+
async def websocket_endpoint(websocket: WebSocket):
|
|
173
|
+
await websocket.accept()
|
|
174
|
+
try:
|
|
175
|
+
while True:
|
|
176
|
+
if len(message_queue) > 0:
|
|
177
|
+
message = message_queue.popleft()
|
|
178
|
+
await websocket.send_json(message)
|
|
179
|
+
await asyncio.sleep(0.1)
|
|
180
|
+
except:
|
|
181
|
+
await websocket.close()
|
|
182
|
+
|
|
183
|
+
def send_ws_message(title: str, image: list[str], text: str = '', callstack: list[WSCallstack] = [], wait: bool = False):
|
|
184
|
+
"""发送 WebSocket 消息"""
|
|
185
|
+
message = WSMessage(
|
|
186
|
+
type="visual",
|
|
187
|
+
data=WSMessageData(
|
|
188
|
+
image=WSImage(type="memory", value=image),
|
|
189
|
+
name=title,
|
|
190
|
+
details=text,
|
|
191
|
+
timestamp=int(time.time() * 1000),
|
|
192
|
+
callstack=callstack
|
|
193
|
+
)
|
|
194
|
+
)
|
|
195
|
+
message_queue.append(message.dict())
|
|
196
|
+
if wait:
|
|
197
|
+
while len(message_queue) > 0:
|
|
198
|
+
time.sleep(0.3)
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
thread = None
|
|
202
|
+
def start_server():
|
|
203
|
+
global thread
|
|
204
|
+
def run_server():
|
|
205
|
+
uvicorn.run(app, host="127.0.0.1", port=8000, log_level='critical' if debug_vars.debug.hide_server_log else None)
|
|
206
|
+
if thread is None:
|
|
207
|
+
thread = threading.Thread(target=run_server, daemon=True)
|
|
208
|
+
thread.start()
|
|
209
|
+
|
|
210
|
+
def wait_message_all_done():
|
|
211
|
+
global thread
|
|
212
|
+
def _wait():
|
|
213
|
+
while len(message_queue) > 0:
|
|
214
|
+
time.sleep(0.1)
|
|
215
|
+
if thread is not None:
|
|
216
|
+
threading.Thread(target=_wait, daemon=True).start()
|
|
217
|
+
|
|
218
|
+
if __name__ == "__main__":
|
|
219
|
+
debug_vars.debug.hide_server_log = False
|
|
220
|
+
process = subprocess.Popen(["pylsp", "--port", "5479", "--ws"])
|
|
221
|
+
print("LSP started. PID=", process.pid)
|
|
222
|
+
uvicorn.run(app, host="127.0.0.1", port=8000, log_level='critical' if debug_vars.debug.hide_server_log else None)
|
|
223
|
+
process.kill()
|