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/ test/
note.md note.md
MCE/config.json 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) # Created by .ignore support plugin (hsz.mobi)
+4
View File
@@ -11,6 +11,10 @@ class ArisuAutoSweeper(AzurLaneAutoScript):
from tasks.login.login import Login from tasks.login.login import Login
Login(self.config, device=self.device).app_start() 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): def goto_main(self):
from tasks.login.login import Login from tasks.login.login import Login
from tasks.base.ui import UI from tasks.base.ui import UI
+3 -1
View File
@@ -2,6 +2,7 @@ import asyncio
import filecmp import filecmp
import os import os
import shutil import shutil
import sys
import typing as t import typing as t
from dataclasses import dataclass from dataclasses import dataclass
@@ -9,7 +10,8 @@ from deploy.Windows.alas import AlasManager
from deploy.Windows.logger import logger from deploy.Windows.logger import logger
from deploy.Windows.utils import cached_property 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 @dataclass
+23 -10
View File
@@ -92,6 +92,7 @@ def iter_images():
for server in ASSET_SERVER: for server in ASSET_SERVER:
for path, folders, files in os.walk(os.path.join(AzurLaneConfig.ASSETS_FOLDER, server)): for path, folders, files in os.walk(os.path.join(AzurLaneConfig.ASSETS_FOLDER, server)):
for file in files: for file in files:
if not file.startswith('.'):
file = os.path.join(path, file).replace('\\', '/') file = os.path.join(path, file).replace('\\', '/')
yield AssetsImage(file) yield AssetsImage(file)
@@ -108,6 +109,11 @@ class DataAssets:
color: t.Tuple[int, int, int] = () color: t.Tuple[int, int, int] = ()
button: t.Tuple[int, 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 @staticmethod
def area_to_search(area): def area_to_search(area):
area = area_pad(area, pad=-20) area = area_pad(area, pad=-20)
@@ -131,12 +137,16 @@ class DataAssets:
self.button = image.bbox self.button = image.bbox
elif image.attr == 'AREA': elif image.attr == 'AREA':
self.area = image.bbox self.area = image.bbox
self.has_raw_area = True
elif image.attr == 'SEARCH': elif image.attr == 'SEARCH':
self.search = image.bbox self.search = image.bbox
self.has_raw_search = True
elif image.attr == 'COLOR': elif image.attr == 'COLOR':
self.color = image.mean self.color = image.mean
self.has_raw_color = True
elif image.attr == 'BUTTON': elif image.attr == 'BUTTON':
self.button = image.bbox self.button = image.bbox
self.has_raw_button = True
else: else:
logger.warning(f'Trying to load an image with unknown attribute: {image}') logger.warning(f'Trying to load an image with unknown attribute: {image}')
@@ -182,19 +192,22 @@ def iter_assets():
# Set `search` # Set `search`
for path, frames in deep_iter(data, depth=3): for path, frames in deep_iter(data, depth=3):
print(path, frames) 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():
frame.search = first.search
else:
for frame in frames.values():
if frame.search:
# Follow frame specific `search`
pass
else:
# Generate `search` from `area` # Generate `search` from `area`
if not frame.has_raw_search:
frame.search = DataAssets.area_to_search(frame.area) 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
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 return data
+4 -4
View File
@@ -2,8 +2,8 @@ import os
from datetime import datetime from datetime import datetime
from PIL import Image from PIL import Image
from pynput import keyboard from pynput import keyboard
from module.config.config import AzurLaneConfig from module.config.config import AzurLaneConfig
from module.config.utils import alas_instance from module.config.utils import alas_instance
from module.device.connection import Connection, ConnectionAttr from module.device.connection import Connection, ConnectionAttr
@@ -50,12 +50,12 @@ def handle_sensitive_info(image):
_ = EmptyConnection() _ = EmptyConnection()
name = input( name = input(
'输入alas配置文件名称,或者模拟器serial,或者模拟器端口号: (默认输入 "alas"):\n' '输入aas配置文件名称,或者模拟器serial,或者模拟器端口号: (默认输入 "aas"):\n'
'例如:"alas", "127.0.0.1:16384", "7555"\n' '例如:"aas", "127.0.0.1:16384", "7555"\n'
) )
name = name.strip().strip('"').strip() name = name.strip().strip('"').strip()
if not name: if not name:
name = 'alas' name = 'aas'
if name.isdigit(): if name.isdigit():
name = f'127.0.0.1:{name}' name = f'127.0.0.1:{name}'
if name in alas_instance(): if name in alas_instance():
+21 -4
View File
@@ -66,9 +66,22 @@ class AzurLaneAutoScript:
logger.exception(e) logger.exception(e)
exit(1) 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): def run(self, command):
try: try:
self.device.screenshot() self.device.screenshot()
self.device.screenshot_tracking.clear()
self.__getattribute__(command)() self.__getattribute__(command)()
return True return True
except TaskEnd: except TaskEnd:
@@ -143,15 +156,19 @@ class AzurLaneAutoScript:
from module.base.utils import save_image from module.base.utils import save_image
from module.handler.sensitive_info import (handle_sensitive_image, handle_sensitive_logs) from module.handler.sensitive_info import (handle_sensitive_image, handle_sensitive_logs)
if self.config.Error_SaveError: if self.config.Error_SaveError:
if not os.path.exists('./log/error'):
os.mkdir('./log/error')
folder = f'./log/error/{int(time.time() * 1000)}' folder = f'./log/error/{int(time.time() * 1000)}'
logger.warning(f'Saving error: {folder}') logger.warning(f'Saving error: {folder}')
os.mkdir(folder) os.makedirs(folder, exist_ok=True)
for data in self.device.screenshot_deque: for data in self.device.screenshot_deque:
image_time = datetime.strftime(data['time'], '%Y-%m-%d_%H-%M-%S-%f') image_time = datetime.strftime(data['time'], '%Y-%m-%d_%H-%M-%S-%f')
image = handle_sensitive_image(data['image']) image = handle_sensitive_image(data['image'])
save_image(image, f'{folder}/{image_time}.png') 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: with open(logger.log_file, 'r', encoding='utf-8') as f:
lines = f.readlines() lines = f.readlines()
start = 0 start = 0
@@ -210,7 +227,7 @@ class AzurLaneAutoScript:
method = self.config.Optimization_WhenTaskQueueEmpty method = self.config.Optimization_WhenTaskQueueEmpty
if method == 'close_game': if method == 'close_game':
logger.info('Close game during wait') logger.info('Close game during wait')
self.device.app_stop() self.run('stop')
release_resources() release_resources()
self.device.release_during_wait() self.device.release_during_wait()
if not self.wait_until(task.next_run): 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.base.utils import *
from module.config.config import AzurLaneConfig from module.config.config import AzurLaneConfig
from module.device.device import Device from module.device.device import Device
from module.device.method.utils import HierarchyButton
from module.logger import logger from module.logger import logger
from module.webui.setting import cached_class_property
class ModuleBase: class ModuleBase:
@@ -45,6 +47,26 @@ class ModuleBase:
self.interval_timer = {} 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): def match_template(self, button, interval=0, similarity=0.85):
""" """
Args: Args:
@@ -120,9 +142,56 @@ class ModuleBase:
return appear 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): def appear_then_click(self, button, interval=5, similarity=0.85):
button = self.xpath(button)
appear = self.appear(button, interval=interval, similarity=similarity) appear = self.appear(button, interval=interval, similarity=similarity)
if appear: if appear:
self.device.click(button) self.device.click(button)
@@ -219,7 +288,26 @@ class ModuleBase:
point = fit_points(points, mod=image_size(image), encourage=encourage) point = fit_points(points, mod=image_size(image), encourage=encourage)
point = ensure_int(point + area[:2]) point = ensure_int(point + area[:2])
button_area = area_offset((-encourage, -encourage, encourage, encourage), offset=point) 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): def interval_reset(self, button, interval=5):
if isinstance(button, (list, tuple)): if isinstance(button, (list, tuple)):
@@ -228,10 +316,7 @@ class ModuleBase:
return return
if button is not None: if button is not None:
if button.name in self.interval_timer: self.get_interval_timer(button, interval=interval).reset()
self.interval_timer[button.name].reset()
else:
self.interval_timer[button.name] = Timer(interval).reset()
def interval_clear(self, button, interval=5): def interval_clear(self, button, interval=5):
if isinstance(button, (list, tuple)): if isinstance(button, (list, tuple)):
@@ -240,19 +325,11 @@ class ModuleBase:
return return
if button is not None: if button is not None:
if button.name in self.interval_timer: self.get_interval_timer(button, interval=interval).clear()
self.interval_timer[button.name].clear()
else:
self.interval_timer[button.name] = Timer(interval).clear()
def interval_is_reached(self, button, interval=5): def interval_is_reached(self, button, interval=5):
if button.name in self.interval_timer: return self.get_interval_timer(button, interval=interval, renew=True).reached()
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.interval_timer[button.name].reached()
_image_file = '' _image_file = ''
@@ -282,3 +359,31 @@ class ModuleBase:
""" """
server_.set_lang(lang) server_.set_lang(lang)
logger.attr('Lang', self.config.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(): for b in self.iter_buttons():
b.search = area 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: class ClickButton:
def __init__(self, area, button=None, name='CLICK_BUTTON'): def __init__(self, area, button=None, name='CLICK_BUTTON'):
+55 -9
View File
@@ -33,23 +33,33 @@ class TabWrapper:
self.suffix += suffix self.suffix += suffix
class VariableWrapper:
def __init__(self, name):
self.name = name
def __repr__(self):
return str(self.name)
__str__ = __repr__
class CodeGenerator: class CodeGenerator:
def __init__(self): def __init__(self):
self.tab_count = 0 self.tab_count = 0
self.lines = [] self.lines = []
def generate(self) -> t.Iterable[str]:
yield ''
def add(self, line, comment=False, newline=True): def add(self, line, comment=False, newline=True):
self.lines.append(self._line_with_tabs(line, comment=comment, newline=newline)) 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): def print(self):
lines = ''.join(self.lines) lines = self.generate()
print(lines) print(lines)
def write(self, file: str = None): def write(self, file: str = None):
lines = ''.join(self.lines) lines = self.generate()
with open(file, 'w', encoding='utf-8', newline='') as f: with open(file, 'w', encoding='utf-8', newline='') as f:
f.write(lines) f.write(lines)
@@ -77,7 +87,10 @@ class CodeGenerator:
return TabWrapper(self) return TabWrapper(self)
def Empty(self): def Empty(self):
self.add('') self.lines.append('\n')
def Pass(self):
self.add('pass')
def Import(self, text, empty=2): def Import(self, text, empty=2):
for line in text.strip().split('\n'): for line in text.strip().split('\n'):
@@ -86,6 +99,9 @@ class CodeGenerator:
for _ in range(empty): for _ in range(empty):
self.Empty() self.Empty()
def Variable(self, name):
return VariableWrapper(name)
def Value(self, key=None, value=None, type_=None, **kwargs): def Value(self, key=None, value=None, type_=None, **kwargs):
if key is not None: if key is not None:
if type_ is not None: if type_ is not None:
@@ -117,7 +133,7 @@ class CodeGenerator:
if key is not None: if key is not None:
return TabWrapper(self, prefix=str(key) + ' = [', suffix=']') return TabWrapper(self, prefix=str(key) + ' = [', suffix=']')
else: else:
return TabWrapper(self, prefix='[', suffix=']', newline=False) return TabWrapper(self, prefix='[', suffix=']')
def ListItem(self, value): def ListItem(self, value):
if isinstance(value, TabWrapper): if isinstance(value, TabWrapper):
@@ -131,7 +147,7 @@ class CodeGenerator:
if key is not None: if key is not None:
return TabWrapper(self, prefix=str(key) + ' = {', suffix='}') return TabWrapper(self, prefix=str(key) + ' = {', suffix='}')
else: else:
return TabWrapper(self, prefix='{', suffix='}', newline=False) return TabWrapper(self, prefix='{', suffix='}')
def DictItem(self, key=None, value=None): def DictItem(self, key=None, value=None):
if isinstance(value, TabWrapper): if isinstance(value, TabWrapper):
@@ -147,7 +163,7 @@ class CodeGenerator:
if key is not None: if key is not None:
return TabWrapper(self, prefix=f'{key} = {object_class}(', suffix=')') return TabWrapper(self, prefix=f'{key} = {object_class}(', suffix=')')
else: 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): def ObjectAttr(self, key=None, value=None):
if isinstance(value, TabWrapper): if isinstance(value, TabWrapper):
@@ -179,3 +195,33 @@ Value = generator.Value
Comment = generator.Comment Comment = generator.Comment
Dict = generator.Dict Dict = generator.Dict
DictItem = generator.DictItem 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}') # logger.info(f'Release {obj}')
obj.resource_release() 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 # Useless in most cases, but just call it
# gc.collect() # 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): def point_limit(point, area):
""" """
Limit point in an area. Limit point in an area.
+2 -1
View File
@@ -41,7 +41,8 @@
"aScreenCap_nc", "aScreenCap_nc",
"DroidCast", "DroidCast",
"DroidCast_raw", "DroidCast_raw",
"scrcpy" "scrcpy",
"nemu_ipc"
], ],
"display": "hide" "display": "hide"
}, },
+1 -1
View File
@@ -26,7 +26,7 @@ Emulator:
option: [ auto, jp, en, zht ] option: [ auto, jp, en, zht ]
ScreenshotMethod: ScreenshotMethod:
value: auto 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: ControlMethod:
value: MaaTouch value: MaaTouch
option: [ minitouch, MaaTouch ] option: [ minitouch, MaaTouch ]
+8 -6
View File
@@ -3,8 +3,6 @@ import datetime
import operator import operator
import threading import threading
import pywebio
from module.base.decorator import cached_property, del_cached_property from module.base.decorator import cached_property, del_cached_property
from module.base.filter import Filter from module.base.filter import Filter
from module.config.config_generated import GeneratedConfig from module.config.config_generated import GeneratedConfig
@@ -129,13 +127,17 @@ class AzurLaneConfig(ConfigUpdater, ManualConfig, GeneratedConfig, ConfigWatcher
""" """
Args: Args:
func (str, Function): Function to run 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): if isinstance(func, Function):
func = func.command 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}") logger.info(f"Bind task {func_list}")
# Bind arguments # Bind arguments
+1 -2
View File
@@ -16,10 +16,9 @@ class GeneratedConfig:
Scheduler_ServerUpdate = '04:00' Scheduler_ServerUpdate = '04:00'
# Group `Emulator` # Group `Emulator`
Emulator_Serial = 'auto'
Emulator_PackageName = 'auto' # auto, JP-Official, OVERSEA-TWHKMO, OVERSEA-Korea, OVERSEA-Asia, OVERSEA-America, OVERSEA-Global Emulator_PackageName = 'auto' # auto, JP-Official, OVERSEA-TWHKMO, OVERSEA-Korea, OVERSEA-Asia, OVERSEA-America, OVERSEA-Global
Emulator_GameLanguage = 'auto' # auto, jp, en, zht 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_ControlMethod = 'MaaTouch' # minitouch, MaaTouch
Emulator_AdbRestart = False Emulator_AdbRestart = False
+60
View File
@@ -1,3 +1,4 @@
import typing as t
from copy import deepcopy from copy import deepcopy
from cached_property import cached_property from cached_property import cached_property
@@ -569,6 +570,65 @@ class ConfigUpdater:
# set_daily('Use_Consumables_1_time', 'achievable') # set_daily('Use_Consumables_1_time', 'achievable')
# return data # 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): def read_file(self, config_name, is_template=False):
""" """
Read and update config file. Read and update config file.
+2 -1
View File
@@ -142,7 +142,8 @@
"aScreenCap_nc": "aScreenCap_nc", "aScreenCap_nc": "aScreenCap_nc",
"DroidCast": "DroidCast", "DroidCast": "DroidCast",
"DroidCast_raw": "DroidCast_raw", "DroidCast_raw": "DroidCast_raw",
"scrcpy": "scrcpy" "scrcpy": "scrcpy",
"nemu_ipc": "nemu_ipc"
}, },
"ControlMethod": { "ControlMethod": {
"name": "Control Method", "name": "Control Method",
+2 -1
View File
@@ -142,7 +142,8 @@
"aScreenCap_nc": "aScreenCap_nc", "aScreenCap_nc": "aScreenCap_nc",
"DroidCast": "DroidCast", "DroidCast": "DroidCast",
"DroidCast_raw": "DroidCast_raw", "DroidCast_raw": "DroidCast_raw",
"scrcpy": "scrcpy" "scrcpy": "scrcpy",
"nemu_ipc": "nemu_ipc"
}, },
"ControlMethod": { "ControlMethod": {
"name": "模拟器控制方案", "name": "模拟器控制方案",
+3
View File
@@ -157,6 +157,9 @@ class StoredCounter(StoredBase):
def get_remain(self) -> int: def get_remain(self) -> int:
return self.total - self.value return self.total - self.value
def add(self, value=1):
self.value += value
@cached_property @cached_property
def _attrs(self) -> dict: def _attrs(self) -> dict:
attrs = super()._attrs attrs = super()._attrs
+1 -1
View File
@@ -543,7 +543,7 @@ def get_server_last_monday_update(daily_trigger):
Returns: Returns:
datetime.datetime datetime.datetime
""" """
update = get_server_next_update(daily_trigger) update = get_server_last_update(daily_trigger)
diff = update.weekday() diff = update.weekday()
update = update - timedelta(days=diff) update = update - timedelta(days=diff)
return update return update
+7 -4
View File
@@ -49,11 +49,14 @@ class AppControl(Adb, WSA, Uiautomator2):
Returns: Returns:
etree._Element: Select elements with `self.hierarchy.xpath('//*[@text="Hermit"]')` for example. etree._Element: Select elements with `self.hierarchy.xpath('//*[@text="Hermit"]')` for example.
""" """
method = self.config.Emulator_ControlMethod # method = self.config.Emulator_ControlMethod
if method in AppControl._app_u2_family: # 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() self.hierarchy = self.dump_hierarchy_uiautomator2()
else:
self.hierarchy = self.dump_hierarchy_adb()
return self.hierarchy return self.hierarchy
def xpath_to_button(self, xpath: str) -> HierarchyButton: def xpath_to_button(self, xpath: str) -> HierarchyButton:
+123 -12
View File
@@ -1,9 +1,9 @@
import ipaddress import ipaddress
import logging import logging
import platform
import re import re
import socket import socket
import subprocess import subprocess
import sys
import time import time
from functools import wraps from functools import wraps
@@ -12,7 +12,8 @@ from adbutils import AdbClient, AdbDevice, AdbTimeout, ForwardItem, ReverseItem
from adbutils.errors import AdbError from adbutils.errors import AdbError
import module.config.server as server_ 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.base.utils import SelectedGrids, ensure_time
from module.device.connection_attr import ConnectionAttr from module.device.connection_attr import ConnectionAttr
from module.device.method.utils import ( from module.device.method.utils import (
@@ -84,6 +85,18 @@ class AdbDeviceWithStatus(AdbDevice):
def __bool__(self): def __bool__(self):
return True 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): class Connection(ConnectionAttr):
def __init__(self, config): def __init__(self, config):
@@ -266,14 +279,20 @@ class Connection(ConnectionAttr):
return True return True
return False 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): def check_mumu_app_keep_alive(self):
if not self.is_mumu_family: if not self.is_mumu_family:
return False return False
res = self.adb_getprop('nemud.app_keep_alive') res = self.nemud_app_keep_alive
logger.attr('nemud.app_keep_alive', res)
if res == '': 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 return True
elif res == 'false': elif res == 'false':
# Disabled # Disabled
@@ -286,6 +305,15 @@ class Connection(ConnectionAttr):
logger.warning(f'Invalid nemud.app_keep_alive value: {res}') logger.warning(f'Invalid nemud.app_keep_alive value: {res}')
return False 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 @cached_property
def _nc_server_host_port(self): def _nc_server_host_port(self):
""" """
@@ -536,14 +564,14 @@ class Connection(ConnectionAttr):
# Disconnect offline device before connecting # Disconnect offline device before connecting
for device in self.list_device(): for device in self.list_device():
if device.status == 'offline': if device.status == 'offline':
logger.warning(f'Device {serial} is offline, disconnect it before connecting') logger.warning(f'Device {device.serial} is offline, disconnect it before connecting')
self.adb_disconnect(serial) self.adb_disconnect(device.serial)
elif device.status == 'unauthorized': 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': elif device.status == 'device':
pass pass
else: 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 # Skip for emulator-5554
if 'emulator-' in serial: if 'emulator-' in serial:
@@ -751,6 +779,17 @@ class Connection(ConnectionAttr):
If serial=='auto' and only 1 device detected, use it If serial=='auto' and only 1 device detected, use it
""" """
logger.hr('Detect device') logger.hr('Detect device')
available = SelectedGrids([])
devices = SelectedGrids([])
@run_once
def brute_force_connect():
logger.info('Brute force connect')
from deploy.Windows.emulator import EmulatorManager
manager = EmulatorManager()
manager.brute_force_connect()
for _ in range(2):
logger.info('Here are the available devices, ' logger.info('Here are the available devices, '
'copy to Alas.Emulator.Serial to use it or set Alas.Emulator.Serial="auto"') 'copy to Alas.Emulator.Serial to use it or set Alas.Emulator.Serial="auto"')
devices = self.list_device() devices = self.list_device()
@@ -769,6 +808,17 @@ class Connection(ConnectionAttr):
for device in unavailable: for device in unavailable:
logger.info(f'{device.serial} ({device.status})') 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 # Auto device detection
if self.config.Emulator_Serial == 'auto': if self.config.Emulator_Serial == 'auto':
if available.count == 0: if available.count == 0:
@@ -777,7 +827,16 @@ class Connection(ConnectionAttr):
raise RequestHumanTakeover raise RequestHumanTakeover
elif available.count == 1: elif available.count == 1:
logger.info(f'Auto device detection found only one device, using it') 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') del_cached_property(self, 'adb')
else: else:
logger.critical('Multiple devices found, auto device detection cannot decide which to choose, ' logger.critical('Multiple devices found, auto device detection cannot decide which to choose, '
@@ -786,6 +845,7 @@ class Connection(ConnectionAttr):
# Handle LDPlayer # Handle LDPlayer
# LDPlayer serial jumps between `127.0.0.1:5555+{X}` and `emulator-5554+{X}` # 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) port_serial, emu_serial = get_serial_pair(self.serial)
if port_serial and emu_serial: if port_serial and emu_serial:
# Might be LDPlayer, check connected devices # Might be LDPlayer, check connected devices
@@ -812,6 +872,57 @@ class Connection(ConnectionAttr):
f'Using serial: {emu_serial}') f'Using serial: {emu_serial}')
self.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 @retry
def list_package(self, show_log=True): def list_package(self, show_log=True):
""" """
@@ -833,7 +944,7 @@ class Connection(ConnectionAttr):
packages = re.findall(r'package:([^\s]+)', output) packages = re.findall(r'package:([^\s]+)', output)
return packages return packages
def list_azurlane_packages(self, show_log=True): def list_known_packages(self, show_log=True):
""" """
Args: Args:
show_log: show_log:
@@ -850,7 +961,7 @@ class Connection(ConnectionAttr):
Show all possible packages with the given keyword on this device. Show all possible packages with the given keyword on this device.
""" """
logger.hr('Detect package') logger.hr('Detect package')
packages = self.list_azurlane_packages() packages = self.list_known_packages()
# Show packages # Show packages
logger.info(f'Here are the available packages in device "{self.serial}", ' 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.base.decorator import cached_property
from module.config.config import AzurLaneConfig from module.config.config import AzurLaneConfig
from module.config.utils import deep_iter
from module.exception import RequestHumanTakeover from module.exception import RequestHumanTakeover
from module.logger import logger from module.logger import logger
@@ -123,11 +122,27 @@ class ConnectionAttr:
def is_wsa(self): def is_wsa(self):
return bool(re.match(r'^wsa', self.serial)) 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 @cached_property
def is_mumu_family(self): def is_mumu_family(self):
# 127.0.0.1:7555 # 127.0.0.1:7555
# 127.0.0.1:16384 + 32*n # 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 @cached_property
def is_emulator(self): def is_emulator(self):
@@ -173,7 +188,8 @@ class ConnectionAttr:
rf"SOFTWARE\BlueStacks_bgp64_hyperv\Guests\{folder_name}\Config") as key: rf"SOFTWARE\BlueStacks_bgp64_hyperv\Guests\{folder_name}\Config") as key:
port = QueryValueEx(key, "BstAdbPort")[0] port = QueryValueEx(key, "BstAdbPort")[0]
except FileNotFoundError: 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('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 ' logger.error(r'Please check if there is any other emulator instances under '
r'registry HKEY_LOCAL_MACHINE\SOFTWARE\BlueStacks_bgp64_hyperv\Guests') 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.hermit import Hermit
from module.device.method.maatouch import MaaTouch from module.device.method.maatouch import MaaTouch
from module.device.method.minitouch import Minitouch from module.device.method.minitouch import Minitouch
from module.device.method.nemu_ipc import NemuIpc
from module.device.method.scrcpy import Scrcpy from module.device.method.scrcpy import Scrcpy
from module.logger import logger from module.logger import logger
import numpy as np
class Control(Hermit, Minitouch, Scrcpy, MaaTouch, NemuIpc):
class Control(Hermit, Minitouch, Scrcpy, MaaTouch):
def handle_control_check(self, button): def handle_control_check(self, button):
# Will be overridden in Device # Will be overridden in Device
pass pass
@@ -24,6 +23,7 @@ class Control(Hermit, Minitouch, Scrcpy, MaaTouch):
'minitouch': self.click_minitouch, 'minitouch': self.click_minitouch,
'Hermit': self.click_hermit, 'Hermit': self.click_hermit,
'MaaTouch': self.click_maatouch, 'MaaTouch': self.click_maatouch,
'nemu_ipc': self.click_nemu_ipc,
} }
def click(self, button, control_check=True): def click(self, button, control_check=True):
@@ -80,6 +80,8 @@ class Control(Hermit, Minitouch, Scrcpy, MaaTouch):
self.long_click_scrcpy(x, y, duration) self.long_click_scrcpy(x, y, duration)
elif method == 'MaaTouch': elif method == 'MaaTouch':
self.long_click_maatouch(x, y, duration) self.long_click_maatouch(x, y, duration)
elif method == 'nemu_ipc':
self.long_click_nemu_ipc(x, y, duration)
else: else:
self.swipe_adb((x, y), (x, y), duration) self.swipe_adb((x, y), (x, y), duration)
@@ -88,13 +90,9 @@ class Control(Hermit, Minitouch, Scrcpy, MaaTouch):
p1, p2 = ensure_int(p1, p2) p1, p2 = ensure_int(p1, p2)
duration = ensure_time(duration) duration = ensure_time(duration)
method = self.config.Emulator_ControlMethod method = self.config.Emulator_ControlMethod
if method == 'minitouch': if method == 'uiautomator2':
logger.info('Swipe %s -> %s' % (point2str(*p1), point2str(*p2)))
elif method == 'uiautomator2':
logger.info('Swipe %s -> %s, %s' % (point2str(*p1), point2str(*p2), duration)) logger.info('Swipe %s -> %s, %s' % (point2str(*p1), point2str(*p2), duration))
elif method == 'scrcpy': elif method in ['minitouch', 'MaaTouch', 'scrcpy', 'nemu_ipc']:
logger.info('Swipe %s -> %s' % (point2str(*p1), point2str(*p2)))
elif method == 'MaaTouch':
logger.info('Swipe %s -> %s' % (point2str(*p1), point2str(*p2))) logger.info('Swipe %s -> %s' % (point2str(*p1), point2str(*p2)))
else: else:
# ADB needs to be slow, or swipe doesn't work # 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) self.swipe_scrcpy(p1, p2)
elif method == 'MaaTouch': elif method == 'MaaTouch':
self.swipe_maatouch(p1, p2) self.swipe_maatouch(p1, p2)
elif method == 'nemu_ipc':
self.swipe_nemu_ipc(p1, p2)
else: else:
self.swipe_adb(p1, p2, duration=duration) 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) self.drag_scrcpy(p1, p2, point_random=point_random)
elif method == 'MaaTouch': elif method == 'MaaTouch':
self.drag_maatouch(p1, p2, point_random=point_random) self.drag_maatouch(p1, p2, point_random=point_random)
elif method == 'nemu_ipc':
self.drag_nemu_ipc(p1, p2, point_random=point_random)
else: else:
logger.warning(f'Control method {method} does not support drag well, ' logger.warning(f'Control method {method} does not support drag well, '
f'falling back to ADB swipe may cause unexpected behaviour') f'falling back to ADB swipe may cause unexpected behaviour')
self.swipe_adb(p1, p2, duration=ensure_time(swipe_duration * 2)) 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 # just used in cafe
def pinch(self, box=(35, 130, 1250, 560), name='PINCH'): def pinch(self, box=(35, 130, 1250, 560), name='PINCH'):
+74 -8
View File
@@ -1,5 +1,4 @@
import sys import collections
from collections import deque
from module.base.timer import Timer from module.base.timer import Timer
from module.device.app_control import AppControl from module.device.app_control import AppControl
@@ -14,16 +13,51 @@ from module.exception import (
) )
from module.logger import logger from module.logger import logger
if sys.platform == 'win32':
from module.device.platform.platform_windows import PlatformWindows as Platform def show_function_call():
else: """
from module.device.platform.platform_base import PlatformBase as Platform 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 _screen_size_checked = False
detect_record = set() detect_record = set()
click_record = deque(maxlen=15) click_record = collections.deque(maxlen=15)
stuck_timer = Timer(60, count=60).start() stuck_timer = Timer(60, count=60).start()
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
@@ -42,12 +76,23 @@ class Device(Screenshot, Control, AppControl, Platform):
) )
raise raise
# Auto-fill emulator info
if self.config.EmulatorInfo_Emulator == 'auto':
_ = self.emulator_instance
self.screenshot_interval_set() self.screenshot_interval_set()
self.method_check()
# Auto-select the fastest screenshot method # Auto-select the fastest screenshot method
if not self.config.is_template_config and self.config.Emulator_ScreenshotMethod == 'auto': if not self.config.is_template_config and self.config.Emulator_ScreenshotMethod == 'auto':
self.run_simple_screenshot_benchmark() 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): def run_simple_screenshot_benchmark(self):
""" """
Perform a screenshot method benchmark, test 3 times on each method. 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) bench = Benchmark(config=self.config, device=self)
method = bench.run_simple_screenshot_benchmark() method = bench.run_simple_screenshot_benchmark()
# Set # Set
with self.config.multi_set():
self.config.Emulator_ScreenshotMethod = method 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): def screenshot(self):
""" """
@@ -87,6 +148,8 @@ class Device(Screenshot, Control, AppControl, Platform):
# stop it during wait # stop it during wait
if self.config.Emulator_ScreenshotMethod == 'scrcpy': if self.config.Emulator_ScreenshotMethod == 'scrcpy':
self._scrcpy_server_stop() self._scrcpy_server_stop()
if self.config.Emulator_ScreenshotMethod == 'nemu_ipc':
self.nemu_ipc_release()
def stuck_record_add(self, button): def stuck_record_add(self, button):
self.detect_record.add(str(button)) self.detect_record.add(str(button))
@@ -104,6 +167,7 @@ class Device(Screenshot, Control, AppControl, Platform):
if not reached: if not reached:
return False return False
show_function_call()
logger.warning('Wait too long') logger.warning('Wait too long')
logger.warning(f'Waiting for {self.detect_record}') logger.warning(f'Waiting for {self.detect_record}')
self.stuck_record_clear() self.stuck_record_clear()
@@ -155,11 +219,13 @@ class Device(Screenshot, Control, AppControl, Platform):
count[key] = count.get(key, 0) + 1 count[key] = count.get(key, 0) + 1
count = sorted(count.items(), key=lambda item: item[1]) count = sorted(count.items(), key=lambda item: item[1])
if count[0][1] >= 12: if count[0][1] >= 12:
show_function_call()
logger.warning(f'Too many click for a button: {count[0][0]}') 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]}') logger.warning(f'History click: {[str(prev) for prev in self.click_record]}')
self.click_record_clear() self.click_record_clear()
raise GameTooManyClickError(f'Too many click for a button: {count[0][0]}') 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: 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'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]}') logger.warning(f'History click: {[str(prev) for prev in self.click_record]}')
self.click_record_clear() self.click_record_clear()
+4 -4
View File
@@ -1,16 +1,16 @@
import re import re
import time
from functools import wraps from functools import wraps
import cv2 import cv2
import numpy as np import numpy as np
import time
from adbutils.errors import AdbError from adbutils.errors import AdbError
from lxml import etree from lxml import etree
from module.base.decorator import Config from module.base.decorator import Config
from module.device.connection import Connection from module.device.connection import Connection
from module.device.method.utils import (RETRY_TRIES, retry_sleep, remove_prefix, handle_adb_error, from module.device.method.utils import (ImageTruncated, PackageNotInstalled, RETRY_TRIES, handle_adb_error,
ImageTruncated, PackageNotInstalled) remove_prefix, retry_sleep)
from module.exception import RequestHumanTakeover, ScriptError from module.exception import RequestHumanTakeover, ScriptError
from module.logger import logger from module.logger import logger
@@ -128,7 +128,7 @@ class Adb(Connection):
if image is None: if image is None:
raise ImageTruncated('Empty image after cv2.imdecode') 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: if image is None:
raise ImageTruncated('Empty image after cv2.cvtColor') 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.base.utils import *
from module.device.connection import Connection from module.device.connection import Connection
from module.device.method.utils import (RETRY_TRIES, retry_sleep, from module.device.method.utils import (ImageTruncated, RETRY_TRIES, handle_adb_error, retry_sleep)
handle_adb_error, ImageTruncated)
from module.exception import RequestHumanTakeover, ScriptError from module.exception import RequestHumanTakeover, ScriptError
from module.logger import logger from module.logger import logger
@@ -165,11 +164,11 @@ class AScreenCap(Connection):
# ValueError: cannot reshape array of size 0 into shape (720,1280,4) # ValueError: cannot reshape array of size 0 into shape (720,1280,4)
raise ImageTruncated(str(e)) raise ImageTruncated(str(e))
image = cv2.flip(image, 0) image = cv2.flip(image, 0, dst=image)
if image is None: if image is None:
raise ImageTruncated('Empty image after cv2.flip') 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: if image is None:
raise ImageTruncated('Empty image after cv2.cvtColor') raise ImageTruncated('Empty image after cv2.cvtColor')
+112 -62
View File
@@ -6,11 +6,11 @@ import numpy as np
import requests import requests
from adbutils.errors import AdbError 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.base.timer import Timer
from module.device.method.uiautomator_2 import Uiautomator2, ProcessInfo from module.device.method.uiautomator_2 import ProcessInfo, Uiautomator2
from module.device.method.utils import (retry_sleep, RETRY_TRIES, handle_adb_error, from module.device.method.utils import (
ImageTruncated, PackageNotInstalled) ImageTruncated, PackageNotInstalled, RETRY_TRIES, handle_adb_error, retry_sleep)
from module.exception import RequestHumanTakeover from module.exception import RequestHumanTakeover
from module.logger import logger from module.logger import logger
@@ -91,10 +91,12 @@ def retry(func):
class DroidCast(Uiautomator2): class DroidCast(Uiautomator2):
""" """
DroidCast, another screenshot method, https://github.com/rayworks/DroidCast 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_port: int = 0
droidcast_width: int = 0
droidcast_height: int = 0
@cached_property @cached_property
def droidcast_session(self): def droidcast_session(self):
@@ -103,106 +105,137 @@ class DroidCast(Uiautomator2):
self._droidcast_port = self.adb_forward('tcp:53516') self._droidcast_port = self.adb_forward('tcp:53516')
return session return session
def droidcast_url(self, url='/screenshot?format=png'):
""" """
Check APIs from source code: Check APIs from source code:
https://github.com/rayworks/DroidCast/blob/master/app/src/main/java/com/rayworks/droidcast/Main.java https://github.com/Torther/DroidCast_raw/blob/DroidCast_raw/app/src/main/java/ink/mol/droidcast_raw/KtMain.kt
Available APIs: Available APIs:
- /screenshot - /screenshot
To get JPG screenshots. To get a RGB565 bitmap
- /screenshot?format=png - /preview
To get PNG screenshots. To get PNG screenshots.
- /screenshot?format=webp
To get WEBP screenshots.
- /src
Websocket to get JPG screenshots.
Note that /screenshot?format=jpg is unavailable.
""" """
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
return f'http://127.0.0.1:{self._droidcast_port}{url}' return f'http://127.0.0.1:{self._droidcast_port}{url}'
@Config.when(DROIDCAST_VERSION='DroidCast')
def droidcast_init(self): def droidcast_init(self):
logger.hr('Droidcast init') logger.hr('DroidCast init')
self.droidcast_stop() self.droidcast_stop()
self._droidcast_update_resolution()
logger.info('Pushing DroidCast apk') logger.info('Pushing DroidCast apk')
self.adb_push(self.config.DROIDCAST_FILEPATH_LOCAL, self.config.DROIDCAST_FILEPATH_REMOTE) self.adb_push(self.config.DROIDCAST_FILEPATH_LOCAL, self.config.DROIDCAST_FILEPATH_REMOTE)
logger.info('Starting DroidCast apk') 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([ resp = self.u2_shell_background([
'CLASSPATH=/data/local/tmp/DroidCast.apk', 'CLASSPATH=/data/local/tmp/DroidCast_raw.apk',
'app_process', 'app_process',
'/', '/',
'com.rayworks.droidcast.Main', 'ink.mol.droidcast_raw.Main',
'>', '>',
'/dev/null' '/dev/null'
]) ])
logger.info(resp) logger.info(resp)
del_cached_property(self, 'droidcast_session') del_cached_property(self, 'droidcast_session')
_ = self.droidcast_session _ = self.droidcast_session
if self.config.DROIDCAST_VERSION == 'DroidCast':
logger.attr('DroidCast', self.droidcast_url()) logger.attr('DroidCast', self.droidcast_url())
self.droidcast_wait_startup() self.droidcast_wait_startup()
elif self.config.DROIDCAST_VERSION == 'DroidCast_raw':
@Config.when(DROIDCAST_VERSION='DroidCast_raw') logger.attr('DroidCast_raw', self.droidcast_raw_url())
def droidcast_init(self):
logger.hr('Droidcast init')
self.resolution_check_uiautomator2()
self.droidcast_stop()
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() self.droidcast_wait_startup()
else:
logger.error(f'Unknown DROIDCAST_VERSION: {self.config.DROIDCAST_VERSION}')
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 @retry
def screenshot_droidcast(self): def screenshot_droidcast(self):
self.config.DROIDCAST_VERSION = 'DroidCast' 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) image = np.frombuffer(image, np.uint8)
if image is None: if image is None:
raise ImageTruncated('Empty image after reading from buffer') raise ImageTruncated('Empty image after reading from buffer')
if image.shape == (1843200,): if image.shape == (1843200,):
raise DroidCastVersionIncompatible('Requesting screenshots from `DroidCast` but server is `DroidCast_raw`') 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) image = cv2.imdecode(image, cv2.IMREAD_COLOR)
if image is None: if image is None:
raise ImageTruncated('Empty image after cv2.imdecode') 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: if image is None:
raise ImageTruncated('Empty image after cv2.cvtColor') 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 return image
@retry @retry
def screenshot_droidcast_raw(self): def screenshot_droidcast_raw(self):
self.config.DROIDCAST_VERSION = 'DroidCast_raw' 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 # DroidCast_raw returns a RGB565 bitmap
try: try:
arr = np.frombuffer(image, dtype=np.uint16).reshape((720, 1280)) arr = np.frombuffer(image, dtype=np.uint16).reshape(shape)
except ValueError as e: except ValueError as e:
if len(image) < 500:
logger.warning(f'Unexpected screenshot: {image}')
# Try to load as `DroidCast` # Try to load as `DroidCast`
image = np.frombuffer(image, np.uint8) image = np.frombuffer(image, np.uint8)
if image is not None: if image is not None:
@@ -228,14 +261,30 @@ class DroidCast(Uiautomator2):
# image = cv2.merge([r, g, b]) # image = cv2.merge([r, g, b])
# The same as the code above but costs about 5ms instead of 10ms. # The same as the code above but costs about 5ms instead of 10ms.
r = cv2.multiply(arr & 0b1111100000000000, 0.00390625).astype(np.uint8) r = cv2.bitwise_and(arr, 0b1111100000000000)
g = cv2.multiply(arr & 0b0000011111100000, 0.125).astype(np.uint8) cv2.multiply(r, 0.00390625, dst=r)
b = cv2.multiply(arr & 0b0000000000011111, 8).astype(np.uint8) r = np.uint8(r)
r = cv2.add(r, cv2.multiply(r, 0.03125)) m = cv2.multiply(r, 0.03125)
g = cv2.add(g, cv2.multiply(g, 0.015625)) cv2.add(r, m, dst=r)
b = cv2.add(b, cv2.multiply(b, 0.03125))
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]) 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 return image
def droidcast_wait_startup(self): def droidcast_wait_startup(self):
@@ -262,13 +311,12 @@ class DroidCast(Uiautomator2):
def droidcast_uninstall(self): def droidcast_uninstall(self):
""" """
Stop all DroidCast processes and remove DroidCast APK. Stop DroidCast processes and remove DroidCast APK.
DroidCast has't been installed but a JAVA class call, uninstall is a file delete. DroidCast hasn't been installed but a JAVA class call, uninstall is a file delete.
""" """
self.droidcast_stop() self.droidcast_stop()
logger.info('Removing DroidCast') logger.info('Removing DroidCast')
self.adb_shell(["rm", self.config.DROIDCAST_FILEPATH_REMOTE]) 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]: def _iter_droidcast_proc(self) -> t.Iterable[ProcessInfo]:
""" """
@@ -280,10 +328,12 @@ class DroidCast(Uiautomator2):
yield proc yield proc
if 'com.torther.droidcasts.Main' in proc.cmdline: if 'com.torther.droidcasts.Main' in proc.cmdline:
yield proc yield proc
if 'ink.mol.droidcast_raw.Main' in proc.cmdline:
yield proc
def droidcast_stop(self): def droidcast_stop(self):
""" """
Stop all DroidCast processes. Stop DroidCast processes.
""" """
logger.info('Stopping DroidCast') logger.info('Stopping DroidCast')
for proc in self._iter_droidcast_proc(): 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)
+5 -5
View File
@@ -1,4 +1,3 @@
import typing as t
from dataclasses import dataclass from dataclasses import dataclass
from functools import wraps from functools import wraps
from json.decoder import JSONDecodeError from json.decoder import JSONDecodeError
@@ -10,8 +9,8 @@ from lxml import etree
from module.base.utils import * from module.base.utils import *
from module.device.connection import Connection from module.device.connection import Connection
from module.device.method.utils import (RETRY_TRIES, retry_sleep, handle_adb_error, from module.device.method.utils import (ImageTruncated, PackageNotInstalled, RETRY_TRIES, handle_adb_error,
ImageTruncated, PackageNotInstalled, possible_reasons) possible_reasons, retry_sleep)
from module.exception import RequestHumanTakeover from module.exception import RequestHumanTakeover
from module.logger import logger from module.logger import logger
@@ -122,7 +121,7 @@ class Uiautomator2(Connection):
if image is None: if image is None:
raise ImageTruncated('Empty image after cv2.imdecode') 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: if image is None:
raise ImageTruncated('Empty image after cv2.cvtColor') raise ImageTruncated('Empty image after cv2.cvtColor')
@@ -243,7 +242,7 @@ class Uiautomator2(Connection):
return hierarchy return hierarchy
@retry @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. Faster u2.window_size(), cause that calls `dumpsys display` twice.
@@ -252,6 +251,7 @@ class Uiautomator2(Connection):
""" """
info = self.u2.http.get('/info').json() info = self.u2.http.get('/info').json()
w, h = info['display']['width'], info['display']['height'] w, h = info['display']['width'], info['display']['height']
if cal_rotation:
rotation = self.get_orientation() rotation = self.get_orientation()
if (w > h) != (rotation % 2 == 1): if (w > h) != (rotation % 2 == 1):
w, h = h, w w, h = h, w
+98 -6
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` # We expect `screencap | nc 192.168.0.1 20298` instead of `screencap '|' nc 192.168.80.1 20298`
import adbutils import adbutils
import subprocess import subprocess
adbutils._utils.list2cmdline = subprocess.list2cmdline adbutils._utils.list2cmdline = subprocess.list2cmdline
adbutils._device.list2cmdline = subprocess.list2cmdline adbutils._device.list2cmdline = subprocess.list2cmdline
# BaseDevice.shell() is missing a check_okay() call before reading output, # BaseDevice.shell() is missing a check_okay() call before reading output,
# resulting in an `OKAY` prefix in output. # resulting in an `OKAY` prefix in output.
def shell(self, def shell(self,
@@ -40,6 +42,7 @@ except ImportError:
output = c.read_until_close() output = c.read_until_close()
return output.rstrip() if rstrip else output return output.rstrip() if rstrip else output
adbutils._device.BaseDevice.shell = shell adbutils._device.BaseDevice.shell = shell
from module.base.decorator import cached_property from module.base.decorator import cached_property
@@ -323,7 +326,7 @@ class HierarchyButton:
if res: if res:
return res[0] return res[0]
else: else:
return 'HierarchyButton' return self.xpath
@cached_property @cached_property
def count(self): def count(self):
@@ -333,15 +336,30 @@ class HierarchyButton:
def exist(self): def exist(self):
return self.count == 1 return self.count == 1
@cached_property
def attrib(self):
if self.exist:
return self.nodes[0].attrib
else:
return {}
@cached_property @cached_property
def area(self): def area(self):
if self.exist: 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)) lx, ly, rx, ry = map(int, re.findall(r"\d+", bounds))
return lx, ly, rx, ry return lx, ly, rx, ry
else: else:
return None 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 @cached_property
def button(self): def button(self):
return self.area return self.area
@@ -352,9 +370,83 @@ class HierarchyButton:
def __str__(self): def __str__(self):
return self.name return self.name
"""
Element props
"""
def _get_bool_prop(self, prop: str) -> bool:
return self.attrib.get(prop, "").lower() == 'true'
@cached_property @cached_property
def focused(self): def index(self) -> int:
if self.exist: try:
return self.nodes[0].attrib.get("focused").lower() == 'true' return int(self.attrib.get("index", 0))
else: 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 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
+31 -4
View File
@@ -36,6 +36,21 @@ def get_serial_pair(serial):
return None, None 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 @dataclass
class EmulatorInstanceBase: class EmulatorInstanceBase:
# Serial for adb connection # Serial for adb connection
@@ -54,7 +69,7 @@ class EmulatorInstanceBase:
Returns: Returns:
str: Emulator type, such as Emulator.NoxPlayer str: Emulator type, such as Emulator.NoxPlayer
""" """
return EmulatorBase.path_to_type(self.path) return self.emulator.type
@cached_property @cached_property
def emulator(self): def emulator(self):
@@ -83,8 +98,9 @@ class EmulatorInstanceBase:
def MuMuPlayer12_id(self): def MuMuPlayer12_id(self):
""" """
Convert MuMu 12 instance name to instance id. Convert MuMu 12 instance name to instance id.
Example name: MuMuPlayer-12.0-3 Example names:
Example ID : 3 MuMuPlayer-12.0-3
YXArkNights-12.0-1
Returns: Returns:
int: Instance ID, or None if this is not a MuMu 12 instance int: Instance ID, or None if this is not a MuMu 12 instance
@@ -92,7 +108,10 @@ class EmulatorInstanceBase:
res = re.search(r'MuMuPlayer-12.0-(\d+)', self.name) res = re.search(r'MuMuPlayer-12.0-(\d+)', self.name)
if res: if res:
return int(res.group(1)) return int(res.group(1))
else: res = re.search(r'YXArkNights-12.0-(\d+)', self.name)
if res:
return int(res.group(1))
return None return None
@@ -201,6 +220,14 @@ class EmulatorBase:
class EmulatorManagerBase: class EmulatorManagerBase:
@staticmethod
def iter_running_emulator():
"""
Yields:
str: Path to emulator executables, may contains duplicate values
"""
return
@cached_property @cached_property
def all_emulators(self) -> t.List[EmulatorBase]: def all_emulators(self) -> t.List[EmulatorBase]:
""" """
+71 -32
View File
@@ -8,8 +8,9 @@ from dataclasses import dataclass
# module/device/platform/emulator_base.py # module/device/platform/emulator_base.py
# module/device/platform/emulator_windows.py # module/device/platform/emulator_windows.py
# Will be used in Alas Easy Install, they shouldn't import any Alas modules. # 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.utils import cached_property, iter_folder
from module.device.platform.emulator_base import EmulatorBase, EmulatorInstanceBase, EmulatorManagerBase
@dataclass @dataclass
@@ -56,14 +57,6 @@ def abspath(path):
class EmulatorInstance(EmulatorInstanceBase): 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 @cached_property
def emulator(self): def emulator(self):
""" """
@@ -78,7 +71,7 @@ class Emulator(EmulatorBase):
def path_to_type(cls, path: str) -> str: def path_to_type(cls, path: str) -> str:
""" """
Args: Args:
path: Path to .exe file path: Path to .exe file, case insensitive
Returns: Returns:
str: Emulator type, such as Emulator.NoxPlayer str: Emulator type, such as Emulator.NoxPlayer
@@ -86,46 +79,49 @@ class Emulator(EmulatorBase):
folder, exe = os.path.split(path) folder, exe = os.path.split(path)
folder, dir1 = os.path.split(folder) folder, dir1 = os.path.split(folder)
folder, dir2 = os.path.split(folder) folder, dir2 = os.path.split(folder)
if exe == 'Nox.exe': exe = exe.lower()
if dir2 == 'Nox': dir1 = dir1.lower()
dir2 = dir2.lower()
if exe == 'nox.exe':
if dir2 == 'nox':
return cls.NoxPlayer return cls.NoxPlayer
elif dir2 == 'Nox64': elif dir2 == 'nox64':
return cls.NoxPlayer64 return cls.NoxPlayer64
else: else:
return cls.NoxPlayer return cls.NoxPlayer
if exe == 'Bluestacks.exe': if exe == 'bluestacks.exe':
if dir1 in ['BlueStacks', 'BlueStacks_cn']: if dir1 in ['bluestacks', 'bluestacks_cn']:
return cls.BlueStacks4 return cls.BlueStacks4
elif dir1 in ['BlueStacks_nxt', 'BlueStacks_nxt_cn']: elif dir1 in ['bluestacks_nxt', 'bluestacks_nxt_cn']:
return cls.BlueStacks5 return cls.BlueStacks5
else: else:
return cls.BlueStacks4 return cls.BlueStacks4
if exe == 'HD-Player.exe': if exe == 'hd-player.exe':
if dir1 in ['BlueStacks', 'BlueStacks_cn']: if dir1 in ['bluestacks', 'bluestacks_cn']:
return cls.BlueStacks4 return cls.BlueStacks4
elif dir1 in ['BlueStacks_nxt', 'BlueStacks_nxt_cn']: elif dir1 in ['bluestacks_nxt', 'bluestacks_nxt_cn']:
return cls.BlueStacks5 return cls.BlueStacks5
else: else:
return cls.BlueStacks5 return cls.BlueStacks5
if exe == 'dnplayer.exe': if exe == 'dnplayer.exe':
if dir1 == 'LDPlayer': if dir1 == 'ldplayer':
return cls.LDPlayer3 return cls.LDPlayer3
elif dir1 == 'LDPlayer4': elif dir1 == 'ldplayer4':
return cls.LDPlayer4 return cls.LDPlayer4
elif dir1 == 'LDPlayer9': elif dir1 == 'ldplayer9':
return cls.LDPlayer9 return cls.LDPlayer9
else: else:
return cls.LDPlayer3 return cls.LDPlayer3
if exe == 'NemuPlayer.exe': if exe == 'nemuplayer.exe':
if dir2 == 'nemu': if dir2 == 'nemu':
return cls.MuMuPlayer return cls.MuMuPlayer
elif dir2 == 'nemu9': elif dir2 == 'nemu9':
return cls.MuMuPlayerX return cls.MuMuPlayerX
else: else:
return cls.MuMuPlayer return cls.MuMuPlayer
if exe == 'MuMuPlayer.exe': if exe == 'mumuplayer.exe':
return cls.MuMuPlayer12 return cls.MuMuPlayer12
if exe == 'MEmu.exe': if exe == 'memu.exe':
return cls.MEmuPlayer return cls.MEmuPlayer
return '' return ''
@@ -151,7 +147,9 @@ class Emulator(EmulatorBase):
elif 'NemuMultiPlayer.exe' in exe: elif 'NemuMultiPlayer.exe' in exe:
yield exe.replace('NemuMultiPlayer.exe', 'NemuPlayer.exe') yield exe.replace('NemuMultiPlayer.exe', 'NemuPlayer.exe')
elif 'MuMuMultiPlayer.exe' in 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: elif 'MEmuConsole.exe' in exe:
yield exe.replace('MEmuConsole.exe', 'MEmu.exe') yield exe.replace('MEmuConsole.exe', 'MEmu.exe')
else: else:
@@ -324,14 +322,18 @@ class EmulatorManager(EmulatorManagerBase):
Get recently executed programs in UserAssist Get recently executed programs in UserAssist
https://github.com/forensicmatt/MonitorUserAssist https://github.com/forensicmatt/MonitorUserAssist
Returns: Yields:
str: Path to emulator executables, may contains duplicate values str: Path to emulator executables, may contains duplicate values
""" """
path = r'Software\Microsoft\Windows\CurrentVersion\Explorer\UserAssist' path = r'Software\Microsoft\Windows\CurrentVersion\Explorer\UserAssist'
# {XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX}\xxx.exe # {XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX}\xxx.exe
regex_hash = re.compile(r'{.*}') regex_hash = re.compile(r'{.*}')
try:
with winreg.OpenKey(winreg.HKEY_CURRENT_USER, path) as reg: with winreg.OpenKey(winreg.HKEY_CURRENT_USER, path) as reg:
folders = list_key(reg) folders = list_key(reg)
except FileNotFoundError:
return
for folder in folders: for folder in folders:
try: try:
with winreg.OpenKey(winreg.HKEY_CURRENT_USER, f'{path}\\{folder}\\Count') as reg: 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 str: Path to emulator executable, may contains duplicate values
""" """
path = r'Software\Classes\Local Settings\Software\Microsoft\Windows\Shell\MuiCache' path = r'Software\Classes\Local Settings\Software\Microsoft\Windows\Shell\MuiCache'
try:
with winreg.OpenKey(winreg.HKEY_CURRENT_USER, path) as reg: with winreg.OpenKey(winreg.HKEY_CURRENT_USER, path) as reg:
rows = list_reg(reg) rows = list_reg(reg)
except FileNotFoundError:
return
regex = re.compile(r'(^.*\.exe)\.') regex = re.compile(r'(^.*\.exe)\.')
for row in rows: for row in rows:
@@ -425,12 +430,16 @@ class EmulatorManager(EmulatorManagerBase):
'MEmu', 'MEmu',
] ]
for path in known_uninstall_registry_path: for path in known_uninstall_registry_path:
try:
with winreg.OpenKey(winreg.HKEY_LOCAL_MACHINE, path) as reg: with winreg.OpenKey(winreg.HKEY_LOCAL_MACHINE, path) as reg:
for software in list_key(reg): software_list = list_key(reg)
except FileNotFoundError:
continue
for software in software_list:
if software not in known_emulator_registry_name: if software not in known_emulator_registry_name:
continue continue
with winreg.OpenKey(winreg.HKEY_LOCAL_MACHINE, f'{path}\\{software}') as software_reg:
try: try:
with winreg.OpenKey(winreg.HKEY_LOCAL_MACHINE, f'{path}\\{software}') as software_reg:
uninstall = winreg.QueryValueEx(software_reg, 'UninstallString')[0] uninstall = winreg.QueryValueEx(software_reg, 'UninstallString')[0]
except FileNotFoundError: except FileNotFoundError:
continue continue
@@ -444,6 +453,31 @@ class EmulatorManager(EmulatorManagerBase):
uninstall = res.group(1) if res else uninstall uninstall = res.group(1) if res else uninstall
yield 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 @cached_property
def all_emulators(self) -> t.List[Emulator]: def all_emulators(self) -> t.List[Emulator]:
""" """
@@ -471,7 +505,7 @@ class EmulatorManager(EmulatorManagerBase):
exe.add(ld) exe.add(ld)
# Uninstall registry # Uninstall registry
for uninstall in self.iter_uninstall_registry(): for uninstall in EmulatorManager.iter_uninstall_registry():
# Find emulator executable from uninstaller # Find emulator executable from uninstaller
for file in iter_folder(abspath(os.path.dirname(uninstall)), ext='.exe'): for file in iter_folder(abspath(os.path.dirname(uninstall)), ext='.exe'):
if Emulator.is_emulator(file) and os.path.exists(file): 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): if Emulator.is_emulator(file) and os.path.exists(file):
exe.add(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 = [Emulator(path).path for path in exe if Emulator.is_emulator(path)]
exe = sorted(set(exe)) exe = [Emulator(path) for path in remove_duplicated_path(exe)]
exe = [Emulator(path) for path in exe]
return exe return exe
@cached_property @cached_property
+55 -24
View File
@@ -1,14 +1,14 @@
import sys import sys
import typing as t import typing as t
import yaml from pydantic import BaseModel
from pydantic import BaseModel, SecretStr
from module.base.decorator import cached_property, del_cached_property 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.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): class EmulatorInfo(BaseModel):
@@ -48,8 +48,20 @@ class PlatformBase(Connection, EmulatorManagerBase):
@cached_property @cached_property
def emulator_info(self) -> EmulatorInfo: def emulator_info(self) -> EmulatorInfo:
emulator = self.config.EmulatorInfo_Emulator emulator = self.config.EmulatorInfo_Emulator
name = str(self.config.EmulatorInfo_name).strip().replace('\n', '') if emulator == 'auto':
path = str(self.config.EmulatorInfo_path).strip().replace('\n', '') 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( return EmulatorInfo(
emulator=emulator, emulator=emulator,
@@ -69,8 +81,14 @@ class PlatformBase(Connection, EmulatorManagerBase):
path=data.path, path=data.path,
name=data.name, 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( instance = self.find_emulator_instance(
serial=str(self.config.Emulator_Serial).strip(), serial=serial,
name=data.name, name=data.name,
path=data.path, path=data.path,
emulator=data.emulator, emulator=data.emulator,
@@ -118,7 +136,7 @@ class PlatformBase(Connection, EmulatorManagerBase):
# Search by serial # Search by serial
select = instances.select(**search_args) select = instances.select(**search_args)
if select.count == 0: 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 return None
if select.count == 1: if select.count == 1:
instance = select[0] instance = select[0]
@@ -131,9 +149,9 @@ class PlatformBase(Connection, EmulatorManagerBase):
search_args['name'] = name search_args['name'] = name
select = instances.select(**search_args) select = instances.select(**search_args)
if select.count == 0: if select.count == 0:
logger.warning(f'No emulator instances with {search_args}') logger.warning(f'No emulator instances with {search_args}, name invalid')
return None search_args.pop('name')
if select.count == 1: elif select.count == 1:
instance = select[0] instance = select[0]
logger.hr('Emulator instance', level=2) logger.hr('Emulator instance', level=2)
logger.info(f'Found emulator instance: {instance}') logger.info(f'Found emulator instance: {instance}')
@@ -144,9 +162,9 @@ class PlatformBase(Connection, EmulatorManagerBase):
search_args['path'] = path search_args['path'] = path
select = instances.select(**search_args) select = instances.select(**search_args)
if select.count == 0: if select.count == 0:
logger.warning(f'No emulator instances with {search_args}') logger.warning(f'No emulator instances with {search_args}, path invalid')
return None search_args.pop('path')
if select.count == 1: elif select.count == 1:
instance = select[0] instance = select[0]
logger.hr('Emulator instance', level=2) logger.hr('Emulator instance', level=2)
logger.info(f'Found emulator instance: {instance}') logger.info(f'Found emulator instance: {instance}')
@@ -157,9 +175,28 @@ class PlatformBase(Connection, EmulatorManagerBase):
search_args['type'] = emulator search_args['type'] = emulator
select = instances.select(**search_args) select = instances.select(**search_args)
if select.count == 0: if select.count == 0:
logger.warning(f'No emulator instances with {search_args}') logger.warning(f'No emulator instances with {search_args}, type invalid')
return None search_args.pop('type')
if select.count == 1: 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] instance = select[0]
logger.hr('Emulator instance', level=2) logger.hr('Emulator instance', level=2)
logger.info(f'Found emulator instance: {instance}') logger.info(f'Found emulator instance: {instance}')
@@ -168,9 +205,3 @@ class PlatformBase(Connection, EmulatorManagerBase):
# Still too many instances # Still too many instances
logger.warning(f'Found multiple emulator instances with {search_args}') logger.warning(f'Found multiple emulator instances with {search_args}')
return None 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.decorator import run_once
from module.base.timer import Timer from module.base.timer import Timer
from module.device.connection import AdbDeviceWithStatus 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.emulator_windows import Emulator, EmulatorInstance, EmulatorManager
from module.device.platform.platform_base import PlatformBase
from module.logger import logger from module.logger import logger
import os import os
@@ -92,6 +92,8 @@ class PlatformWindows(PlatformBase, EmulatorManager):
self.execute(f'"{exe}" -m {instance.name}') self.execute(f'"{exe}" -m {instance.name}')
elif instance == Emulator.MuMuPlayer12: elif instance == Emulator.MuMuPlayer12:
# MuMuPlayer.exe -v 0 # 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}') self.execute(f'"{exe}" -v {instance.MuMuPlayer12_id}')
elif instance == Emulator.NoxPlayerFamily: elif instance == Emulator.NoxPlayerFamily:
# Nox.exe -clone:Nox_1 # Nox.exe -clone:Nox_1
@@ -148,6 +150,8 @@ class PlatformWindows(PlatformBase, EmulatorManager):
# MuMu 12 has 2 processes: # MuMu 12 has 2 processes:
# E:\ProgramFiles\Netease\MuMuPlayer-12.0\shell\MuMuPlayer.exe -v 0 # 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 # "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( self.kill_process_by_regex(
rf'(' rf'('
rf'MuMuVMMHeadless.exe.*--comment {instance.name}' rf'MuMuVMMHeadless.exe.*--comment {instance.name}'
@@ -276,7 +280,7 @@ class PlatformWindows(PlatformBase, EmulatorManager):
show_ping(pong) show_ping(pong)
# Check azuelane package # Check azuelane package
packages = self.list_azurlane_packages(show_log=False) packages = self.list_known_packages(show_log=False)
if len(packages): if len(packages):
pass pass
else: else:
@@ -326,4 +330,5 @@ class PlatformWindows(PlatformBase, EmulatorManager):
if __name__ == '__main__': if __name__ == '__main__':
self = PlatformWindows('alas') 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.adb import Adb
from module.device.method.ascreencap import AScreenCap from module.device.method.ascreencap import AScreenCap
from module.device.method.droidcast import DroidCast 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.scrcpy import Scrcpy
from module.device.method.wsa import WSA from module.device.method.wsa import WSA
from module.exception import RequestHumanTakeover, ScriptError from module.exception import RequestHumanTakeover, ScriptError
from module.logger import logger 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_size_checked = False
_screen_black_checked = False _screen_black_checked = False
_minicap_uninstalled = False _minicap_uninstalled = False
@@ -38,6 +39,7 @@ class Screenshot(Adb, WSA, DroidCast, AScreenCap, Scrcpy):
'DroidCast': self.screenshot_droidcast, 'DroidCast': self.screenshot_droidcast,
'DroidCast_raw': self.screenshot_droidcast_raw, 'DroidCast_raw': self.screenshot_droidcast_raw,
'scrcpy': self.screenshot_scrcpy, 'scrcpy': self.screenshot_scrcpy,
'nemu_ipc': self.screenshot_nemu_ipc,
} }
def screenshot(self): def screenshot(self):
@@ -70,6 +72,10 @@ class Screenshot(Adb, WSA, DroidCast, AScreenCap, Scrcpy):
return self.image 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): def _handle_orientated_image(self, image):
""" """
Args: Args:
@@ -98,7 +104,18 @@ class Screenshot(Adb, WSA, DroidCast, AScreenCap, Scrcpy):
@cached_property @cached_property
def screenshot_deque(self): 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): def save_screenshot(self, genre='items', interval=None, to_base_folder=False):
"""Save a screenshot. Use millisecond timestamp as file name. """Save a screenshot. Use millisecond timestamp as file name.
@@ -148,6 +165,9 @@ class Screenshot(Adb, WSA, DroidCast, AScreenCap, Scrcpy):
if interval != origin: if interval != origin:
logger.warning(f'Optimization.ScreenshotInterval {origin} is revised to {interval}') logger.warning(f'Optimization.ScreenshotInterval {origin} is revised to {interval}')
self.config.Optimization_ScreenshotInterval = 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': elif interval == 'combat':
origin = self.config.Optimization_CombatScreenshotInterval origin = self.config.Optimization_CombatScreenshotInterval
interval = limit_in(origin, 0.3, 1.0) interval = limit_in(origin, 0.3, 1.0)
+6
View File
@@ -29,6 +29,12 @@ class GamePageUnknownError(Exception):
pass 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): class RequestHumanTakeover(Exception):
# Request human takeover # Request human takeover
# Alas is unable to handle such error, probably because of wrong settings. # 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 from module.logger import logger
def handle_notify(*args, **kwargs): onepush.core.log = logger
logger.error('Error notify is not supported yet')
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 import module.config.server as server
from module.exception import ScriptError 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): def parse_name(n):
n = REGEX_PUNCTUATION.sub('', str(n)).lower() n = REGEX_PUNCTUATION.sub('', str(n)).lower()
return n return n.strip()
@dataclass @dataclass
@@ -179,3 +180,39 @@ class Keyword:
# Not found # Not found
raise ScriptError(f'Cannot find a {cls.__name__} instance that matches "{name}"') 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 re
import time import time
from datetime import timedelta from datetime import timedelta
from typing import Optional
import cv2
import numpy as np import numpy as np
from pponnxcr.predict_system import BoxedResult from pponnxcr.predict_system import BoxedResult
import module.config.server as server import module.config.server as server
from module.base.button import ButtonWrapper from module.base.button import ButtonWrapper
from module.base.decorator import cached_property 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.exception import ScriptError
from module.logger import logger from module.logger import logger
from module.ocr.keyword import Keyword
from module.ocr.models import OCR_MODEL, TextSystem from module.ocr.models import OCR_MODEL, TextSystem
from module.ocr.utils import merge_buttons from module.ocr.utils import merge_buttons
class OcrResultButton: class OcrResultButton:
def __init__(self, boxed_result: BoxedResult, matched_keyword: Optional[Keyword]): def __init__(self, boxed_result: BoxedResult, matched_keyword):
""" """
Args: Args:
boxed_result: BoxedResult from ppocr-onnx boxed_result: BoxedResult from ppocr-onnx
@@ -349,6 +348,10 @@ class DigitCounter(Ocr):
def __init__(self, button: ButtonWrapper, lang='en', name=None): def __init__(self, button: ButtonWrapper, lang='en', name=None):
super().__init__(button, lang=lang, name=name) super().__init__(button, lang=lang, name=name)
@classmethod
def is_format_matched(cls, result) -> bool:
return '/' in result
def after_process(self, result): def after_process(self, result):
result = super().after_process(result) result = super().after_process(result)
result = result.replace('%', '/') result = result.replace('%', '/')
@@ -364,7 +367,7 @@ class DigitCounter(Ocr):
result = self.after_process(result) result = self.after_process(result)
logger.attr(name=self.name, text=str(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: if res:
groups = [int(s) for s in res.groups()] groups = [int(s) for s in res.groups()]
current, total = int(groups[0]), int(groups[1]) current, total = int(groups[0]), int(groups[1])
@@ -421,3 +424,20 @@ class Duration(Ocr):
if number is None: if number is None:
return 0 return 0
return int(number) 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. Pair buttons in group1 with those in group2 in the relative_area.
Args: Args:
group1 (list[OcrResultButton]): group1 (list[OcrResultButton], Iterable[OcrResultButton]):
group2 (list[OcrResultButton]): group2 (list[OcrResultButton], Iterable[OcrResultButton]):
relative_area (tuple[int, int, int, int]): relative_area (tuple[int, int, int, int]):
Yields: Yields:
+4
View File
@@ -160,6 +160,7 @@ class DraggableList:
logger.info(f'Insight row: {row}, index={row_index}') logger.info(f'Insight row: {row}, index={row_index}')
last_buttons: set[OcrResultButton] = None last_buttons: set[OcrResultButton] = None
bottom_check = Timer(3, count=5).start()
while 1: while 1:
if skip_first_screenshot: if skip_first_screenshot:
skip_first_screenshot = False skip_first_screenshot = False
@@ -183,8 +184,11 @@ class DraggableList:
0, count=0), timeout=Timer(1.5, count=5)) 0, count=0), timeout=Timer(1.5, count=5))
skip_first_screenshot = True skip_first_screenshot = True
if self.cur_buttons and last_buttons == set(self.cur_buttons): if self.cur_buttons and last_buttons == set(self.cur_buttons):
if bottom_check.reached():
logger.warning(f'No more rows in {self}') logger.warning(f'No more rows in {self}')
return False return False
else:
bottom_check.reset()
last_buttons = set(self.cur_buttons) last_buttons = set(self.cur_buttons)
return True return True
+66 -5
View File
@@ -1,9 +1,10 @@
import numpy as np import numpy as np
from scipy import signal
from module.base.base import ModuleBase from module.base.base import ModuleBase
from module.base.button import Button, ButtonWrapper from module.base.button import Button, ButtonWrapper
from module.base.timer import Timer 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 from module.logger import logger
@@ -46,7 +47,7 @@ class Scroll:
Returns: Returns:
np.ndarray: Shape (n,), dtype bool. 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) image = color_similarity_2d(image, color=self.color)
mask = np.max(image, axis=1 if self.is_vertical else 0) > self.color_threshold mask = np.max(image, axis=1 if self.is_vertical else 0) > self.color_threshold
self.length = np.sum(mask) self.length = np.sum(mask)
@@ -110,6 +111,20 @@ class Scroll:
""" """
return np.mean(self.match_color(main)) > 0.1 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): def at_top(self, main):
return self.cal_position(main) < self.edge_threshold return self.cal_position(main) < self.edge_threshold
@@ -189,10 +204,56 @@ class Scroll:
multiply = self.length / (self.total - self.length) multiply = self.length / (self.total - self.length)
target = current + page * multiply target = current + page * multiply
target = round(min(max(target, 0), 1), 3) 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): 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): 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, parse_pin_value,
raise_exception, raise_exception,
re_fullmatch, re_fullmatch,
to_pin_value,
) )
from module.webui.widgets import ( from module.webui.widgets import (
BinarySwitchButton, BinarySwitchButton,
@@ -105,6 +106,7 @@ class AlasGUI(Frame):
self.alas_name = "" self.alas_name = ""
self.alas_mod = "alas" self.alas_mod = "alas"
self.alas_config = AzurLaneConfig("template") self.alas_config = AzurLaneConfig("template")
self.alas_config_hidden = set()
self.initial() self.initial()
@use_scope("aside", clear=True) @use_scope("aside", clear=True)
@@ -235,6 +237,7 @@ class AlasGUI(Frame):
) )
config = self.alas_config.read_file(self.alas_name) 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): for group, arg_dict in deep_iter(self.ALAS_ARGS[task], depth=1):
if self.set_group(group, arg_dict, config, task): if self.set_group(group, arg_dict, config, task):
self.set_navigator(group) self.set_navigator(group)
@@ -290,6 +293,9 @@ class AlasGUI(Frame):
if o is not None: if o is not None:
# output will inherit current scope when created, override here # output will inherit current scope when created, override here
o.spec["scope"] = f"#pywebio-scope-group_{group_name}" 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) output_list.append(o)
if not output_list: if not output_list:
@@ -456,8 +462,7 @@ class AlasGUI(Frame):
try: try:
d = self.modified_config_queue.get(timeout=10) d = self.modified_config_queue.get(timeout=10)
config_name = self.alas_name config_name = self.alas_name
read = self.alas_config.read_file config_updater = self.alas_config
write = self.alas_config.write_file
except queue.Empty: except queue.Empty:
continue continue
modified[d["name"]] = d["value"] modified[d["name"]] = d["value"]
@@ -466,7 +471,7 @@ class AlasGUI(Frame):
d = self.modified_config_queue.get(timeout=1) d = self.modified_config_queue.get(timeout=1)
modified[d["name"]] = d["value"] modified[d["name"]] = d["value"]
except queue.Empty: except queue.Empty:
self._save_config(modified, config_name, read, write) self._save_config(modified, config_name, config_updater)
modified.clear() modified.clear()
break break
@@ -474,13 +479,12 @@ class AlasGUI(Frame):
self, self,
modified: Dict[str, str], modified: Dict[str, str],
config_name: str, config_name: str,
read=State.config_updater.read_file, config_updater: AzurLaneConfig = State.config_updater,
write=State.config_updater.write_file,
) -> None: ) -> None:
try: try:
valid = [] valid = []
invalid = [] invalid = []
config = read(config_name) config = config_updater.read_file(config_name)
for k, v in modified.copy().items(): for k, v in modified.copy().items():
valuetype = deep_get(self.ALAS_ARGS, k + ".valuetype") valuetype = deep_get(self.ALAS_ARGS, k + ".valuetype")
v = parse_pin_value(v, valuetype) v = parse_pin_value(v, valuetype)
@@ -497,22 +501,24 @@ class AlasGUI(Frame):
modified[k] = v modified[k] = v
valid.append(k) valid.append(k)
# update Emotion Record if Emotion Value is changed for set_key, set_value in config_updater.save_callback(k, v):
if "Emotion" in k and "Value" in k: modified[set_key] = set_value
k = k.split(".") deep_set(config, set_key, set_value)
k[-1] = k[-1].replace("Value", "Record") valid.append(set_key)
k = ".".join(k) pin["_".join(set_key.split("."))] = to_pin_value(set_value)
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
else: else:
modified.pop(k) modified.pop(k)
invalid.append(k) invalid.append(k)
logger.warning(f"Invalid value {v} for key {k}, skip saving.") logger.warning(f"Invalid value {v} for key {k}, skip saving.")
self.pin_remove_invalid_mark(valid) self.pin_remove_invalid_mark(valid)
self.pin_set_invalid_mark(invalid) 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: if modified:
toast( toast(
t("Gui.Toast.ConfigSaved"), t("Gui.Toast.ConfigSaved"),
@@ -523,7 +529,7 @@ class AlasGUI(Frame):
logger.info( logger.info(
f"Save config {filepath_config(config_name)}, {dict_to_kv(modified)}" 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: except Exception as e:
logger.exception(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 pywebio.session import defer_call, info, run_js
from module.webui.utils import Icon, WebIOTaskHandler, set_localstorage from module.webui.utils import Icon, WebIOTaskHandler, set_localstorage
from module.webui.widgets import type_to_html
class Base: class Base:
@@ -167,3 +168,33 @@ class Frame(Base):
run_js(js) run_js(js)
# for key in keys: # for key in keys:
# pin_update(key, valid_status=0) # 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 import pywebio
from pywebio.input import PASSWORD, input from pywebio.input import PASSWORD, input
from pywebio.output import PopupSize, popup, put_html, toast from pywebio.output import PopupSize, popup, put_html, toast
from pywebio.session import eval_js from pywebio.session import eval_js, info as session_info, register_thread, run_js
from pywebio.session import info as session_info
from pywebio.session import register_thread, run_js
from rich.console import Console from rich.console import Console
from rich.terminal_theme import TerminalTheme from rich.terminal_theme import TerminalTheme
@@ -427,6 +425,18 @@ def parse_pin_value(val, valuetype: str = None):
return v 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): def login(password):
if get_localstorage("password") == str(password): if get_localstorage("password") == str(password):
return True return True
+22 -2
View File
@@ -2,7 +2,7 @@ import copy
import json import json
import random import random
import string 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.exceptions import SessionException
from pywebio.io_ctrl import Output from pywebio.io_ctrl import Output
@@ -10,7 +10,7 @@ from pywebio.output import *
from pywebio.session import eval_js, local, run_js from pywebio.session import eval_js, local, run_js
from rich.console import ConsoleRenderable 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.lang import t
from module.webui.pin import put_checkbox, put_input, put_select, put_textarea from module.webui.pin import put_checkbox, put_input, put_select, put_textarea
from module.webui.process_manager import ProcessManager from module.webui.process_manager import ProcessManager
@@ -424,6 +424,7 @@ def put_arg_textarea(kwargs: T_Output_Kwargs) -> Output:
) )
return put_scope( return put_scope(
# This aims to be a typo, don't correct it, leave it as it is
f"arg_contianer-textarea-{name}", f"arg_contianer-textarea-{name}",
[ [
get_title_help(kwargs), 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) 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: def get_loading_style(shape: str, fill: bool) -> str:
if fill: if fill:
return f"--loading-{shape}-fill--" return f"--loading-{shape}-fill--"
+3 -2
View File
@@ -20,13 +20,14 @@ jellyfish
pyyaml pyyaml
inflection inflection
prettytable==2.2.1 prettytable==2.2.1
pydantic pydantic>=2.4
onepush==1.3.0
# OCR # OCR
pponnxcr==2.0 pponnxcr==2.0
# Webui # Webui
pywebio==1.6.2 pywebio==1.8.3
starlette==0.14.2 starlette==0.14.2
uvicorn[standard]==0.17.6 uvicorn[standard]==0.17.6
aiofiles aiofiles
+8 -4
View File
@@ -8,6 +8,7 @@
adbutils==1.2.9 # via -r requirements-in.txt, uiautomator2 adbutils==1.2.9 # via -r requirements-in.txt, uiautomator2
aiofiles==23.1.0 # via -r requirements-in.txt aiofiles==23.1.0 # via -r requirements-in.txt
annotated-types==0.5.0 # via pydantic
anyio==3.6.2 # via watchgod anyio==3.6.2 # via watchgod
apkutils2==1.0.0 # via adbutils apkutils2==1.0.0 # via adbutils
asgiref==3.6.0 # via uvicorn 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 mdurl==0.1.2 # via markdown-it-py
mpmath==1.3.0 # via sympy mpmath==1.3.0 # via sympy
numpy==1.24.3 # via -r requirements-in.txt, onnxruntime, opencv-python, pponnxcr, scipy, shapely 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 onnxruntime==1.14.1 # via pponnxcr
opencv-python==4.7.0.72 # via -r requirements-in.txt, pponnxcr opencv-python==4.7.0.72 # via -r requirements-in.txt, pponnxcr
packaging==20.9 # via deprecation, onnxruntime, uiautomator2 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 psutil==5.9.3 # via -r requirements-in.txt
py==1.11.0 # via retry py==1.11.0 # via retry
pyclipper==1.3.0.post4 # via pponnxcr 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 pyelftools==0.29 # via apkutils2
pygments==2.15.1 # via rich pygments==2.15.1 # via rich
pynput==1.7.6 # via -r requirements-in.txt pynput==1.7.6 # via -r requirements-in.txt
pyparsing==3.0.9 # via packaging pyparsing==3.0.9 # via packaging
pyreadline3==3.4.1 # via humanfriendly pyreadline3==3.4.1 # via humanfriendly
python-dotenv==1.0.0 # via uvicorn 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 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 retry==0.9.2 # via adbutils, uiautomator2
rich==13.3.5 # via -r requirements-in.txt rich==13.3.5 # via -r requirements-in.txt
scipy==1.10.1 # 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 sympy==1.12 # via onnxruntime
tornado==6.3.1 # via pywebio tornado==6.3.1 # via pywebio
tqdm==4.65.0 # via -r requirements-in.txt 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 ua-parser==0.16.1 # via user-agents
uiautomator2==2.16.17 # via -r requirements-in.txt uiautomator2==2.16.17 # via -r requirements-in.txt
uiautomator2cache==0.3.0.1 # via -r requirements-in.txt uiautomator2cache==0.3.0.1 # via -r requirements-in.txt