mirror of
https://github.com/TheFunny/ArisuAutoSweeper
synced 2026-10-24 04:42:19 +00:00
Compare commits
57
Commits
ff2f226713
...
ef9dde3da5
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ef9dde3da5 | ||
|
|
723aa66e69 | ||
|
|
0b28ed6f89
|
||
|
|
d54ff72565
|
||
|
|
a2bd36f570
|
||
|
|
50c4bc5bba
|
||
|
|
3cef2ee01b
|
||
|
|
0bd294582b
|
||
|
|
49149413d5
|
||
|
|
108f648517
|
||
|
|
aa4afbf6a2
|
||
|
|
d34c082ef3
|
||
|
|
d48427abb2
|
||
|
|
ce8eb1c452
|
||
|
|
d0e6c78cc1
|
||
|
|
75b8b78b24
|
||
|
|
cac19c10c3
|
||
|
|
d806c5e9e7
|
||
|
|
87975760ae
|
||
|
|
6a4b1998fc
|
||
|
|
d7db56eb5a
|
||
|
|
d017d9eefd
|
||
|
|
97c3ed30e9
|
||
|
|
52964c2ff0
|
||
|
|
be44b23d70
|
||
|
|
0590eebd44
|
||
|
|
11f5f1dd64
|
||
|
|
5ffaec0fb1
|
||
|
|
24e9bde5c7
|
||
|
|
64bb8e3a12
|
||
|
|
baddf23df5
|
||
|
|
fc5847d6d8
|
||
|
|
84ac3dc9b8
|
||
|
|
af3e178c88
|
||
|
|
7d084db107
|
||
|
|
fcab832be1
|
||
|
|
e8cf0a3517
|
||
|
|
808a02ddf5
|
||
|
|
5df288b577
|
||
|
|
0a10c61c59
|
||
|
|
247a9a8770
|
||
|
|
63d4b50461
|
||
|
|
8741cb6142
|
||
|
|
091b5c011a
|
||
|
|
c43f8e5458
|
||
|
|
6f6dab1c04
|
||
|
|
f6f2470e5b
|
||
|
|
2275133638
|
||
|
|
da34dc4b7d
|
||
|
|
07a7a19929
|
||
|
|
a7290f253e
|
||
|
|
0b1a4efd09
|
||
|
|
aaa56b928f
|
||
|
|
8dc527b06b
|
||
|
|
004af69d8f
|
||
|
|
a397539c69
|
||
|
|
864ac15282
|
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
+27
-14
@@ -92,8 +92,9 @@ 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:
|
||||||
file = os.path.join(path, file).replace('\\', '/')
|
if not file.startswith('.'):
|
||||||
yield AssetsImage(file)
|
file = os.path.join(path, file).replace('\\', '/')
|
||||||
|
yield AssetsImage(file)
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
@@ -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
|
for frame in frames.values():
|
||||||
first = frames[1]
|
# Generate `search` from `area`
|
||||||
if first.search:
|
if not frame.has_raw_search:
|
||||||
for frame in frames.values():
|
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
|
frame.search = first.search
|
||||||
else:
|
if not frame.has_raw_color and first.has_raw_color:
|
||||||
for frame in frames.values():
|
frame.color = first.color
|
||||||
if frame.search:
|
if not frame.has_raw_button and first.has_raw_button:
|
||||||
# Follow frame specific `search`
|
frame.button = first.button
|
||||||
pass
|
|
||||||
else:
|
|
||||||
# Generate `search` from `area`
|
|
||||||
frame.search = DataAssets.area_to_search(frame.area)
|
|
||||||
|
|
||||||
return data
|
return data
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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)
|
||||||
|
|||||||
@@ -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'):
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -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.
|
||||||
|
|||||||
@@ -41,7 +41,8 @@
|
|||||||
"aScreenCap_nc",
|
"aScreenCap_nc",
|
||||||
"DroidCast",
|
"DroidCast",
|
||||||
"DroidCast_raw",
|
"DroidCast_raw",
|
||||||
"scrcpy"
|
"scrcpy",
|
||||||
|
"nemu_ipc"
|
||||||
],
|
],
|
||||||
"display": "hide"
|
"display": "hide"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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 ]
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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": "模拟器控制方案",
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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()
|
# self.hierarchy = self.dump_hierarchy_uiautomator2()
|
||||||
else:
|
# else:
|
||||||
self.hierarchy = self.dump_hierarchy_adb()
|
# self.hierarchy = self.dump_hierarchy_adb()
|
||||||
|
|
||||||
|
# Using uiautomator2
|
||||||
|
self.hierarchy = self.dump_hierarchy_uiautomator2()
|
||||||
return self.hierarchy
|
return self.hierarchy
|
||||||
|
|
||||||
def xpath_to_button(self, xpath: str) -> HierarchyButton:
|
def xpath_to_button(self, xpath: str) -> HierarchyButton:
|
||||||
|
|||||||
+138
-27
@@ -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,23 +779,45 @@ 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')
|
||||||
logger.info('Here are the available devices, '
|
available = SelectedGrids([])
|
||||||
'copy to Alas.Emulator.Serial to use it or set Alas.Emulator.Serial="auto"')
|
devices = SelectedGrids([])
|
||||||
devices = self.list_device()
|
|
||||||
|
|
||||||
# Show available devices
|
@run_once
|
||||||
available = devices.select(status='device')
|
def brute_force_connect():
|
||||||
for device in available:
|
logger.info('Brute force connect')
|
||||||
logger.info(device.serial)
|
from deploy.Windows.emulator import EmulatorManager
|
||||||
if not len(available):
|
manager = EmulatorManager()
|
||||||
logger.info('No available devices')
|
manager.brute_force_connect()
|
||||||
|
|
||||||
# Show unavailable devices if having any
|
for _ in range(2):
|
||||||
unavailable = devices.delete(available)
|
logger.info('Here are the available devices, '
|
||||||
if len(unavailable):
|
'copy to Alas.Emulator.Serial to use it or set Alas.Emulator.Serial="auto"')
|
||||||
logger.info('Here are the devices detected but unavailable')
|
devices = self.list_device()
|
||||||
for device in unavailable:
|
|
||||||
logger.info(f'{device.serial} ({device.status})')
|
# Show available devices
|
||||||
|
available = devices.select(status='device')
|
||||||
|
for device in available:
|
||||||
|
logger.info(device.serial)
|
||||||
|
if not len(available):
|
||||||
|
logger.info('No available devices')
|
||||||
|
|
||||||
|
# Show unavailable devices if having any
|
||||||
|
unavailable = devices.delete(available)
|
||||||
|
if len(unavailable):
|
||||||
|
logger.info('Here are the devices detected but unavailable')
|
||||||
|
for device in unavailable:
|
||||||
|
logger.info(f'{device.serial} ({device.status})')
|
||||||
|
|
||||||
|
# brute_force_connect
|
||||||
|
if self.config.Emulator_Serial == 'auto' and available.count == 0:
|
||||||
|
logger.warning(f'No available device found')
|
||||||
|
if sys.platform == 'win32':
|
||||||
|
brute_force_connect()
|
||||||
|
continue
|
||||||
|
else:
|
||||||
|
break
|
||||||
|
else:
|
||||||
|
break
|
||||||
|
|
||||||
# Auto device detection
|
# Auto device detection
|
||||||
if self.config.Emulator_Serial == 'auto':
|
if self.config.Emulator_Serial == 'auto':
|
||||||
@@ -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}", '
|
||||||
|
|||||||
@@ -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
@@ -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'):
|
||||||
|
|||||||
+75
-9
@@ -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
|
||||||
self.config.Emulator_ScreenshotMethod = method
|
with self.config.multi_set():
|
||||||
|
self.config.Emulator_ScreenshotMethod = method
|
||||||
|
# if method == 'nemu_ipc':
|
||||||
|
# self.config.Emulator_ControlMethod = 'nemu_ipc'
|
||||||
|
|
||||||
|
def method_check(self):
|
||||||
|
"""
|
||||||
|
Check combinations of screenshot method and control methods
|
||||||
|
"""
|
||||||
|
# nemu_ipc should be together
|
||||||
|
# if self.config.Emulator_ScreenshotMethod == 'nemu_ipc' and self.config.Emulator_ControlMethod != 'nemu_ipc':
|
||||||
|
# logger.warning('When using nemu_ipc, both screenshot and control should use nemu_ipc')
|
||||||
|
# self.config.Emulator_ControlMethod = 'nemu_ipc'
|
||||||
|
# if self.config.Emulator_ScreenshotMethod != 'nemu_ipc' and self.config.Emulator_ControlMethod == 'nemu_ipc':
|
||||||
|
# logger.warning('When not using nemu_ipc, both screenshot and control should not use nemu_ipc')
|
||||||
|
# self.config.Emulator_ControlMethod = 'minitouch'
|
||||||
|
pass
|
||||||
|
|
||||||
def screenshot(self):
|
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()
|
||||||
|
|||||||
@@ -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')
|
||||||
|
|
||||||
|
|||||||
@@ -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')
|
||||||
|
|
||||||
|
|||||||
@@ -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/Torther/DroidCast_raw/blob/DroidCast_raw/app/src/main/java/ink/mol/droidcast_raw/KtMain.kt
|
||||||
https://github.com/rayworks/DroidCast/blob/master/app/src/main/java/com/rayworks/droidcast/Main.java
|
Available APIs:
|
||||||
|
- /screenshot
|
||||||
|
To get a RGB565 bitmap
|
||||||
|
- /preview
|
||||||
|
To get PNG screenshots.
|
||||||
|
"""
|
||||||
|
|
||||||
Available APIs:
|
def droidcast_url(self, url='/preview'):
|
||||||
- /screenshot
|
if self.is_mumu_over_version_356:
|
||||||
To get JPG screenshots.
|
w, h = self.droidcast_width, self.droidcast_height
|
||||||
- /screenshot?format=png
|
if self.orientation == 0:
|
||||||
To get PNG screenshots.
|
return f'http://127.0.0.1:{self._droidcast_port}{url}?width={w}&height={h}'
|
||||||
- /screenshot?format=webp
|
elif self.orientation == 1:
|
||||||
To get WEBP screenshots.
|
return f'http://127.0.0.1:{self._droidcast_port}{url}?width={h}&height={w}'
|
||||||
- /src
|
else:
|
||||||
Websocket to get JPG screenshots.
|
# logger.warning('DroidCast receives invalid device orientation')
|
||||||
|
pass
|
||||||
|
|
||||||
|
return f'http://127.0.0.1:{self._droidcast_port}{url}'
|
||||||
|
|
||||||
|
def droidcast_raw_url(self, url='/screenshot'):
|
||||||
|
if self.is_mumu_over_version_356:
|
||||||
|
w, h = self.droidcast_width, self.droidcast_height
|
||||||
|
if self.orientation == 0:
|
||||||
|
return f'http://127.0.0.1:{self._droidcast_port}{url}?width={w}&height={h}'
|
||||||
|
elif self.orientation == 1:
|
||||||
|
return f'http://127.0.0.1:{self._droidcast_port}{url}?width={h}&height={w}'
|
||||||
|
else:
|
||||||
|
# logger.warning('DroidCast receives invalid device orientation')
|
||||||
|
pass
|
||||||
|
|
||||||
Note that /screenshot?format=jpg is unavailable.
|
|
||||||
"""
|
|
||||||
return f'http://127.0.0.1:{self._droidcast_port}{url}'
|
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
|
||||||
logger.attr('DroidCast', self.droidcast_url())
|
|
||||||
self.droidcast_wait_startup()
|
|
||||||
|
|
||||||
@Config.when(DROIDCAST_VERSION='DroidCast_raw')
|
if self.config.DROIDCAST_VERSION == 'DroidCast':
|
||||||
def droidcast_init(self):
|
logger.attr('DroidCast', self.droidcast_url())
|
||||||
logger.hr('Droidcast init')
|
self.droidcast_wait_startup()
|
||||||
self.resolution_check_uiautomator2()
|
elif self.config.DROIDCAST_VERSION == 'DroidCast_raw':
|
||||||
self.droidcast_stop()
|
logger.attr('DroidCast_raw', self.droidcast_raw_url())
|
||||||
|
self.droidcast_wait_startup()
|
||||||
|
else:
|
||||||
|
logger.error(f'Unknown DROIDCAST_VERSION: {self.config.DROIDCAST_VERSION}')
|
||||||
|
|
||||||
logger.info('Pushing DroidCast apk')
|
def _droidcast_update_resolution(self):
|
||||||
self.adb_push(self.config.DROIDCAST_RAW_FILEPATH_LOCAL, self.config.DROIDCAST_RAW_FILEPATH_REMOTE)
|
if self.is_mumu_over_version_356:
|
||||||
|
logger.info('Update droidcast resolution')
|
||||||
logger.info('Starting DroidCast apk')
|
w, h = self.resolution_uiautomator2(cal_rotation=False)
|
||||||
# DroidCastS-release-1.1.5.apk
|
self.get_orientation()
|
||||||
# CLASSPATH=/data/local/tmp/DroidCastS-release-1.1.5.apk app_process / com.torther.droidcasts.Main > /dev/null
|
# 720, 1280
|
||||||
resp = self.u2_shell_background([
|
# mumu12 > 3.5.6 is always a vertical device
|
||||||
'CLASSPATH=/data/local/tmp/DroidCastS.apk',
|
self.droidcast_width, self.droidcast_height = w, h
|
||||||
'app_process',
|
logger.info(f'Droicast resolution: {(w, h)}')
|
||||||
'/',
|
|
||||||
'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()
|
|
||||||
|
|
||||||
@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():
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -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,9 +251,10 @@ 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']
|
||||||
rotation = self.get_orientation()
|
if cal_rotation:
|
||||||
if (w > h) != (rotation % 2 == 1):
|
rotation = self.get_orientation()
|
||||||
w, h = h, w
|
if (w > h) != (rotation % 2 == 1):
|
||||||
|
w, h = h, w
|
||||||
return w, h
|
return w, h
|
||||||
|
|
||||||
def resolution_check_uiautomator2(self):
|
def resolution_check_uiautomator2(self):
|
||||||
|
|||||||
@@ -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 False
|
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
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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,8 +108,11 @@ 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)
|
||||||
return None
|
if res:
|
||||||
|
return int(res.group(1))
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
class EmulatorBase:
|
class EmulatorBase:
|
||||||
@@ -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]:
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -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'{.*}')
|
||||||
with winreg.OpenKey(winreg.HKEY_CURRENT_USER, path) as reg:
|
try:
|
||||||
folders = list_key(reg)
|
with winreg.OpenKey(winreg.HKEY_CURRENT_USER, path) as 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'
|
||||||
with winreg.OpenKey(winreg.HKEY_CURRENT_USER, path) as reg:
|
try:
|
||||||
rows = list_reg(reg)
|
with winreg.OpenKey(winreg.HKEY_CURRENT_USER, path) as 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,24 +430,53 @@ class EmulatorManager(EmulatorManagerBase):
|
|||||||
'MEmu',
|
'MEmu',
|
||||||
]
|
]
|
||||||
for path in known_uninstall_registry_path:
|
for path in known_uninstall_registry_path:
|
||||||
with winreg.OpenKey(winreg.HKEY_LOCAL_MACHINE, path) as reg:
|
try:
|
||||||
for software in list_key(reg):
|
with winreg.OpenKey(winreg.HKEY_LOCAL_MACHINE, path) as reg:
|
||||||
if software not in known_emulator_registry_name:
|
software_list = list_key(reg)
|
||||||
continue
|
except FileNotFoundError:
|
||||||
|
continue
|
||||||
|
for software in software_list:
|
||||||
|
if software not in known_emulator_registry_name:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
with winreg.OpenKey(winreg.HKEY_LOCAL_MACHINE, f'{path}\\{software}') as software_reg:
|
with winreg.OpenKey(winreg.HKEY_LOCAL_MACHINE, f'{path}\\{software}') as software_reg:
|
||||||
try:
|
uninstall = winreg.QueryValueEx(software_reg, 'UninstallString')[0]
|
||||||
uninstall = winreg.QueryValueEx(software_reg, 'UninstallString')[0]
|
except FileNotFoundError:
|
||||||
except FileNotFoundError:
|
continue
|
||||||
continue
|
if not uninstall:
|
||||||
if not uninstall:
|
continue
|
||||||
continue
|
# UninstallString is like:
|
||||||
# UninstallString is like:
|
# C:\Program Files\BlueStacks_nxt\BlueStacksUninstaller.exe -tmp
|
||||||
# C:\Program Files\BlueStacks_nxt\BlueStacksUninstaller.exe -tmp
|
# "E:\ProgramFiles\Microvirt\MEmu\uninstall\uninstall.exe" -u
|
||||||
# "E:\ProgramFiles\Microvirt\MEmu\uninstall\uninstall.exe" -u
|
# Extract path in ""
|
||||||
# Extract path in ""
|
res = re.search('"(.*?)"', uninstall)
|
||||||
res = re.search('"(.*?)"', uninstall)
|
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
|
||||||
|
|||||||
@@ -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,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)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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:
|
||||||
|
|||||||
@@ -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):
|
||||||
logger.warning(f'No more rows in {self}')
|
if bottom_check.reached():
|
||||||
return False
|
logger.warning(f'No more rows in {self}')
|
||||||
|
return False
|
||||||
|
else:
|
||||||
|
bottom_check.reset()
|
||||||
last_buttons = set(self.cur_buttons)
|
last_buttons = set(self.cur_buttons)
|
||||||
|
|
||||||
return True
|
return True
|
||||||
|
|||||||
+66
-5
@@ -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
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user