57 Commits
Author SHA1 Message Date
YoursFunny ef9dde3da5 Merge pull request #37 from TheFunny/sync_src
Sync with src
2026-07-05 21:20:49 +08:00
YoursFunny 723aa66e69 Merge branch 'dev' into sync_src 2026-07-05 21:17:22 +08:00
YoursFunny 0b28ed6f89 feat: migrate notify.py from ALAS and upgrade dep onepush 1.3.0 2026-07-05 20:21:50 +08:00
YoursFunny d54ff72565 feat: use nemu_ipc if avaliable 2026-07-05 20:18:18 +08:00
YoursFunny a2bd36f570 feat: add nemu_ipc 2026-07-05 20:16:51 +08:00
YoursFunny 50c4bc5bba feat: enhance device connection handling and droidcast resolution management 2026-07-05 20:06:53 +08:00
YoursFunny 3cef2ee01b feat: improve emulator management 2026-07-05 19:40:17 +08:00
YoursFunny 0bd294582b refactor: move ThreadPoolExecutor import inside worker method for better encapsulation 2026-07-05 19:27:47 +08:00
YoursFunny 49149413d5 fix: add timeout check to prevent infinite loop in draggable list 2026-07-05 19:27:22 +08:00
YoursFunny 108f648517 fix: set Windows event loop policy conditionally based on platform 2026-07-05 18:57:05 +08:00
YoursFunny aa4afbf6a2 perf: reuse image array to improve memory performance 2026-07-05 18:55:37 +08:00
YoursFunny d34c082ef3 feat: add AdaptiveScroll to handle transparent scroll on complex background 2026-07-05 18:51:41 +08:00
YoursFunny d48427abb2 refactor: cache nemud.app_keep_alive 2026-07-05 18:49:35 +08:00
YoursFunny ce8eb1c452 refactor: streamline platform imports and remove redundant type method 2026-07-05 18:47:10 +08:00
YoursFunny d0e6c78cc1 feat: autofill emulator info even not used 2026-07-05 18:43:46 +08:00
YoursFunny 75b8b78b24 refactor: rename list_azurlane_packages to list_known_packages for clarity 2026-07-05 18:39:45 +08:00
YoursFunny cac19c10c3 fix: enhance auto device detection, and ignore 127.0.0.1:7555 from MuMu12 2026-07-05 18:38:33 +08:00
YoursFunny d806c5e9e7 fix: exclude hidden files from asset extraction 2026-07-05 18:35:33 +08:00
YoursFunny 87975760ae feat: proxy element props 2026-07-05 18:33:25 +08:00
YoursFunny 6a4b1998fc fix: ignore files that are added by deployer 2026-07-05 18:29:59 +08:00
YoursFunny d7db56eb5a fix: update input prompts in screenshot.py 2026-07-05 18:27:58 +08:00
YoursFunny d017d9eefd fix: update REGEX_PUNCTUATION to include additional symbols 2026-07-05 18:26:01 +08:00
YoursFunny 97c3ed30e9 feat: add set_search_offset() to be compatible with Alas 2026-07-05 18:25:31 +08:00
YoursFunny 52964c2ff0 feat: add TaskError exception for task error handling 2026-07-05 18:21:29 +08:00
YoursFunny be44b23d70 fix: support emulator index parsing from MuMu ArkNights version 2026-07-05 18:19:44 +08:00
YoursFunny 0590eebd44 fix: handle FileNotFoundError when accessing Windows registry keys 2026-07-05 18:15:19 +08:00
YoursFunny 11f5f1dd64 fix: copy raw attributes of first frame to all 2026-07-05 18:11:56 +08:00
YoursFunny 5ffaec0fb1 feat: add stop method to Login class and implement button handling in base.py 2026-07-05 18:09:57 +08:00
YoursFunny 24e9bde5c7 fix: update punctuation regex to include ellipsis 2026-07-05 18:01:38 +08:00
YoursFunny 64bb8e3a12 feat: add method to increment value in StoredCounter 2026-07-05 18:00:49 +08:00
YoursFunny baddf23df5 refactor: update scroll methods to return results for better chaining 2026-07-05 17:56:17 +08:00
YoursFunny fc5847d6d8 feat: add KeywordDigitCounter class for filtering digit counters in OCR results 2026-07-05 17:55:16 +08:00
YoursFunny 84ac3dc9b8 feat: add is_format_matched method and improve regex for result formatting 2026-07-05 17:51:46 +08:00
YoursFunny af3e178c88 fix: limit length of screenshot deque 2026-07-05 17:50:14 +08:00
YoursFunny 7d084db107 fix: do not track screenshot when save error disabled 2026-07-05 17:47:26 +08:00
YoursFunny fcab832be1 fix: update func_list handling to ensure proper task binding 2026-07-05 17:36:36 +08:00
YoursFunny e8cf0a3517 fix: extend height of log-bar in daemon overview 2026-07-05 17:34:39 +08:00
YoursFunny 808a02ddf5 feat: add find_name method to retrieve Keyword instances by name 2026-07-05 17:31:12 +08:00
YoursFunny 5df288b577 feat: hide custom settings if custom not selected 2026-07-05 17:29:56 +08:00
YoursFunny 0a10c61c59 feat: show function calls on error 2026-07-05 17:22:09 +08:00
YoursFunny 247a9a8770 feat: save tracking screenshots 2026-07-05 17:19:11 +08:00
YoursFunny 63d4b50461 fix: add retry for check_mumu_app_keep_alive() 2026-07-05 17:12:50 +08:00
YoursFunny 8741cb6142 fix: checkbox cannot be updated dynamically 2026-07-05 17:11:07 +08:00
YoursFunny 091b5c011a refactor: replace direct import of deque with collections.deque for clarity 2026-07-05 17:07:17 +08:00
YoursFunny c43f8e5458 feat: add is_draggable method to improve swipe responsiveness 2026-07-05 17:02:27 +08:00
YoursFunny 6f6dab1c04 fix: correct function to retrieve last server update 2026-07-05 16:57:17 +08:00
YoursFunny f6f2470e5b refactor: add config update callback 2026-07-05 16:56:12 +08:00
YoursFunny 2275133638 dep: update to pywebio 1.8.3 2026-07-05 16:42:50 +08:00
YoursFunny da34dc4b7d fix: recheck in-game text language during waiting for next run 2026-07-05 16:41:20 +08:00
YoursFunny 07a7a19929 feat: add area_center function and enhance OCR processing for white letters 2026-07-05 16:40:00 +08:00
YoursFunny a7290f253e fix: remove another dash in ocr results 2026-07-05 16:37:22 +08:00
YoursFunny 0b1a4efd09 fix: renew timer in interval_is_reached() only 2026-07-05 16:36:01 +08:00
YoursFunny aaa56b928f feat: add interval timer management methods for buttons 2026-07-05 16:34:09 +08:00
YoursFunny 8dc527b06b fix: remove semicolon and dot in ocr results 2026-07-05 16:22:33 +08:00
YoursFunny 004af69d8f fix: use 'area' in 'ClickButton' 2026-07-05 16:20:03 +08:00
YoursFunny a397539c69 feat: add simple markdown table generator 2026-07-05 16:12:19 +08:00
YoursFunny 864ac15282 dep: upgrade to pydantic v2 2026-07-05 16:07:37 +08:00
50 changed files with 1925 additions and 336 deletions
+8
View File
@@ -21,6 +21,14 @@ test.py
test/
note.md
MCE/config.json
resources/
locales/
*.pak
*.dll
*.dat
v8_context_snapshot.bin
snapshot_blob.bin
vk_swiftshader_icd.json
# Created by .ignore support plugin (hsz.mobi)
+4
View File
@@ -11,6 +11,10 @@ class ArisuAutoSweeper(AzurLaneAutoScript):
from tasks.login.login import Login
Login(self.config, device=self.device).app_start()
def stop(self):
from tasks.login.login import Login
Login(self.config, device=self.device).app_stop()
def goto_main(self):
from tasks.login.login import Login
from tasks.base.ui import UI
+3 -1
View File
@@ -2,6 +2,7 @@ import asyncio
import filecmp
import os
import shutil
import sys
import typing as t
from dataclasses import dataclass
@@ -9,7 +10,8 @@ from deploy.Windows.alas import AlasManager
from deploy.Windows.logger import logger
from deploy.Windows.utils import cached_property
asyncio.set_event_loop_policy(asyncio.WindowsProactorEventLoopPolicy())
if sys.platform.startswith('win'):
asyncio.set_event_loop_policy(asyncio.WindowsProactorEventLoopPolicy())
@dataclass
+27 -14
View File
@@ -92,8 +92,9 @@ def iter_images():
for server in ASSET_SERVER:
for path, folders, files in os.walk(os.path.join(AzurLaneConfig.ASSETS_FOLDER, server)):
for file in files:
file = os.path.join(path, file).replace('\\', '/')
yield AssetsImage(file)
if not file.startswith('.'):
file = os.path.join(path, file).replace('\\', '/')
yield AssetsImage(file)
@dataclass
@@ -108,6 +109,11 @@ class DataAssets:
color: t.Tuple[int, int, int] = ()
button: t.Tuple[int, int, int, int] = ()
has_raw_area = False
has_raw_search = False
has_raw_color = False
has_raw_button = False
@staticmethod
def area_to_search(area):
area = area_pad(area, pad=-20)
@@ -131,12 +137,16 @@ class DataAssets:
self.button = image.bbox
elif image.attr == 'AREA':
self.area = image.bbox
self.has_raw_area = True
elif image.attr == 'SEARCH':
self.search = image.bbox
self.has_raw_search = True
elif image.attr == 'COLOR':
self.color = image.mean
self.has_raw_color = True
elif image.attr == 'BUTTON':
self.button = image.bbox
self.has_raw_button = True
else:
logger.warning(f'Trying to load an image with unknown attribute: {image}')
@@ -182,19 +192,22 @@ def iter_assets():
# Set `search`
for path, frames in deep_iter(data, depth=3):
print(path, frames)
# If `search` attribute is set in the first frame, apply to all
first = frames[1]
if first.search:
for frame in frames.values():
for frame in frames.values():
# Generate `search` from `area`
if not frame.has_raw_search:
frame.search = DataAssets.area_to_search(frame.area)
# If an attribute is set in the first frame, apply to all
first: DataAssets = frames[1]
for frame in frames.values():
# frame: DataAssets = frame
if not frame.has_raw_area and first.has_raw_area:
frame.area = first.area
if not frame.has_raw_search and first.has_raw_search:
frame.search = first.search
else:
for frame in frames.values():
if frame.search:
# Follow frame specific `search`
pass
else:
# Generate `search` from `area`
frame.search = DataAssets.area_to_search(frame.area)
if not frame.has_raw_color and first.has_raw_color:
frame.color = first.color
if not frame.has_raw_button and first.has_raw_button:
frame.button = first.button
return data
+4 -4
View File
@@ -2,8 +2,8 @@ import os
from datetime import datetime
from PIL import Image
from pynput import keyboard
from module.config.config import AzurLaneConfig
from module.config.utils import alas_instance
from module.device.connection import Connection, ConnectionAttr
@@ -50,12 +50,12 @@ def handle_sensitive_info(image):
_ = EmptyConnection()
name = input(
'输入alas配置文件名称,或者模拟器serial,或者模拟器端口号: (默认输入 "alas"):\n'
'例如:"alas", "127.0.0.1:16384", "7555"\n'
'输入aas配置文件名称,或者模拟器serial,或者模拟器端口号: (默认输入 "aas"):\n'
'例如:"aas", "127.0.0.1:16384", "7555"\n'
)
name = name.strip().strip('"').strip()
if not name:
name = 'alas'
name = 'aas'
if name.isdigit():
name = f'127.0.0.1:{name}'
if name in alas_instance():
+21 -4
View File
@@ -66,9 +66,22 @@ class AzurLaneAutoScript:
logger.exception(e)
exit(1)
def restart(self):
raise NotImplemented
def start(self):
raise NotImplemented
def stop(self):
raise NotImplemented
def goto_main(self):
raise NotImplemented
def run(self, command):
try:
self.device.screenshot()
self.device.screenshot_tracking.clear()
self.__getattribute__(command)()
return True
except TaskEnd:
@@ -143,15 +156,19 @@ class AzurLaneAutoScript:
from module.base.utils import save_image
from module.handler.sensitive_info import (handle_sensitive_image, handle_sensitive_logs)
if self.config.Error_SaveError:
if not os.path.exists('./log/error'):
os.mkdir('./log/error')
folder = f'./log/error/{int(time.time() * 1000)}'
logger.warning(f'Saving error: {folder}')
os.mkdir(folder)
os.makedirs(folder, exist_ok=True)
for data in self.device.screenshot_deque:
image_time = datetime.strftime(data['time'], '%Y-%m-%d_%H-%M-%S-%f')
image = handle_sensitive_image(data['image'])
save_image(image, f'{folder}/{image_time}.png')
if self.device.screenshot_tracking:
os.makedirs(f'{folder}/tracking', exist_ok=True)
for data in self.device.screenshot_tracking:
image_time = datetime.strftime(data['time'], '%Y-%m-%d_%H-%M-%S-%f')
with open(f'{folder}/tracking/{image_time}.png', 'wb') as f:
f.write(data['image'].getvalue())
with open(logger.log_file, 'r', encoding='utf-8') as f:
lines = f.readlines()
start = 0
@@ -210,7 +227,7 @@ class AzurLaneAutoScript:
method = self.config.Optimization_WhenTaskQueueEmpty
if method == 'close_game':
logger.info('Close game during wait')
self.device.app_stop()
self.run('stop')
release_resources()
self.device.release_during_wait()
if not self.wait_until(task.next_run):
+121 -16
View File
@@ -4,7 +4,9 @@ from module.base.timer import Timer
from module.base.utils import *
from module.config.config import AzurLaneConfig
from module.device.device import Device
from module.device.method.utils import HierarchyButton
from module.logger import logger
from module.webui.setting import cached_class_property
class ModuleBase:
@@ -45,6 +47,26 @@ class ModuleBase:
self.interval_timer = {}
@cached_class_property
def worker(self):
"""
A thread pool to run things at background
Examples:
```
def func(image):
logger.info('Update thread start')
with self.config.multi_set():
self.dungeon_get_simuni_point(image)
self.dungeon_update_stamina(image)
ModuleBase.worker.submit(func, self.device.image)
```
"""
logger.hr('Creating worker')
from concurrent.futures import ThreadPoolExecutor
pool = ThreadPoolExecutor(1)
return pool
def match_template(self, button, interval=0, similarity=0.85):
"""
Args:
@@ -120,9 +142,56 @@ class ModuleBase:
return appear
appear = match_template
def xpath(self, xpath) -> HierarchyButton:
if isinstance(xpath, str):
return HierarchyButton(self.device.hierarchy, xpath)
else:
return xpath
def xpath_appear(self, xpath: str, interval=0):
button = self.xpath(xpath)
self.device.stuck_record_add(button)
if interval and not self.interval_is_reached(button, interval=interval):
return False
appear = bool(button)
if appear and interval:
self.interval_reset(button, interval=interval)
return appear
def appear(self, button, interval=0, similarity=0.85):
"""
Args:
button (Button, ButtonWrapper, HierarchyButton, str):
interval (int, float): interval between two active events.
Returns:
bool:
Examples:
Template match:
```
self.device.screenshot()
self.appear(POPUP_CONFIRM)
```
Hierarchy detection (detect elements with xpath):
```
self.device.dump_hierarchy()
self.appear('//*[@resource-id="..."]')
```
"""
if isinstance(button, (HierarchyButton, str)):
return self.xpath_appear(button, interval=interval)
else:
return self.match_template(button, interval=interval, similarity=similarity)
def appear_then_click(self, button, interval=5, similarity=0.85):
button = self.xpath(button)
appear = self.appear(button, interval=interval, similarity=similarity)
if appear:
self.device.click(button)
@@ -219,7 +288,26 @@ class ModuleBase:
point = fit_points(points, mod=image_size(image), encourage=encourage)
point = ensure_int(point + area[:2])
button_area = area_offset((-encourage, -encourage, encourage, encourage), offset=point)
return ClickButton(button=button_area, name=name)
return ClickButton(area=button_area, name=name)
def get_interval_timer(self, button, interval=5, renew=False) -> Timer:
if hasattr(button, 'name'):
name = button.name
elif callable(button):
name = button.__name__
else:
name = str(button)
try:
timer = self.interval_timer[name]
if renew and timer.limit != interval:
timer = Timer(interval)
self.interval_timer[name] = timer
return timer
except KeyError:
timer = Timer(interval)
self.interval_timer[name] = timer
return timer
def interval_reset(self, button, interval=5):
if isinstance(button, (list, tuple)):
@@ -228,10 +316,7 @@ class ModuleBase:
return
if button is not None:
if button.name in self.interval_timer:
self.interval_timer[button.name].reset()
else:
self.interval_timer[button.name] = Timer(interval).reset()
self.get_interval_timer(button, interval=interval).reset()
def interval_clear(self, button, interval=5):
if isinstance(button, (list, tuple)):
@@ -240,19 +325,11 @@ class ModuleBase:
return
if button is not None:
if button.name in self.interval_timer:
self.interval_timer[button.name].clear()
else:
self.interval_timer[button.name] = Timer(interval).clear()
self.get_interval_timer(button, interval=interval).clear()
def interval_is_reached(self, button, interval=5):
if button.name in self.interval_timer:
if self.interval_timer[button.name].limit != interval:
self.interval_timer[button.name] = Timer(interval)
else:
self.interval_timer[button.name] = Timer(interval)
return self.get_interval_timer(button, interval=interval, renew=True).reached()
return self.interval_timer[button.name].reached()
_image_file = ''
@@ -282,3 +359,31 @@ class ModuleBase:
"""
server_.set_lang(lang)
logger.attr('Lang', self.config.LANG)
def screenshot_tracking_add(self):
"""
Add a tracking image, image will be saved
"""
if not self.config.Error_SaveError:
return
logger.info('screenshot_tracking_add')
data = self.device.screenshot_deque[-1]
image = data['image']
now = data['time']
def image_encode(im, ti):
import io
from module.handler.sensitive_info import handle_sensitive_image
output = io.BytesIO()
im = handle_sensitive_image(im)
Image.fromarray(im, mode='RGB').save(output, format='png')
output.seek(0)
self.device.screenshot_tracking.append({
'time': ti,
'image': output
})
ModuleBase.worker.submit(image_encode, image, now)
+28
View File
@@ -306,6 +306,34 @@ class ButtonWrapper(Resource):
for b in self.iter_buttons():
b.search = area
def set_search_offset(self, offset):
"""
Compatible with Alas’ `offset` attribute
In ALAS:
if self.appear(BUTTON, offset=(20, 20)):
pass
In SRC:
BUTTON.set_search_offset((20, 20))
if self.appear(BUTTON):
pass
Note that `search` attribute will be set, and it's irreversible.
Args:
offset (tuple): (x, y) or (left, up, right, bottom)
"""
if len(offset) == 2:
left, up, right, bottom = -offset[0], -offset[1], offset[0], offset[1]
else:
left, up, right, bottom = offset
for b in self.iter_buttons():
upper_left_x, upper_left_y, bottom_right_x, bottom_right_y = b.area
b.search = (
upper_left_x + left,
upper_left_y + up,
bottom_right_x + right,
bottom_right_y + bottom,
)
class ClickButton:
def __init__(self, area, button=None, name='CLICK_BUTTON'):
+55 -9
View File
@@ -33,23 +33,33 @@ class TabWrapper:
self.suffix += suffix
class VariableWrapper:
def __init__(self, name):
self.name = name
def __repr__(self):
return str(self.name)
__str__ = __repr__
class CodeGenerator:
def __init__(self):
self.tab_count = 0
self.lines = []
def generate(self) -> t.Iterable[str]:
yield ''
def add(self, line, comment=False, newline=True):
self.lines.append(self._line_with_tabs(line, comment=comment, newline=newline))
def generate(self) -> t.Iterable[str]:
return ''.join(self.lines)
def print(self):
lines = ''.join(self.lines)
lines = self.generate()
print(lines)
def write(self, file: str = None):
lines = ''.join(self.lines)
lines = self.generate()
with open(file, 'w', encoding='utf-8', newline='') as f:
f.write(lines)
@@ -77,7 +87,10 @@ class CodeGenerator:
return TabWrapper(self)
def Empty(self):
self.add('')
self.lines.append('\n')
def Pass(self):
self.add('pass')
def Import(self, text, empty=2):
for line in text.strip().split('\n'):
@@ -86,6 +99,9 @@ class CodeGenerator:
for _ in range(empty):
self.Empty()
def Variable(self, name):
return VariableWrapper(name)
def Value(self, key=None, value=None, type_=None, **kwargs):
if key is not None:
if type_ is not None:
@@ -117,7 +133,7 @@ class CodeGenerator:
if key is not None:
return TabWrapper(self, prefix=str(key) + ' = [', suffix=']')
else:
return TabWrapper(self, prefix='[', suffix=']', newline=False)
return TabWrapper(self, prefix='[', suffix=']')
def ListItem(self, value):
if isinstance(value, TabWrapper):
@@ -131,7 +147,7 @@ class CodeGenerator:
if key is not None:
return TabWrapper(self, prefix=str(key) + ' = {', suffix='}')
else:
return TabWrapper(self, prefix='{', suffix='}', newline=False)
return TabWrapper(self, prefix='{', suffix='}')
def DictItem(self, key=None, value=None):
if isinstance(value, TabWrapper):
@@ -147,7 +163,7 @@ class CodeGenerator:
if key is not None:
return TabWrapper(self, prefix=f'{key} = {object_class}(', suffix=')')
else:
return TabWrapper(self, prefix=f'{object_class}(', suffix=')', newline=False)
return TabWrapper(self, prefix=f'{object_class}(', suffix=')')
def ObjectAttr(self, key=None, value=None):
if isinstance(value, TabWrapper):
@@ -179,3 +195,33 @@ Value = generator.Value
Comment = generator.Comment
Dict = generator.Dict
DictItem = generator.DictItem
class MarkdownGenerator:
def __init__(self, column: t.List[str]):
self.rows = [column]
def add_row(self, row):
self.rows.append([str(ele) for ele in row])
def product_line(self, row, max_width):
row = [ele.ljust(width) for ele, width in zip(row, max_width)]
row = ' | '.join(row)
row = '| ' + row + ' |'
return row
def generate(self) -> t.List[str]:
import numpy as np
width = np.array([
[len(ele) for ele in row] for row in self.rows
])
max_width = np.max(width, axis=0)
dash = ['-' * width for width in max_width]
rows = [
self.product_line(self.rows[0], max_width),
self.product_line(dash, max_width),
] + [
self.product_line(row, max_width) for row in self.rows[1:]
]
return rows
+6
View File
@@ -78,5 +78,11 @@ def release_resources(next_task=''):
# logger.info(f'Release {obj}')
obj.resource_release()
# If no task, check in-game text language again at next run
# cause user may change it
if not next_task:
from tasks.base.main_page import MainPage
MainPage._lang_checked = False
# Useless in most cases, but just call it
# gc.collect()
+14
View File
@@ -274,6 +274,20 @@ def area_size(area):
)
def area_center(area):
"""
Get the center of an area
Args:
area: (upper_left_x, upper_left_y, bottom_right_x, bottom_right_y)
Returns:
tuple: (x, y)
"""
x1, y1, x2, y2 = area
return (x1 + x2) / 2, (y1 + y2) / 2
def point_limit(point, area):
"""
Limit point in an area.
+2 -1
View File
@@ -41,7 +41,8 @@
"aScreenCap_nc",
"DroidCast",
"DroidCast_raw",
"scrcpy"
"scrcpy",
"nemu_ipc"
],
"display": "hide"
},
+1 -1
View File
@@ -26,7 +26,7 @@ Emulator:
option: [ auto, jp, en, zht ]
ScreenshotMethod:
value: auto
option: [ auto, ADB, ADB_nc, uiautomator2, aScreenCap, aScreenCap_nc, DroidCast, DroidCast_raw, scrcpy ]
option: [ auto, ADB, ADB_nc, uiautomator2, aScreenCap, aScreenCap_nc, DroidCast, DroidCast_raw, scrcpy, nemu_ipc ]
ControlMethod:
value: MaaTouch
option: [ minitouch, MaaTouch ]
+8 -6
View File
@@ -3,8 +3,6 @@ import datetime
import operator
import threading
import pywebio
from module.base.decorator import cached_property, del_cached_property
from module.base.filter import Filter
from module.config.config_generated import GeneratedConfig
@@ -129,13 +127,17 @@ class AzurLaneConfig(ConfigUpdater, ManualConfig, GeneratedConfig, ConfigWatcher
"""
Args:
func (str, Function): Function to run
func_list (set): Set of tasks to be bound
func_list (list[str]): List of tasks to be bound
"""
if func_list is None:
func_list = ["Alas"]
if isinstance(func, Function):
func = func.command
func_list.append(func)
# func_list: ["Alas", <task>, *func_list]
if func_list is None:
func_list = []
if func not in func_list:
func_list.insert(0, func)
if "Alas" not in func_list:
func_list.insert(0, "Alas")
logger.info(f"Bind task {func_list}")
# Bind arguments
+1 -2
View File
@@ -16,10 +16,9 @@ class GeneratedConfig:
Scheduler_ServerUpdate = '04:00'
# Group `Emulator`
Emulator_Serial = 'auto'
Emulator_PackageName = 'auto' # auto, JP-Official, OVERSEA-TWHKMO, OVERSEA-Korea, OVERSEA-Asia, OVERSEA-America, OVERSEA-Global
Emulator_GameLanguage = 'auto' # auto, jp, en, zht
Emulator_ScreenshotMethod = 'auto' # auto, ADB, ADB_nc, uiautomator2, aScreenCap, aScreenCap_nc, DroidCast, DroidCast_raw, scrcpy
Emulator_ScreenshotMethod = 'auto' # auto, ADB, ADB_nc, uiautomator2, aScreenCap, aScreenCap_nc, DroidCast, DroidCast_raw, scrcpy, nemu_ipc
Emulator_ControlMethod = 'MaaTouch' # minitouch, MaaTouch
Emulator_AdbRestart = False
+60
View File
@@ -1,3 +1,4 @@
import typing as t
from copy import deepcopy
from cached_property import cached_property
@@ -569,6 +570,65 @@ class ConfigUpdater:
# set_daily('Use_Consumables_1_time', 'achievable')
# return data
def save_callback(self, key: str, value: t.Any) -> t.Iterable[t.Tuple[str, t.Any]]:
"""
Args:
key: Key path in config json, such as "Main.Emotion.Fleet1Value"
value: Value set by user, such as "98"
Yields:
str: Key path to set config json, such as "Main.Emotion.Fleet1Record"
any: Value to set, such as "2020-01-01 00:00:00"
"""
# if key.startswith('Dungeon.Dungeon') or key.startswith('Dungeon.DungeonDaily'):
# from tasks.dungeon.keywords.dungeon import DungeonList
# from module.exception import ScriptError
# try:
# dungeon = DungeonList.find(value)
# except ScriptError:
# return
# if key.endswith('Name'):
# if dungeon.is_Calyx_Golden:
# yield 'Dungeon.Dungeon.NameAtDoubleCalyx', value
# yield 'Dungeon.DungeonDaily.CalyxGolden', value
# elif dungeon.is_Calyx_Crimson:
# yield 'Dungeon.Dungeon.NameAtDoubleCalyx', value
# yield 'Dungeon.DungeonDaily.CalyxCrimson', value
# elif dungeon.is_Stagnant_Shadow:
# yield 'Dungeon.DungeonDaily.StagnantShadow', value
# elif dungeon.is_Cavern_of_Corrosion:
# yield 'Dungeon.Dungeon.NameAtDoubleRelic', value
# yield 'Dungeon.DungeonDaily.CavernOfCorrosion', value
# elif key.endswith('NameAtDoubleCalyx'):
# if dungeon.is_Calyx_Golden:
# yield 'Dungeon.DungeonDaily.CalyxGolden', value
# elif dungeon.is_Calyx_Crimson:
# yield 'Dungeon.DungeonDaily.CalyxCrimson', value
# elif key.endswith('NameAtDoubleRelic'):
# yield 'Dungeon.DungeonDaily.CavernOfCorrosion', value
# elif key.endswith('CavernOfCorrosion'):
# yield 'Dungeon.Dungeon.NameAtDoubleRelic', value
pass
def iter_hidden_args(self, data) -> t.Iterator[str]:
"""
Args:
data (dict): config
Yields:
str: Arg path that should be hidden
"""
# if deep_get(data, 'Rogue.RoguePath.PresetResonanceFilter') != 'custom':
# yield 'Rogue.RoguePath.CustomResonanceFilter'
pass
def get_hidden_args(self, data) -> t.Set[str]:
"""
Return a set of hidden args
"""
out = list(self.iter_hidden_args(data))
return set(out)
def read_file(self, config_name, is_template=False):
"""
Read and update config file.
+2 -1
View File
@@ -142,7 +142,8 @@
"aScreenCap_nc": "aScreenCap_nc",
"DroidCast": "DroidCast",
"DroidCast_raw": "DroidCast_raw",
"scrcpy": "scrcpy"
"scrcpy": "scrcpy",
"nemu_ipc": "nemu_ipc"
},
"ControlMethod": {
"name": "Control Method",
+2 -1
View File
@@ -142,7 +142,8 @@
"aScreenCap_nc": "aScreenCap_nc",
"DroidCast": "DroidCast",
"DroidCast_raw": "DroidCast_raw",
"scrcpy": "scrcpy"
"scrcpy": "scrcpy",
"nemu_ipc": "nemu_ipc"
},
"ControlMethod": {
"name": "模拟器控制方案",
+3
View File
@@ -157,6 +157,9 @@ class StoredCounter(StoredBase):
def get_remain(self) -> int:
return self.total - self.value
def add(self, value=1):
self.value += value
@cached_property
def _attrs(self) -> dict:
attrs = super()._attrs
+1 -1
View File
@@ -543,7 +543,7 @@ def get_server_last_monday_update(daily_trigger):
Returns:
datetime.datetime
"""
update = get_server_next_update(daily_trigger)
update = get_server_last_update(daily_trigger)
diff = update.weekday()
update = update - timedelta(days=diff)
return update
+8 -5
View File
@@ -49,11 +49,14 @@ class AppControl(Adb, WSA, Uiautomator2):
Returns:
etree._Element: Select elements with `self.hierarchy.xpath('//*[@text="Hermit"]')` for example.
"""
method = self.config.Emulator_ControlMethod
if method in AppControl._app_u2_family:
self.hierarchy = self.dump_hierarchy_uiautomator2()
else:
self.hierarchy = self.dump_hierarchy_adb()
# method = self.config.Emulator_ControlMethod
# if method in AppControl._app_u2_family:
# self.hierarchy = self.dump_hierarchy_uiautomator2()
# else:
# self.hierarchy = self.dump_hierarchy_adb()
# Using uiautomator2
self.hierarchy = self.dump_hierarchy_uiautomator2()
return self.hierarchy
def xpath_to_button(self, xpath: str) -> HierarchyButton:
+138 -27
View File
@@ -1,9 +1,9 @@
import ipaddress
import logging
import platform
import re
import socket
import subprocess
import sys
import time
from functools import wraps
@@ -12,7 +12,8 @@ from adbutils import AdbClient, AdbDevice, AdbTimeout, ForwardItem, ReverseItem
from adbutils.errors import AdbError
import module.config.server as server_
from module.base.decorator import Config, cached_property, del_cached_property
import platform
from module.base.decorator import Config, cached_property, del_cached_property, run_once
from module.base.utils import SelectedGrids, ensure_time
from module.device.connection_attr import ConnectionAttr
from module.device.method.utils import (
@@ -84,6 +85,18 @@ class AdbDeviceWithStatus(AdbDevice):
def __bool__(self):
return True
@cached_property
def port(self) -> int:
try:
return int(self.serial.split(':')[1])
except (IndexError, ValueError):
return 0
@cached_property
def may_mumu12_family(self):
# 127.0.0.1:16XXX
return 16384 <= self.port <= 17408
class Connection(ConnectionAttr):
def __init__(self, config):
@@ -266,14 +279,20 @@ class Connection(ConnectionAttr):
return True
return False
@cached_property
def nemud_app_keep_alive(self) -> str:
res = self.adb_getprop('nemud.app_keep_alive')
logger.attr('nemud.app_keep_alive', res)
return res
@retry
def check_mumu_app_keep_alive(self):
if not self.is_mumu_family:
return False
res = self.adb_getprop('nemud.app_keep_alive')
logger.attr('nemud.app_keep_alive', res)
res = self.nemud_app_keep_alive
if res == '':
# Empry property, might not be a mumu emulator or might be an old mumu
# Empty property, probably MuMu6 or MuMu12 version < 3.5.6
return True
elif res == 'false':
# Disabled
@@ -286,6 +305,15 @@ class Connection(ConnectionAttr):
logger.warning(f'Invalid nemud.app_keep_alive value: {res}')
return False
@cached_property
def is_mumu_over_version_356(self) -> bool:
"""
Returns:
bool: If MuMu12 version >= 3.5.6,
which has nemud.app_keep_alive and always be a vertical device
"""
return self.nemud_app_keep_alive != ''
@cached_property
def _nc_server_host_port(self):
"""
@@ -536,14 +564,14 @@ class Connection(ConnectionAttr):
# Disconnect offline device before connecting
for device in self.list_device():
if device.status == 'offline':
logger.warning(f'Device {serial} is offline, disconnect it before connecting')
self.adb_disconnect(serial)
logger.warning(f'Device {device.serial} is offline, disconnect it before connecting')
self.adb_disconnect(device.serial)
elif device.status == 'unauthorized':
logger.error(f'Device {serial} is unauthorized, please accept ADB debugging on your device')
logger.error(f'Device {device.serial} is unauthorized, please accept ADB debugging on your device')
elif device.status == 'device':
pass
else:
logger.warning(f'Device {serial} is is having a unknown status: {device.status}')
logger.warning(f'Device {device.serial} is is having a unknown status: {device.status}')
# Skip for emulator-5554
if 'emulator-' in serial:
@@ -751,23 +779,45 @@ class Connection(ConnectionAttr):
If serial=='auto' and only 1 device detected, use it
"""
logger.hr('Detect device')
logger.info('Here are the available devices, '
'copy to Alas.Emulator.Serial to use it or set Alas.Emulator.Serial="auto"')
devices = self.list_device()
available = SelectedGrids([])
devices = SelectedGrids([])
# Show available devices
available = devices.select(status='device')
for device in available:
logger.info(device.serial)
if not len(available):
logger.info('No available devices')
@run_once
def brute_force_connect():
logger.info('Brute force connect')
from deploy.Windows.emulator import EmulatorManager
manager = EmulatorManager()
manager.brute_force_connect()
# Show unavailable devices if having any
unavailable = devices.delete(available)
if len(unavailable):
logger.info('Here are the devices detected but unavailable')
for device in unavailable:
logger.info(f'{device.serial} ({device.status})')
for _ in range(2):
logger.info('Here are the available devices, '
'copy to Alas.Emulator.Serial to use it or set Alas.Emulator.Serial="auto"')
devices = self.list_device()
# Show available devices
available = devices.select(status='device')
for device in available:
logger.info(device.serial)
if not len(available):
logger.info('No available devices')
# Show unavailable devices if having any
unavailable = devices.delete(available)
if len(unavailable):
logger.info('Here are the devices detected but unavailable')
for device in unavailable:
logger.info(f'{device.serial} ({device.status})')
# brute_force_connect
if self.config.Emulator_Serial == 'auto' and available.count == 0:
logger.warning(f'No available device found')
if sys.platform == 'win32':
brute_force_connect()
continue
else:
break
else:
break
# Auto device detection
if self.config.Emulator_Serial == 'auto':
@@ -777,7 +827,16 @@ class Connection(ConnectionAttr):
raise RequestHumanTakeover
elif available.count == 1:
logger.info(f'Auto device detection found only one device, using it')
self.serial = devices[0].serial
self.config.Emulator_Serial = self.serial = available[0].serial
del_cached_property(self, 'adb')
elif available.count == 2 \
and available.select(serial='127.0.0.1:7555') \
and available.select(may_mumu12_family=True):
logger.info(f'Auto device detection found MuMu12 device, using it')
# For MuMu12 serials like 127.0.0.1:7555 and 127.0.0.1:16384
# ignore 7555 use 16384
remain = available.select(may_mumu12_family=True).first_or_none()
self.config.Emulator_Serial = self.serial = remain.serial
del_cached_property(self, 'adb')
else:
logger.critical('Multiple devices found, auto device detection cannot decide which to choose, '
@@ -786,6 +845,7 @@ class Connection(ConnectionAttr):
# Handle LDPlayer
# LDPlayer serial jumps between `127.0.0.1:5555+{X}` and `emulator-5554+{X}`
# No config write since it's dynamic
port_serial, emu_serial = get_serial_pair(self.serial)
if port_serial and emu_serial:
# Might be LDPlayer, check connected devices
@@ -812,6 +872,57 @@ class Connection(ConnectionAttr):
f'Using serial: {emu_serial}')
self.serial = emu_serial
# Redirect MuMu12 from 127.0.0.1:7555 to 127.0.0.1:16xxx
if self.serial == '127.0.0.1:7555':
for _ in range(2):
mumu12 = available.select(may_mumu12_family=True)
if mumu12.count == 1:
emu_serial = mumu12.first_or_none().serial
logger.warning(f'Redirect MuMu12 {self.serial} to {emu_serial}')
self.config.Emulator_Serial = self.serial = emu_serial
break
elif mumu12.count >= 2:
logger.warning(f'Multiple MuMu12 serial found, cannot redirect')
break
else:
# Only 127.0.0.1:7555
if self.is_mumu_over_version_356:
# is_mumu_over_version_356 and nemud_app_keep_alive was cached
# Acceptable since it's the same device
logger.warning(f'Device {self.serial} is MuMu12 but corresponding port not found')
brute_force_connect()
devices = self.list_device()
# Show available devices
available = devices.select(status='device')
for device in available:
logger.info(device.serial)
if not len(available):
logger.info('No available devices')
continue
else:
# MuMu6
break
# MuMu12 uses 127.0.0.1:16385 if port 16384 is occupied, auto redirect
# No config write since it's dynamic
if self.is_mumu12_family:
matched = False
for device in available.select(may_mumu12_family=True):
if device.port == self.port:
# Exact match
matched = True
break
if not matched:
for device in available.select(may_mumu12_family=True):
if -2 <= device.port - self.port <= 2:
# Port switched
logger.info(f'MuMu12 port switches from {self.serial} to {device.serial}')
del_cached_property(self, 'port')
del_cached_property(self, 'is_mumu12_family')
del_cached_property(self, 'is_mumu_family')
self.serial = device.serial
break
@retry
def list_package(self, show_log=True):
"""
@@ -833,7 +944,7 @@ class Connection(ConnectionAttr):
packages = re.findall(r'package:([^\s]+)', output)
return packages
def list_azurlane_packages(self, show_log=True):
def list_known_packages(self, show_log=True):
"""
Args:
show_log:
@@ -850,7 +961,7 @@ class Connection(ConnectionAttr):
Show all possible packages with the given keyword on this device.
"""
logger.hr('Detect package')
packages = self.list_azurlane_packages()
packages = self.list_known_packages()
# Show packages
logger.info(f'Here are the available packages in device "{self.serial}", '
+19 -3
View File
@@ -7,7 +7,6 @@ from adbutils import AdbClient, AdbDevice
from module.base.decorator import cached_property
from module.config.config import AzurLaneConfig
from module.config.utils import deep_iter
from module.exception import RequestHumanTakeover
from module.logger import logger
@@ -123,11 +122,27 @@ class ConnectionAttr:
def is_wsa(self):
return bool(re.match(r'^wsa', self.serial))
@cached_property
def port(self) -> int:
try:
return int(self.serial.split(':')[1])
except (IndexError, ValueError):
return 0
@cached_property
def is_mumu12_family(self):
# 127.0.0.1:16XXX
return 16384 <= self.port <= 17408
@cached_property
def is_mumu_family(self):
# 127.0.0.1:7555
# 127.0.0.1:16384 + 32*n
return self.serial == '127.0.0.1:7555' or self.serial.startswith('127.0.0.1:16')
return self.serial == '127.0.0.1:7555' or self.is_mumu12_family
@cached_property
def is_nox_family(self):
return 62001 <= self.port <= 63025
@cached_property
def is_emulator(self):
@@ -173,7 +188,8 @@ class ConnectionAttr:
rf"SOFTWARE\BlueStacks_bgp64_hyperv\Guests\{folder_name}\Config") as key:
port = QueryValueEx(key, "BstAdbPort")[0]
except FileNotFoundError:
logger.error(rf'Unable to find registry HKEY_LOCAL_MACHINE\SOFTWARE\BlueStacks_bgp64_hyperv\Guests\{folder_name}\Config')
logger.error(
rf'Unable to find registry HKEY_LOCAL_MACHINE\SOFTWARE\BlueStacks_bgp64_hyperv\Guests\{folder_name}\Config')
logger.error('Please confirm that your are using BlueStack 4 hyper-v and not regular BlueStacks 4')
logger.error(r'Please check if there is any other emulator instances under '
r'registry HKEY_LOCAL_MACHINE\SOFTWARE\BlueStacks_bgp64_hyperv\Guests')
+12 -10
View File
@@ -5,13 +5,12 @@ from module.base.utils import *
from module.device.method.hermit import Hermit
from module.device.method.maatouch import MaaTouch
from module.device.method.minitouch import Minitouch
from module.device.method.nemu_ipc import NemuIpc
from module.device.method.scrcpy import Scrcpy
from module.logger import logger
import numpy as np
class Control(Hermit, Minitouch, Scrcpy, MaaTouch):
class Control(Hermit, Minitouch, Scrcpy, MaaTouch, NemuIpc):
def handle_control_check(self, button):
# Will be overridden in Device
pass
@@ -24,6 +23,7 @@ class Control(Hermit, Minitouch, Scrcpy, MaaTouch):
'minitouch': self.click_minitouch,
'Hermit': self.click_hermit,
'MaaTouch': self.click_maatouch,
'nemu_ipc': self.click_nemu_ipc,
}
def click(self, button, control_check=True):
@@ -80,6 +80,8 @@ class Control(Hermit, Minitouch, Scrcpy, MaaTouch):
self.long_click_scrcpy(x, y, duration)
elif method == 'MaaTouch':
self.long_click_maatouch(x, y, duration)
elif method == 'nemu_ipc':
self.long_click_nemu_ipc(x, y, duration)
else:
self.swipe_adb((x, y), (x, y), duration)
@@ -88,13 +90,9 @@ class Control(Hermit, Minitouch, Scrcpy, MaaTouch):
p1, p2 = ensure_int(p1, p2)
duration = ensure_time(duration)
method = self.config.Emulator_ControlMethod
if method == 'minitouch':
logger.info('Swipe %s -> %s' % (point2str(*p1), point2str(*p2)))
elif method == 'uiautomator2':
if method == 'uiautomator2':
logger.info('Swipe %s -> %s, %s' % (point2str(*p1), point2str(*p2), duration))
elif method == 'scrcpy':
logger.info('Swipe %s -> %s' % (point2str(*p1), point2str(*p2)))
elif method == 'MaaTouch':
elif method in ['minitouch', 'MaaTouch', 'scrcpy', 'nemu_ipc']:
logger.info('Swipe %s -> %s' % (point2str(*p1), point2str(*p2)))
else:
# ADB needs to be slow, or swipe doesn't work
@@ -116,6 +114,8 @@ class Control(Hermit, Minitouch, Scrcpy, MaaTouch):
self.swipe_scrcpy(p1, p2)
elif method == 'MaaTouch':
self.swipe_maatouch(p1, p2)
elif method == 'nemu_ipc':
self.swipe_nemu_ipc(p1, p2)
else:
self.swipe_adb(p1, p2, duration=duration)
@@ -165,11 +165,13 @@ class Control(Hermit, Minitouch, Scrcpy, MaaTouch):
self.drag_scrcpy(p1, p2, point_random=point_random)
elif method == 'MaaTouch':
self.drag_maatouch(p1, p2, point_random=point_random)
elif method == 'nemu_ipc':
self.drag_nemu_ipc(p1, p2, point_random=point_random)
else:
logger.warning(f'Control method {method} does not support drag well, '
f'falling back to ADB swipe may cause unexpected behaviour')
self.swipe_adb(p1, p2, duration=ensure_time(swipe_duration * 2))
self.click(ClickButton(button=area_offset(point_random, p2), name=name))
self.click(ClickButton(area=area_offset(point_random, p2), name=name))
# just used in cafe
def pinch(self, box=(35, 130, 1250, 560), name='PINCH'):
+75 -9
View File
@@ -1,5 +1,4 @@
import sys
from collections import deque
import collections
from module.base.timer import Timer
from module.device.app_control import AppControl
@@ -14,16 +13,51 @@ from module.exception import (
)
from module.logger import logger
if sys.platform == 'win32':
from module.device.platform.platform_windows import PlatformWindows as Platform
else:
from module.device.platform.platform_base import PlatformBase as Platform
def show_function_call():
"""
INFO 21:07:31.554 │ Function calls:
<string> L1 <module>
spawn.py L116 spawn_main()
spawn.py L129 _main()
process.py L314 _bootstrap()
process.py L108 run()
process_manager.py L149 run_process()
alas.py L285 loop()
alas.py L69 run()
src.py L55 rogue()
rogue.py L36 run()
rogue.py L18 rogue_once()
entry.py L335 rogue_world_enter()
path.py L193 rogue_path_select()
"""
import os
import traceback
stack = traceback.extract_stack()
func_list = []
for row in stack:
filename, line_number, function_name, _ = row
filename = os.path.basename(filename)
# /tasks/character/switch.py:64 character_update()
func_list.append([filename, str(line_number), function_name])
max_filename = max([len(row[0]) for row in func_list])
max_linenum = max([len(row[1]) for row in func_list]) + 1
def format_(file, line, func):
file = file.rjust(max_filename, " ")
line = f'L{line}'.rjust(max_linenum, " ")
if not func.startswith('<'):
func = f'{func}()'
return f'{file} {line} {func}'
func_list = [f'\n{format_(*row)}' for row in func_list]
logger.info('Function calls:' + ''.join(func_list))
class Device(Screenshot, Control, AppControl, Platform):
class Device(Screenshot, Control, AppControl):
_screen_size_checked = False
detect_record = set()
click_record = deque(maxlen=15)
click_record = collections.deque(maxlen=15)
stuck_timer = Timer(60, count=60).start()
def __init__(self, *args, **kwargs):
@@ -42,12 +76,23 @@ class Device(Screenshot, Control, AppControl, Platform):
)
raise
# Auto-fill emulator info
if self.config.EmulatorInfo_Emulator == 'auto':
_ = self.emulator_instance
self.screenshot_interval_set()
self.method_check()
# Auto-select the fastest screenshot method
if not self.config.is_template_config and self.config.Emulator_ScreenshotMethod == 'auto':
self.run_simple_screenshot_benchmark()
# AAS only, use nemu_ipc if available
available = self.nemu_ipc_available()
logger.attr('nemu_ipc_available', available)
if available:
self.config.override(Emulator_ScreenshotMethod='nemu_ipc')
def run_simple_screenshot_benchmark(self):
"""
Perform a screenshot method benchmark, test 3 times on each method.
@@ -61,7 +106,23 @@ class Device(Screenshot, Control, AppControl, Platform):
bench = Benchmark(config=self.config, device=self)
method = bench.run_simple_screenshot_benchmark()
# Set
self.config.Emulator_ScreenshotMethod = method
with self.config.multi_set():
self.config.Emulator_ScreenshotMethod = method
# if method == 'nemu_ipc':
# self.config.Emulator_ControlMethod = 'nemu_ipc'
def method_check(self):
"""
Check combinations of screenshot method and control methods
"""
# nemu_ipc should be together
# if self.config.Emulator_ScreenshotMethod == 'nemu_ipc' and self.config.Emulator_ControlMethod != 'nemu_ipc':
# logger.warning('When using nemu_ipc, both screenshot and control should use nemu_ipc')
# self.config.Emulator_ControlMethod = 'nemu_ipc'
# if self.config.Emulator_ScreenshotMethod != 'nemu_ipc' and self.config.Emulator_ControlMethod == 'nemu_ipc':
# logger.warning('When not using nemu_ipc, both screenshot and control should not use nemu_ipc')
# self.config.Emulator_ControlMethod = 'minitouch'
pass
def screenshot(self):
"""
@@ -87,6 +148,8 @@ class Device(Screenshot, Control, AppControl, Platform):
# stop it during wait
if self.config.Emulator_ScreenshotMethod == 'scrcpy':
self._scrcpy_server_stop()
if self.config.Emulator_ScreenshotMethod == 'nemu_ipc':
self.nemu_ipc_release()
def stuck_record_add(self, button):
self.detect_record.add(str(button))
@@ -104,6 +167,7 @@ class Device(Screenshot, Control, AppControl, Platform):
if not reached:
return False
show_function_call()
logger.warning('Wait too long')
logger.warning(f'Waiting for {self.detect_record}')
self.stuck_record_clear()
@@ -155,11 +219,13 @@ class Device(Screenshot, Control, AppControl, Platform):
count[key] = count.get(key, 0) + 1
count = sorted(count.items(), key=lambda item: item[1])
if count[0][1] >= 12:
show_function_call()
logger.warning(f'Too many click for a button: {count[0][0]}')
logger.warning(f'History click: {[str(prev) for prev in self.click_record]}')
self.click_record_clear()
raise GameTooManyClickError(f'Too many click for a button: {count[0][0]}')
if len(count) >= 2 and count[0][1] >= 6 and count[1][1] >= 6:
show_function_call()
logger.warning(f'Too many click between 2 buttons: {count[0][0]}, {count[1][0]}')
logger.warning(f'History click: {[str(prev) for prev in self.click_record]}')
self.click_record_clear()
+4 -4
View File
@@ -1,16 +1,16 @@
import re
import time
from functools import wraps
import cv2
import numpy as np
import time
from adbutils.errors import AdbError
from lxml import etree
from module.base.decorator import Config
from module.device.connection import Connection
from module.device.method.utils import (RETRY_TRIES, retry_sleep, remove_prefix, handle_adb_error,
ImageTruncated, PackageNotInstalled)
from module.device.method.utils import (ImageTruncated, PackageNotInstalled, RETRY_TRIES, handle_adb_error,
remove_prefix, retry_sleep)
from module.exception import RequestHumanTakeover, ScriptError
from module.logger import logger
@@ -128,7 +128,7 @@ class Adb(Connection):
if image is None:
raise ImageTruncated('Empty image after cv2.imdecode')
image = cv2.cvtColor(image, cv2.COLOR_BGR2RGB)
cv2.cvtColor(image, cv2.COLOR_BGR2RGB, dst=image)
if image is None:
raise ImageTruncated('Empty image after cv2.cvtColor')
+3 -4
View File
@@ -6,8 +6,7 @@ from adbutils.errors import AdbError
from module.base.utils import *
from module.device.connection import Connection
from module.device.method.utils import (RETRY_TRIES, retry_sleep,
handle_adb_error, ImageTruncated)
from module.device.method.utils import (ImageTruncated, RETRY_TRIES, handle_adb_error, retry_sleep)
from module.exception import RequestHumanTakeover, ScriptError
from module.logger import logger
@@ -165,11 +164,11 @@ class AScreenCap(Connection):
# ValueError: cannot reshape array of size 0 into shape (720,1280,4)
raise ImageTruncated(str(e))
image = cv2.flip(image, 0)
image = cv2.flip(image, 0, dst=image)
if image is None:
raise ImageTruncated('Empty image after cv2.flip')
image = cv2.cvtColor(image, cv2.COLOR_BGR2RGB)
image = cv2.cvtColor(image, cv2.COLOR_BGR2RGB, dst=image)
if image is None:
raise ImageTruncated('Empty image after cv2.cvtColor')
+117 -67
View File
@@ -6,11 +6,11 @@ import numpy as np
import requests
from adbutils.errors import AdbError
from module.base.decorator import Config, cached_property, del_cached_property
from module.base.decorator import cached_property, del_cached_property
from module.base.timer import Timer
from module.device.method.uiautomator_2 import Uiautomator2, ProcessInfo
from module.device.method.utils import (retry_sleep, RETRY_TRIES, handle_adb_error,
ImageTruncated, PackageNotInstalled)
from module.device.method.uiautomator_2 import ProcessInfo, Uiautomator2
from module.device.method.utils import (
ImageTruncated, PackageNotInstalled, RETRY_TRIES, handle_adb_error, retry_sleep)
from module.exception import RequestHumanTakeover
from module.logger import logger
@@ -91,10 +91,12 @@ def retry(func):
class DroidCast(Uiautomator2):
"""
DroidCast, another screenshot method, https://github.com/rayworks/DroidCast
DroidCast_raw, a modified version of DroidCast sending raw bitmap https://github.com/Torther/DroidCastS
DroidCast_raw, a modified version of DroidCast sending raw bitmap and png, https://github.com/Torther/DroidCastS
"""
_droidcast_port: int = 0
droidcast_width: int = 0
droidcast_height: int = 0
@cached_property
def droidcast_session(self):
@@ -103,106 +105,137 @@ class DroidCast(Uiautomator2):
self._droidcast_port = self.adb_forward('tcp:53516')
return session
def droidcast_url(self, url='/screenshot?format=png'):
"""
Check APIs from source code:
https://github.com/rayworks/DroidCast/blob/master/app/src/main/java/com/rayworks/droidcast/Main.java
"""
Check APIs from source code:
https://github.com/Torther/DroidCast_raw/blob/DroidCast_raw/app/src/main/java/ink/mol/droidcast_raw/KtMain.kt
Available APIs:
- /screenshot
To get a RGB565 bitmap
- /preview
To get PNG screenshots.
"""
Available APIs:
- /screenshot
To get JPG screenshots.
- /screenshot?format=png
To get PNG screenshots.
- /screenshot?format=webp
To get WEBP screenshots.
- /src
Websocket to get JPG screenshots.
def droidcast_url(self, url='/preview'):
if self.is_mumu_over_version_356:
w, h = self.droidcast_width, self.droidcast_height
if self.orientation == 0:
return f'http://127.0.0.1:{self._droidcast_port}{url}?width={w}&height={h}'
elif self.orientation == 1:
return f'http://127.0.0.1:{self._droidcast_port}{url}?width={h}&height={w}'
else:
# logger.warning('DroidCast receives invalid device orientation')
pass
return f'http://127.0.0.1:{self._droidcast_port}{url}'
def droidcast_raw_url(self, url='/screenshot'):
if self.is_mumu_over_version_356:
w, h = self.droidcast_width, self.droidcast_height
if self.orientation == 0:
return f'http://127.0.0.1:{self._droidcast_port}{url}?width={w}&height={h}'
elif self.orientation == 1:
return f'http://127.0.0.1:{self._droidcast_port}{url}?width={h}&height={w}'
else:
# logger.warning('DroidCast receives invalid device orientation')
pass
Note that /screenshot?format=jpg is unavailable.
"""
return f'http://127.0.0.1:{self._droidcast_port}{url}'
@Config.when(DROIDCAST_VERSION='DroidCast')
def droidcast_init(self):
logger.hr('Droidcast init')
logger.hr('DroidCast init')
self.droidcast_stop()
self._droidcast_update_resolution()
logger.info('Pushing DroidCast apk')
self.adb_push(self.config.DROIDCAST_FILEPATH_LOCAL, self.config.DROIDCAST_FILEPATH_REMOTE)
logger.info('Starting DroidCast apk')
# CLASSPATH=/data/local/tmp/DroidCast.apk app_process / com.rayworks.droidcast.Main > /dev/null
# DroidCast_raw-release-1.0.apk
# CLASSPATH=/data/local/tmp/DroidCast_raw.apk app_process / ink.mol.droidcast_raw.Main > /dev/null
# adb shell CLASSPATH=/data/local/tmp/DroidCast_raw.apk app_process / ink.mol.droidcast_raw.Main
resp = self.u2_shell_background([
'CLASSPATH=/data/local/tmp/DroidCast.apk',
'CLASSPATH=/data/local/tmp/DroidCast_raw.apk',
'app_process',
'/',
'com.rayworks.droidcast.Main',
'ink.mol.droidcast_raw.Main',
'>',
'/dev/null'
])
logger.info(resp)
del_cached_property(self, 'droidcast_session')
_ = self.droidcast_session
logger.attr('DroidCast', self.droidcast_url())
self.droidcast_wait_startup()
@Config.when(DROIDCAST_VERSION='DroidCast_raw')
def droidcast_init(self):
logger.hr('Droidcast init')
self.resolution_check_uiautomator2()
self.droidcast_stop()
if self.config.DROIDCAST_VERSION == 'DroidCast':
logger.attr('DroidCast', self.droidcast_url())
self.droidcast_wait_startup()
elif self.config.DROIDCAST_VERSION == 'DroidCast_raw':
logger.attr('DroidCast_raw', self.droidcast_raw_url())
self.droidcast_wait_startup()
else:
logger.error(f'Unknown DROIDCAST_VERSION: {self.config.DROIDCAST_VERSION}')
logger.info('Pushing DroidCast apk')
self.adb_push(self.config.DROIDCAST_RAW_FILEPATH_LOCAL, self.config.DROIDCAST_RAW_FILEPATH_REMOTE)
logger.info('Starting DroidCast apk')
# DroidCastS-release-1.1.5.apk
# CLASSPATH=/data/local/tmp/DroidCastS-release-1.1.5.apk app_process / com.torther.droidcasts.Main > /dev/null
resp = self.u2_shell_background([
'CLASSPATH=/data/local/tmp/DroidCastS.apk',
'app_process',
'/',
'com.torther.droidcasts.Main',
'>',
'/dev/null'
])
logger.info(resp)
del_cached_property(self, 'droidcast_session')
_ = self.droidcast_session
logger.attr('DroidCast', self.droidcast_url())
self.droidcast_wait_startup()
def _droidcast_update_resolution(self):
if self.is_mumu_over_version_356:
logger.info('Update droidcast resolution')
w, h = self.resolution_uiautomator2(cal_rotation=False)
self.get_orientation()
# 720, 1280
# mumu12 > 3.5.6 is always a vertical device
self.droidcast_width, self.droidcast_height = w, h
logger.info(f'Droicast resolution: {(w, h)}')
@retry
def screenshot_droidcast(self):
self.config.DROIDCAST_VERSION = 'DroidCast'
image = self.droidcast_session.get(self.droidcast_url(), timeout=3).content
if self.is_mumu_over_version_356:
if not self.droidcast_width or not self.droidcast_height:
self._droidcast_update_resolution()
resp = self.droidcast_session.get(self.droidcast_url(), timeout=3)
if resp.status_code == 404:
raise DroidCastVersionIncompatible('DroidCast server does not have /preview')
image = resp.content
image = np.frombuffer(image, np.uint8)
if image is None:
raise ImageTruncated('Empty image after reading from buffer')
if image.shape == (1843200,):
raise DroidCastVersionIncompatible('Requesting screenshots from `DroidCast` but server is `DroidCast_raw`')
if image.size < 500:
logger.warning(f'Unexpected screenshot: {resp.content}')
image = cv2.imdecode(image, cv2.IMREAD_COLOR)
if image is None:
raise ImageTruncated('Empty image after cv2.imdecode')
image = cv2.cvtColor(image, cv2.COLOR_BGR2RGB)
cv2.cvtColor(image, cv2.COLOR_BGR2RGB, dst=image)
if image is None:
raise ImageTruncated('Empty image after cv2.cvtColor')
if self.is_mumu_over_version_356:
if self.orientation == 1:
image = cv2.rotate(image, cv2.ROTATE_90_CLOCKWISE)
return image
@retry
def screenshot_droidcast_raw(self):
self.config.DROIDCAST_VERSION = 'DroidCast_raw'
image = self.droidcast_session.get(self.droidcast_url(), timeout=3).content
shape = (720, 1280)
if self.is_mumu_over_version_356:
if not self.droidcast_width or not self.droidcast_height:
self._droidcast_update_resolution()
if self.droidcast_height and self.droidcast_width:
shape = (self.droidcast_height, self.droidcast_width)
image = self.droidcast_session.get(self.droidcast_raw_url(), timeout=3).content
# DroidCast_raw returns a RGB565 bitmap
try:
arr = np.frombuffer(image, dtype=np.uint16).reshape((720, 1280))
arr = np.frombuffer(image, dtype=np.uint16).reshape(shape)
except ValueError as e:
if len(image) < 500:
logger.warning(f'Unexpected screenshot: {image}')
# Try to load as `DroidCast`
image = np.frombuffer(image, np.uint8)
if image is not None:
@@ -228,14 +261,30 @@ class DroidCast(Uiautomator2):
# image = cv2.merge([r, g, b])
# The same as the code above but costs about 5ms instead of 10ms.
r = cv2.multiply(arr & 0b1111100000000000, 0.00390625).astype(np.uint8)
g = cv2.multiply(arr & 0b0000011111100000, 0.125).astype(np.uint8)
b = cv2.multiply(arr & 0b0000000000011111, 8).astype(np.uint8)
r = cv2.add(r, cv2.multiply(r, 0.03125))
g = cv2.add(g, cv2.multiply(g, 0.015625))
b = cv2.add(b, cv2.multiply(b, 0.03125))
r = cv2.bitwise_and(arr, 0b1111100000000000)
cv2.multiply(r, 0.00390625, dst=r)
r = np.uint8(r)
m = cv2.multiply(r, 0.03125)
cv2.add(r, m, dst=r)
g = cv2.bitwise_and(arr, 0b0000011111100000)
cv2.multiply(g, 0.125, dst=g)
g = np.uint8(g)
m = cv2.multiply(g, 0.015625)
cv2.add(g, m, dst=g)
b = cv2.bitwise_and(arr, 0b0000000000011111)
cv2.multiply(b, 8, dst=b)
b = np.uint8(b)
m = cv2.multiply(b, 0.03125)
cv2.add(b, m, dst=b)
image = cv2.merge([r, g, b])
if self.is_mumu_over_version_356:
if self.orientation == 1:
image = cv2.rotate(image, cv2.ROTATE_90_CLOCKWISE)
return image
def droidcast_wait_startup(self):
@@ -262,13 +311,12 @@ class DroidCast(Uiautomator2):
def droidcast_uninstall(self):
"""
Stop all DroidCast processes and remove DroidCast APK.
DroidCast has't been installed but a JAVA class call, uninstall is a file delete.
Stop DroidCast processes and remove DroidCast APK.
DroidCast hasn't been installed but a JAVA class call, uninstall is a file delete.
"""
self.droidcast_stop()
logger.info('Removing DroidCast')
self.adb_shell(["rm", self.config.DROIDCAST_FILEPATH_REMOTE])
self.adb_shell(["rm", self.config.DROIDCAST_RAW_FILEPATH_REMOTE])
def _iter_droidcast_proc(self) -> t.Iterable[ProcessInfo]:
"""
@@ -280,10 +328,12 @@ class DroidCast(Uiautomator2):
yield proc
if 'com.torther.droidcasts.Main' in proc.cmdline:
yield proc
if 'ink.mol.droidcast_raw.Main' in proc.cmdline:
yield proc
def droidcast_stop(self):
"""
Stop all DroidCast processes.
Stop DroidCast processes.
"""
logger.info('Stopping DroidCast')
for proc in self._iter_droidcast_proc():
+541
View File
@@ -0,0 +1,541 @@
import asyncio
import ctypes
import os
import sys
from functools import partial, wraps
import cv2
import numpy as np
from module.base.decorator import cached_property, del_cached_property, has_cached_property
from module.base.utils import ensure_time
from module.device.method.minitouch import insert_swipe, random_rectangle_point
from module.device.method.utils import RETRY_TRIES, retry_sleep
from module.device.platform import Platform
from module.exception import RequestHumanTakeover
from module.logger import logger
class NemuIpcIncompatible(Exception):
pass
class NemuIpcError(Exception):
pass
class CaptureStd:
"""
Capture stdout and stderr from both python and C library
https://stackoverflow.com/questions/5081657/how-do-i-prevent-a-c-shared-library-to-print-on-stdout-in-python/17954769
```
with CaptureStd() as capture:
# String wasn't printed
print('whatever')
# But captured in ``capture.stdout``
print(f'Got stdout: "{capture.stdout}"')
print(f'Got stderr: "{capture.stderr}"')
```
"""
def __init__(self):
self.stdout = b''
self.stderr = b''
def _redirect_stdout(self, to):
sys.stdout.close()
os.dup2(to, self.fdout)
sys.stdout = os.fdopen(self.fdout, 'w')
def _redirect_stderr(self, to):
sys.stderr.close()
os.dup2(to, self.fderr)
sys.stderr = os.fdopen(self.fderr, 'w')
def __enter__(self):
self.fdout = sys.stdout.fileno()
self.fderr = sys.stderr.fileno()
self.reader_out, self.writer_out = os.pipe()
self.reader_err, self.writer_err = os.pipe()
self.old_stdout = os.dup(self.fdout)
self.old_stderr = os.dup(self.fderr)
file_out = os.fdopen(self.writer_out, 'w')
file_err = os.fdopen(self.writer_err, 'w')
self._redirect_stdout(to=file_out.fileno())
self._redirect_stderr(to=file_err.fileno())
return self
def __exit__(self, exc_type, exc_val, exc_tb):
self._redirect_stdout(to=self.old_stdout)
self._redirect_stderr(to=self.old_stderr)
os.close(self.old_stdout)
os.close(self.old_stderr)
self.stdout = self.recvall(self.reader_out)
self.stderr = self.recvall(self.reader_err)
os.close(self.reader_out)
os.close(self.reader_err)
@staticmethod
def recvall(reader, length=1024) -> bytes:
fragments = []
while 1:
chunk = os.read(reader, length)
if chunk:
fragments.append(chunk)
else:
break
output = b''.join(fragments)
return output
class CaptureNemuIpc(CaptureStd):
instance = None
def is_capturing(self):
"""
Only capture at the topmost wrapper to avoid nested capturing
If a capture is ongoing, this instance does nothing
"""
cls = self.__class__
return isinstance(cls.instance, cls) and cls.instance != self
def __enter__(self):
if self.is_capturing():
return self
super().__enter__()
CaptureNemuIpc.instance = self
return self
def __exit__(self, exc_type, exc_val, exc_tb):
if self.is_capturing():
return
CaptureNemuIpc.instance = None
super().__exit__(exc_type, exc_val, exc_tb)
self.check_stdout()
self.check_stderr()
def check_stdout(self):
if not self.stdout:
return
logger.info(f'NemuIpc stdout: {self.stdout}')
def check_stderr(self):
if not self.stderr:
return
logger.error(f'NemuIpc stderr: {self.stderr}')
# Calling an old MuMu12 player
# Tested on 3.4.0
# b'nemu_capture_display rpc error: 1783\r\n'
# Tested on 3.7.3
# b'nemu_capture_display rpc error: 1745\r\n'
if b'error: 1783' in self.stderr or b'error: 1745' in self.stderr:
raise NemuIpcIncompatible(
f'NemuIpc requires MuMu12 version >= 3.8.13, please check your version')
# contact_id incorrect
# b'nemu_capture_display cannot find rpc connection\r\n'
if b'cannot find rpc connection' in self.stderr:
raise NemuIpcError(self.stderr)
# Emulator died
# b'nemu_capture_display rpc error: 1722\r\n'
# MuMuVMMSVC.exe died
# b'nemu_capture_display rpc error: 1726\r\n'
# No idea how to handle yet
if b'error: 1722' in self.stderr or b'error: 1726' in self.stderr:
raise NemuIpcError('Emulator instance is probably dead')
def retry(func):
@wraps(func)
def retry_wrapper(self, *args, **kwargs):
"""
Args:
self (NemuIpcImpl):
"""
init = None
for _ in range(RETRY_TRIES):
try:
if callable(init):
retry_sleep(_)
init()
return func(self, *args, **kwargs)
# Can't handle
except RequestHumanTakeover:
break
# Can't handle
except NemuIpcIncompatible as e:
logger.error(e)
break
# Function call timeout
except asyncio.TimeoutError:
logger.warning(f'Func {func.__name__}() call timeout, retrying: {_}')
def init():
self.reconnect()
# NemuIpcError
except NemuIpcError as e:
logger.error(e)
def init():
self.reconnect()
# Unknown, probably a trucked image
except Exception as e:
logger.exception(e)
def init():
pass
logger.critical(f'Retry {func.__name__}() failed')
raise RequestHumanTakeover
return retry_wrapper
class NemuIpcImpl:
def __init__(self, nemu_folder: str, instance_id: int, display_id: int = 0):
"""
Args:
nemu_folder: Installation path of MuMu12, e.g. E:/ProgramFiles/MuMuPlayer-12.0
instance_id: Emulator instance ID, starting from 0
display_id: Always 0 if keep app alive was disabled
"""
self.nemu_folder: str = nemu_folder
self.instance_id: int = instance_id
self.display_id: int = display_id
ipc_dll = os.path.abspath(os.path.join(nemu_folder, './shell/sdk/external_renderer_ipc.dll'))
logger.info(
f'NemuIpcImpl init, '
f'nemu_folder={nemu_folder}, '
f'ipc_dll={ipc_dll}, '
f'instance_id={instance_id}, '
f'display_id={display_id}'
)
try:
self.lib = ctypes.CDLL(ipc_dll)
except OSError as e:
logger.error(e)
# OSError: [WinError 126] 找不到指定的模块。
if not os.path.exists(ipc_dll):
raise NemuIpcIncompatible(
f'ipc_dll={ipc_dll} does not exist, '
f'NemuIpc requires MuMu12 version >= 3.8.13, please check your version')
else:
raise NemuIpcIncompatible(
f'ipc_dll={ipc_dll} exists, but cannot be loaded')
self.connect_id: int = 0
self.width = 0
self.height = 0
def connect(self):
if self.connect_id > 0:
return
connect_id = self.ev_run_sync(
self.lib.nemu_connect,
self.nemu_folder, self.instance_id
)
if connect_id == 0:
raise NemuIpcError(
'Connection failed, please check if nemu_folder is correct and emulator is running'
)
self.connect_id = connect_id
# logger.info(f'NemuIpc connected: {self.connect_id}')
def disconnect(self):
if self.connect_id == 0:
return
self.ev_run_sync(
self.lib.nemu_disconnect,
self.connect_id
)
# logger.info(f'NemuIpc disconnected: {self.connect_id}')
self.connect_id = 0
def reconnect(self):
self.disconnect()
self.connect()
def __enter__(self):
self.connect()
return self
def __exit__(self, exc_type, exc_val, exc_tb):
self.disconnect()
@cached_property
def _ev(self):
return asyncio.new_event_loop()
async def ev_run_async(self, func, *args, **kwargs):
"""
Args:
func: Sync function to call
*args:
**kwargs:
Raises:
asyncio.TimeoutError: If function call timeout
"""
func_wrapped = partial(func, *args, **kwargs)
# Increased timeout for slow PCs
# Default screenshot interval is 0.2s, so a 0.15s timeout would have a fast retry without extra time costs
result = await asyncio.wait_for(self._ev.run_in_executor(None, func_wrapped), timeout=0.15)
return result
def ev_run_sync(self, func, *args, **kwargs):
"""
Args:
func: Sync function to call
*args:
**kwargs:
Raises:
asyncio.TimeoutError: If function call timeout
NemuIpcIncompatible:
NemuIpcError
"""
result = self._ev.run_until_complete(self.ev_run_async(func, *args, **kwargs))
err = False
if func.__name__ == 'nemu_connect':
if result == 0:
err = True
else:
if result > 0:
err = True
# Get to actual error message printed in std
if err:
logger.warning(f'Failed to call {func.__name__}, result={result}')
with CaptureNemuIpc():
result = self._ev.run_until_complete(self.ev_run_async(func, *args, **kwargs))
return result
def get_resolution(self):
"""
Get emulator resolution, `self.width` and `self.height` will be set
"""
if self.connect_id == 0:
self.connect()
width_ptr = ctypes.pointer(ctypes.c_int(0))
height_ptr = ctypes.pointer(ctypes.c_int(0))
nullptr = ctypes.POINTER(ctypes.c_int)()
ret = self.ev_run_sync(
self.lib.nemu_capture_display,
self.connect_id, self.display_id, 0, width_ptr, height_ptr, nullptr
)
if ret > 0:
raise NemuIpcError('nemu_capture_display failed during get_resolution()')
self.width = width_ptr.contents.value
self.height = height_ptr.contents.value
@retry
def screenshot(self):
"""
Returns:
np.ndarray: Image array in RGBA color space
Note that image is upside down
"""
if self.connect_id == 0:
self.connect()
self.get_resolution()
width_ptr = ctypes.pointer(ctypes.c_int(self.width))
height_ptr = ctypes.pointer(ctypes.c_int(self.height))
length = self.width * self.height * 4
pixels_pointer = ctypes.pointer((ctypes.c_ubyte * length)())
ret = self.ev_run_sync(
self.lib.nemu_capture_display,
self.connect_id, self.display_id, length, width_ptr, height_ptr, pixels_pointer
)
if ret > 0:
raise NemuIpcError('nemu_capture_display failed during screenshot()')
# image = np.ctypeslib.as_array(pixels_pointer, shape=(self.height, self.width, 4))
image = np.ctypeslib.as_array(pixels_pointer.contents).reshape((self.height, self.width, 4))
return image
def convert_xy(self, x, y):
"""
Convert classic ADB coordinates to Nemu's
`self.height` must be updated before calling this method
Returns:
int, int
"""
x, y = int(x), int(y)
x, y = self.height - y, x
return x, y
@retry
def down(self, x, y):
"""
Contact down, continuous contact down will be considered as swipe
"""
if self.connect_id == 0:
self.connect()
if self.height == 0:
self.get_resolution()
x, y = self.convert_xy(x, y)
ret = self.ev_run_sync(
self.lib.nemu_input_event_touch_down,
self.connect_id, self.display_id, x, y
)
if ret > 0:
raise NemuIpcError('nemu_input_event_touch_down failed')
@retry
def up(self):
"""
Contact up
"""
if self.connect_id == 0:
self.connect()
ret = self.ev_run_sync(
self.lib.nemu_input_event_touch_up,
self.connect_id, self.display_id
)
if ret > 0:
raise NemuIpcError('nemu_input_event_touch_up failed')
def serial_to_id(serial: str):
"""
Predict instance ID from serial
E.g.
"127.0.0.1:16384" -> 0
"127.0.0.1:16416" -> 1
Returns:
int: instance_id, or None if failed to predict
"""
try:
port = int(serial.split(':')[1])
except (IndexError, ValueError):
return None
index, offset = divmod(port - 16384, 32)
if 0 <= index < 32 and offset in [0, 1, 2]:
return index
else:
return None
class NemuIpc(Platform):
@cached_property
def nemu_ipc(self) -> NemuIpcImpl:
"""
Initialize a nemu ipc implementation
"""
# Try existing settings first
if self.config.EmulatorInfo_path:
folder = os.path.abspath(os.path.join(self.config.EmulatorInfo_path, '../../'))
index = serial_to_id(self.serial)
if index is not None:
try:
return NemuIpcImpl(
nemu_folder=folder,
instance_id=index,
display_id=0
).__enter__()
except (NemuIpcIncompatible, NemuIpcError) as e:
logger.error(e)
logger.error('Emulator info incorrect')
# Search emulator instance
# with E:\ProgramFiles\MuMuPlayer-12.0\shell\MuMuPlayer.exe
# installation path is E:\ProgramFiles\MuMuPlayer-12.0
if self.emulator_instance is None:
logger.error('Unable to use NemuIpc because emulator instance not found')
raise RequestHumanTakeover
try:
return NemuIpcImpl(
nemu_folder=self.emulator_instance.emulator.abspath('../'),
instance_id=self.emulator_instance.MuMuPlayer12_id,
display_id=0
).__enter__()
except (NemuIpcIncompatible, NemuIpcError) as e:
logger.error(e)
logger.error('Unable to initialize NemuIpc')
raise RequestHumanTakeover
def nemu_ipc_available(self) -> bool:
if not self.is_mumu_family:
return False
if self.nemud_app_keep_alive == '':
return False
try:
_ = self.nemu_ipc
except RequestHumanTakeover:
return False
return True
def nemu_ipc_release(self):
if has_cached_property(self, 'nemu_ipc'):
self.nemu_ipc.disconnect()
del_cached_property(self, 'nemu_ipc')
logger.info('nemu_ipc released')
def screenshot_nemu_ipc(self):
image = self.nemu_ipc.screenshot()
image = cv2.cvtColor(image, cv2.COLOR_BGRA2BGR)
cv2.flip(image, 0, dst=image)
return image
def click_nemu_ipc(self, x, y):
down = ensure_time((0.010, 0.020))
self.nemu_ipc.down(x, y)
self.sleep(down)
self.nemu_ipc.up()
self.sleep(0.050 - down)
def long_click_nemu_ipc(self, x, y, duration=1.0):
self.nemu_ipc.down(x, y)
self.sleep(duration)
self.nemu_ipc.up()
self.sleep(0.050)
def swipe_nemu_ipc(self, p1, p2):
points = insert_swipe(p0=p1, p3=p2)
for point in points:
self.nemu_ipc.down(*point)
self.sleep(0.010)
self.nemu_ipc.up()
self.sleep(0.050)
def drag_nemu_ipc(self, p1, p2, point_random=(-10, -10, 10, 10)):
p1 = np.array(p1) - random_rectangle_point(point_random)
p2 = np.array(p2) - random_rectangle_point(point_random)
points = insert_swipe(p0=p1, p3=p2, speed=20)
for point in points:
self.nemu_ipc.down(*point)
self.sleep(0.010)
self.nemu_ipc.down(*p2)
self.sleep(0.140)
self.nemu_ipc.down(*p2)
self.sleep(0.140)
self.nemu_ipc.up()
self.sleep(0.050)
+8 -8
View File
@@ -1,4 +1,3 @@
import typing as t
from dataclasses import dataclass
from functools import wraps
from json.decoder import JSONDecodeError
@@ -10,8 +9,8 @@ from lxml import etree
from module.base.utils import *
from module.device.connection import Connection
from module.device.method.utils import (RETRY_TRIES, retry_sleep, handle_adb_error,
ImageTruncated, PackageNotInstalled, possible_reasons)
from module.device.method.utils import (ImageTruncated, PackageNotInstalled, RETRY_TRIES, handle_adb_error,
possible_reasons, retry_sleep)
from module.exception import RequestHumanTakeover
from module.logger import logger
@@ -122,7 +121,7 @@ class Uiautomator2(Connection):
if image is None:
raise ImageTruncated('Empty image after cv2.imdecode')
image = cv2.cvtColor(image, cv2.COLOR_BGR2RGB)
image = cv2.cvtColor(image, cv2.COLOR_BGR2RGB, dst=image)
if image is None:
raise ImageTruncated('Empty image after cv2.cvtColor')
@@ -243,7 +242,7 @@ class Uiautomator2(Connection):
return hierarchy
@retry
def resolution_uiautomator2(self) -> t.Tuple[int, int]:
def resolution_uiautomator2(self, cal_rotation=True) -> t.Tuple[int, int]:
"""
Faster u2.window_size(), cause that calls `dumpsys display` twice.
@@ -252,9 +251,10 @@ class Uiautomator2(Connection):
"""
info = self.u2.http.get('/info').json()
w, h = info['display']['width'], info['display']['height']
rotation = self.get_orientation()
if (w > h) != (rotation % 2 == 1):
w, h = h, w
if cal_rotation:
rotation = self.get_orientation()
if (w > h) != (rotation % 2 == 1):
w, h = h, w
return w, h
def resolution_check_uiautomator2(self):
+99 -7
View File
@@ -18,9 +18,11 @@ except ImportError:
# We expect `screencap | nc 192.168.0.1 20298` instead of `screencap '|' nc 192.168.80.1 20298`
import adbutils
import subprocess
adbutils._utils.list2cmdline = subprocess.list2cmdline
adbutils._device.list2cmdline = subprocess.list2cmdline
# BaseDevice.shell() is missing a check_okay() call before reading output,
# resulting in an `OKAY` prefix in output.
def shell(self,
@@ -40,6 +42,7 @@ except ImportError:
output = c.read_until_close()
return output.rstrip() if rstrip else output
adbutils._device.BaseDevice.shell = shell
from module.base.decorator import cached_property
@@ -323,7 +326,7 @@ class HierarchyButton:
if res:
return res[0]
else:
return 'HierarchyButton'
return self.xpath
@cached_property
def count(self):
@@ -333,15 +336,30 @@ class HierarchyButton:
def exist(self):
return self.count == 1
@cached_property
def attrib(self):
if self.exist:
return self.nodes[0].attrib
else:
return {}
@cached_property
def area(self):
if self.exist:
bounds = self.nodes[0].attrib.get("bounds")
bounds = self.attrib.get("bounds")
lx, ly, rx, ry = map(int, re.findall(r"\d+", bounds))
return lx, ly, rx, ry
else:
return None
@cached_property
def size(self):
if self.area is not None:
lx, ly, rx, ry = self.area
return rx - lx, ry - ly
else:
return None
@cached_property
def button(self):
return self.area
@@ -352,9 +370,83 @@ class HierarchyButton:
def __str__(self):
return self.name
"""
Element props
"""
def _get_bool_prop(self, prop: str) -> bool:
return self.attrib.get(prop, "").lower() == 'true'
@cached_property
def focused(self):
if self.exist:
return self.nodes[0].attrib.get("focused").lower() == 'true'
else:
return False
def index(self) -> int:
try:
return int(self.attrib.get("index", 0))
except IndexError:
return 0
@cached_property
def text(self) -> str:
return self.attrib.get("text", "").strip()
@cached_property
def resourceId(self) -> str:
return self.attrib.get("resourceId", "").strip()
@cached_property
def package(self) -> str:
return self.attrib.get("resourceId", "").strip()
@cached_property
def description(self) -> str:
return self.attrib.get("resourceId", "").strip()
@cached_property
def checkable(self) -> bool:
return self._get_bool_prop('checkable')
@cached_property
def clickable(self) -> bool:
return self._get_bool_prop('clickable')
@cached_property
def enabled(self) -> bool:
return self._get_bool_prop('enabled')
@cached_property
def fucusable(self) -> bool:
return self._get_bool_prop('fucusable')
@cached_property
def focused(self) -> bool:
return self._get_bool_prop('focused')
@cached_property
def scrollable(self) -> bool:
return self._get_bool_prop('scrollable')
@cached_property
def longClickable(self) -> bool:
return self._get_bool_prop('longClickable')
@cached_property
def password(self) -> bool:
return self._get_bool_prop('password')
@cached_property
def selected(self) -> bool:
return self._get_bool_prop('selected')
class AreaButton:
def __init__(self, area, name='AREA_BUTTON'):
self.area = area
self.color = ()
self.name = name
self.button = area
def __str__(self):
return self.name
def __bool__(self):
# Cannot appear
return False
+6
View File
@@ -0,0 +1,6 @@
import sys
if sys.platform == 'win32':
from module.device.platform.platform_windows import PlatformWindows as Platform
else:
from module.device.platform.platform_base import PlatformBase as Platform
+32 -5
View File
@@ -36,6 +36,21 @@ def get_serial_pair(serial):
return None, None
def remove_duplicated_path(paths):
"""
Args:
paths (list[str]):
Returns:
list[str]:
"""
paths = sorted(set(paths))
dic = {}
for path in paths:
dic.setdefault(path.lower(), path)
return list(dic.values())
@dataclass
class EmulatorInstanceBase:
# Serial for adb connection
@@ -54,7 +69,7 @@ class EmulatorInstanceBase:
Returns:
str: Emulator type, such as Emulator.NoxPlayer
"""
return EmulatorBase.path_to_type(self.path)
return self.emulator.type
@cached_property
def emulator(self):
@@ -83,8 +98,9 @@ class EmulatorInstanceBase:
def MuMuPlayer12_id(self):
"""
Convert MuMu 12 instance name to instance id.
Example name: MuMuPlayer-12.0-3
Example ID : 3
Example names:
MuMuPlayer-12.0-3
YXArkNights-12.0-1
Returns:
int: Instance ID, or None if this is not a MuMu 12 instance
@@ -92,8 +108,11 @@ class EmulatorInstanceBase:
res = re.search(r'MuMuPlayer-12.0-(\d+)', self.name)
if res:
return int(res.group(1))
else:
return None
res = re.search(r'YXArkNights-12.0-(\d+)', self.name)
if res:
return int(res.group(1))
return None
class EmulatorBase:
@@ -201,6 +220,14 @@ class EmulatorBase:
class EmulatorManagerBase:
@staticmethod
def iter_running_emulator():
"""
Yields:
str: Path to emulator executables, may contains duplicate values
"""
return
@cached_property
def all_emulators(self) -> t.List[EmulatorBase]:
"""
+90 -51
View File
@@ -8,8 +8,9 @@ from dataclasses import dataclass
# module/device/platform/emulator_base.py
# module/device/platform/emulator_windows.py
# Will be used in Alas Easy Install, they shouldn't import any Alas modules.
from module.device.platform.emulator_base import EmulatorBase, EmulatorInstanceBase, EmulatorManagerBase, \
remove_duplicated_path
from module.device.platform.utils import cached_property, iter_folder
from module.device.platform.emulator_base import EmulatorBase, EmulatorInstanceBase, EmulatorManagerBase
@dataclass
@@ -56,14 +57,6 @@ def abspath(path):
class EmulatorInstance(EmulatorInstanceBase):
@cached_property
def type(self) -> str:
"""
Returns:
str: Emulator type, such as Emulator.NoxPlayer
"""
return Emulator.path_to_type(self.path)
@cached_property
def emulator(self):
"""
@@ -78,7 +71,7 @@ class Emulator(EmulatorBase):
def path_to_type(cls, path: str) -> str:
"""
Args:
path: Path to .exe file
path: Path to .exe file, case insensitive
Returns:
str: Emulator type, such as Emulator.NoxPlayer
@@ -86,46 +79,49 @@ class Emulator(EmulatorBase):
folder, exe = os.path.split(path)
folder, dir1 = os.path.split(folder)
folder, dir2 = os.path.split(folder)
if exe == 'Nox.exe':
if dir2 == 'Nox':
exe = exe.lower()
dir1 = dir1.lower()
dir2 = dir2.lower()
if exe == 'nox.exe':
if dir2 == 'nox':
return cls.NoxPlayer
elif dir2 == 'Nox64':
elif dir2 == 'nox64':
return cls.NoxPlayer64
else:
return cls.NoxPlayer
if exe == 'Bluestacks.exe':
if dir1 in ['BlueStacks', 'BlueStacks_cn']:
if exe == 'bluestacks.exe':
if dir1 in ['bluestacks', 'bluestacks_cn']:
return cls.BlueStacks4
elif dir1 in ['BlueStacks_nxt', 'BlueStacks_nxt_cn']:
elif dir1 in ['bluestacks_nxt', 'bluestacks_nxt_cn']:
return cls.BlueStacks5
else:
return cls.BlueStacks4
if exe == 'HD-Player.exe':
if dir1 in ['BlueStacks', 'BlueStacks_cn']:
if exe == 'hd-player.exe':
if dir1 in ['bluestacks', 'bluestacks_cn']:
return cls.BlueStacks4
elif dir1 in ['BlueStacks_nxt', 'BlueStacks_nxt_cn']:
elif dir1 in ['bluestacks_nxt', 'bluestacks_nxt_cn']:
return cls.BlueStacks5
else:
return cls.BlueStacks5
if exe == 'dnplayer.exe':
if dir1 == 'LDPlayer':
if dir1 == 'ldplayer':
return cls.LDPlayer3
elif dir1 == 'LDPlayer4':
elif dir1 == 'ldplayer4':
return cls.LDPlayer4
elif dir1 == 'LDPlayer9':
elif dir1 == 'ldplayer9':
return cls.LDPlayer9
else:
return cls.LDPlayer3
if exe == 'NemuPlayer.exe':
if exe == 'nemuplayer.exe':
if dir2 == 'nemu':
return cls.MuMuPlayer
elif dir2 == 'nemu9':
return cls.MuMuPlayerX
else:
return cls.MuMuPlayer
if exe == 'MuMuPlayer.exe':
if exe == 'mumuplayer.exe':
return cls.MuMuPlayer12
if exe == 'MEmu.exe':
if exe == 'memu.exe':
return cls.MEmuPlayer
return ''
@@ -151,7 +147,9 @@ class Emulator(EmulatorBase):
elif 'NemuMultiPlayer.exe' in exe:
yield exe.replace('NemuMultiPlayer.exe', 'NemuPlayer.exe')
elif 'MuMuMultiPlayer.exe' in exe:
yield exe.replace('MuMuMultiPlayer.exe', 'MuMuManager.exe')
yield exe.replace('MuMuMultiPlayer.exe', 'MuMuPlayer.exe')
elif 'MuMuManager.exe' in exe:
yield exe.replace('MuMuManager.exe', 'MuMuPlayer.exe')
elif 'MEmuConsole.exe' in exe:
yield exe.replace('MEmuConsole.exe', 'MEmu.exe')
else:
@@ -324,14 +322,18 @@ class EmulatorManager(EmulatorManagerBase):
Get recently executed programs in UserAssist
https://github.com/forensicmatt/MonitorUserAssist
Returns:
Yields:
str: Path to emulator executables, may contains duplicate values
"""
path = r'Software\Microsoft\Windows\CurrentVersion\Explorer\UserAssist'
# {XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX}\xxx.exe
regex_hash = re.compile(r'{.*}')
with winreg.OpenKey(winreg.HKEY_CURRENT_USER, path) as reg:
folders = list_key(reg)
try:
with winreg.OpenKey(winreg.HKEY_CURRENT_USER, path) as reg:
folders = list_key(reg)
except FileNotFoundError:
return
for folder in folders:
try:
with winreg.OpenKey(winreg.HKEY_CURRENT_USER, f'{path}\\{folder}\\Count') as reg:
@@ -358,8 +360,11 @@ class EmulatorManager(EmulatorManagerBase):
str: Path to emulator executable, may contains duplicate values
"""
path = r'Software\Classes\Local Settings\Software\Microsoft\Windows\Shell\MuiCache'
with winreg.OpenKey(winreg.HKEY_CURRENT_USER, path) as reg:
rows = list_reg(reg)
try:
with winreg.OpenKey(winreg.HKEY_CURRENT_USER, path) as reg:
rows = list_reg(reg)
except FileNotFoundError:
return
regex = re.compile(r'(^.*\.exe)\.')
for row in rows:
@@ -425,24 +430,53 @@ class EmulatorManager(EmulatorManagerBase):
'MEmu',
]
for path in known_uninstall_registry_path:
with winreg.OpenKey(winreg.HKEY_LOCAL_MACHINE, path) as reg:
for software in list_key(reg):
if software not in known_emulator_registry_name:
continue
try:
with winreg.OpenKey(winreg.HKEY_LOCAL_MACHINE, path) as reg:
software_list = list_key(reg)
except FileNotFoundError:
continue
for software in software_list:
if software not in known_emulator_registry_name:
continue
try:
with winreg.OpenKey(winreg.HKEY_LOCAL_MACHINE, f'{path}\\{software}') as software_reg:
try:
uninstall = winreg.QueryValueEx(software_reg, 'UninstallString')[0]
except FileNotFoundError:
continue
if not uninstall:
continue
# UninstallString is like:
# C:\Program Files\BlueStacks_nxt\BlueStacksUninstaller.exe -tmp
# "E:\ProgramFiles\Microvirt\MEmu\uninstall\uninstall.exe" -u
# Extract path in ""
res = re.search('"(.*?)"', uninstall)
uninstall = res.group(1) if res else uninstall
yield uninstall
uninstall = winreg.QueryValueEx(software_reg, 'UninstallString')[0]
except FileNotFoundError:
continue
if not uninstall:
continue
# UninstallString is like:
# C:\Program Files\BlueStacks_nxt\BlueStacksUninstaller.exe -tmp
# "E:\ProgramFiles\Microvirt\MEmu\uninstall\uninstall.exe" -u
# Extract path in ""
res = re.search('"(.*?)"', uninstall)
uninstall = res.group(1) if res else uninstall
yield uninstall
@staticmethod
def iter_running_emulator():
"""
Yields:
str: Path to emulator executables, may contains duplicate values
"""
try:
import psutil
except ModuleNotFoundError:
return
# Since this is a one-time-usage, we access psutil._psplatform.Process directly
# to bypass the call of psutil.Process.is_running().
# This only costs about 0.017s.
for pid in psutil.pids():
proc = psutil._psplatform.Process(pid)
try:
exe = proc.cmdline()
exe = exe[0].replace(r'\\', '/').replace('\\', '/')
except (psutil.AccessDenied, IndexError):
# psutil.AccessDenied
continue
if Emulator.is_emulator(exe):
yield exe
@cached_property
def all_emulators(self) -> t.List[Emulator]:
@@ -471,7 +505,7 @@ class EmulatorManager(EmulatorManagerBase):
exe.add(ld)
# Uninstall registry
for uninstall in self.iter_uninstall_registry():
for uninstall in EmulatorManager.iter_uninstall_registry():
# Find emulator executable from uninstaller
for file in iter_folder(abspath(os.path.dirname(uninstall)), ext='.exe'):
if Emulator.is_emulator(file) and os.path.exists(file):
@@ -485,9 +519,14 @@ class EmulatorManager(EmulatorManagerBase):
if Emulator.is_emulator(file) and os.path.exists(file):
exe.add(file)
# Running
for file in EmulatorManager.iter_running_emulator():
if os.path.exists(file):
exe.add(file)
# De-redundancy
exe = [Emulator(path).path for path in exe if Emulator.is_emulator(path)]
exe = sorted(set(exe))
exe = [Emulator(path) for path in exe]
exe = [Emulator(path) for path in remove_duplicated_path(exe)]
return exe
@cached_property
+55 -24
View File
@@ -1,14 +1,14 @@
import sys
import typing as t
import yaml
from pydantic import BaseModel, SecretStr
from pydantic import BaseModel
from module.base.decorator import cached_property, del_cached_property
from module.device.connection import Connection
from module.device.platform.emulator_base import EmulatorInstanceBase, EmulatorManagerBase
from module.logger import logger
from module.base.utils import SelectedGrids
from module.device.connection import Connection
from module.device.method.utils import get_serial_pair
from module.device.platform.emulator_base import EmulatorInstanceBase, EmulatorManagerBase, remove_duplicated_path
from module.logger import logger
class EmulatorInfo(BaseModel):
@@ -48,8 +48,20 @@ class PlatformBase(Connection, EmulatorManagerBase):
@cached_property
def emulator_info(self) -> EmulatorInfo:
emulator = self.config.EmulatorInfo_Emulator
name = str(self.config.EmulatorInfo_name).strip().replace('\n', '')
path = str(self.config.EmulatorInfo_path).strip().replace('\n', '')
if emulator == 'auto':
emulator = ''
def parse_info(value):
if isinstance(value, str):
value = value.strip().replace('\n', '')
if value in ['None', 'False', 'True']:
value = ''
return value
else:
return ''
name = parse_info(self.config.EmulatorInfo_name)
path = parse_info(self.config.EmulatorInfo_path)
return EmulatorInfo(
emulator=emulator,
@@ -69,8 +81,14 @@ class PlatformBase(Connection, EmulatorManagerBase):
path=data.path,
name=data.name,
)
# Redirect emulator-5554 to 127.0.0.1:5555
serial = self.serial
port_serial, _ = get_serial_pair(self.serial)
if port_serial is not None:
serial = port_serial
instance = self.find_emulator_instance(
serial=str(self.config.Emulator_Serial).strip(),
serial=serial,
name=data.name,
path=data.path,
emulator=data.emulator,
@@ -118,7 +136,7 @@ class PlatformBase(Connection, EmulatorManagerBase):
# Search by serial
select = instances.select(**search_args)
if select.count == 0:
logger.warning(f'No emulator instance with {search_args}')
logger.warning(f'No emulator instance with {search_args}, serial invalid')
return None
if select.count == 1:
instance = select[0]
@@ -131,9 +149,9 @@ class PlatformBase(Connection, EmulatorManagerBase):
search_args['name'] = name
select = instances.select(**search_args)
if select.count == 0:
logger.warning(f'No emulator instances with {search_args}')
return None
if select.count == 1:
logger.warning(f'No emulator instances with {search_args}, name invalid')
search_args.pop('name')
elif select.count == 1:
instance = select[0]
logger.hr('Emulator instance', level=2)
logger.info(f'Found emulator instance: {instance}')
@@ -144,9 +162,9 @@ class PlatformBase(Connection, EmulatorManagerBase):
search_args['path'] = path
select = instances.select(**search_args)
if select.count == 0:
logger.warning(f'No emulator instances with {search_args}')
return None
if select.count == 1:
logger.warning(f'No emulator instances with {search_args}, path invalid')
search_args.pop('path')
elif select.count == 1:
instance = select[0]
logger.hr('Emulator instance', level=2)
logger.info(f'Found emulator instance: {instance}')
@@ -157,9 +175,28 @@ class PlatformBase(Connection, EmulatorManagerBase):
search_args['type'] = emulator
select = instances.select(**search_args)
if select.count == 0:
logger.warning(f'No emulator instances with {search_args}')
return None
if select.count == 1:
logger.warning(f'No emulator instances with {search_args}, type invalid')
search_args.pop('type')
elif select.count == 1:
instance = select[0]
logger.hr('Emulator instance', level=2)
logger.info(f'Found emulator instance: {instance}')
return instance
# Still too many instances, search from running emulators
running = remove_duplicated_path(list(self.iter_running_emulator()))
logger.info('Running emulators')
for exe in running:
logger.info(exe)
if len(running) == 1:
logger.info('Only one running emulator')
# Same as searching path
search_args['path'] = running[0]
select = instances.select(**search_args)
if select.count == 0:
logger.warning(f'No emulator instances with {search_args}, path invalid')
search_args.pop('path')
elif select.count == 1:
instance = select[0]
logger.hr('Emulator instance', level=2)
logger.info(f'Found emulator instance: {instance}')
@@ -168,9 +205,3 @@ class PlatformBase(Connection, EmulatorManagerBase):
# Still too many instances
logger.warning(f'Found multiple emulator instances with {search_args}')
return None
if __name__ == '__main__':
self = PlatformBase('alas')
d = self.emulator_instance
print(d)
+8 -3
View File
@@ -8,8 +8,8 @@ from deploy.Windows.utils import DataProcessInfo
from module.base.decorator import run_once
from module.base.timer import Timer
from module.device.connection import AdbDeviceWithStatus
from module.device.platform.platform_base import PlatformBase
from module.device.platform.emulator_windows import Emulator, EmulatorInstance, EmulatorManager
from module.device.platform.platform_base import PlatformBase
from module.logger import logger
import os
@@ -92,6 +92,8 @@ class PlatformWindows(PlatformBase, EmulatorManager):
self.execute(f'"{exe}" -m {instance.name}')
elif instance == Emulator.MuMuPlayer12:
# MuMuPlayer.exe -v 0
if instance.MuMuPlayer12_id is None:
logger.warning(f'Cannot get MuMu instance index from name {instance.name}')
self.execute(f'"{exe}" -v {instance.MuMuPlayer12_id}')
elif instance == Emulator.NoxPlayerFamily:
# Nox.exe -clone:Nox_1
@@ -148,6 +150,8 @@ class PlatformWindows(PlatformBase, EmulatorManager):
# MuMu 12 has 2 processes:
# E:\ProgramFiles\Netease\MuMuPlayer-12.0\shell\MuMuPlayer.exe -v 0
# "C:\Program Files\MuMuVMMVbox\Hypervisor\MuMuVMMHeadless.exe" --comment MuMuPlayer-12.0-0 --startvm xxx
if instance.MuMuPlayer12_id is None:
logger.warning(f'Cannot get MuMu instance index from name {instance.name}')
self.kill_process_by_regex(
rf'('
rf'MuMuVMMHeadless.exe.*--comment {instance.name}'
@@ -276,7 +280,7 @@ class PlatformWindows(PlatformBase, EmulatorManager):
show_ping(pong)
# Check azuelane package
packages = self.list_azurlane_packages(show_log=False)
packages = self.list_known_packages(show_log=False)
if len(packages):
pass
else:
@@ -326,4 +330,5 @@ class PlatformWindows(PlatformBase, EmulatorManager):
if __name__ == '__main__':
self = PlatformWindows('alas')
self.emulator_start()
d = self.emulator_instance
print(d)
+22 -2
View File
@@ -13,13 +13,14 @@ from module.base.utils import get_color, image_size, limit_in, save_image
from module.device.method.adb import Adb
from module.device.method.ascreencap import AScreenCap
from module.device.method.droidcast import DroidCast
from module.device.method.nemu_ipc import NemuIpc
from module.device.method.scrcpy import Scrcpy
from module.device.method.wsa import WSA
from module.exception import RequestHumanTakeover, ScriptError
from module.logger import logger
class Screenshot(Adb, WSA, DroidCast, AScreenCap, Scrcpy):
class Screenshot(Adb, WSA, DroidCast, AScreenCap, Scrcpy, NemuIpc):
_screen_size_checked = False
_screen_black_checked = False
_minicap_uninstalled = False
@@ -38,6 +39,7 @@ class Screenshot(Adb, WSA, DroidCast, AScreenCap, Scrcpy):
'DroidCast': self.screenshot_droidcast,
'DroidCast_raw': self.screenshot_droidcast_raw,
'scrcpy': self.screenshot_scrcpy,
'nemu_ipc': self.screenshot_nemu_ipc,
}
def screenshot(self):
@@ -70,6 +72,10 @@ class Screenshot(Adb, WSA, DroidCast, AScreenCap, Scrcpy):
return self.image
@property
def has_cached_image(self):
return hasattr(self, 'image') and self.image is not None
def _handle_orientated_image(self, image):
"""
Args:
@@ -98,7 +104,18 @@ class Screenshot(Adb, WSA, DroidCast, AScreenCap, Scrcpy):
@cached_property
def screenshot_deque(self):
return deque(maxlen=int(self.config.Error_ScreenshotLength))
try:
length = int(self.config.Error_ScreenshotLength)
except ValueError:
logger.error(f'Error_ScreenshotLength={self.config.Error_ScreenshotLength} is not an integer')
raise RequestHumanTakeover
# Limit in 1~300
length = max(1, min(length, 300))
return deque(maxlen=length)
@cached_property
def screenshot_tracking(self):
return []
def save_screenshot(self, genre='items', interval=None, to_base_folder=False):
"""Save a screenshot. Use millisecond timestamp as file name.
@@ -148,6 +165,9 @@ class Screenshot(Adb, WSA, DroidCast, AScreenCap, Scrcpy):
if interval != origin:
logger.warning(f'Optimization.ScreenshotInterval {origin} is revised to {interval}')
self.config.Optimization_ScreenshotInterval = interval
# Allow nemu_ipc to have a lower default
if self.config.Emulator_ScreenshotMethod == 'nemu_ipc':
interval = limit_in(origin, 0.1, 0.2)
elif interval == 'combat':
origin = self.config.Optimization_CombatScreenshotInterval
interval = limit_in(origin, 0.3, 1.0)
+6
View File
@@ -29,6 +29,12 @@ class GamePageUnknownError(Exception):
pass
class TaskError(Exception):
# An error occurred in task,
# task itself should have error handled before raising TaskError,
# then task will be re-scheduled
pass
class RequestHumanTakeover(Exception):
# Request human takeover
# Alas is unable to handle such error, probably because of wrong settings.
+73 -2
View File
@@ -1,4 +1,75 @@
import onepush.core
import yaml
from onepush import get_notifier
from onepush.core import Provider
from onepush.exceptions import OnePushException
from onepush.providers.custom import Custom
from requests import Response
from module.logger import logger
def handle_notify(*args, **kwargs):
logger.error('Error notify is not supported yet')
onepush.core.log = logger
def handle_notify(_config: str, **kwargs) -> bool:
try:
config = {}
for item in yaml.safe_load_all(_config):
config.update(item)
except Exception:
logger.error("Fail to load onepush config, skip sending")
return False
try:
provider_name: str = config.pop("provider", None)
if provider_name is None:
logger.info("No provider specified, skip sending")
return False
notifier: Provider = get_notifier(provider_name)
required: list[str] = notifier.params["required"]
config.update(kwargs)
# pre check
for key in required:
if key not in config:
logger.warning(
f"Notifier {notifier.name} require param '{key}' but not provided"
)
if isinstance(notifier, Custom):
if "method" not in config or config["method"] == "post":
config["datatype"] = "json"
if not ("data" in config or isinstance(config["data"], dict)):
config["data"] = {}
if "title" in kwargs:
config["data"]["title"] = kwargs["title"]
if "content" in kwargs:
config["data"]["content"] = kwargs["content"]
if provider_name.lower() == "gocqhttp":
access_token = config.get("access_token")
if access_token:
config["token"] = access_token
resp = notifier.notify(**config)
if isinstance(resp, Response):
if resp.status_code != 200:
logger.warning("Push notify failed!")
logger.warning(f"HTTP Code:{resp.status_code}")
return False
else:
if provider_name.lower() == "gocqhttp":
return_data: dict = resp.json()
if return_data["status"] == "failed":
logger.warning("Push notify failed!")
logger.warning(
f"Return message:{return_data['wording']}")
return False
except OnePushException:
logger.exception("Push notify failed")
return False
except Exception as e:
logger.exception(e)
return False
logger.info("Push notify success")
return True
+39 -2
View File
@@ -6,12 +6,13 @@ from typing import ClassVar
import module.config.server as server
from module.exception import ScriptError
REGEX_PUNCTUATION = re.compile(r'[ ,.\'"“”,。::!!??·•\-—/\\\n\t()\[\]()「」『』【】《》[]]')
# ord('.') = 65294
REGEX_PUNCTUATION = re.compile(r'[ ,..\'"“”,。…::;;!!??·・•●〇°*※\-—-/\\\n\t()\[\]()「」『』【】《》[]]')
def parse_name(n):
n = REGEX_PUNCTUATION.sub('', str(n)).lower()
return n
return n.strip()
@dataclass
@@ -179,3 +180,39 @@ class Keyword:
# Not found
raise ScriptError(f'Cannot find a {cls.__name__} instance that matches "{name}"')
@classmethod
def find_name(cls, name):
"""
Args:
name: Attribute name of keyword.
Returns:
Keyword instance.
Raises:
ScriptError: If nothing found.
"""
if isinstance(name, Keyword):
return name
for instance in cls.instances.values():
if name == instance.name:
return instance
# Not found
raise ScriptError(f'Cannot find a {cls.__name__} instance that matches "{name}"')
class KeywordDigitCounter(Keyword):
"""
A fake Keyword class to filter digit counters in ocr results
OcrResultButton.match_keyword will be a str
"""
@classmethod
def find(cls, name, lang: str = None, ignore_punctuation=True):
from module.ocr.ocr import DigitCounter
if DigitCounter.is_format_matched(name):
return name
else:
raise ScriptError
+25 -5
View File
@@ -1,24 +1,23 @@
import re
import time
from datetime import timedelta
from typing import Optional
import cv2
import numpy as np
from pponnxcr.predict_system import BoxedResult
import module.config.server as server
from module.base.button import ButtonWrapper
from module.base.decorator import cached_property
from module.base.utils import area_pad, corner2area, crop, float2str
from module.base.utils import area_pad, corner2area, crop, extract_white_letters, float2str
from module.exception import ScriptError
from module.logger import logger
from module.ocr.keyword import Keyword
from module.ocr.models import OCR_MODEL, TextSystem
from module.ocr.utils import merge_buttons
class OcrResultButton:
def __init__(self, boxed_result: BoxedResult, matched_keyword: Optional[Keyword]):
def __init__(self, boxed_result: BoxedResult, matched_keyword):
"""
Args:
boxed_result: BoxedResult from ppocr-onnx
@@ -349,6 +348,10 @@ class DigitCounter(Ocr):
def __init__(self, button: ButtonWrapper, lang='en', name=None):
super().__init__(button, lang=lang, name=name)
@classmethod
def is_format_matched(cls, result) -> bool:
return '/' in result
def after_process(self, result):
result = super().after_process(result)
result = result.replace('%', '/')
@@ -364,7 +367,7 @@ class DigitCounter(Ocr):
result = self.after_process(result)
logger.attr(name=self.name, text=str(result))
res = re.search(r'(\d+)/(\d+)', result)
res = re.search(r'(\d+)\s*/\s*(\d+)', result)
if res:
groups = [int(s) for s in res.groups()]
current, total = int(groups[0]), int(groups[1])
@@ -421,3 +424,20 @@ class Duration(Ocr):
if number is None:
return 0
return int(number)
class OcrWhiteLetterOnComplexBackground(Ocr):
def pre_process(self, image):
image = extract_white_letters(image, threshold=255)
image = cv2.merge([image, image, image])
return image
def detect_and_ocr(self, *args, **kwargs):
# Try hard to lower TextSystem.box_thresh
backup = self.model.text_detector.box_thresh
self.model.text_detector.box_thresh = 0.2
result = super().detect_and_ocr(*args, **kwargs)
self.model.text_detector.box_thresh = backup
return result
+2 -2
View File
@@ -74,8 +74,8 @@ def pair_buttons(group1, group2, relative_area):
Pair buttons in group1 with those in group2 in the relative_area.
Args:
group1 (list[OcrResultButton]):
group2 (list[OcrResultButton]):
group1 (list[OcrResultButton], Iterable[OcrResultButton]):
group2 (list[OcrResultButton], Iterable[OcrResultButton]):
relative_area (tuple[int, int, int, int]):
Yields:
+6 -2
View File
@@ -160,6 +160,7 @@ class DraggableList:
logger.info(f'Insight row: {row}, index={row_index}')
last_buttons: set[OcrResultButton] = None
bottom_check = Timer(3, count=5).start()
while 1:
if skip_first_screenshot:
skip_first_screenshot = False
@@ -183,8 +184,11 @@ class DraggableList:
0, count=0), timeout=Timer(1.5, count=5))
skip_first_screenshot = True
if self.cur_buttons and last_buttons == set(self.cur_buttons):
logger.warning(f'No more rows in {self}')
return False
if bottom_check.reached():
logger.warning(f'No more rows in {self}')
return False
else:
bottom_check.reset()
last_buttons = set(self.cur_buttons)
return True
+66 -5
View File
@@ -1,9 +1,10 @@
import numpy as np
from scipy import signal
from module.base.base import ModuleBase
from module.base.button import Button, ButtonWrapper
from module.base.timer import Timer
from module.base.utils import color_similarity_2d, random_rectangle_point
from module.base.utils import color_similarity_2d, random_rectangle_point, rgb2gray
from module.logger import logger
@@ -46,7 +47,7 @@ class Scroll:
Returns:
np.ndarray: Shape (n,), dtype bool.
"""
image = main.image_crop(self.area)
image = main.image_crop(self.area, copy=False)
image = color_similarity_2d(image, color=self.color)
mask = np.max(image, axis=1 if self.is_vertical else 0) > self.color_threshold
self.length = np.sum(mask)
@@ -110,6 +111,20 @@ class Scroll:
"""
return np.mean(self.match_color(main)) > 0.1
def is_draggable(self, main):
"""
If scroll `length` is just a little smaller than `total`,
game client may not respond to such a short swipe.
Args:
main (ModuleBase):
Returns:
bool:
"""
_ = self.cal_position(main)
return self.length / self.total < 0.95
def at_top(self, main):
return self.cal_position(main) < self.edge_threshold
@@ -189,10 +204,56 @@ class Scroll:
multiply = self.length / (self.total - self.length)
target = current + page * multiply
target = round(min(max(target, 0), 1), 3)
self.set(target, main=main, random_range=random_range, skip_first_screenshot=True)
return self.set(target, main=main, random_range=random_range, skip_first_screenshot=True)
def next_page(self, main, page=0.8, random_range=(-0.01, 0.01), skip_first_screenshot=True):
self.drag_page(page, main=main, random_range=random_range, skip_first_screenshot=skip_first_screenshot)
return self.drag_page(page, main=main, random_range=random_range, skip_first_screenshot=skip_first_screenshot)
def prev_page(self, main, page=0.8, random_range=(-0.01, 0.01), skip_first_screenshot=True):
self.drag_page(-page, main=main, random_range=random_range, skip_first_screenshot=skip_first_screenshot)
return self.drag_page(-page, main=main, random_range=random_range, skip_first_screenshot=skip_first_screenshot)
class AdaptiveScroll(Scroll):
def __init__(self, area, parameters: dict = None, background=5, is_vertical=True, name='Scroll'):
"""
Args:
area (Button, tuple): A button or area of the whole scroll.
prominence (dict): Parameters passing to scipy.find_peaks
background (int):
is_vertical (bool): True if vertical, false if horizontal.
name (str):
"""
if parameters is None:
parameters = {}
self.parameters = parameters
self.background = background
super().__init__(area, color=(255, 255, 255), is_vertical=is_vertical, name=name)
def match_color(self, main):
if self.is_vertical:
area = (self.area[0] - self.background, self.area[1], self.area[2] + self.background, self.area[3])
image = main.image_crop(area, copy=False)
image = rgb2gray(image)
image = image.flatten()
wlen = area[2] - area[0]
else:
area = (self.area[0], self.area[1] - self.background, self.area[2], self.area[3] + self.background)
image = main.image_crop(area, copy=False)
image = rgb2gray(image)
image = image.flatten('F')
wlen = area[3] - area[1]
parameters = {
'height': 128,
'prominence': 30,
'wlen': wlen,
'width': 2,
}
parameters.update(self.parameters)
peaks, _ = signal.find_peaks(image, **parameters)
peaks //= wlen
self.length = len(peaks)
mask = np.zeros((self.total,), dtype=np.bool_)
mask[peaks] = 1
return mask
+23 -17
View File
@@ -71,6 +71,7 @@ from module.webui.utils import (
parse_pin_value,
raise_exception,
re_fullmatch,
to_pin_value,
)
from module.webui.widgets import (
BinarySwitchButton,
@@ -105,6 +106,7 @@ class AlasGUI(Frame):
self.alas_name = ""
self.alas_mod = "alas"
self.alas_config = AzurLaneConfig("template")
self.alas_config_hidden = set()
self.initial()
@use_scope("aside", clear=True)
@@ -235,6 +237,7 @@ class AlasGUI(Frame):
)
config = self.alas_config.read_file(self.alas_name)
self.alas_config_hidden = self.alas_config.get_hidden_args(config)
for group, arg_dict in deep_iter(self.ALAS_ARGS[task], depth=1):
if self.set_group(group, arg_dict, config, task):
self.set_navigator(group)
@@ -290,6 +293,9 @@ class AlasGUI(Frame):
if o is not None:
# output will inherit current scope when created, override here
o.spec["scope"] = f"#pywebio-scope-group_{group_name}"
# Add hidden-arg
if f"{task}.{group_name}.{arg_name}" in self.alas_config_hidden:
o.style("display:none")
output_list.append(o)
if not output_list:
@@ -456,8 +462,7 @@ class AlasGUI(Frame):
try:
d = self.modified_config_queue.get(timeout=10)
config_name = self.alas_name
read = self.alas_config.read_file
write = self.alas_config.write_file
config_updater = self.alas_config
except queue.Empty:
continue
modified[d["name"]] = d["value"]
@@ -466,7 +471,7 @@ class AlasGUI(Frame):
d = self.modified_config_queue.get(timeout=1)
modified[d["name"]] = d["value"]
except queue.Empty:
self._save_config(modified, config_name, read, write)
self._save_config(modified, config_name, config_updater)
modified.clear()
break
@@ -474,13 +479,12 @@ class AlasGUI(Frame):
self,
modified: Dict[str, str],
config_name: str,
read=State.config_updater.read_file,
write=State.config_updater.write_file,
config_updater: AzurLaneConfig = State.config_updater,
) -> None:
try:
valid = []
invalid = []
config = read(config_name)
config = config_updater.read_file(config_name)
for k, v in modified.copy().items():
valuetype = deep_get(self.ALAS_ARGS, k + ".valuetype")
v = parse_pin_value(v, valuetype)
@@ -497,22 +501,24 @@ class AlasGUI(Frame):
modified[k] = v
valid.append(k)
# update Emotion Record if Emotion Value is changed
if "Emotion" in k and "Value" in k:
k = k.split(".")
k[-1] = k[-1].replace("Value", "Record")
k = ".".join(k)
v = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
modified[k] = v
deep_set(config, k, v)
valid.append(k)
pin["_".join(k.split("."))] = v
for set_key, set_value in config_updater.save_callback(k, v):
modified[set_key] = set_value
deep_set(config, set_key, set_value)
valid.append(set_key)
pin["_".join(set_key.split("."))] = to_pin_value(set_value)
else:
modified.pop(k)
invalid.append(k)
logger.warning(f"Invalid value {v} for key {k}, skip saving.")
self.pin_remove_invalid_mark(valid)
self.pin_set_invalid_mark(invalid)
new_hidden_args = config_updater.get_hidden_args(config)
for k in new_hidden_args - self.alas_config_hidden:
self.pin_set_hidden_arg(k, type_=deep_get(self.ALAS_ARGS, f"{k}.type"))
for k in self.alas_config_hidden - new_hidden_args:
self.pin_remove_hidden_arg(k, type_=deep_get(self.ALAS_ARGS, f"{k}.type"))
self.alas_config_hidden = new_hidden_args
if modified:
toast(
t("Gui.Toast.ConfigSaved"),
@@ -523,7 +529,7 @@ class AlasGUI(Frame):
logger.info(
f"Save config {filepath_config(config_name)}, {dict_to_kv(modified)}"
)
write(config_name, config)
config_updater.write_file(config_name, config)
except Exception as e:
logger.exception(e)
+31
View File
@@ -4,6 +4,7 @@ from pywebio.output import clear, put_html, put_scope, put_text, use_scope
from pywebio.session import defer_call, info, run_js
from module.webui.utils import Icon, WebIOTaskHandler, set_localstorage
from module.webui.widgets import type_to_html
class Base:
@@ -167,3 +168,33 @@ class Frame(Base):
run_js(js)
# for key in keys:
# pin_update(key, valid_status=0)
@staticmethod
def pin_set_hidden_arg(key, type_) -> None:
"""
Hide arg
Args:
key: Path
type_: Type in _widget_type_to_func
"""
type_ = type_to_html(type_)
key = "_".join(key.split("."))
key = f"pywebio-scope-arg_container-{type_}-{key}"
# This aims to be a typo, don't correct it, leave it as it is
if type_ == 'textarea':
key = key.replace('container', 'contianer')
js = f"""$("#{key}").css("display","none");"""
if js:
run_js(js)
@staticmethod
def pin_remove_hidden_arg(key, type_) -> None:
type_ = type_to_html(type_)
key = "_".join(key.split("."))
key = f"pywebio-scope-arg_container-{type_}-{key}"
if type_ == 'textarea':
key = key.replace('container', 'contianer')
js = f"""$("#{key}").removeAttr('style');"""
if js:
run_js(js)
+13 -3
View File
@@ -11,9 +11,7 @@ from typing import Callable, Generator, List
import pywebio
from pywebio.input import PASSWORD, input
from pywebio.output import PopupSize, popup, put_html, toast
from pywebio.session import eval_js
from pywebio.session import info as session_info
from pywebio.session import register_thread, run_js
from pywebio.session import eval_js, info as session_info, register_thread, run_js
from rich.console import Console
from rich.terminal_theme import TerminalTheme
@@ -427,6 +425,18 @@ def parse_pin_value(val, valuetype: str = None):
return v
def to_pin_value(val):
"""
Convert bool to checkbox
"""
if val is True:
return [True]
elif val is False:
return []
else:
return val
def login(password):
if get_localstorage("password") == str(password):
return True
+22 -2
View File
@@ -2,7 +2,7 @@ import copy
import json
import random
import string
from typing import TYPE_CHECKING, Any, Callable, Dict, Generator, List, Optional, Union
from typing import Any, Callable, Dict, Generator, List, Optional, TYPE_CHECKING, Union
from pywebio.exceptions import SessionException
from pywebio.io_ctrl import Output
@@ -10,7 +10,7 @@ from pywebio.output import *
from pywebio.session import eval_js, local, run_js
from rich.console import ConsoleRenderable
from module.logger import WEB_THEME, Highlighter, HTMLConsole
from module.logger import HTMLConsole, Highlighter, WEB_THEME
from module.webui.lang import t
from module.webui.pin import put_checkbox, put_input, put_select, put_textarea
from module.webui.process_manager import ProcessManager
@@ -424,6 +424,7 @@ def put_arg_textarea(kwargs: T_Output_Kwargs) -> Output:
)
return put_scope(
# This aims to be a typo, don't correct it, leave it as it is
f"arg_contianer-textarea-{name}",
[
get_title_help(kwargs),
@@ -507,6 +508,25 @@ def put_output(output_kwargs: T_Output_Kwargs) -> Optional[Output]:
return _widget_type_to_func[output_kwargs["widget_type"]](output_kwargs)
def type_to_html(type_: str) -> str:
"""
Args:
type_: Type defined in _widget_type_to_func and argument.yaml
Returns:
str: Html element name
"""
if type_ == "checkbox":
return "checkbox"
if type_ in ["input", "lock", "datetime"]:
return "input"
if type_ in ["select", "state"]:
return "select"
if type_ in ["textarea", "storage"]:
return "textarea"
return type_
def get_loading_style(shape: str, fill: bool) -> str:
if fill:
return f"--loading-{shape}-fill--"
+3 -2
View File
@@ -20,13 +20,14 @@ jellyfish
pyyaml
inflection
prettytable==2.2.1
pydantic
pydantic>=2.4
onepush==1.3.0
# OCR
pponnxcr==2.0
# Webui
pywebio==1.6.2
pywebio==1.8.3
starlette==0.14.2
uvicorn[standard]==0.17.6
aiofiles
+8 -4
View File
@@ -8,6 +8,7 @@
adbutils==1.2.9 # via -r requirements-in.txt, uiautomator2
aiofiles==23.1.0 # via -r requirements-in.txt
annotated-types==0.5.0 # via pydantic
anyio==3.6.2 # via watchgod
apkutils2==1.0.0 # via adbutils
asgiref==3.6.0 # via uvicorn
@@ -39,6 +40,7 @@ markdown-it-py==2.2.0 # via rich
mdurl==0.1.2 # via markdown-it-py
mpmath==1.3.0 # via sympy
numpy==1.24.3 # via -r requirements-in.txt, onnxruntime, opencv-python, pponnxcr, scipy, shapely
onepush==1.3.0 # via -r requirements-in.txt
onnxruntime==1.14.1 # via pponnxcr
opencv-python==4.7.0.72 # via -r requirements-in.txt, pponnxcr
packaging==20.9 # via deprecation, onnxruntime, uiautomator2
@@ -50,16 +52,18 @@ protobuf==4.23.0 # via onnxruntime
psutil==5.9.3 # via -r requirements-in.txt
py==1.11.0 # via retry
pyclipper==1.3.0.post4 # via pponnxcr
pydantic==1.10.7 # via -r requirements-in.txt
pycryptodome==3.20.0 # via onepush
pydantic==2.4.2 # via -r requirements-in.txt
pydantic-core==2.10.1 # via pydantic
pyelftools==0.29 # via apkutils2
pygments==2.15.1 # via rich
pynput==1.7.6 # via -r requirements-in.txt
pyparsing==3.0.9 # via packaging
pyreadline3==3.4.1 # via humanfriendly
python-dotenv==1.0.0 # via uvicorn
pywebio==1.6.2 # via -r requirements-in.txt
pywebio==1.8.3 # via -r requirements-in.txt
pyyaml==6.0 # via -r requirements-in.txt, uvicorn
requests==2.30.0 # via adbutils, uiautomator2
requests==2.30.0 # via adbutils, onepush, uiautomator2
retry==0.9.2 # via adbutils, uiautomator2
rich==13.3.5 # via -r requirements-in.txt
scipy==1.10.1 # via -r requirements-in.txt
@@ -70,7 +74,7 @@ starlette==0.14.2 # via -r requirements-in.txt
sympy==1.12 # via onnxruntime
tornado==6.3.1 # via pywebio
tqdm==4.65.0 # via -r requirements-in.txt
typing-extensions==4.5.0 # via pydantic
typing-extensions==4.8.0 # via pydantic, pydantic-core
ua-parser==0.16.1 # via user-agents
uiautomator2==2.16.17 # via -r requirements-in.txt
uiautomator2cache==0.3.0.1 # via -r requirements-in.txt