286 Commits
Author SHA1 Message Date
YoursFunny 830c7bfaa2 fixup! dep: update to pywebio 1.8.3 2026-07-06 22:41:04 +08:00
YoursFunny 04f8b2fdc5 fix: add external_renderer_ipc.dll path for mumu12 6.0 2026-07-06 22:41:04 +08:00
YoursFunny 1ff838bdfa fix: support get MuMuPlayer12_id from mumu15 2026-07-06 22:41:04 +08:00
YoursFunny 292d1d1e3a fix: solve MuMuPlayer12 failing to launch when starting multiple instances concurrently 2026-07-06 22:41:04 +08:00
YoursFunny ad186c5d34 fix: enhance warning handling for AMD GPU driver when running redroid on minimal linux system 2026-07-06 22:41:04 +08:00
YoursFunny 6c007c0e35 feat: backport removeprefix and removesuffix functions for compatibility with Python <3.9 2026-07-06 22:41:03 +08:00
YoursFunny 7e0f33be23 fix: handle another format of multiscreen warning 2026-07-06 22:41:03 +08:00
YoursFunny f905524fb4 feat: support LDPlayer14 2026-07-06 22:41:03 +08:00
YoursFunny 47cf57771a fix: limit Android SDK version when benchmarking Droidcast 2026-07-06 22:41:03 +08:00
YoursFunny b431ab02d3 fix: use search by emulator type first, which is more trustworthy 2026-07-06 22:41:02 +08:00
YoursFunny bb1df87280 fix: skip permission errors when replacing adb 2026-07-06 22:41:02 +08:00
YoursFunny 58fc1a2be9 feat: enhance Ocr class with matched keyword handling and button generation 2026-07-06 22:41:02 +08:00
YoursFunny 089ffc78f6 feat: add fix_py37_subprocess_communicate for Windows Python 3.7 compatibility 2026-07-06 22:41:02 +08:00
YoursFunny 825f2409b9 fix: handle serial like 5555,16384 2026-07-06 22:41:02 +08:00
YoursFunny bf3cbeaaba feat: add patch_mimetype function for consistent mimetype handling 2026-07-06 22:41:01 +08:00
YoursFunny 0ca581f1e6 fix: add retry on first nemu_ipc connect 2026-07-06 22:41:01 +08:00
YoursFunny 4b10dcc7f3 fix: try to reuse existing server when converting package to server 2026-07-06 22:41:01 +08:00
YoursFunny f3cecca1f8 feat: add timeout support to loop method for enhanced state management 2026-07-06 22:41:01 +08:00
YoursFunny 1bb468c84e feat: add swipe handling to switch logic 2026-07-06 22:41:01 +08:00
YoursFunny 41ac4a69f0 perf: improve RGB565 to RGB888 speed 2026-07-06 22:41:00 +08:00
YoursFunny eaf806e02d fix: handle first screenshot in loop() 2026-07-06 22:41:00 +08:00
YoursFunny d033dd17c7 refactor: enhance Timer class with improved timing methods and access count tracking 2026-07-06 22:41:00 +08:00
YoursFunny 0c6c866142 feat: expose lang and ignore_punctuation in matched_ocr 2026-07-06 22:41:00 +08:00
YoursFunny 9ca70847f9 feat: add Keyword.find_startswith 2026-07-06 22:40:59 +08:00
YoursFunny a76d47b6cf fix: revert local remote in _adb_reverse_transport 2026-07-06 22:40:59 +08:00
YoursFunny 8ddce8d648 feat: add thread-safe singleton and named singleton metaclasses 2026-07-06 22:40:59 +08:00
YoursFunny 97ead3487e fix: implement custom adb reverse transport method to backport fixes 2026-07-06 22:40:59 +08:00
YoursFunny 04083cb309 fix: add fallback of nemu_ipc and ldopengl if not running on windows 2026-07-06 22:40:59 +08:00
YoursFunny 3d32669431 fix: handle emulator restart command by clearing cached properties 2026-07-06 22:40:58 +08:00
YoursFunny eb5e07c9ff fix: ensure BlueStacks Air serial auto increase from 127.0.0.1:5555 + 10*n 2026-07-06 22:40:58 +08:00
YoursFunny 6ad9b29ba1 fix: log warning when emulator instance is not found in check_mumu_bridge_network 2026-07-06 22:40:58 +08:00
YoursFunny 9135b74484 fix: handle copy-pasted serial 5555,16384 to be fool-proof 2026-07-06 22:40:58 +08:00
YoursFunny d7277fb22d fix: handle upside-down emulator in ldopengl screenshot 2026-07-06 22:40:58 +08:00
YoursFunny 7cfd24f042 fix: allow close emulator when free 2026-07-06 22:40:57 +08:00
YoursFunny 58bd699c3f fix: allow gracefully exit of workers when killed 2026-07-06 22:40:57 +08:00
YoursFunny bdc16b19e1 fix: remove warnings from multiscreen devices 2026-07-06 22:40:57 +08:00
YoursFunny ea2de5d17c perf: avoid import lz4 globally in ascreencap.py 2026-07-06 22:40:57 +08:00
YoursFunny 380b1f9e6e fix: handle nemu ipc lib being none 2026-07-06 22:40:56 +08:00
YoursFunny ed6370b9f9 fix: extend exception handling to include JobTimeout 2026-07-06 22:40:56 +08:00
YoursFunny e7b60ba3ea fix: reuse Switch.get() in Switch.appear() 2026-07-06 22:40:56 +08:00
YoursFunny fbc63d0a54 fix: optimize screenshot handling by removing unnecessary copy 2026-07-06 22:40:56 +08:00
YoursFunny 148a39b591 fix: update image flipping to create a writable copy 2026-07-06 22:40:56 +08:00
YoursFunny 2073af147b fix: allow hide as plain dict 2026-07-06 22:40:55 +08:00
YoursFunny 605fde2d7c feat: add SSL support 2026-07-06 22:40:55 +08:00
YoursFunny 593799a0a6 fix: handle connection exceptions gracefully 2026-07-06 22:40:55 +08:00
YoursFunny 56875e46b3 fix: add notice to disable network bridging 2026-07-06 22:40:55 +08:00
YoursFunny bfcbf268fc perf: replace ThreadPoolExecutor with alas threadpool 2026-07-06 22:40:54 +08:00
YoursFunny cedc003f08 fix: match 7555 in vbox config with MuMu instance_id 2026-07-06 22:40:54 +08:00
YoursFunny 3dc24d56e1 feat: support MuMuPlayer12 5.0 2026-07-06 22:40:54 +08:00
YoursFunny 6774c2070b fix: close pillow Image object 2026-07-06 22:40:54 +08:00
YoursFunny 0942a9bba7 fix: handle empty rows from "ldconsole.exe list2" 2026-07-06 22:40:54 +08:00
YoursFunny 3d32cccc7e feat: add state loop methods for screenshots and hierarchy dumping 2026-07-06 22:40:53 +08:00
YoursFunny 30b77d2dbe perf: faster get_bbox() 2026-07-06 22:40:53 +08:00
YoursFunny 9922e25d38 fix: remove render errors on waydroid 2026-07-06 22:40:53 +08:00
YoursFunny 5fbd775b9a fix: hide traceback of notify 2026-07-06 22:40:53 +08:00
YoursFunny fe2998aea6 refactor: improve logic for nemud player version checks in nemu_ipc.py 2026-07-06 22:40:53 +08:00
YoursFunny a2ab36408d refactor: simplify type hints, enhance docstrings, and solve various race conditions in pool.py 2026-07-06 22:40:52 +08:00
YoursFunny 88cc9fbd8f dep: mark dev only dependencies 2026-07-06 22:40:52 +08:00
YoursFunny 6e75e3d19c refactor: update type hint for modify parameter in run_set function 2026-07-06 22:40:52 +08:00
YoursFunny 2f4afcd5e8 feat: add cached_property for OCR initialization in draggable_list 2026-07-06 22:40:52 +08:00
YoursFunny dcd4b8f0f4 fix: handle FileNotFoundError in atomic_rmtree 2026-07-06 22:40:51 +08:00
YoursFunny b854f1f858 perf: improve performance in base/utils/utils.py functions 2026-07-06 22:40:51 +08:00
YoursFunny 576e73b5dc refactor: streamline file operations and enhance atomic read/write functions 2026-07-06 22:40:51 +08:00
YoursFunny ba4e63c401 perf: improve iter_process speed by directly access psutil internal functions 2026-07-06 22:40:51 +08:00
YoursFunny 748fb94562 fix: do not import platform while importing emulator_windows 2026-07-06 22:40:51 +08:00
YoursFunny 86e0f30e58 fix: use site to get site-packages 2026-07-06 22:40:50 +08:00
YoursFunny c275f8cb7e perf: optimize image cropping by avoiding unnecessary copies 2026-07-06 22:40:50 +08:00
YoursFunny 2c4f0bf848 feat: implement rendered state caching and dynamic instance status updates 2026-07-06 22:40:50 +08:00
YoursFunny 744680a93a fix: treat GameNotRunningError as task failure 2026-07-06 22:40:50 +08:00
YoursFunny e560f9fc58 feat: enhance atomic file operations with Windows compatibility and temp file management 2026-07-06 22:40:49 +08:00
YoursFunny 237f42ef72 fix: ensure wait for emulator-* devices appear after reconnect 2026-07-06 22:40:49 +08:00
YoursFunny f9ac1f79f5 fix: handle adb service reset error and prompt client reconnection 2026-07-06 22:40:49 +08:00
YoursFunny 266ceac297 refactor: add app_current() 2026-07-06 22:40:49 +08:00
YoursFunny 003ec83a87 fix: change hierarchy dump compression to false for better compatibility 2026-07-06 22:40:49 +08:00
YoursFunny 9b388c554d feat: add ModuleBase.match_template_luma() 2026-07-06 22:40:48 +08:00
YoursFunny f420d72906 fix: update task handling to properly restart and clear cached properties 2026-07-06 22:40:48 +08:00
YoursFunny f1ea051b85 feat: add support for MuMU Pro emulator detection and connection handling 2026-07-06 22:40:48 +08:00
YoursFunny 6d85dfc7ce perf: replace filelock with threading lock to reduce IO operations 2026-07-06 22:40:48 +08:00
YoursFunny 251b2eada4 perf: reduce deep_get in config_update 2026-07-06 22:40:47 +08:00
YoursFunny e513fdde51 refactor: improve deep methods for better performance 2026-07-06 22:40:47 +08:00
YoursFunny b95a45496b refactor: use atomic file read/write for better performance 2026-07-06 22:40:47 +08:00
YoursFunny 844d390017 fix: ensure screenshot interval is not applied to scrcpy if screenshot_metho_override is true 2026-07-06 22:40:47 +08:00
YoursFunny bbc0d1352d fix: refactor merge_result_button to use keywords for result identification 2026-07-06 22:40:47 +08:00
YoursFunny 4a59ec58bd fix: disable redirect 5555 to MuMu serials on Mac due to being occupied by BlueStacks Air 2026-07-06 22:40:46 +08:00
YoursFunny 9c4e25fd36 fix: use get method for safer config comparison 2026-07-06 22:40:46 +08:00
YoursFunny 2e610bc8ef fix: reduce unnecessary file writes at startup 2026-07-06 22:40:46 +08:00
YoursFunny 2789be852e fix: add missing import 2026-07-06 22:40:46 +08:00
YoursFunny 32da27567c fix: correct threshold default in get_bbox_reversed 2026-07-06 22:40:46 +08:00
YoursFunny 9b078b02d7 fix: enhance subprocess.Popen to prevent emulator termination 2026-07-06 22:40:45 +08:00
YoursFunny df34d2c1fe fix: allow ScreenshotMethod to be configured manually 2026-07-06 22:40:45 +08:00
YoursFunny 5226a9542c fix: prefer MaaTouch over minitouch when both are fastest 2026-07-06 22:40:45 +08:00
YoursFunny 091a35009b feat: add support for BlueStacks Air detection in connection logic 2026-07-06 22:40:45 +08:00
YoursFunny 0f8df69f6b fix: make benchmark available 2026-07-06 22:40:44 +08:00
YoursFunny 3193c7c327 perf: patch aiofiles to reduce thread pool size 2026-07-06 22:40:44 +08:00
YoursFunny dcd4619d63 refactor: update return types in OCR methods to use Keyword instead of OcrResultButton 2026-07-06 22:40:44 +08:00
YoursFunny b9e0f0fc19 fix: mark nemu_ipc and ldopengl unavailable on non-Windows 2026-07-06 22:40:44 +08:00
YoursFunny 5a7428ac9b perf: use match luma template in match_template_color() 2026-07-06 22:40:44 +08:00
YoursFunny 6582754025 fix: extend nemu_ipc timeout on retries dynamically for slow PCs 2026-07-06 22:40:43 +08:00
YoursFunny 1e152c4641 refactor: use trio-like thread pool in nemu_ipc instead of asyncio.run_in_executor() 2026-07-06 22:40:43 +08:00
YoursFunny 2d745d41b6 fix: fallback to auto if nemu_ipc and ldopengl are selected on non-corresponding emulators 2026-07-06 22:40:43 +08:00
YoursFunny bebb68d3b2 fix: ensure handlers wait for unknown first 2026-07-06 22:40:43 +08:00
YoursFunny 21a1088ea4 fix: remove unnecessary return statement in switch logic 2026-07-06 22:40:42 +08:00
YoursFunny 1799547606 dep: lock deps version 2026-07-06 22:40:42 +08:00
YoursFunny d83ce3bdc3 feat: add error postprocessing method and invoke on exceptions 2026-07-06 22:40:42 +08:00
YoursFunny 892fabe501 fix: add wait for list end after swiping 2026-07-06 22:40:42 +08:00
YoursFunny fbf2970f88 fix: add missing wait after down event 2026-07-06 22:40:42 +08:00
YoursFunny cf52a2fc2d feat: save error log on ScriptError 2026-07-06 22:40:41 +08:00
YoursFunny 4112509fe3 fix: skip trying ldopengl if it is not a LDPlayer 2026-07-06 22:40:41 +08:00
YoursFunny 7c3ef72447 feat: add screenshot method ldopengl 2026-07-06 22:40:41 +08:00
YoursFunny d817bb15e8 fix: skip trying nemu_ipc on MuMu Global 2026-07-06 22:40:41 +08:00
YoursFunny e69983ee85 fix: add merge_result_button function for combining OCR results 2026-07-06 22:40:41 +08:00
YoursFunny dfd0a4086b fix: share screenshot_method_override across tasks 2026-07-06 22:40:40 +08:00
YoursFunny dda8e6b992 fix: add area_distance function and return sorted pair buttons 2026-07-06 22:40:40 +08:00
YoursFunny dbf39d025a fix: improve deep_get, deep_set, deep_pop, and deep_iter functions for better error handling and performance 2026-07-06 22:40:40 +08:00
YoursFunny 04ce2efbeb fix: limit pool size when using run_in_executor() 2026-07-06 22:40:40 +08:00
YoursFunny 1e8686c2a4 fix: catch OSError when iter running emulators 2026-07-06 22:40:40 +08:00
YoursFunny 8c8dd37ce0 fix: handle MuMu12 v4.0.4 default instance serial and improve instance naming 2026-07-06 22:40:39 +08:00
YoursFunny e45eb6082b fix: check app keep alive on mumu >= 4.0 2026-07-06 22:40:39 +08:00
YoursFunny cc7905e6ee fix: connect all possible serial at once 2026-07-06 22:40:39 +08:00
YoursFunny 643a66a0b9 fix: add launch parameters on waydroid 2026-07-06 22:40:39 +08:00
YoursFunny dcb15deb4b fix: handle empty getprop on mumu12 >= 4.0 2026-07-06 22:40:38 +08:00
YoursFunny b535ee5f7e fix: keep uiautomator_2.py start method same as adb 2026-07-06 22:40:38 +08:00
YoursFunny 0ad395b949 fix: catch Permission Denial on am start 2026-07-06 22:40:38 +08:00
YoursFunny ae19dbeb0d feat: launch app with activity name 2026-07-06 22:40:38 +08:00
YoursFunny abdaa277f8 feat: add LDPlayer_id property and improve emulator stop logic 2026-07-06 22:40:38 +08:00
YoursFunny 194043a892 fix: handle dynamic MuMu serial in nemu_ipc.py 2026-07-06 22:40:37 +08:00
YoursFunny 9331d9fdf0 fix: skip nemud_app_keep_alive check on other emulators 2026-07-06 22:40:37 +08:00
YoursFunny f4bbc2f2c3 feat: add emulator console convert and quit method 2026-07-06 22:40:37 +08:00
YoursFunny 47f75460f7 fix: release cache after adb_restart() 2026-07-06 22:40:37 +08:00
YoursFunny 10ea7b1034 fix: try handle MuMu12 port switches 2026-07-06 22:40:37 +08:00
YoursFunny 36d6831ce8 fix: update cached property names for minitouch and maatouch builders 2026-07-06 22:40:36 +08:00
YoursFunny 430d69695c feat: add handle AdbError: unknown host service 2026-07-06 22:40:36 +08:00
YoursFunny 63d238171f fix: increase emulator startup trials and adjust timeout duration 2026-07-06 22:40:36 +08:00
YoursFunny 94ca975d56 fix: use autofill emulator info on Windows only 2026-07-06 22:40:36 +08:00
YoursFunny c9edcd0a40 fix: allow Hermit on VMOS only 2026-07-06 22:40:36 +08:00
YoursFunny 67bd1d9a1c fix: update Device.config during scheduler run 2026-07-06 22:40:35 +08:00
YoursFunny 3d5c2ee537 feat: add interval on dump_hierarchy() 2026-07-06 22:40:35 +08:00
YoursFunny dd8f28621f fix: correct command line argument for BlueStacks 5 instance 2026-07-06 22:40:35 +08:00
YoursFunny 4c0dd1b7d9 fix: enhance nemud_app_keep_alive check for Mac compatibility 2026-07-06 22:40:35 +08:00
YoursFunny 99f86e359f refactor: abstract platform checks 2026-07-06 22:40:35 +08:00
YoursFunny 07df89653c fix: remove chinese comma from ocr results 2026-07-06 22:40:34 +08:00
YoursFunny 8af0639490 fix: update rootName array in getAlasABSPath function 2026-07-06 22:40:34 +08:00
YoursFunny b89637b195 fix: add themeConfig.ts 2026-07-06 22:40:34 +08:00
YoursFunny b3f4f48251 fix: show empty stored 2026-07-06 22:40:34 +08:00
YoursFunny a59cd12c01 fix: patch to fix atx_agent_url on arm64-v8a 2026-07-06 22:40:33 +08:00
YoursFunny e9f12b556c dep: upgrade uiautomator2cache to 0.3.1 2026-07-06 22:40:33 +08:00
YoursFunny 07cb9480c2 dep: lock deps version 2026-07-06 22:40:33 +08:00
YoursFunny c57d64040a fix: use dynamic nemu_ipc timeout to fit screenshot interval 2026-07-06 22:40:33 +08:00
YoursFunny 013ba8b749 fix: update return type documentation for OCR counter method 2026-07-06 22:40:33 +08:00
YoursFunny 3d8f06db85 fix: increase sleep duration and timeout for process shutdown 2026-07-06 22:40:32 +08:00
YoursFunny a640960383 feat: add Switch.wait() 2026-07-06 22:40:32 +08:00
YoursFunny c45aad9f67 fix: remove eta of weekly items since it's meaningless 2026-07-06 22:40:32 +08:00
YoursFunny c793672933 feat: add set_cached_property function to manage cached properties 2026-07-06 22:40:32 +08:00
YoursFunny 2504a03680 fix: add return statement to prevent further execution on server change 2026-07-06 22:40:32 +08:00
YoursFunny 4f56aece00 feat: enhance dashboard display with conditional ETA formatting 2026-07-06 22:40:31 +08:00
YoursFunny be70c88036 feat: add clear method to StoredInt and StoredCounter classes 2026-07-06 22:40:31 +08:00
YoursFunny b029d9f732 feat: add weekly limit for next run in scheduler 2026-07-06 22:40:31 +08:00
YoursFunny feb1e1ae5c fix: update parameter types in switch function documentation 2026-07-06 22:40:31 +08:00
YoursFunny 415304b7a4 fix: comment out logging message for game server status check 2026-07-06 22:40:31 +08:00
YoursFunny 2bb2c2f4a4 feat: add ETA display and comment support in dashboard 2026-07-06 22:40:30 +08:00
YoursFunny e15eb7a9a5 refactor: rename image processing methods to use luma instead of binary 2026-07-06 22:40:30 +08:00
YoursFunny f30deb0614 fix: update logging messages to reflect AAS instead of Alas 2026-07-06 22:40:30 +08:00
YoursFunny caf74d924d fix: handle psutil.NoSuchProcess 2026-07-06 22:40:30 +08:00
YoursFunny 05c30a2e75 fix: try nc command on android first 2026-07-06 22:40:30 +08:00
YoursFunny 3f38ac3827 fix: add more fool-proof serial input redirection 2026-07-06 22:40:29 +08:00
YoursFunny 4dfdcf42f5 perf: reuse image array to improve memory performance 2026-07-06 22:40:29 +08:00
YoursFunny 5168e4edf0 refactor: move func methods to the Alas class 2026-07-06 22:40:29 +08:00
YoursFunny c878ece7c8 fix: prevent starting task if command is 'Restart' 2026-07-06 22:40:29 +08:00
YoursFunny 1831356cee fix: log ScriptError as exception so traceback can be logged 2026-07-06 22:40:29 +08:00
YoursFunny 7afc408587 feat: extract ButtonGrid assets 2026-07-06 22:40:28 +08:00
YoursFunny 77d4b19f9c feat: add HandledError exception and log errors in alas.py 2026-07-06 22:40:28 +08:00
YoursFunny 511a5b40ca feat: implement error logging functionality in new error.py module 2026-07-06 22:40:28 +08:00
YoursFunny 9637d6f9ee fix: improve detection of single digit in TextDetector 2026-07-06 22:40:28 +08:00
YoursFunny 5b6e25d73b fix: remove set_repo() 2026-07-06 22:40:27 +08:00
YoursFunny 41d9c02331 feat: show planner on gui 2026-07-06 22:40:27 +08:00
YoursFunny b00676261d fix: typo in doc 2026-07-06 22:40:27 +08:00
YoursFunny db1a470d94 feat: add white preprocessing option in OcrWhiteLetterOnComplexBackground 2026-07-06 22:40:26 +08:00
YoursFunny 622f2a467d fix: update REGEX_PUNCTUATION to include the pipe character 2026-07-06 22:40:26 +08:00
YoursFunny 090a141307 feat: add Button.match_template_binary() 2026-07-06 22:40:26 +08:00
YoursFunny 41d29438c1 fix: use corresponding model in Digit and DigitCounter 2026-07-06 22:40:26 +08:00
YoursFunny eb59fdb477 fix: set nemu_ipc before screenshot_interval_set 2026-07-06 22:40:25 +08:00
YoursFunny de3d7b60d7 feat: improve ui appearance of stored object 2026-07-06 22:40:25 +08:00
YoursFunny 2616a0ebe6 fix: update REGEX_PUNCTUATION to include the correct dash character 2026-07-06 00:00:32 +08:00
YoursFunny 3ae8ac3c7a feat: add Button.is_offset_in() 2026-07-05 23:52:42 +08:00
YoursFunny d674c7fd4d fix: handle socket timeouts and improve error logging in ScrcpyCore 2026-07-05 23:51:28 +08:00
YoursFunny b601c324b6 perf: improve DroidCast_raw image parsing 2026-07-05 23:49:05 +08:00
YoursFunny 22e132db0a fix: ensure minimum points in minitouch for valid distance calculation 2026-07-05 23:48:07 +08:00
YoursFunny 4e01310b5f perf: improve memory performance in utils 2026-07-05 23:46:44 +08:00
YoursFunny 8db00715b1 fix: patch uiautomator2 loggers so they can be logged 2026-07-05 23:42:42 +08:00
YoursFunny 42459df920 fix: add method uninstall_uiautomator2 2026-07-05 23:41:55 +08:00
YoursFunny 8eed513497 fix: patch u2.init.appdir in runtime 2026-07-05 23:40:56 +08:00
YoursFunny f280648153 perf: import onepush lazily 2026-07-05 23:39:51 +08:00
YoursFunny 0b9da16ff3 fix: handle non-existent adb forward/reverse removal gracefully 2026-07-05 23:36:13 +08:00
YoursFunny 70b784c2f4 fix: remove minitouch port forwarding on reconnect 2026-07-05 23:33:59 +08:00
YoursFunny 38b6ce50c6 fix: use current python executable if deploy.yaml one does not exist 2026-07-05 23:29:37 +08:00
YoursFunny d6e52d7720 fix: redirect deploy config after Updator.delay() 2026-07-05 23:27:02 +08:00
YoursFunny 99862137f1 fix: add default value for _minitouch_client 2026-07-05 23:10:45 +08:00
YoursFunny 7415c7e217 fix: re-init MaaTouch when orientation changes 2026-07-05 23:08:36 +08:00
YoursFunny 6d2b536050 dep: upgrade MaaTouch binary to 1.10 2026-07-05 23:03:18 +08:00
YoursFunny 0481463c07 fix: add detection of global MuMu12 id 2026-07-05 23:01:17 +08:00
YoursFunny 810e669118 fix: add retry for minitouch and MaaTouch builders 2026-07-05 23:00:23 +08:00
YoursFunny 63c464b51a fix: site-packages detection on manual package builds 2026-07-05 22:56:40 +08:00
YoursFunny 433931477e perf: patch pkg_resources for faster startup 2026-07-05 22:55:00 +08:00
YoursFunny dbc1789ae1 perf: early init minitouch and MaaTouch for faster startup 2026-07-05 22:52:14 +08:00
YoursFunny 2f4eb5d047 fix: update config_updater to yield default values 2026-07-05 22:27:48 +08:00
YoursFunny ef9dde3da5 Merge pull request #37 from TheFunny/sync_src
Sync with src
2026-07-05 21:20:49 +08:00
YoursFunny 723aa66e69 Merge branch 'dev' into sync_src 2026-07-05 21:17:22 +08:00
YoursFunny 0b28ed6f89 feat: migrate notify.py from ALAS and upgrade dep onepush 1.3.0 2026-07-05 20:21:50 +08:00
YoursFunny d54ff72565 feat: use nemu_ipc if avaliable 2026-07-05 20:18:18 +08:00
YoursFunny a2bd36f570 feat: add nemu_ipc 2026-07-05 20:16:51 +08:00
YoursFunny 50c4bc5bba feat: enhance device connection handling and droidcast resolution management 2026-07-05 20:06:53 +08:00
YoursFunny 3cef2ee01b feat: improve emulator management 2026-07-05 19:40:17 +08:00
YoursFunny 0bd294582b refactor: move ThreadPoolExecutor import inside worker method for better encapsulation 2026-07-05 19:27:47 +08:00
YoursFunny 49149413d5 fix: add timeout check to prevent infinite loop in draggable list 2026-07-05 19:27:22 +08:00
YoursFunny 108f648517 fix: set Windows event loop policy conditionally based on platform 2026-07-05 18:57:05 +08:00
YoursFunny aa4afbf6a2 perf: reuse image array to improve memory performance 2026-07-05 18:55:37 +08:00
YoursFunny d34c082ef3 feat: add AdaptiveScroll to handle transparent scroll on complex background 2026-07-05 18:51:41 +08:00
YoursFunny d48427abb2 refactor: cache nemud.app_keep_alive 2026-07-05 18:49:35 +08:00
YoursFunny ce8eb1c452 refactor: streamline platform imports and remove redundant type method 2026-07-05 18:47:10 +08:00
YoursFunny d0e6c78cc1 feat: autofill emulator info even not used 2026-07-05 18:43:46 +08:00
YoursFunny 75b8b78b24 refactor: rename list_azurlane_packages to list_known_packages for clarity 2026-07-05 18:39:45 +08:00
YoursFunny cac19c10c3 fix: enhance auto device detection, and ignore 127.0.0.1:7555 from MuMu12 2026-07-05 18:38:33 +08:00
YoursFunny d806c5e9e7 fix: exclude hidden files from asset extraction 2026-07-05 18:35:33 +08:00
YoursFunny 87975760ae feat: proxy element props 2026-07-05 18:33:25 +08:00
YoursFunny 6a4b1998fc fix: ignore files that are added by deployer 2026-07-05 18:29:59 +08:00
YoursFunny d7db56eb5a fix: update input prompts in screenshot.py 2026-07-05 18:27:58 +08:00
YoursFunny d017d9eefd fix: update REGEX_PUNCTUATION to include additional symbols 2026-07-05 18:26:01 +08:00
YoursFunny 97c3ed30e9 feat: add set_search_offset() to be compatible with Alas 2026-07-05 18:25:31 +08:00
YoursFunny 52964c2ff0 feat: add TaskError exception for task error handling 2026-07-05 18:21:29 +08:00
YoursFunny be44b23d70 fix: support emulator index parsing from MuMu ArkNights version 2026-07-05 18:19:44 +08:00
YoursFunny 0590eebd44 fix: handle FileNotFoundError when accessing Windows registry keys 2026-07-05 18:15:19 +08:00
YoursFunny 11f5f1dd64 fix: copy raw attributes of first frame to all 2026-07-05 18:11:56 +08:00
YoursFunny 5ffaec0fb1 feat: add stop method to Login class and implement button handling in base.py 2026-07-05 18:09:57 +08:00
YoursFunny 24e9bde5c7 fix: update punctuation regex to include ellipsis 2026-07-05 18:01:38 +08:00
YoursFunny 64bb8e3a12 feat: add method to increment value in StoredCounter 2026-07-05 18:00:49 +08:00
YoursFunny baddf23df5 refactor: update scroll methods to return results for better chaining 2026-07-05 17:56:17 +08:00
YoursFunny fc5847d6d8 feat: add KeywordDigitCounter class for filtering digit counters in OCR results 2026-07-05 17:55:16 +08:00
YoursFunny 84ac3dc9b8 feat: add is_format_matched method and improve regex for result formatting 2026-07-05 17:51:46 +08:00
YoursFunny af3e178c88 fix: limit length of screenshot deque 2026-07-05 17:50:14 +08:00
YoursFunny 7d084db107 fix: do not track screenshot when save error disabled 2026-07-05 17:47:26 +08:00
YoursFunny fcab832be1 fix: update func_list handling to ensure proper task binding 2026-07-05 17:36:36 +08:00
YoursFunny e8cf0a3517 fix: extend height of log-bar in daemon overview 2026-07-05 17:34:39 +08:00
YoursFunny 808a02ddf5 feat: add find_name method to retrieve Keyword instances by name 2026-07-05 17:31:12 +08:00
YoursFunny 5df288b577 feat: hide custom settings if custom not selected 2026-07-05 17:29:56 +08:00
YoursFunny 0a10c61c59 feat: show function calls on error 2026-07-05 17:22:09 +08:00
YoursFunny 247a9a8770 feat: save tracking screenshots 2026-07-05 17:19:11 +08:00
YoursFunny 63d4b50461 fix: add retry for check_mumu_app_keep_alive() 2026-07-05 17:12:50 +08:00
YoursFunny 8741cb6142 fix: checkbox cannot be updated dynamically 2026-07-05 17:11:07 +08:00
YoursFunny 091b5c011a refactor: replace direct import of deque with collections.deque for clarity 2026-07-05 17:07:17 +08:00
YoursFunny c43f8e5458 feat: add is_draggable method to improve swipe responsiveness 2026-07-05 17:02:27 +08:00
YoursFunny 6f6dab1c04 fix: correct function to retrieve last server update 2026-07-05 16:57:17 +08:00
YoursFunny f6f2470e5b refactor: add config update callback 2026-07-05 16:56:12 +08:00
YoursFunny 2275133638 dep: update to pywebio 1.8.3 2026-07-05 16:42:50 +08:00
YoursFunny da34dc4b7d fix: recheck in-game text language during waiting for next run 2026-07-05 16:41:20 +08:00
YoursFunny 07a7a19929 feat: add area_center function and enhance OCR processing for white letters 2026-07-05 16:40:00 +08:00
YoursFunny a7290f253e fix: remove another dash in ocr results 2026-07-05 16:37:22 +08:00
YoursFunny 0b1a4efd09 fix: renew timer in interval_is_reached() only 2026-07-05 16:36:01 +08:00
YoursFunny aaa56b928f feat: add interval timer management methods for buttons 2026-07-05 16:34:09 +08:00
YoursFunny 8dc527b06b fix: remove semicolon and dot in ocr results 2026-07-05 16:22:33 +08:00
YoursFunny 004af69d8f fix: use 'area' in 'ClickButton' 2026-07-05 16:20:03 +08:00
YoursFunny a397539c69 feat: add simple markdown table generator 2026-07-05 16:12:19 +08:00
YoursFunny 864ac15282 dep: upgrade to pydantic v2 2026-07-05 16:07:37 +08:00
YoursFunny ff2f226713 fix: update assets file 2026-05-26 21:54:23 +08:00
YoursFunny 8177d0f56d fix: update ui assets for OVERSEA server 2026-05-26 21:53:53 +08:00
YoursFunny b606108871 fix: update assets file 2026-04-24 19:30:05 +08:00
YoursFunny be09b39bbe fix: update ui assets for jp 2026-04-24 19:26:36 +08:00
YoursFunny fafb9d5a5c fix: update assets file 2026-01-20 19:45:18 +08:00
YoursFunny 998d52b56c fix: update ui assets for jp 2026-01-20 19:43:55 +08:00
YoursFunny 3e6a82764a fix(login): disable clicking back when login for jp 2025-10-27 14:30:45 +08:00
YoursFunny 3cd8161afb feat(momotalk): add a single tool page for momotalk,
remove its scheduler,
and some minor improvements
2025-10-13 11:41:48 +08:00
YoursFunny 02b8de6d18 fix: update assets file 2025-09-23 20:02:23 +08:00
YoursFunny 7a321eae48 fix: update configs to enable new bounty stage for OVERSEA server 2025-09-23 20:01:32 +08:00
YoursFunny ad19268e5a fix: update ui assets for zht 2025-09-23 19:59:49 +08:00
YoursFunny 8bc170e170 fix: update ui assets for en 2025-09-23 19:59:26 +08:00
YoursFunny 1d618d1624 fix: update assets file 2025-08-09 13:19:41 +08:00
YoursFunny 9eb3bde649 fix: update ui assets for OVERSEA server 2025-08-09 13:18:23 +08:00
YoursFunny 62ef4c733c fix: update assets file 2025-07-22 20:13:27 +08:00
YoursFunny 27dbd31434 fix: update ui assets for jp 2025-07-22 20:12:46 +08:00
YoursFunny 74ef1a7b4d fix: update configs 2025-05-18 19:57:13 +08:00
YoursFunny 43282354cd fix(bounty): update stage select option 2025-05-18 19:53:27 +08:00
YoursFunny f92d92aec0 fix: update assets file 2025-05-13 21:54:43 +08:00
YoursFunny 3a00c47125 fix(cafe): update ui assets for zht 2025-05-13 21:53:18 +08:00
YoursFunny bca3395cb9 fix(cafe): update ui assets for jp & en 2025-05-13 21:53:08 +08:00
YoursFunny 766cb78377 fix(cafe): synchronize Cafe No.2 for global server 2025-05-13 21:52:07 +08:00
YoursFunny 84acb67061 fix: update assets file 2025-04-22 19:53:10 +08:00
YoursFunny 9c07251669 fix: update ui assets for jp 2025-04-22 19:52:59 +08:00
160 changed files with 8215 additions and 1820 deletions
+8
View File
@@ -21,6 +21,14 @@ test.py
test/
note.md
MCE/config.json
resources/
locales/
*.pak
*.dll
*.dat
v8_context_snapshot.bin
snapshot_blob.bin
vk_swiftshader_icd.json
# Created by .ignore support plugin (hsz.mobi)
+9 -1
View File
@@ -11,6 +11,10 @@ class ArisuAutoSweeper(AzurLaneAutoScript):
from tasks.login.login import Login
Login(self.config, device=self.device).app_start()
def stop(self):
from tasks.login.login import Login
Login(self.config, device=self.device).app_stop()
def goto_main(self):
from tasks.login.login import Login
from tasks.base.ui import UI
@@ -56,7 +60,7 @@ class ArisuAutoSweeper(AzurLaneAutoScript):
def momotalk(self):
from tasks.momotalk.momotalk import MomoTalk
MomoTalk(config=self.config, device=self.device).run()
MomoTalk(config=self.config, device=self.device, task="Momotalk").run()
def mission(self):
from tasks.mission.mission import Mission
@@ -70,6 +74,10 @@ class ArisuAutoSweeper(AzurLaneAutoScript):
from tasks.item.data_update import DataUpdate
DataUpdate(config=self.config, device=self.device).run()
def benchmark(self):
from module.daemon.benchmark import run_benchmark
run_benchmark(config=self.config)
if __name__ == '__main__':
aas = ArisuAutoSweeper('aas')
aas.loop()
Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.1 KiB

After

Width:  |  Height:  |  Size: 6.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.5 KiB

After

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.0 KiB

After

Width:  |  Height:  |  Size: 5.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.0 KiB

After

Width:  |  Height:  |  Size: 5.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.6 KiB

After

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.9 KiB

After

Width:  |  Height:  |  Size: 9.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.8 KiB

After

Width:  |  Height:  |  Size: 6.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.6 KiB

After

Width:  |  Height:  |  Size: 6.7 KiB

+27 -6
View File
@@ -315,6 +315,16 @@ pre.rich-traceback-code {
margin: 0;
}
.anim-rotate {
animation: rotate-keyframes 0.4s linear infinite
}
@keyframes rotate-keyframes {
100% {
transform: rotate(360deg)
}
}
#pywebio-scope-contents {
margin-top: 0;
overflow-y: auto;
@@ -377,6 +387,7 @@ pre.rich-traceback-code {
#pywebio-scope-scheduler-bar,
#pywebio-scope-log-bar,
#pywebio-scope-log,
#pywebio-scope-daemon-log-bar,
#pywebio-scope-daemon-overview #pywebio-scope-groups {
font-weight: 500;
margin: 0.3125rem;
@@ -407,13 +418,13 @@ pre.rich-traceback-code {
#pywebio-scope-dashboard > i {
flex-grow: 1;
align-self: flex-end;
width: 10rem;
width: 9.5rem;
}
[id^="pywebio-scope-dashboard-row-"] {
display: flex;
flex-grow: 1;
width: 10rem;
width: 9.5rem;
}
.dashboard-icon {
@@ -435,16 +446,26 @@ pre.rich-traceback-code {
overflow-wrap: break-word;
}
*[style*="--dashboard-bold--"] {
font-size: 1rem;
font-weight: bold;
color: #7a77bb;
overflow-wrap: break-word;
}
[id^="pywebio-scope-dashboard-row-"] p {
margin-bottom: 0;
}
[id^="pywebio-scope-dashboard-value-"] {
display: flex;
align-items: flex-end;
align-items: baseline;
height: 1.5rem;
}
[id^="pywebio-scope-arg_stored-stored-value-"] p {
margin-bottom: 0;
}
#pywebio-scope-log {
line-height: 1.2;
@@ -549,19 +570,19 @@ pre.rich-traceback-code {
padding-right: .25rem;
}
*[style*="--loading-grow--"] {
*[style*="--loading-grow--"] > div {
width: 1.5rem;
height: 1.5rem;
}
*[style*="--loading-border--"] {
*[style*="--loading-border--"] > div {
width: 1.5rem;
height: 1.5rem;
border: .2em solid currentColor;
border-right-color: transparent;
}
*[style*="--loading-border-fill--"] {
*[style*="--loading-border-fill--"] > div {
width: 1.5rem;
height: 1.5rem;
border: .2em solid currentColor;
+7 -6
View File
@@ -63,18 +63,18 @@ select {
background-image: url("data:image/png;base64,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");
}
.state > select {
select.state {
border-bottom: 0;
background-image: none;
pointer-events: none;
}
.state-bold > select {
select.state-bold {
font-weight: bold;
color: #7a77bb;
}
.state-light > select {
select.state-light {
color: #777777;
}
@@ -133,12 +133,13 @@ pre.rich-traceback-code {
color: #c9d1d9;
}
#pywebio-scope-scheduler-bar,
#pywebio-scope-log-bar,
#pywebio-scope-log,
#pywebio-scope-running,
#pywebio-scope-pending,
#pywebio-scope-waiting,
#pywebio-scope-scheduler-bar,
#pywebio-scope-log-bar,
#pywebio-scope-log,
#pywebio-scope-daemon-log-bar,
#pywebio-scope-daemon-overview #pywebio-scope-groups {
background-color: #2f3136;
border: 1px solid #21262d;
+7 -6
View File
@@ -64,18 +64,18 @@ select {
background-image: url("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAMgAAADICAYAAACtWK6eAAAAAXNSR0IArs4c6QAAC85JREFUeF7tnUezdVURhh+qmDixnGOVVjmxyj+AOsGBEyeGKrMYMGfBCIYqE2LOWVTMOeeMGRQjZhBMmAOCAXO9sE7V+eC711699z5n791vj3vtu/vp9Zy1bh+43zE4TMAEDiRwjNmYgAkcTMCCeHeYwCEELIi3hwlYEO8BE8gR8AmS4+ZVRQhYkCKNdpk5AhYkx82rihCwIEUa7TJzBCxIjptXFSFgQYo02mXmCFiQHDevKkLAghRptMvMEbAgOW5eVYSABSnSaJeZI2BBcty8qggBC1Kk0S4zR8CC5Lh5VRECFqRIo11mjoAFyXHzqiIELEiRRrvMHAELkuPmVUUIWJAijXaZOQIWJMfNq4oQsCBFGu0ycwQsSI6bVxUhYEGKNNpl5ghYkBw3rypCwIIUabTLzBGwIDluXlWEgAUp0miXmSNgQXLcvKoIAQtSpNEuM0fAguS4eVURAhakSKNdZo6ABclx86oiBCxIkUa7zBwBC5Lj5lVFCFiQIo12mTkCFiTHzauKELAgRRrtMnMELEiOm1cVIWBBijTaZeYIWJAcN68qQsCCFGm0y8wRsCA5bl5VhIAFKdJol5kjYEFy3LyqCAELUqTRLjNHwILkuHlVEQIWpEijXWaOgAXJcfOqIgQsSJFGu8wcAQuS4+ZVRQhYkCKNdpk5AhYkx82rihCwIEUa7TJzBCxIjptXFSFgQYo02mXmCFiQHDevKkLAghRptMvMEZizILcAjgOuA1wIfBy4LFemV62AwLWBGwPHA2cD5+1iP8xVkE8DJ1ytqecCpwGfXEGzXUIfgZOBU4Drbi27GLgH8Jm+R/Vlz1GQC4AbHFLG7YC395Xp7AUTeBtw20Pe/0bAd6eqb26CnA6cGij29oDAOdZN4K2APhAPi0uAm08lydwEOR/QJ0Ik7gAIoGOdBN4C6IMwEvpQPSOS2JszN0H+ClyrowhL0gFrQak9cqis9wG3nKK+uQlyEXD9zkLvCAioYx0E3gzog68nXgI8sGdBNHdugrwLuHX05bfy7gQIrGPZBDJyqOLJPiTnJsgNgU+07z96W21JeonNK/9NbaP3vpXGvDfrXRTNn5sgem+N9LITqjsDAu1YFoE3AvqAy4R+99DvIJPEHAVRoRrtZSdUdwEE3LEMAlk5/tv2yTumLHOugliSKbs+n2e/AdCp3xs7kUMvNWdB9H6ag2cnVCcCaoBjngReD+i0743/tJPjnb0LM/lzF0Q1aeSXnVDdFVAjHPMikJXj300OTTt3EksQxJLsZCvs7Ie8DtDp3hs7l2MJV6xtiJp1ZydUdwPUGMd+CZwF6FTvjX+1k+PdvQuH5i/lBNnUqVFgdkJlSYbulmHrs3L8s/0uunM5lnaCjCHJ3QE1yrFbAq8F9AHVG5JDI//39C4cK39pJ8imbo0GsxMq/U82aphjNwReA+iDqTf+0eR4b+/CMfOXKogYaESYnVBZkjF30cHPyspxRbtW7VWOpV6xttsxRJKTADXQMQ2BV7f/Jbb36ZJD16rJ/vORnhda8gmyqVMjw+yE6p6AGukYl0BWjr83Od4/7uvkn7YGQVS9JcnvgbFXngnodO6Nv7Vr1WzkWMMVa7sJmq9nJ1T3AtRYxzACrwJ0KveG5NC16gO9C6fOX8sJsuGkUWJ2QnVvQA125Ahk5dD/Zi05Ppj7sdOuWpsgomVJpt0zR3v6KwGdwr3xl3atmqUca7tibTdHc/fshOo+gBruiBF4BaDTtzckh06OD/Uu3GX+Gk+QDT9915GdUN0XUOMdhxPIynF5k+PDcwe8ZkHE3pJMtwNfDui07Y3FyLHmK9Z20zRyzE6o7gdoIziOJPAyQKdsb+iPj+ta9ZHehfvKX/sJsuGq0WN2QmVJxpHjz+0X8sXIUeUEGUOS+wP61KweLwX0gdEbkkMnx0d7F+47v8oJsuGsUWR2QvUAQBukamTluLTJ8bElgqsmiHqkkWR2QlVVEv1pT52ivfGndq1apBzVrljbzR0iif4GrDZMlXgxoA+G3pAculbpXwZbbFQ8QTbN0ogyO6F6EKCNs/bIyvHHJof+jOyio7IgapwlOXj7vij5F9P/0K5Vi5ej8hVre1tonp+dUD0Y0EZaW7wQ0CnZG5JD16rV/DuS1U+QzQbQ6DI7oXoIoA21lsjK8fsmx6fWAsInyJGdtCTwAkCnYm/8rl2rViWHBbnmNtAoMzuheihXbbClxvMBnYa9ITl0rdI/3b268BXrmi3VSDM7oXoYoI22tMjK8dsmx6T/Vvk+YVqQo9OvJMnzAJ1+vbF6OXzFOnxL6AvB7ITqZEAbb+7xXECnXm/8pp0cZ/cuXFq+T5DDO6ZRZ3ZCNXdJsnL8uv1Cvno5fILEPs6GSHIKoI04t3gOIIF7Q3LoF/LP9i5car5PkFjnNPrMTqgeDmhDziWycvyqyfG5uRSyi/ewIHHKGoFmJ1RzkeTZgE613vhlu1aVksNXrN5tctX3BFlJHgFog+4rngVI1N6QHLpWfb534RryfYL0d1Ej0eyE6pGANuquIyvHJU2OL+z6hefy8yxIrhNLkuSZgE6v3vhFu1aVlcNXrN4tc2S+vj/ITqgeBWjjTh3PAHRq9Ybk0LXqi70L15bvE2RYRzUqzU6oHg1oA08VWTl+3uT40lQvtqTnWpDh3ZqjJE8HdEr1xs/atcpyNHIWpHcLHT1fo9PshOoxgDb0WHEGoNOpNySHrlVf7l245nwLMl53NULNTqjGkiQrx0/byWE5rrYfLMh4guhJQyQ5FdAGz8bTAInWGz9pcpzTu7BCvgUZv8saqWYnVKcB2ui9cTogwXpDcuhadW7vwir5FmSaTmu0mp1Q9UqSlePidnJYjkP2gAWZRhA9dYgkjwW08f9fPBWQUL0hOXRyfKV3YbV8CzJtxzVqzU6oHgdIgIPiKYBE6o2Lmhxf7V1YMd+CTN/1KSTJyvHjdq2yHMG+W5AgqIFp+l4iO6F6PCAhNvFkQKdLb0gOXavO611YOd+C7K77GsFmJlR6wycAEiMrx4VNjq/trtx1/CQLsts+DpFEf1rnhMTrXtCuVZYjAc+CJKANXKLvKyITqoE/5srlkkPXqq+P8bCKz7Ag++m6RrOHTajGeKsfNTm+McbDqj7Dguyv81NK8sN2rbIcA/trQQYCHLhc32NsT6gGPu7K5ZJD16pvjvGw6s+wIPvfARrZajo1RvygnRyWYwyagAUZCeTAx4whyfebHN8a+C5evkXAgsxnO+gLwSclX0dy6Fr17eR6LzuAgAWZ19bQF4JP7Hyl77WTw3J0goukW5AIpd3m9EiiUe5tgPN3+4p1fpoFmWevI3904VLgpsB35lnCOt7Kgsy3j2cCJx3wepcDx1uO6ZtnQaZnPOQn3AQ4C7gecCxwWftjbrcCrhjyYK+NEbAgMU7OKkrAghRtvMuOEbAgMU7OKkrAghRtvMuOEbAgMU7OKkrAghRtvMuOEbAgMU7OKkrAghRtvMuOEbAgMU7OKkrAghRtvMuOEbAgMU7OKkrAghRtvMuOEbAgMU7OKkrAghRtvMuOEbAgMU7OKkrAghRtvMuOEbAgMU7OKkrAghRtvMuOEbAgMU7OKkrAghRtvMuOEbAgMU7OKkrAghRtvMuOEbAgMU7OKkrAghRtvMuOEbAgMU7OKkrAghRtvMuOEbAgMU7OKkrAghRtvMuOEbAgMU7OKkrAghRtvMuOEbAgMU7OKkrAghRtvMuOEbAgMU7OKkrAghRtvMuOEbAgMU7OKkrAghRtvMuOEbAgMU7OKkrAghRtvMuOEbAgMU7OKkrAghRtvMuOEbAgMU7OKkrAghRtvMuOEbAgMU7OKkrAghRtvMuOEbAgMU7OKkrAghRtvMuOEbAgMU7OKkrAghRtvMuOEbAgMU7OKkrAghRtvMuOEbAgMU7OKkrAghRtvMuOEbAgMU7OKkrAghRtvMuOEbAgMU7OKkrAghRtvMuOEbAgMU7OKkrAghRtvMuOEbAgMU7OKkrAghRtvMuOEbAgMU7OKkrAghRtvMuOEfgf9hZk2PNubUAAAAAASUVORK5CYII=");
}
.state > select {
select.state {
border-bottom: 0;
background-image: none;
pointer-events: none;
}
.state-bold > select {
select.state-bold {
font-weight: bold;
color: #7a77bb;
}
.state-light > select {
select.state-light {
color: #777777;
}
@@ -133,12 +133,13 @@ pre.rich-traceback-code {
border: 1px solid lightgrey;
}
#pywebio-scope-scheduler-bar,
#pywebio-scope-log-bar,
#pywebio-scope-log,
#pywebio-scope-running,
#pywebio-scope-pending,
#pywebio-scope-waiting,
#pywebio-scope-scheduler-bar,
#pywebio-scope-log-bar,
#pywebio-scope-log,
#pywebio-scope-daemon-log-bar,
#pywebio-scope-daemon-overview #pywebio-scope-groups {
background-color: white;
border: 1px solid lightgrey;
Binary file not shown.

After

Width:  |  Height:  |  Size: 5.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.8 KiB

After

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.1 KiB

After

Width:  |  Height:  |  Size: 6.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.6 KiB

After

Width:  |  Height:  |  Size: 6.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.1 KiB

After

Width:  |  Height:  |  Size: 7.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

After

Width:  |  Height:  |  Size: 7.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.2 KiB

After

Width:  |  Height:  |  Size: 8.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 8.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.1 KiB

After

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.5 KiB

After

Width:  |  Height:  |  Size: 7.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.1 KiB

After

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.0 KiB

After

Width:  |  Height:  |  Size: 7.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.6 KiB

After

Width:  |  Height:  |  Size: 6.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.2 KiB

After

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.0 KiB

After

Width:  |  Height:  |  Size: 5.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.0 KiB

After

Width:  |  Height:  |  Size: 5.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.2 KiB

After

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.6 KiB

After

Width:  |  Height:  |  Size: 7.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.2 KiB

After

Width:  |  Height:  |  Size: 6.7 KiB

Binary file not shown.
+8
View File
@@ -157,6 +157,14 @@ Deploy:
# '["alas"]' specified "alas" config
# '["alas","alas2"]' specified "alas" "alas2" configs
Run: null
# SSL support
# Only effective when both parameters below are set
# --ssl-key. Path to SSL key file
# [Default] null (no SSL)
WebuiSSLKey: null
# --ssl-cert. Path to SSL cert file
# [Default] null (no SSL)
WebuiSSLCert: null
# To update app.asar
# [In most cases] true
AppAsarUpdate: true
+8
View File
@@ -157,6 +157,14 @@ Deploy:
# '["alas"]' specified "alas" config
# '["alas","alas2"]' specified "alas" "alas2" configs
Run: null
# SSL support
# Only effective when both parameters below are set
# --ssl-key. Path to SSL key file
# [Default] null (no SSL)
WebuiSSLKey: null
# --ssl-cert. Path to SSL cert file
# [Default] null (no SSL)
WebuiSSLCert: null
# To update app.asar
# [In most cases] true
AppAsarUpdate: true
+2 -5
View File
@@ -250,11 +250,8 @@
}
},
"Momotalk": {
"Scheduler": {
"Enable": false,
"NextRun": "2020-01-01 00:00:00",
"Command": "Momotalk",
"ServerUpdate": "04:00"
"Momotalk": {
"Enable": true
}
}
}
+21 -19
View File
@@ -1,10 +1,10 @@
import os
import time
import typing as t
from typing import Iterable
from deploy.Windows.config import DeployConfig
from deploy.Windows.logger import Progress, logger
from deploy.Windows.utils import DataProcessInfo, cached_property, iter_process
from deploy.Windows.utils import cached_property, iter_process
class AlasManager(DeployConfig):
@@ -19,43 +19,45 @@ class AlasManager(DeployConfig):
def self_pid(self):
return os.getpid()
def list_process(self) -> t.List[DataProcessInfo]:
def list_process(self) -> "list[tuple[int, list[str]]]":
logger.info('List process')
process = list(iter_process())
logger.info(f'Found {len(process)} processes')
return process
process_data = list(iter_process())
logger.info(f'Found {len(process_data)} processes')
return process_data
def iter_process_by_names(self, names, in_alas=False) -> t.Iterable[DataProcessInfo]:
def iter_process_by_names(self, names, in_alas=False) -> "Iterable[int]":
"""
Args:
names (str, list[str]): process name, such as 'alas.exe'
in_alas (bool): If the output process must in Alas
Yields:
DataProcessInfo:
pid:
"""
if not isinstance(names, list):
names = [names]
try:
for proc in self.list_process():
for pid, cmdline in self.list_process():
if pid == self.self_pid:
continue
exe = cmdline[0]
name = os.path.basename(exe)
if not (name and name in names):
continue
if not (proc.name and proc.name in names):
continue
if proc.pid == self.self_pid:
continue
if in_alas:
cmdline = proc.cmdline.replace(r"\\", "/").replace("\\", "/")
exe = exe.replace(r"\\", "/").replace("\\", "/")
for folder in self.alas_folder:
if folder in cmdline:
yield proc
if folder in exe:
yield pid
else:
yield proc
yield pid
except Exception as e:
logger.info(str(e))
return False
def kill_process(self, process: DataProcessInfo):
self.execute(f'taskkill /f /t /pid {process.pid}', allow_failure=True, output=False)
def kill_process(self, pid: int):
self.execute(f'taskkill /f /t /pid {pid}', allow_failure=True, output=False)
def alas_kill(self):
for _ in range(10):
+630
View File
@@ -0,0 +1,630 @@
import os
import random
import string
import time
from typing import Iterable, Union
IS_WINDOWS = os.name == 'nt'
# Max attempt if another process is reading/writing, effective only on Windows
WINDOWS_MAX_ATTEMPT = 5
# Base time to wait between retries (seconds)
WINDOWS_RETRY_DELAY = 0.05
def random_id():
"""
Returns:
str: Random ID, like "sTD2kF"
"""
# 6 random letter (62^6 combinations) would be enough
return ''.join(random.sample(string.ascii_letters + string.digits, 6))
def is_tmp_file(file: str) -> bool:
"""
Check if a filename is tmp file
"""
# Check suffix first to reduce regex calls
if not file.endswith('.tmp'):
return False
# Check temp file format
dot = file[-11:-10]
if not dot:
return False
rid = file[-10:-4]
return rid.isalnum()
def to_tmp_file(file: str) -> str:
"""
Convert a filename or directory name to tmp
filename -> filename.sTD2kF.tmp
"""
suffix = random_id()
return f'{file}.{suffix}.tmp'
def to_nontmp_file(file: str) -> str:
"""
Convert a tmp filename or directory name to original file
filename.sTD2kF.tmp -> filename
"""
if is_tmp_file(file):
return file[:-11]
else:
return file
def windows_attempt_delay(attempt: int) -> float:
"""
Exponential Backoff if file is in use on Windows
Args:
attempt: Current attempt, starting from 0
Returns:
float: Seconds to wait
"""
return 2 ** attempt * WINDOWS_RETRY_DELAY
def replace_tmp(tmp: str, file: str):
"""
Replace temp file to file
Raises:
PermissionError: (Windows only) If another process is still reading the file and all retries failed
FileNotFoundError: If tmp file gets deleted unexpectedly
"""
if IS_WINDOWS:
# PermissionError on Windows if another process is reading
last_error = None
for attempt in range(WINDOWS_MAX_ATTEMPT):
try:
# Atomic operation
os.replace(tmp, file)
# success
return
except PermissionError as e:
last_error = e
delay = windows_attempt_delay(attempt)
time.sleep(delay)
continue
except FileNotFoundError:
# tmp file gets deleted unexpectedly
raise
except Exception as e:
last_error = e
break
else:
# Linux and Mac allow existing reading
try:
# Atomic operation
os.replace(tmp, file)
# success
return
except FileNotFoundError:
raise
except Exception as e:
last_error = e
# Clean up tmp file on failure
try:
os.unlink(tmp)
except FileNotFoundError:
# tmp file already get deleted
pass
except:
pass
if last_error is not None:
raise last_error from None
def atomic_replace(replace_from: str, replace_to: str):
"""
Replace file or directory
Raises:
PermissionError: (Windows only) If another process is still reading the file and all retries failed
FileNotFoundError:
"""
if IS_WINDOWS:
# PermissionError on Windows if another process is reading
last_error = None
for attempt in range(WINDOWS_MAX_ATTEMPT):
try:
# Atomic operation
os.replace(replace_from, replace_to)
# success
return
except PermissionError as e:
last_error = e
delay = windows_attempt_delay(attempt)
time.sleep(delay)
continue
except FileNotFoundError:
raise
except Exception as e:
last_error = e
break
if last_error is not None:
raise last_error from None
else:
# Linux and Mac
os.replace(replace_from, replace_to)
def file_write(file: str, data: Union[str, bytes]):
"""
Write data into file, auto create directory
Auto determines write mode based on the type of data.
"""
if isinstance(data, str):
mode = 'w'
encoding = 'utf-8'
newline = ''
elif isinstance(data, bytes):
mode = 'wb'
encoding = None
newline = None
# Create memoryview as Pathlib do
data = memoryview(data)
else:
typename = str(type(data))
if typename == "<class 'numpy.ndarray'>":
mode = 'wb'
encoding = None
newline = None
else:
mode = 'w'
encoding = 'utf-8'
newline = ''
try:
# Write temp file
with open(file, mode=mode, encoding=encoding, newline=newline) as f:
f.write(data)
# Ensure data flush to disk
f.flush()
os.fsync(f.fileno())
except FileNotFoundError:
# Create parent directory
directory = os.path.dirname(file)
if directory:
os.makedirs(directory, exist_ok=True)
# Write again
with open(file, mode=mode, encoding=encoding, newline=newline) as f:
f.write(data)
# Ensure data flush to disk
f.flush()
os.fsync(f.fileno())
def file_write_stream(file: str, data_generator):
"""
Only creates a file if the generator yields at least one data chunk.
Auto determines write mode based on the type of first chunk.
Args:
file: Target file path
data_generator: An iterable that yields data chunks (str or bytes)
"""
# Convert generator to iterator to ensure we can peek at first chunk
data_iter = iter(data_generator)
# Try to get the first chunk
try:
first_chunk = next(data_iter)
except StopIteration:
# Generator is empty, no file will be created
return
# Determine mode, encoding and newline from first chunk
if isinstance(first_chunk, str):
mode = 'w'
encoding = 'utf-8'
newline = ''
elif isinstance(first_chunk, bytes):
mode = 'wb'
encoding = None
newline = None
else:
# Default to text mode for other types
mode = 'w'
encoding = 'utf-8'
newline = ''
try:
# Write temp file
with open(file, mode=mode, encoding=encoding, newline=newline) as f:
f.write(first_chunk)
for chunk in data_iter:
f.write(chunk)
# Ensure data flush to disk
f.flush()
os.fsync(f.fileno())
except FileNotFoundError:
# Create parent directory
directory = os.path.dirname(file)
if directory:
os.makedirs(directory, exist_ok=True)
# Write again
with open(file, mode=mode, encoding=encoding, newline=newline) as f:
f.write(first_chunk)
for chunk in data_iter:
f.write(chunk)
# Ensure data flush to disk
f.flush()
os.fsync(f.fileno())
def atomic_write(
file: str,
data: Union[str, bytes],
):
"""
Atomic file write with minimal IO operation
and handles cases where file might be read by another process.
os.replace() is an atomic operation among all OS,
we write to temp file then do os.replace()
Args:
file:
data:
"""
tmp = to_tmp_file(file)
file_write(tmp, data)
replace_tmp(tmp, file)
def atomic_write_stream(
file: str,
data_generator,
):
"""
Atomic file write with streaming data support.
Handles cases where file might be read by another process.
os.replace() is an atomic operation among all OS,
we write to temp file then do os.replace()
Args:
file: Target file path
data_generator: An iterable that yields data chunks (str or bytes)
"""
tmp = to_tmp_file(file)
file_write_stream(tmp, data_generator)
replace_tmp(tmp, file)
def file_read_text(
file: str,
encoding: str = 'utf-8',
errors: str = 'strict'
) -> str:
"""
Args:
file:
encoding:
errors: 'strict', 'ignore', 'replace' and any other errors mode in open()
"""
try:
with open(file, mode='r', encoding=encoding, errors=errors) as f:
return f.read()
except FileNotFoundError:
return ''
def file_read_text_stream(
file: str,
encoding: str = 'utf-8',
errors: str = 'strict',
chunk_size: int = 8192
) -> Iterable[str]:
"""
Args:
file:
encoding:
errors: 'strict', 'ignore', 'replace' and any other errors mode in open()
chunk_size:
"""
try:
with open(file, mode='r', encoding=encoding, errors=errors) as f:
while 1:
chunk = f.read(chunk_size)
if not chunk:
return
yield chunk
except FileNotFoundError:
return
def file_read_bytes(file: str) -> bytes:
"""
Args:
file:
"""
try:
# No python-side buffering when reading the entire file to speedup reading
# https://github.com/python/cpython/pull/122111
with open(file, mode='rb', buffering=0) as f:
return f.read()
except FileNotFoundError:
return b''
def file_read_bytes_stream(file: str, chunk_size: int = 8192) -> Iterable[bytes]:
"""
Args:
file:
chunk_size:
"""
try:
with open(file, mode='rb') as f:
while 1:
chunk = f.read(chunk_size)
if not chunk:
return
yield chunk
except FileNotFoundError:
return
def atomic_read_text(
file: str,
encoding: str = 'utf-8',
errors: str = 'strict'
) -> str:
"""
Atomic file read with minimal IO operation
Args:
file:
encoding:
errors: 'strict', 'ignore', 'replace' and any other errors mode in open()
"""
if IS_WINDOWS:
# PermissionError on Windows if another process is replacing
last_error = None
for attempt in range(WINDOWS_MAX_ATTEMPT):
try:
return file_read_text(file, encoding=encoding, errors=errors)
except PermissionError as e:
last_error = e
delay = windows_attempt_delay(attempt)
time.sleep(delay)
continue
if last_error is not None:
raise last_error from None
else:
# Linux and Mac allow reading while replacing
return file_read_text(file, encoding=encoding, errors=errors)
def atomic_read_text_stream(
file: str,
encoding: str = 'utf-8',
errors: str = 'strict',
chunk_size: int = 8192
) -> Iterable[str]:
"""
Args:
file:
encoding:
errors: 'strict', 'ignore', 'replace' and any other errors mode in open()
chunk_size:
"""
if IS_WINDOWS:
# PermissionError on Windows if another process is replacing
last_error = None
for attempt in range(WINDOWS_MAX_ATTEMPT):
try:
yield from file_read_text_stream(file, encoding=encoding, errors=errors, chunk_size=chunk_size)
return
except PermissionError as e:
last_error = e
delay = windows_attempt_delay(attempt)
time.sleep(delay)
continue
if last_error is not None:
raise last_error from None
else:
# Linux and Mac allow reading while replacing
yield from file_read_text_stream(file, encoding=encoding, errors=errors, chunk_size=chunk_size)
return
def atomic_read_bytes(file: str) -> bytes:
"""
Atomic file read with minimal IO operation
"""
if IS_WINDOWS:
# PermissionError on Windows if another process is replacing
last_error = None
for attempt in range(WINDOWS_MAX_ATTEMPT):
try:
return file_read_bytes(file)
except PermissionError as e:
last_error = e
delay = windows_attempt_delay(attempt)
time.sleep(delay)
continue
if last_error is not None:
raise last_error from None
else:
# Linux and Mac allow reading while replacing
return file_read_bytes(file)
def atomic_read_bytes_stream(file: str, chunk_size: int = 8192) -> Iterable[bytes]:
"""
Args:
file:
chunk_size:
"""
if IS_WINDOWS:
# PermissionError on Windows if another process is replacing
last_error = None
for attempt in range(WINDOWS_MAX_ATTEMPT):
try:
yield from file_read_bytes_stream(file, chunk_size=chunk_size)
return
except PermissionError as e:
last_error = e
delay = windows_attempt_delay(attempt)
time.sleep(delay)
continue
if last_error is not None:
raise last_error from None
else:
# Linux and Mac allow reading while replacing
yield from file_read_bytes_stream(file, chunk_size=chunk_size)
return
def file_remove(file: str):
"""
Remove a file non-atomic
"""
try:
os.unlink(file)
except FileNotFoundError:
# If file not exist, just no need to remove
pass
def atomic_remove(file: str):
"""
Atomic file remove
Args:
file:
"""
if IS_WINDOWS:
# PermissionError on Windows if another process is replacing
last_error = None
for attempt in range(WINDOWS_MAX_ATTEMPT):
try:
return file_remove(file)
except PermissionError as e:
last_error = e
delay = windows_attempt_delay(attempt)
time.sleep(delay)
continue
if last_error is not None:
raise last_error from None
else:
# Linux and Mac allow deleting while another process is reading
# The directory entry is removed but the storage allocated to the file is not made available
# until the original file is no longer in use.
return file_remove(file)
def folder_rmtree(folder, may_symlinks=True):
"""
Recursively remove a folder and its content
Args:
folder:
may_symlinks: Default to True
False if you already know it's not a symlink
Returns:
bool: If success
"""
try:
# If it's a symlinks, unlink it
if may_symlinks and os.path.islink(folder):
file_remove(folder)
return True
# Iter folder
with os.scandir(folder) as entries:
for entry in entries:
if entry.is_dir(follow_symlinks=False):
folder_rmtree(entry.path, may_symlinks=False)
else:
# File or symlink
# Just remove the symlink, not what it points to
try:
file_remove(entry.path)
except PermissionError:
# Another process is reading/writing
pass
except FileNotFoundError:
# directory to clean up does not exist, no need to clean up
return True
except NotADirectoryError:
file_remove(folder)
return True
# Remove empty folder
# May raise OSError if it's still not empty
try:
os.rmdir(folder)
return True
except FileNotFoundError:
return True
except NotADirectoryError:
file_remove(folder)
return True
except OSError:
return False
def atomic_rmtree(folder: str):
"""
Atomic folder rmtree
Rename folder as temp folder and remove it,
folder can be removed by atomic_failure_cleanup at next startup if remove gets interrupted
"""
tmp = to_tmp_file(folder)
try:
atomic_replace(folder, tmp)
except FileNotFoundError:
# Folder not exist, no need to rmtree
return
folder_rmtree(tmp)
def atomic_failure_cleanup(folder: str, recursive: bool = False):
"""
Cleanup remaining temp file under given path.
In most cases there should be no remaining temp files unless write process get interrupted.
This method should only be called at startup
to avoid deleting temp files that another process is writing.
"""
try:
with os.scandir(folder) as entries:
for entry in entries:
if is_tmp_file(entry.name):
try:
# Delete temp file or directory
if entry.is_dir(follow_symlinks=False):
folder_rmtree(entry.path, may_symlinks=False)
else:
file_remove(entry.path)
except PermissionError:
# Another process is reading/writing
pass
except:
pass
else:
if recursive:
try:
if entry.is_dir(follow_symlinks=False):
# Normal directory
atomic_failure_cleanup(entry.path, recursive=True)
except:
pass
except FileNotFoundError:
# directory to clean up does not exist, no need to clean up
pass
except NotADirectoryError:
file_remove(folder)
except:
# Ignore all failures, it doesn't matter if tmp files still exist
pass
+37 -15
View File
@@ -1,6 +1,7 @@
import copy
import os
import subprocess
import sys
from typing import Optional, Union
from deploy.Windows.logger import logger
@@ -56,6 +57,8 @@ class ConfigModel:
# Webui
WebuiHost: str = "0.0.0.0"
WebuiPort: int = 23467
WebuiSSLKey: Optional[str] = None
WebuiSSLCert: Optional[str] = None
Language: str = "en-US"
Theme: str = "default"
DpiScaling: bool = True
@@ -77,42 +80,52 @@ class DeployConfig(ConfigModel):
self.config_template = {}
self.read()
self.set_repo()
self.write()
self.show_config()
def set_repo(self):
# Bypass webui.config.DeployConfig.__setattr__()
# Don't write these into deploy.yaml
if self.Repository == 'cn':
super().__setattr__('Repository', 'https://git.yoursfunny.top/YoursFunny/ArisuAutoSweeper.git')
if self.Repository == 'global':
super().__setattr__('Repository', 'https://github.com/TheFunny/ArisuAutoSweeper')
def show_config(self):
logger.hr("Show deploy config", 1)
for k, v in self.config.items():
if k in ("Password", "SSHUser"):
continue
if self.config_template[k] == v:
if self.config_template.get(k) == v:
continue
logger.info(f"{k}: {v}")
logger.info(f"Rest of the configs are the same as default")
def read(self):
"""
Read and update deploy config, copy `self.configs` to properties.
"""
self.config = poor_yaml_read(DEPLOY_TEMPLATE)
self.config_template = copy.deepcopy(self.config)
self.config.update(poor_yaml_read(self.file))
origin = poor_yaml_read(self.file)
self.config.update(origin)
for key, value in self.config.items():
if hasattr(self, key):
super().__setattr__(key, value)
self.config_redirect()
if self.config != origin:
self.write()
def write(self):
poor_yaml_write(self.config, self.file)
def config_redirect(self):
"""
Redirect deploy config, must be called after each `read()`
"""
# Bypass webui.config.DeployConfig.__setattr__()
# Don't write these into deploy.yaml
if self.Repository == 'cn':
super().__setattr__('Repository', 'https://git.yoursfunny.top/YoursFunny/ArisuAutoSweeper.git')
if self.Repository == 'global':
super().__setattr__('Repository', 'https://github.com/TheFunny/ArisuAutoSweeper')
def filepath(self, path):
"""
Args:
@@ -144,7 +157,7 @@ class DeployConfig(ConfigModel):
if os.path.exists(exe):
return exe
logger.warning(f'AdbExecutable: {exe} does not exists, use `adb` instead')
logger.warning(f'AdbExecutable: {exe} does not exist, use `adb` instead')
return 'adb'
@cached_property
@@ -153,12 +166,21 @@ class DeployConfig(ConfigModel):
if os.path.exists(exe):
return exe
logger.warning(f'GitExecutable: {exe} does not exists, use `git` instead')
logger.warning(f'GitExecutable: {exe} does not exist, use `git` instead')
return 'git'
@cached_property
def python(self) -> str:
return self.filepath(self.PythonExecutable)
# No need to read PythonExecutable
# since you run this code with python, current python is the python
# exe = self.filepath(self.PythonExecutable)
# if os.path.exists(exe):
# return exe
current = sys.executable.replace("\\", "/")
# logger.warning(f'PythonExecutable: {exe} does not exist, use current python instead: {current}')
return current
@cached_property
def requirements_file(self) -> str:
+10 -5
View File
@@ -2,6 +2,7 @@ import asyncio
import filecmp
import os
import shutil
import sys
import typing as t
from dataclasses import dataclass
@@ -9,7 +10,8 @@ from deploy.Windows.alas import AlasManager
from deploy.Windows.logger import logger
from deploy.Windows.utils import cached_property
asyncio.set_event_loop_policy(asyncio.WindowsProactorEventLoopPolicy())
if sys.platform.startswith('win'):
asyncio.set_event_loop_policy(asyncio.WindowsProactorEventLoopPolicy())
@dataclass
@@ -140,10 +142,13 @@ class EmulatorManager(AlasManager):
for adb in replace:
logger.info(f'Replacing {adb}')
bak = self.adb_path_to_backup(adb, new_backup=True)
logger.info(f'{adb} -----> {bak}')
shutil.move(adb, bak)
logger.info(f'{self.adb} -----> {adb}')
shutil.copy(self.adb, adb)
try:
logger.info(f'{adb} -----> {bak}')
shutil.move(adb, bak)
logger.info(f'{self.adb} -----> {adb}')
shutil.copy(self.adb, adb)
except OSError as e:
logger.warning(f'Failed to replace {adb}, {e}')
def adb_recover(self):
"""
+30 -2
View File
@@ -59,17 +59,34 @@ def patch_uiautomator2():
https://tool.appetizer.io is added to bypass the wall in China but https://tool.appetizer.io is slow outside of CN
plus some CN users cannot access it for unknown reason.
So we patch `uiautomator2/init.py` to a local assets cache `uiautomator2cache/cache`.
1. So we patch `uiautomator2/init.py` to a local assets cache `uiautomator2cache/cache`.
appdir = os.path.join(os.path.expanduser('~'), '.uiautomator2')
to:
appdir = os.path.join(__file__, '../../uiautomator2cache')
And we also remove minicap installations since emulators doesn't need it.
2. And we also remove minicap installations since emulators doesn't need it.
for url in self.minicap_urls:
self.push_url(url)
to:
for url in []:
self.push_url(url)
3. Fix atx_agent_url so ARM Mac can have correct ATX installed
```
@property
def atx_agent_url(self):
files = {
'armeabi-v7a': 'atx-agent_{v}_linux_armv7.tar.gz',
'arm64-v8a': 'atx-agent_{v}_linux_armv7.tar.gz',
'armeabi': 'atx-agent_{v}_linux_armv6.tar.gz',
'x86': 'atx-agent_{v}_linux_386.tar.gz',
'x86_64': 'atx-agent_{v}_linux_386.tar.gz',
}
```
where
'arm64-v8a': 'atx-agent_{v}_linux_armv7.tar.gz',
to
'arm64-v8a': 'atx-agent_{v}_linux_arm64.tar.gz',
"""
init_file = './toolkit/Lib/site-packages/uiautomator2/init.py'
cache_dir = './toolkit/Lib/site-packages/uiautomator2cache/cache'
@@ -92,6 +109,17 @@ def patch_uiautomator2():
else:
logger.info(f'{init_file} minicap_urls no need to patch')
# Patch atx_agent_url
res = re.search(r"'arm64-v8a': 'atx-agent_\{v}_linux_armv7.tar.gz'", content)
if res:
content = re.sub(r"'arm64-v8a': 'atx-agent_\{v}_linux_armv7.tar.gz'",
"'arm64-v8a': 'atx-agent_{v}_linux_arm64.tar.gz'",
content)
modified = True
logger.info(f'{init_file} atx_agent_url patched')
else:
logger.info(f'{init_file} atx_agent_url no need to patch')
# Patch appdir
if os.path.exists(cache_dir):
res = re.search(r'appdir ?=(.*)\n', content)
+10 -4
View File
@@ -5,7 +5,7 @@ from dataclasses import dataclass
from urllib.parse import urlparse
from deploy.Windows.config import DeployConfig
from deploy.Windows.logger import logger, Progress
from deploy.Windows.logger import Progress, logger
from deploy.Windows.utils import cached_property
@@ -46,9 +46,15 @@ class PipManager(DeployConfig):
return f'"{self.python}" -m pip'
@cached_property
def python_site_packages(self):
return os.path.abspath(os.path.join(self.python, '../Lib/site-packages')) \
.replace(r"\\", "/").replace("\\", "/")
def python_site_packages(self) -> str:
import site
paths = site.getsitepackages()
# site-packages should be site-packages folder
for path in paths:
if path.endswith('site-packages'):
return path
# Otherwise pick first
return paths[0]
@cached_property
def set_installed_dependency(self) -> t.Set[DataDependency]:
+8
View File
@@ -157,6 +157,14 @@ Deploy:
# '["alas"]' specified "alas" config
# '["alas","alas2"]' specified "alas" "alas2" configs
Run: null
# SSL support
# Only effective when both parameters below are set
# --ssl-key. Path to SSL key file
# [Default] null (no SSL)
WebuiSSLKey: null
# --ssl-cert. Path to SSL cert file
# [Default] null (no SSL)
WebuiSSLCert: null
# To update app.asar
# [In most cases] true
AppAsarUpdate: true
+72 -65
View File
@@ -1,8 +1,9 @@
import os
import re
from dataclasses import dataclass
from typing import Callable, Generic, Iterable, TypeVar
from deploy.Windows.atomic import atomic_read_text, atomic_write
T = TypeVar("T")
DEPLOY_CONFIG = './config/deploy.yaml'
@@ -64,29 +65,26 @@ def poor_yaml_read(file):
Returns:
dict:
"""
if not os.path.exists(file):
return {}
content = atomic_read_text(file)
data = {}
regex = re.compile(r'^(.*?):(.*?)$')
with open(file, 'r', encoding='utf-8') as f:
for line in f.readlines():
line = line.strip('\n\r\t ').replace('\\', '/')
if line.startswith('#'):
continue
result = re.match(regex, line)
if result:
k, v = result.group(1), result.group(2).strip('\n\r\t\' ')
if v:
if v.lower() == 'null':
v = None
elif v.lower() == 'false':
v = False
elif v.lower() == 'true':
v = True
elif v.isdigit():
v = int(v)
data[k] = v
for line in content.splitlines():
line = line.strip('\n\r\t ').replace('\\', '/')
if line.startswith('#'):
continue
result = re.match(regex, line)
if result:
k, v = result.group(1), result.group(2).strip('\n\r\t\' ')
if v:
if v.lower() == 'null':
v = None
elif v.lower() == 'false':
v = False
elif v.lower() == 'true':
v = True
elif v.isdigit():
v = int(v)
data[k] = v
return data
@@ -98,8 +96,8 @@ def poor_yaml_write(data, file, template_file=DEPLOY_TEMPLATE):
file (str):
template_file (str):
"""
with open(template_file, 'r', encoding='utf-8') as f:
text = f.read().replace('\\', '/')
text = atomic_read_text(template_file)
text = text.replace('\\', '/')
for key, value in data.items():
if value is None:
@@ -110,38 +108,15 @@ def poor_yaml_write(data, file, template_file=DEPLOY_TEMPLATE):
value = "false"
text = re.sub(f'{key}:.*?\n', f'{key}: {value}\n', text)
with open(file, 'w', encoding='utf-8', newline='') as f:
f.write(text)
atomic_write(file, text)
@dataclass
class DataProcessInfo:
proc: object # psutil.Process or psutil._pswindows.Process
pid: int
@cached_property
def name(self):
name = self.proc.name()
return name
@cached_property
def cmdline(self):
try:
cmdline = self.proc.cmdline()
except:
# psutil.AccessDenied
cmdline = []
cmdline = ' '.join(cmdline).replace(r'\\', '/').replace('\\', '/')
return cmdline
def __str__(self):
# Don't print `proc`, it will take some time to get process properties
return f'DataProcessInfo(name="{self.name}", pid={self.pid}, cmdline="{self.cmdline}")'
__repr__ = __str__
def iter_process() -> Iterable[DataProcessInfo]:
def iter_process() -> "Iterable[tuple[int, list[str]]]":
"""
Yields:
int: pid
list[str]: cmdline, and it's guaranteed to have at least one element
"""
try:
import psutil
except ModuleNotFoundError:
@@ -151,16 +126,48 @@ def iter_process() -> Iterable[DataProcessInfo]:
# 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.
# If you do psutil.process_iter(['pid', 'cmdline']) it will take over 1s
import psutil._psutil_windows as cetx
for pid in psutil.pids():
# 0 and 4 are always represented in taskmgr and process-hacker
if pid == 0 or pid == 4:
continue
try:
# This would be fast on psutil<=5.9.8 taking overall time 0.027s
# but taking 0.39s on psutil>=6.0.0
cmdline = cetx.proc_cmdline(pid, use_peb=True)
except (psutil.AccessDenied, psutil.NoSuchProcess, OSError):
# psutil.AccessDenied
# NoSuchProcess: process no longer exists (pid=xxx)
# ProcessLookupError: [Errno 3] assume no such process (originated from psutil_pid_is_running -> 0)
# OSError: [WinError 87] 参数错误。: '(originated from ReadProcessMemory)'
continue
# Validate cmdline
if not cmdline:
continue
try:
exe = cmdline[0]
except IndexError:
continue
# \??\C:\Windows\system32\conhost.exe
if exe.startswith(r'\??'):
continue
yield pid, cmdline
else:
# No optimizations yet
for pid in psutil.pids():
proc = psutil._psplatform.Process(pid)
yield DataProcessInfo(
proc=proc,
pid=proc.pid,
)
else:
# This will cost about 0.45s, even `attr` is given.
for proc in psutil.process_iter():
yield DataProcessInfo(
proc=proc,
pid=proc.pid,
)
try:
cmdline = proc.cmdline()
except (psutil.AccessDenied, psutil.NoSuchProcess, OSError):
continue
# Validate cmdline
if not cmdline:
continue
try:
cmdline[0]
except IndexError:
continue
yield pid, cmdline
+2
View File
@@ -13,6 +13,8 @@ from deploy.Windows.pip import PipManager
class Installer(GitManager, PipManager, AdbManager, AppManager, AlasManager):
def install(self):
from deploy.Windows.atomic import atomic_failure_cleanup
atomic_failure_cleanup('./config')
try:
self.git_install()
self.alas_kill()
+125 -27
View File
@@ -3,20 +3,61 @@ import re
import typing as t
from dataclasses import dataclass
import cv2
import numpy as np
from tqdm import tqdm
from module.base.code_generator import CodeGenerator
from module.base.utils import SelectedGrids, area_limit, area_pad, get_bbox, get_color, image_size, load_image
from module.base.utils import SelectedGrids, area_center, area_limit, area_pad, corner2area, get_bbox, get_color, \
image_size, load_image
from module.config.config_manual import ManualConfig as AzurLaneConfig
from module.config.deep import deep_get, deep_iter, deep_set
from module.config.server import VALID_LANG
from module.config.utils import deep_get, deep_iter, deep_set, iter_folder
from module.config.utils import iter_folder
from module.logger import logger
SHARE_SERVER = 'share'
ASSET_SERVER = [SHARE_SERVER] + VALID_LANG
def parse_grid(image):
"""
Args:
image:
Returns:
dict: Key: Grid position (x, y)
Value: Area on image
"""
image = cv2.inRange(image, (127, 127, 127), (255, 255, 255))
contours, _ = cv2.findContours(image, cv2.RETR_TREE, cv2.CHAIN_APPROX_SIMPLE)
dic_rect = {}
for corners in contours:
area = corner2area(corners.reshape(4, 2)) + (0, 0, 1, 1)
center = area_center(area)
dic_rect[center] = area
# for k, v in dic_rect.items():
# print(k, v)
dic_grid = {}
prev_y = -100
grid_y = -1
stack_center = []
for center in sorted(dic_rect.keys(), key=lambda x: x[1]):
if center[1] > prev_y + 3:
for x, c in enumerate(sorted(stack_center, key=lambda x: x[0])):
dic_grid[(x, grid_y)] = tuple(dic_rect[c].astype(int))
grid_y += 1
stack_center = []
stack_center.append(center)
prev_y = center[1]
for x, c in enumerate(sorted(stack_center, key=lambda x: x[0])):
dic_grid[(x, grid_y)] = tuple(dic_rect[c].astype(int))
# for k, v in dic_grid.items():
# print(k, v)
return dic_grid
class AssetsImage:
REGEX_ASSETS = re.compile(
f'^{AzurLaneConfig.ASSETS_FOLDER}/'
@@ -24,7 +65,7 @@ class AssetsImage:
f'(?P<module>[a-zA-Z0-9_/]+?)/'
f'(?P<assets>\w+)'
f'(?P<frame>\.\d+)?'
f'(?P<attr>\.AREA|\.SEARCH|\.COLOR|\.BUTTON)?'
f'(?P<attr>\.AREA|\.SEARCH|\.COLOR|\.BUTTON|\.GRID)?'
f'\.png$'
)
@@ -46,6 +87,7 @@ class AssetsImage:
self.assets = ''
self.frame = 1
self.attr = ''
self.posi = None
if res:
self.valid = True
@@ -66,6 +108,7 @@ class AssetsImage:
self.bbox: t.Tuple = ()
self.mean: t.Tuple = ()
self.grids = {}
def parse(self):
image = load_image(self.file)
@@ -79,6 +122,10 @@ class AssetsImage:
mean = tuple(np.rint(mean).astype(int))
self.bbox = bbox
self.mean = mean
if self.attr == 'GRID':
self.grids = parse_grid(image)
return bbox, mean
def __str__(self):
@@ -87,13 +134,40 @@ class AssetsImage:
else:
return f'AssetsImage(file={self.file}, valid={self.valid})'
@property
def is_GRID(self):
return self.attr == 'GRID'
@property
def is_base(self):
return self.attr == ''
def iter_grids(self):
frame = 0
for posi, rect in self.grids.items():
frame += 1
image = AssetsImage(self.file)
image.attr = ''
image.bbox = rect
image.mean = self.mean
image.frame = frame
image.posi = posi
yield image
def iter_images():
for server in ASSET_SERVER:
for path, folders, files in os.walk(os.path.join(AzurLaneConfig.ASSETS_FOLDER, server)):
for file in files:
file = os.path.join(path, file).replace('\\', '/')
yield AssetsImage(file)
if not file.startswith('.'):
file = os.path.join(path, file).replace('\\', '/')
yield AssetsImage(file)
def iter_grids(images):
for image in images:
for grid in image.iter_grids():
yield grid
@dataclass
@@ -103,11 +177,17 @@ class DataAssets:
server: str
frame: int
file: str = ''
posi = None
area: t.Tuple[int, int, int, int] = ()
search: t.Tuple[int, int, int, int] = ()
color: t.Tuple[int, int, int] = ()
button: t.Tuple[int, int, int, int] = ()
has_raw_area = False
has_raw_search = False
has_raw_color = False
has_raw_button = False
@staticmethod
def area_to_search(area):
area = area_pad(area, pad=-20)
@@ -120,7 +200,6 @@ class DataAssets:
Product DataAssets from AssetsImage with attr=""
"""
data = cls(module=image.module, assets=image.assets, server=image.server, frame=image.frame, file=image.file)
data.load_image(image)
return data
def load_image(self, image: AssetsImage):
@@ -129,14 +208,21 @@ class DataAssets:
self.area = image.bbox
self.color = image.mean
self.button = image.bbox
self.posi = image.posi
elif image.attr == 'AREA':
self.area = image.bbox
self.has_raw_area = True
elif image.attr == 'SEARCH':
self.search = image.bbox
self.has_raw_search = True
elif image.attr == 'COLOR':
self.color = image.mean
self.has_raw_color = True
elif image.attr == 'BUTTON':
self.button = image.bbox
self.has_raw_button = True
elif image.attr == 'GRID':
pass
else:
logger.warning(f'Trying to load an image with unknown attribute: {image}')
@@ -151,17 +237,22 @@ def iter_assets():
for image in tqdm(images):
image.parse()
images += list(iter_grids(images))
# Validate images
images = SelectedGrids(images).select(valid=True)
images.create_index('module', 'assets', 'server', 'frame', 'attr')
for image in images.filter(lambda x: bool(x.attr)):
image: AssetsImage = image
if not images.indexed_select(image.module, image.assets, image.server, image.frame, ''):
logger.warning(f'Attribute assets has no parent assets: {image.file}')
image.valid = False
if not images.indexed_select(image.module, image.assets, image.server, 1, ''):
logger.warning(f'Attribute assets has no first frame: {image.file}')
image.valid = False
if image.is_GRID:
pass
else:
if not images.indexed_select(image.module, image.assets, image.server, image.frame, ''):
logger.warning(f'Attribute assets has no parent assets: {image.file}')
image.valid = False
if not images.indexed_select(image.module, image.assets, image.server, 1, ''):
logger.warning(f'Attribute assets has no first frame: {image.file}')
image.valid = False
if image.attr == 'SEARCH' and image.frame > 1:
logger.warning(f'Attribute SEARCH with frame > 1 is not allowed: {image.file}')
image.valid = False
@@ -170,31 +261,34 @@ def iter_assets():
# Convert to DataAssets
data = {}
for image in images:
if image.attr == '':
if image.is_base:
row = DataAssets.product(image)
row.load_image(image)
deep_set(data, keys=[image.module, image.assets, image.server, image.frame], value=row)
# Load attribute images
for image in images:
if image.attr != '':
if not image.is_base:
row = deep_get(data, keys=[image.module, image.assets, image.server, image.frame])
row.load_image(image)
# Set `search`
for path, frames in deep_iter(data, depth=3):
print(path, frames)
# If `search` attribute is set in the first frame, apply to all
first = frames[1]
if first.search:
for frame in frames.values():
# print(path, frames)
for frame in frames.values():
# Generate `search` from `area`
if not frame.has_raw_search:
frame.search = DataAssets.area_to_search(frame.area)
# If an attribute is set in the first frame, apply to all
first: DataAssets = frames[1]
for frame in frames.values():
# frame: DataAssets = frame
if not frame.has_raw_area and first.has_raw_area:
frame.area = first.area
if not frame.has_raw_search and first.has_raw_search:
frame.search = first.search
else:
for frame in frames.values():
if frame.search:
# Follow frame specific `search`
pass
else:
# Generate `search` from `area`
frame.search = DataAssets.area_to_search(frame.area)
if not frame.has_raw_color and first.has_raw_color:
frame.color = first.color
if not frame.has_raw_button and first.has_raw_button:
frame.button = first.button
return data
@@ -240,6 +334,8 @@ def generate_code():
gen.ObjectAttr(key='search', value=frame.search)
gen.ObjectAttr(key='color', value=frame.color)
gen.ObjectAttr(key='button', value=frame.button)
if frame.posi is not None:
gen.ObjectAttr(key='posi', value=frame.posi)
elif len(frames) == 1:
frame = frames[0]
with gen.ObjectAttr(key=server, value=gen.Object(object_class='Button')):
@@ -248,6 +344,8 @@ def generate_code():
gen.ObjectAttr(key='search', value=frame.search)
gen.ObjectAttr(key='color', value=frame.color)
gen.ObjectAttr(key='button', value=frame.button)
if frame.posi is not None:
gen.ObjectAttr(key='posi', value=frame.posi)
else:
gen.ObjectAttr(key=server, value=None)
gen.write(os.path.join(output, f'assets_{module.replace("/", "_")}.py'))
+4 -4
View File
@@ -2,8 +2,8 @@ import os
from datetime import datetime
from PIL import Image
from pynput import keyboard
from module.config.config import AzurLaneConfig
from module.config.utils import alas_instance
from module.device.connection import Connection, ConnectionAttr
@@ -50,12 +50,12 @@ def handle_sensitive_info(image):
_ = EmptyConnection()
name = input(
'输入alas配置文件名称,或者模拟器serial,或者模拟器端口号: (默认输入 "alas"):\n'
'例如:"alas", "127.0.0.1:16384", "7555"\n'
'输入aas配置文件名称,或者模拟器serial,或者模拟器端口号: (默认输入 "aas"):\n'
'例如:"aas", "127.0.0.1:16384", "7555"\n'
)
name = name.strip().strip('"').strip()
if not name:
name = 'alas'
name = 'aas'
if name.isdigit():
name = f'127.0.0.1:{name}'
if name in alas_instance():
+20 -1
View File
@@ -40,6 +40,12 @@ def func(ev: threading.Event):
parser.add_argument(
"--electron", action="store_true", help="Runs by electron client."
)
parser.add_argument(
"--ssl-key", dest="ssl_key", type=str, help="SSL key file path for HTTPS support"
)
parser.add_argument(
"--ssl-cert", type=str, help="SSL certificate file path for HTTPS support"
)
parser.add_argument(
"--run",
nargs="+",
@@ -50,11 +56,15 @@ def func(ev: threading.Event):
host = args.host or State.deploy_config.WebuiHost or "0.0.0.0"
port = args.port or int(State.deploy_config.WebuiPort) or 23467
ssl_key = args.ssl_key or State.deploy_config.WebuiSSLKey
ssl_cert = args.ssl_cert or State.deploy_config.WebuiSSLCert
ssl = ssl_key is not None and ssl_cert is not None
State.electron = args.electron
logger.hr("Launcher config")
logger.attr("Host", host)
logger.attr("Port", port)
logger.attr("SSL", ssl)
logger.attr("Electron", args.electron)
logger.attr("Reload", ev is not None)
@@ -64,7 +74,16 @@ def func(ev: threading.Event):
from module.logger.logger import console_hdlr
logger.removeHandler(console_hdlr)
uvicorn.run("module.webui.app:app", host=host, port=port, factory=True)
if ssl_cert is None and ssl_key is not None:
logger.error("SSL key provided without certificate. Please provide both SSL key and certificate.")
elif ssl_key is None and ssl_cert is not None:
logger.error("SSL certificate provided without key. Please provide both SSL key and certificate.")
if ssl:
uvicorn.run("module.webui.app:app", host=host, port=port, factory=True, ssl_keyfile=ssl_key,
ssl_certfile=ssl_cert)
else:
uvicorn.run("module.webui.app:app", host=host, port=port, factory=True)
if __name__ == "__main__":
+1 -1
View File
@@ -20,7 +20,7 @@ def run_print_test():
run()
def run_set(modify=t.List[str]) -> t.Dict[str, str]:
def run_set(modify: t.List[str]) -> t.Dict[str, str]:
data = {}
for kv in modify:
if "=" in kv:
+74 -43
View File
@@ -1,5 +1,5 @@
import os
import re
import platform
import subprocess
import threading
import time
from datetime import datetime, timedelta
@@ -7,16 +7,14 @@ from datetime import datetime, timedelta
import inflection
from cached_property import cached_property
from MCE.custom_widgets.ctkmessagebox import CTkMessagebox
from module.base.decorator import del_cached_property
from module.config.config import AzurLaneConfig, TaskEnd
from module.config.utils import deep_get, deep_set
from module.config.deep import deep_get, deep_set
from module.exception import *
from module.logger import logger
from module.logger import logger, save_error_log
from module.notify import handle_notify
from MCE.custom_widgets.ctkmessagebox import CTkMessagebox
import subprocess
import platform
class AzurLaneAutoScript:
stop_event: threading.Event = None
@@ -66,9 +64,22 @@ class AzurLaneAutoScript:
logger.exception(e)
exit(1)
def restart(self):
raise NotImplemented
def start(self):
raise NotImplemented
def stop(self):
raise NotImplemented
def goto_main(self):
raise NotImplemented
def run(self, command):
try:
self.device.screenshot()
self.device.screenshot_tracking.clear()
self.__getattribute__(command)()
return True
except TaskEnd:
@@ -76,61 +87,68 @@ class AzurLaneAutoScript:
except GameNotRunningError as e:
logger.warning(e)
self.config.task_call('Restart')
return True
return False
except (GameStuckError, GameTooManyClickError) as e:
logger.error(e)
self.save_error_log()
logger.warning(f'Game stuck, {self.device.package} will be restarted in 10 seconds')
logger.warning('If you are playing by hand, please stop Alas')
logger.warning('If you are playing by hand, please stop AAS')
self.config.task_call('Restart')
self.device.sleep(10)
return False
except GameBugError as e:
logger.warning(e)
self.save_error_log()
logger.warning('An error has occurred in Azur Lane game client, Alas is unable to handle')
logger.warning('An error has occurred in Blue Archive game client, AAS is unable to handle')
logger.warning(f'Restarting {self.device.package} to fix it')
self.config.task_call('Restart')
self.device.sleep(10)
return False
except GamePageUnknownError:
logger.info('Game server may be under maintenance or network may be broken, check server status now')
# logger.info('Game server may be under maintenance or network may be broken, check server status now')
self.checker.check_now()
if self.checker.is_available():
logger.critical('Game page unknown')
self.save_error_log()
handle_notify(
self.config.Error_OnePushConfig,
title=f"Alas <{self.config_name}> crashed",
title=f"AAS <{self.config_name}> crashed",
content=f"<{self.config_name}> GamePageUnknownError",
)
exit(1)
else:
self.checker.wait_until_available()
return False
except HandledError as e:
logger.error(e)
return False
except ScriptError as e:
logger.critical(e)
logger.exception(e)
self.error_postprocess()
logger.critical('This is likely to be a mistake of developers, but sometimes just random issues')
self.save_error_log()
handle_notify(
self.config.Error_OnePushConfig,
title=f"Alas <{self.config_name}> crashed",
title=f"AAS <{self.config_name}> crashed",
content=f"<{self.config_name}> ScriptError",
)
exit(1)
except RequestHumanTakeover:
logger.critical('Request human takeover')
self.error_postprocess()
handle_notify(
self.config.Error_OnePushConfig,
title=f"Alas <{self.config_name}> crashed",
title=f"AAS <{self.config_name}> crashed",
content=f"<{self.config_name}> RequestHumanTakeover",
)
exit(1)
except Exception as e:
logger.exception(e)
self.error_postprocess()
self.save_error_log()
handle_notify(
self.config.Error_OnePushConfig,
title=f"Alas <{self.config_name}> crashed",
title=f"AAS <{self.config_name}> crashed",
content=f"<{self.config_name}> Exception occured",
)
exit(1)
@@ -140,29 +158,13 @@ class AzurLaneAutoScript:
Save last 60 screenshots in ./log/error/<timestamp>
Save logs to ./log/error/<timestamp>/log.txt
"""
from module.base.utils import save_image
from module.handler.sensitive_info import (handle_sensitive_image, handle_sensitive_logs)
if self.config.Error_SaveError:
if not os.path.exists('./log/error'):
os.mkdir('./log/error')
folder = f'./log/error/{int(time.time() * 1000)}'
logger.warning(f'Saving error: {folder}')
os.mkdir(folder)
for data in self.device.screenshot_deque:
image_time = datetime.strftime(data['time'], '%Y-%m-%d_%H-%M-%S-%f')
image = handle_sensitive_image(data['image'])
save_image(image, f'{folder}/{image_time}.png')
with open(logger.log_file, 'r', encoding='utf-8') as f:
lines = f.readlines()
start = 0
for index, line in enumerate(lines):
line = line.strip(' \r\t\n')
if re.match('^═{15,}$', line):
start = index
lines = lines[start - 2:]
lines = handle_sensitive_logs(lines)
with open(f'{folder}/log.txt', 'w', encoding='utf-8') as f:
f.writelines(lines)
save_error_log(config=self.config, device=self.device)
def error_postprocess(self):
"""
Do something when error occurred
"""
pass
def wait_until(self, future):
"""
@@ -210,13 +212,16 @@ class AzurLaneAutoScript:
method = self.config.Optimization_WhenTaskQueueEmpty
if method == 'close_game':
logger.info('Close game during wait')
self.device.app_stop()
self.run('stop')
release_resources()
self.device.release_during_wait()
if not self.wait_until(task.next_run):
del_cached_property(self, 'config')
continue
self.run('start')
if task.command != 'Restart':
self.config.task_call('Restart')
del_cached_property(self, 'config')
continue
elif method == 'goto_main':
logger.info('Goto main page during wait')
self.run('goto_main')
@@ -232,6 +237,31 @@ class AzurLaneAutoScript:
if not self.wait_until(task.next_run):
del_cached_property(self, 'config')
continue
elif method == 'close_emulator':
logger.info('Close emulator during wait')
self.run('stop')
release_resources()
self.device.release_during_wait()
# 关闭模拟器
try:
self.device.emulator_stop()
logger.info('Emulator stopped successfully')
except Exception as e:
logger.warning(f'Failed to stop emulator: {e}')
if not self.wait_until(task.next_run):
del_cached_property(self, 'config')
del_cached_property(self, 'device')
continue
if task.command == 'Restart':
del_cached_property(self, 'config')
del_cached_property(self, 'device')
continue
# 重新启动模拟器
if task.command != 'Restart':
self.config.task_call('Restart')
del_cached_property(self, 'config')
del_cached_property(self, 'device')
continue
elif method == 'exit_aas':
if abs(task.next_run - datetime.now()) >= timedelta(minutes=2): # ensure tactical challenge is fully ran
self.config.Optimization_WhenTaskQueueEmpty = 'goto_main'
@@ -293,7 +323,7 @@ class AzurLaneAutoScript:
if self.stop_event is not None:
if self.stop_event.is_set():
logger.info("Update event detected")
logger.info(f"Alas [{self.config_name}] exited.")
logger.info(f"AAS [{self.config_name}] exited.")
break
# Check game server maintenance
self.checker.wait_until_available()
@@ -309,6 +339,7 @@ class AzurLaneAutoScript:
task = self.get_next_task()
# Init device and change server
_ = self.device
self.device.config = self.config
# Skip first restart
if self.is_first_task and task == 'Restart':
logger.info('Skip task `Restart` at scheduler start')
@@ -338,7 +369,7 @@ class AzurLaneAutoScript:
logger.critical('Request human takeover')
handle_notify(
self.config.Error_OnePushConfig,
title=f"Alas <{self.config_name}> crashed",
title=f"AAS <{self.config_name}> crashed",
content=f"<{self.config_name}> RequestHumanTakeover\nTask `{task}` failed 3 or more times.",
)
exit(1)
+239 -18
View File
@@ -4,7 +4,9 @@ from module.base.timer import Timer
from module.base.utils import *
from module.config.config import AzurLaneConfig
from module.device.device import Device
from module.device.method.utils import HierarchyButton
from module.logger import logger
from module.webui.setting import cached_class_property
class ModuleBase:
@@ -26,6 +28,8 @@ class ModuleBase:
"""
if isinstance(config, AzurLaneConfig):
self.config = config
if task is not None:
self.config.init_task(task)
elif isinstance(config, str):
self.config = AzurLaneConfig(config, task=task)
else:
@@ -45,6 +49,111 @@ class ModuleBase:
self.interval_timer = {}
@cached_class_property
def worker(self):
"""
A thread pool to run things at background
Examples:
```
def func(image):
logger.info('Update thread start')
with self.config.multi_set():
self.dungeon_get_simuni_point(image)
self.dungeon_update_stamina(image)
ModuleBase.worker.submit(func, self.device.image)
```
"""
logger.hr('Creating worker')
from concurrent.futures import ThreadPoolExecutor
pool = ThreadPoolExecutor(1)
return pool
def loop(self, skip_first=True, timeout=None):
"""
A syntactic sugar to start a state loop
Args:
skip_first (bool): Usually to be True to reuse the previous screenshot
timeout (int | float | Timer): Seconds of timeout or a Timer object
Yields:
np.ndarray: screenshot
Examples:
# state machine that handle clicking until destination
for _ in self.loop():
if self.appear(...):
break
if self.appear_then_click(...):
continue
Examples:
# state machine with timeout
for _ in self.loop(timeout=2):
if self.appear(...):
logger.info('Wait success')
break
else:
logger.warning('Wait timeout')
"""
if timeout is not None:
if isinstance(timeout, Timer):
timeout.reset()
else:
timeout = Timer.from_seconds(timeout).start()
while 1:
if timeout is not None:
if timeout.reached():
return
if skip_first:
skip_first = False
else:
self.device.screenshot()
try:
yield self.device.image
except AttributeError:
self.device.screenshot()
yield self.device.image
def loop_hierarchy(self, skip_first=True):
"""
A syntactic sugar to start a hierarchy state loop
Args:
skip_first (bool): Usually to be True to reuse the previous hierarchy
Yields:
etree._Element: hierarchy
"""
while 1:
if skip_first:
skip_first = False
else:
self.device.dump_hierarchy()
yield self.device.hierarchy
def loop_screenshot_hierarchy(self, skip_first=True):
"""
A syntactic sugar to start a state loop that takes screenshots and dump hierarchy
Args:
skip_first (bool): Usually to be True to reuse the previous screenshot
Yields:
tuple[np.ndarray, etree._Element]: screenshot, hierarchy
"""
while 1:
if skip_first:
skip_first = False
else:
self.device.screenshot()
self.device.dump_hierarchy()
yield self.device.image, self.device.hierarchy
def match_template(self, button, interval=0, similarity=0.85):
"""
Args:
@@ -75,6 +184,36 @@ class ModuleBase:
return appear
def match_template_luma(self, button, interval=0, similarity=0.85):
"""
Args:
button (ButtonWrapper):
interval (int, float): interval between two active events.
similarity (int, float): 0 to 1.
Returns:
bool:
Examples:
Image detection:
```
self.device.screenshot()
self.appear(Button(area=(...), color=(...), button=(...))
self.appear(Template(file='...')
```
"""
self.device.stuck_record_add(button)
if interval and not self.interval_is_reached(button, interval=interval):
return False
appear = button.match_template_luma(self.device.image, similarity=similarity)
if appear and interval:
self.interval_reset(button, interval=interval)
return appear
def match_color(self, button, interval=0, threshold=10):
"""
Args:
@@ -120,9 +259,56 @@ class ModuleBase:
return appear
appear = match_template
def xpath(self, xpath) -> HierarchyButton:
if isinstance(xpath, str):
return HierarchyButton(self.device.hierarchy, xpath)
else:
return xpath
def xpath_appear(self, xpath: str, interval=0):
button = self.xpath(xpath)
self.device.stuck_record_add(button)
if interval and not self.interval_is_reached(button, interval=interval):
return False
appear = bool(button)
if appear and interval:
self.interval_reset(button, interval=interval)
return appear
def appear(self, button, interval=0, similarity=0.85):
"""
Args:
button (Button, ButtonWrapper, HierarchyButton, str):
interval (int, float): interval between two active events.
Returns:
bool:
Examples:
Template match:
```
self.device.screenshot()
self.appear(POPUP_CONFIRM)
```
Hierarchy detection (detect elements with xpath):
```
self.device.dump_hierarchy()
self.appear('//*[@resource-id="..."]')
```
"""
if isinstance(button, (HierarchyButton, str)):
return self.xpath_appear(button, interval=interval)
else:
return self.match_template(button, interval=interval, similarity=similarity)
def appear_then_click(self, button, interval=5, similarity=0.85):
button = self.xpath(button)
appear = self.appear(button, interval=interval, similarity=similarity)
if appear:
self.device.click(button)
@@ -210,7 +396,7 @@ class ModuleBase:
Returns:
Button: Or None if nothing matched.
"""
image = color_similarity_2d(self.image_crop(area), color=color)
image = color_similarity_2d(self.image_crop(area, copy=False), color=color)
points = np.array(np.where(image > color_threshold)).T[:, ::-1]
if points.shape[0] < encourage ** 2:
# Not having enough pixels to match
@@ -219,7 +405,26 @@ class ModuleBase:
point = fit_points(points, mod=image_size(image), encourage=encourage)
point = ensure_int(point + area[:2])
button_area = area_offset((-encourage, -encourage, encourage, encourage), offset=point)
return ClickButton(button=button_area, name=name)
return ClickButton(area=button_area, name=name)
def get_interval_timer(self, button, interval=5, renew=False) -> Timer:
if hasattr(button, 'name'):
name = button.name
elif callable(button):
name = button.__name__
else:
name = str(button)
try:
timer = self.interval_timer[name]
if renew and timer.limit != interval:
timer = Timer(interval)
self.interval_timer[name] = timer
return timer
except KeyError:
timer = Timer(interval)
self.interval_timer[name] = timer
return timer
def interval_reset(self, button, interval=5):
if isinstance(button, (list, tuple)):
@@ -228,10 +433,7 @@ class ModuleBase:
return
if button is not None:
if button.name in self.interval_timer:
self.interval_timer[button.name].reset()
else:
self.interval_timer[button.name] = Timer(interval).reset()
self.get_interval_timer(button, interval=interval).reset()
def interval_clear(self, button, interval=5):
if isinstance(button, (list, tuple)):
@@ -240,19 +442,10 @@ class ModuleBase:
return
if button is not None:
if button.name in self.interval_timer:
self.interval_timer[button.name].clear()
else:
self.interval_timer[button.name] = Timer(interval).clear()
self.get_interval_timer(button, interval=interval).clear()
def interval_is_reached(self, button, interval=5):
if button.name in self.interval_timer:
if self.interval_timer[button.name].limit != interval:
self.interval_timer[button.name] = Timer(interval)
else:
self.interval_timer[button.name] = Timer(interval)
return self.interval_timer[button.name].reached()
return self.get_interval_timer(button, interval=interval, renew=True).reached()
_image_file = ''
@@ -282,3 +475,31 @@ class ModuleBase:
"""
server_.set_lang(lang)
logger.attr('Lang', self.config.LANG)
def screenshot_tracking_add(self):
"""
Add a tracking image, image will be saved
"""
if not self.config.Error_SaveError:
return
logger.info('screenshot_tracking_add')
data = self.device.screenshot_deque[-1]
image = data['image']
now = data['time']
def image_encode(im, ti):
import io
from module.handler.sensitive_info import handle_sensitive_image
output = io.BytesIO()
im = handle_sensitive_image(im)
Image.fromarray(im, mode='RGB').save(output, format='png')
output.seek(0)
self.device.screenshot_tracking.append({
'time': ti,
'image': output
})
ModuleBase.worker.submit(image_encode, image, now)
+120 -10
View File
@@ -6,7 +6,7 @@ from module.exception import ScriptError
class Button(Resource):
def __init__(self, file, area, search, color, button):
def __init__(self, file, area, search, color, button, posi=None):
"""
Args:
file: Filepath to an assets
@@ -20,6 +20,7 @@ class Button(Resource):
self.search: t.Tuple[int, int, int, int] = search
self.color: t.Tuple[int, int, int] = color
self._button: t.Tuple[int, int, int, int] = button
self.posi: t.Optional[t.Tuple[int, int]] = posi
self.resource_add(self.file)
self._button_offset: t.Tuple[int, int] = (0, 0)
@@ -28,18 +29,60 @@ class Button(Resource):
def button(self):
return area_offset(self._button, self._button_offset)
def load_offset(self, button):
self._button_offset = button._button_offset
def load_offset(self, offset):
"""
Args:
offset (Button | ButtonWrapper | tuple[int, int]):
"""
if isinstance(offset, ButtonWrapper):
offset = offset.matched_button._button_offset
elif isinstance(offset, Button):
offset = offset._button_offset
self._button_offset = offset
def load_search(self, search):
"""
Args:
search (Button | ButtonWrapper | tuple[int, int]):
"""
if isinstance(search, ButtonWrapper):
search = search.search
elif isinstance(search, Button):
search = search.search
self.search = search
def clear_offset(self):
self._button_offset = (0, 0)
def is_offset_in(self, x=0, y=0):
"""
Args:
x:
y:
Returns:
bool: If _button_offset is in (-x, -y, x, y)
"""
if x:
if self._button_offset[0] < -x or self._button_offset[0] > x:
return False
if y:
if self._button_offset[1] < -y or self._button_offset[1] > y:
return False
return True
@cached_property
def image(self):
return load_image(self.file, self.area)
@cached_property
def image_luma(self):
return rgb2luma(self.image)
def resource_release(self):
del_cached_property(self, 'image')
del_cached_property(self, 'image_luma')
self.clear_offset()
def __str__(self):
@@ -92,6 +135,29 @@ class Button(Resource):
image = crop(image, self.search, copy=False)
res = cv2.matchTemplate(self.image, image, cv2.TM_CCOEFF_NORMED)
_, sim, _, point = cv2.minMaxLoc(res)
self._button_offset = np.array(point) + self.search[:2] - self.area[:2]
return sim > similarity
def match_template_luma(self, image, similarity=0.85, direct_match=False) -> bool:
"""
Detects assets by template matching.
To Some buttons, its location may not be static, `_button_offset` will be set.
Args:
image: Screenshot.
similarity (float): 0-1.
direct_match: True to ignore `self.search`
Returns:
bool.
"""
if not direct_match:
image = crop(image, self.search, copy=False)
image = rgb2luma(image)
res = cv2.matchTemplate(self.image_luma, image, cv2.TM_CCOEFF_NORMED)
_, sim, _, point = cv2.minMaxLoc(res)
self._button_offset = np.array(point) + self.search[:2] - self.area[:2]
return sim > similarity
@@ -134,7 +200,7 @@ class Button(Resource):
Returns:
bool.
"""
matched = self.match_template(image, similarity=similarity, direct_match=direct_match)
matched = self.match_template_luma(image, similarity=similarity, direct_match=direct_match)
if not matched:
return False
@@ -208,6 +274,13 @@ class ButtonWrapper(Resource):
return True
return False
def match_template_luma(self, image, similarity=0.85, direct_match=False) -> bool:
for assets in self.buttons:
if assets.match_template_luma(image, similarity=similarity, direct_match=direct_match):
self._matched_button = assets
return True
return False
def match_multi_template(self, image, similarity=0.85, threshold=5, direct_match=False):
"""
Detects assets by template matching, return multiple results
@@ -284,10 +357,8 @@ class ButtonWrapper(Resource):
Load offset from another button.
Args:
button (Button, ButtonWrapper):
button (Button | ButtonWrapper | tuple[int, int]):
"""
if isinstance(button, ButtonWrapper):
button = button.matched_button
for b in self.iter_buttons():
b.load_offset(button)
@@ -295,16 +366,55 @@ class ButtonWrapper(Resource):
for b in self.iter_buttons():
b.clear_offset()
def load_search(self, area):
def is_offset_in(self, x=0, y=0):
"""
Args:
x:
y:
Returns:
bool: If _button_offset is in (-x, -y, x, y)
"""
return self.matched_button.is_offset_in(x=x, y=y)
def load_search(self, search):
"""
Set `search` attribute.
Note that this method is irreversible.
Args:
area:
search (Button | ButtonWrapper | tuple[int, int, int, int]):
"""
for b in self.iter_buttons():
b.search = area
b.load_search(search)
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:
+55 -9
View File
@@ -33,23 +33,33 @@ class TabWrapper:
self.suffix += suffix
class VariableWrapper:
def __init__(self, name):
self.name = name
def __repr__(self):
return str(self.name)
__str__ = __repr__
class CodeGenerator:
def __init__(self):
self.tab_count = 0
self.lines = []
def generate(self) -> t.Iterable[str]:
yield ''
def add(self, line, comment=False, newline=True):
self.lines.append(self._line_with_tabs(line, comment=comment, newline=newline))
def generate(self) -> t.Iterable[str]:
return ''.join(self.lines)
def print(self):
lines = ''.join(self.lines)
lines = self.generate()
print(lines)
def write(self, file: str = None):
lines = ''.join(self.lines)
lines = self.generate()
with open(file, 'w', encoding='utf-8', newline='') as f:
f.write(lines)
@@ -77,7 +87,10 @@ class CodeGenerator:
return TabWrapper(self)
def Empty(self):
self.add('')
self.lines.append('\n')
def Pass(self):
self.add('pass')
def Import(self, text, empty=2):
for line in text.strip().split('\n'):
@@ -86,6 +99,9 @@ class CodeGenerator:
for _ in range(empty):
self.Empty()
def Variable(self, name):
return VariableWrapper(name)
def Value(self, key=None, value=None, type_=None, **kwargs):
if key is not None:
if type_ is not None:
@@ -117,7 +133,7 @@ class CodeGenerator:
if key is not None:
return TabWrapper(self, prefix=str(key) + ' = [', suffix=']')
else:
return TabWrapper(self, prefix='[', suffix=']', newline=False)
return TabWrapper(self, prefix='[', suffix=']')
def ListItem(self, value):
if isinstance(value, TabWrapper):
@@ -131,7 +147,7 @@ class CodeGenerator:
if key is not None:
return TabWrapper(self, prefix=str(key) + ' = {', suffix='}')
else:
return TabWrapper(self, prefix='{', suffix='}', newline=False)
return TabWrapper(self, prefix='{', suffix='}')
def DictItem(self, key=None, value=None):
if isinstance(value, TabWrapper):
@@ -147,7 +163,7 @@ class CodeGenerator:
if key is not None:
return TabWrapper(self, prefix=f'{key} = {object_class}(', suffix=')')
else:
return TabWrapper(self, prefix=f'{object_class}(', suffix=')', newline=False)
return TabWrapper(self, prefix=f'{object_class}(', suffix=')')
def ObjectAttr(self, key=None, value=None):
if isinstance(value, TabWrapper):
@@ -179,3 +195,33 @@ Value = generator.Value
Comment = generator.Comment
Dict = generator.Dict
DictItem = generator.DictItem
class MarkdownGenerator:
def __init__(self, column: t.List[str]):
self.rows = [column]
def add_row(self, row):
self.rows.append([str(ele) for ele in row])
def product_line(self, row, max_width):
row = [ele.ljust(width) for ele, width in zip(row, max_width)]
row = ' | '.join(row)
row = '| ' + row + ' |'
return row
def generate(self) -> t.List[str]:
import numpy as np
width = np.array([
[len(ele) for ele in row] for row in self.rows
])
max_width = np.max(width, axis=0)
dash = ['-' * width for width in max_width]
rows = [
self.product_line(self.rows[0], max_width),
self.product_line(dash, max_width),
] + [
self.product_line(row, max_width) for row in self.rows[1:]
]
return rows
+12
View File
@@ -123,6 +123,18 @@ def has_cached_property(obj, name):
return name in obj.__dict__
def set_cached_property(obj, name, value):
"""
Set a cached property.
Args:
obj:
name (str):
value:
"""
obj.__dict__[name] = value
def function_drop(rate=0.5, default=None):
"""
Drop function calls to simulate random emulator stuck, for testing purpose.
+6
View File
@@ -78,5 +78,11 @@ def release_resources(next_task=''):
# logger.info(f'Release {obj}')
obj.resource_release()
# If no task, check in-game text language again at next run
# cause user may change it
if not next_task:
from tasks.base.main_page import MainPage
MainPage._lang_checked = False
# Useless in most cases, but just call it
# gc.collect()
+110
View File
@@ -0,0 +1,110 @@
import threading
from typing import Type, TypeVar
T = TypeVar('T')
class Singleton(type):
"""
A metaclass for creating a global singleton.
Any class using this metaclass will have only one instance.
Subclasses will have their own unique singleton instance.
This implementation is thread-safe.
"""
def __init__(cls, name, bases, dct):
super().__init__(name, bases, dct)
cls.__instances = None
cls.__lock = threading.Lock()
def __call__(cls: Type[T], *args, **kwargs) -> T:
# return cached instance directly
instance = cls.__instances
if instance is not None:
return instance
# create new instance
with cls.__lock:
# another thread may have created while we are waiting
instance = cls.__instances
if instance is not None:
return instance
# create
instance = super().__call__(*args, **kwargs)
cls.__instances = instance
return instance
def singleton_clear_all(cls):
"""
Remove all instances
"""
with cls.__lock:
cls.__instances = None
class SingletonNamed(type):
"""
A metaclass for creating a named singleton.
Instances are created based on the first argument provided to the constructor.
Each class will have its own separate cache of named instances.
This implementation is thread-safe.
"""
def __init__(cls, name, bases, dct):
super().__init__(name, bases, dct)
cls.__instances = {}
cls.__lock = threading.Lock()
def __call__(cls: Type[T], name, *args, **kwargs) -> T:
# return cached instance directly
try:
return cls.__instances[name]
except KeyError:
pass
# create new instance
with cls.__lock:
# another thread may have created while we are waiting
try:
return cls.__instances[name]
except KeyError:
pass
# create
instance = super().__call__(name, *args, **kwargs)
cls.__instances[name] = instance
return instance
def singleton_remove(cls, name):
"""
Remove a specific instance.
Instance will be re-created, the nest time it is requested.
Returns:
bool: If removed
"""
# delete from dict is threadsafe
try:
del cls.__instances[name]
return True
except KeyError:
return False
def singleton_clear_all(cls):
"""
Remove all instances
"""
with cls.__lock:
cls.__instances.clear()
def singleton_instances(cls):
"""
Access all instances directly
Returns:
dict:
"""
return cls.__instances
+120 -88
View File
@@ -1,137 +1,168 @@
import time
from datetime import datetime, timedelta
from datetime import datetime
from functools import wraps
from time import sleep, time
def timer(function):
"""
Decorator to time a function, for debug only
"""
@wraps(function)
def function_timer(*args, **kwargs):
t0 = time.time()
start = time()
result = function(*args, **kwargs)
t1 = time.time()
print('%s: %s s' % (function.__name__, str(round(t1 - t0, 10))))
cost = time() - start
print(f'{function.__name__}: {cost:.10f} s')
return result
return function_timer
def future_time(string):
def now():
"""
Args:
string (str): Such as 14:59.
Returns:
datetime.datetime: Time with given hour, minute in the future.
Get datatime now without timezone
"""
hour, minute = [int(x) for x in string.split(':')]
future = datetime.now().replace(hour=hour, minute=minute, second=0, microsecond=0)
future = future + timedelta(days=1) if future < datetime.now() else future
return future
return datetime.now().replace(microsecond=0)
def past_time(string):
def nowtz():
"""
Args:
string (str): Such as 14:59.
Returns:
datetime.datetime: Time with given hour, minute in the past.
Get datatime now with timezone
"""
hour, minute = [int(x) for x in string.split(':')]
past = datetime.now().replace(hour=hour, minute=minute, second=0, microsecond=0)
past = past - timedelta(days=1) if past > datetime.now() else past
return past
def future_time_range(string):
"""
Args:
string (str): Such as 23:30-06:30.
Returns:
tuple(datetime.datetime): (time start, time end).
"""
start, end = [future_time(s) for s in string.split('-')]
if start > end:
start = start - timedelta(days=1)
return start, end
def time_range_active(time_range):
"""
Args:
time_range(tuple(datetime.datetime)): (time start, time end).
Returns:
bool:
"""
return time_range[0] < datetime.now() < time_range[1]
return datetime.now().replace(microsecond=0).astimezone()
class Timer:
def __init__(self, limit, count=0):
"""
Dual timer for time count and access count.
Access count can provide robustness on slow devices where screenshot time cost > timer.limit
Args:
limit (int, float): Timer limit
count (int): Timer reach confirm count. Default to 0.
When using a structure like this, must set a count.
Otherwise it goes wrong, if screenshot time cost greater than limit.
if self.appear(MAIN_CHECK):
if confirm_timer.reached():
pass
else:
confirm_timer.reset()
Also, It's a good idea to set `count`, to make alas run more stable on slow computers.
Expected speed is 0.35 second / screenshot.
limit (int | float): Timer limit
count (int): Timer access count. Default to 0.
"""
self.limit = limit
self.count = count
self._current = 0
self._reach_count = count
self._start = 0.
self._access = 0
@classmethod
def from_seconds(cls, limit, speed=0.5):
"""
Create timer from given seconds
Args:
limit (int | float):
speed (int | float): Approximate screenshot time cost
if time cost > 0.5s, device is considered slow
"""
count = int(limit / speed)
return cls(limit, count=count)
def start(self):
if not self.started():
self._current = time.time()
self._reach_count = 0
"""
Start current timer.
If timer not started, reached() always return True. So we can have fast first try on:
interval = Timer(2)
while 1:
if interval.reached():
pass
"""
if self._start <= 0:
self._start = time()
self._access = 0
return self
def started(self):
return bool(self._current)
def current(self):
"""
Returns:
float
bool:
"""
if self.started():
return time.time() - self._current
return self._start > 0
def current_time(self):
"""
Returns:
float:
"""
if self._start > 0:
diff = time() - self._start
if diff < 0:
diff = 0.
return diff
else:
return 0.
def set_current(self, current, count=0):
self._current = time.time() - current
self._reach_count = count
def current_count(self):
"""
Returns:
int:
"""
return self._access
def set(self, current=None, count=None, speed=0.5):
"""
Set internal state directly
Args:
current (int, float):
count (int):
speed (int, float):
"""
if current is not None:
if count is not None:
# set both
self._start = time() - current
self._access = count
else:
# set current only, calculate count
count = int(current / speed)
self._start = time() - current
self._access = count
else:
if count is not None:
# set count only
self._access = count
else:
# nothing to set
pass
return self
def add_count(self):
self._access += 1
return self
def reached(self):
"""
Returns:
bool
bool:
"""
self._reach_count += 1
return time.time() - self._current > self.limit and self._reach_count > self.count
# each reached() call is consider as an access
self._access += 1
if self._start > 0:
return self._access > self.count and time() - self._start > self.limit
else:
# not started, return True for fast first try
return True
def reset(self):
self._current = time.time()
self._reach_count = 0
"""
Reset the timer as if it just started
"""
self._start = time()
self._access = 0
return self
def clear(self):
self._current = 0
self._reach_count = self.count
"""
Reset the timer as if it never started
"""
self._start = 0.
self._access = self.count
return self
def reached_and_reset(self):
@@ -149,15 +180,16 @@ class Timer:
"""
Wait until timer reached.
"""
diff = self._current + self.limit - time.time()
diff = self._start + self.limit - time()
if diff > 0:
time.sleep(diff)
sleep(diff)
def show(self):
from module.logger import logger
logger.info(str(self))
def __str__(self):
return f'Timer(limit={round(self.current(), 3)}/{self.limit}, count={self._reach_count}/{self.count})'
# Timer(limit=2.351/3, count=4/6)
return f'Timer(limit={round(self.current_time(), 3)}/{self.limit}, count={self._access}/{self.count})'
__repr__ = __str__
+366 -65
View File
@@ -1,3 +1,4 @@
import random
import re
import cv2
@@ -8,8 +9,9 @@ REGEX_NODE = re.compile(r'(-?[A-Za-z]+)(-?\d+)')
def random_normal_distribution_int(a, b, n=3):
"""Generate a normal distribution int within the interval. Use the average value of several random numbers to
simulate normal distribution.
"""
Generate a normal distribution int within the interval.
Use the average value of several random numbers to simulate normal distribution.
Args:
a (int): The minimum of the interval.
@@ -19,9 +21,13 @@ def random_normal_distribution_int(a, b, n=3):
Returns:
int
"""
a = round(a)
b = round(b)
if a < b:
output = np.mean(np.random.randint(a, b, size=n))
return int(output.round())
total = 0
for _ in range(n):
total += random.randint(a, b)
return round(total / n)
else:
return b
@@ -274,6 +280,20 @@ def area_size(area):
)
def area_center(area):
"""
Get the center of an area
Args:
area: (upper_left_x, upper_left_y, bottom_right_x, bottom_right_y)
Returns:
tuple: (x, y)
"""
x1, y1, x2, y2 = area
return (x1 + x2) / 2, (y1 + y2) / 2
def point_limit(point, area):
"""
Limit point in an area.
@@ -497,6 +517,22 @@ def location2node(location):
return col2name(x) + str(y)
def xywh2xyxy(area):
"""
Convert (x, y, width, height) to (x1, y1, x2, y2)
"""
x, y, w, h = area
return x, y, x + w, y + h
def xyxy2xywh(area):
"""
Convert (x1, y1, x2, y2) to (x, y, width, height)
"""
x1, y1, x2, y2 = area
return min(x1, x2), min(y1, y2), abs(x2 - x1), abs(y2 - y1)
def load_image(file, area=None):
"""
Load an image like pillow and drop alpha channel.
@@ -508,13 +544,17 @@ def load_image(file, area=None):
Returns:
np.ndarray:
"""
image = Image.open(file)
if area is not None:
image = image.crop(area)
image = np.array(image)
channel = image.shape[2] if len(image.shape) > 2 else 1
if channel > 3:
image = image[:, :, :3].copy()
# always remember to close Image object
with Image.open(file) as f:
if area is not None:
f = f.crop(area)
image = np.array(f)
channel = image_channel(image)
if channel == 4:
image = cv2.cvtColor(image, cv2.COLOR_RGBA2RGB)
return image
@@ -531,6 +571,19 @@ def save_image(image, file):
Image.fromarray(image).save(file)
def copy_image(src):
"""
Equivalent to image.copy() but a little bit faster
Time cost to copy a 1280*720*3 image:
image.copy() 0.743ms
copy_image(image) 0.639ms
"""
dst = np.empty_like(src)
cv2.copyTo(src, None, dst)
return dst
def crop(image, area, copy=True):
"""
Crop image like pillow, when using opencv / numpy.
@@ -544,16 +597,72 @@ def crop(image, area, copy=True):
Returns:
np.ndarray:
"""
x1, y1, x2, y2 = map(int, map(round, area))
h, w = image.shape[:2]
border = np.maximum((0 - y1, y2 - h, 0 - x1, x2 - w), 0)
x1, y1, x2, y2 = np.maximum((x1, y1, x2, y2), 0)
# map(round, area)
x1, y1, x2, y2 = area
x1 = round(x1)
y1 = round(y1)
x2 = round(x2)
y2 = round(y2)
# h, w = image.shape[:2]
shape = image.shape
h = shape[0]
w = shape[1]
# top, bottom, left, right
# border = np.maximum((0 - y1, y2 - h, 0 - x1, x2 - w), 0)
overflow = False
if y1 >= 0:
top = 0
if y1 >= h:
overflow = True
else:
top = -y1
if y2 > h:
bottom = y2 - h
else:
bottom = 0
if y2 <= 0:
overflow = True
if x1 >= 0:
left = 0
if x1 >= w:
overflow = True
else:
left = -x1
if x2 > w:
right = x2 - w
else:
right = 0
if x2 <= 0:
overflow = True
# If overflowed, return empty image
if overflow:
if len(shape) == 2:
size = (y2 - y1, x2 - x1)
else:
size = (y2 - y1, x2 - x1, shape[2])
return np.zeros(size, dtype=image.dtype)
# x1, y1, x2, y2 = np.maximum((x1, y1, x2, y2), 0)
if x1 < 0:
x1 = 0
if y1 < 0:
y1 = 0
if x2 < 0:
x2 = 0
if y2 < 0:
y2 = 0
# crop image
image = image[y1:y2, x1:x2]
if sum(border) > 0:
image = cv2.copyMakeBorder(image, *border, borderType=cv2.BORDER_CONSTANT, value=(0, 0, 0))
if copy:
image = image.copy()
return image
# if border
if top or bottom or left or right:
if len(shape) == 2:
value = 0
else:
value = tuple(0 for _ in range(image.shape[2]))
return cv2.copyMakeBorder(image, top, bottom, left, right, borderType=cv2.BORDER_CONSTANT, value=value)
elif copy:
return copy_image(image)
else:
return image
def resize(image, size):
@@ -611,17 +720,29 @@ def image_paste(image, background, origin):
def rgb2gray(image):
"""
gray = ( MAX(r, g, b) + MIN(r, g, b)) / 2
Args:
image (np.ndarray): Shape (height, width, channel)
Returns:
np.ndarray: Shape (height, width)
"""
# r, g, b = cv2.split(image)
# return cv2.add(
# cv2.multiply(cv2.max(cv2.max(r, g), b), 0.5),
# cv2.multiply(cv2.min(cv2.min(r, g), b), 0.5)
# )
r, g, b = cv2.split(image)
return cv2.add(
cv2.multiply(cv2.max(cv2.max(r, g), b), 0.5),
cv2.multiply(cv2.min(cv2.min(r, g), b), 0.5)
)
maximum = cv2.max(r, g)
cv2.min(r, g, dst=r)
cv2.max(maximum, b, dst=maximum)
cv2.min(r, b, dst=r)
# minimum = r
cv2.convertScaleAbs(maximum, alpha=0.5, dst=maximum)
cv2.convertScaleAbs(r, alpha=0.5, dst=r)
cv2.add(maximum, r, dst=maximum)
return maximum
def rgb2hsv(image):
@@ -636,7 +757,7 @@ def rgb2hsv(image):
np.ndarray: Hue (0~360), Saturation (0~100), Value (0~100).
"""
image = cv2.cvtColor(image, cv2.COLOR_RGB2HSV).astype(float)
image *= (360 / 180, 100 / 255, 100 / 255)
cv2.multiply(image, (360 / 180, 100 / 255, 100 / 255, 0), dst=image)
return image
@@ -684,40 +805,133 @@ def get_color(image, area):
return color[:3]
class ImageNotSupported(Exception):
"""
Raised if we can't perform image calculation on this image
"""
pass
def get_bbox(image, threshold=0):
"""
A numpy implementation of the getbbox() in pillow.
Get outbound box of the content in image
A opencv implementation of the getbbox() in pillow
Args:
image (np.ndarray): Screenshot.
threshold (int): Color <= threshold will be considered black
image (np.ndarray):
threshold (int):
color > threshold will be considered as content
color <= threshold will be considered background
Returns:
tuple: (upper_left_x, upper_left_y, bottom_right_x, bottom_right_y)
tuple[int, int, int, int]: area
Raises:
ImageNotSupported: if failed to get bbox
"""
if image_channel(image) == 3:
image = np.max(image, axis=2)
x = np.where(np.max(image, axis=0) > threshold)[0]
y = np.where(np.max(image, axis=1) > threshold)[0]
return x[0], y[0], x[-1] + 1, y[-1] + 1
channel = image_channel(image)
# convert to grayscale
if channel == 3:
# RGB
mask = cv2.cvtColor(image, cv2.COLOR_RGB2GRAY)
cv2.threshold(mask, threshold, 255, cv2.THRESH_BINARY, dst=mask)
elif channel == 0:
# grayscale
_, mask = cv2.threshold(image, threshold, 255, cv2.THRESH_BINARY)
elif channel == 4:
# RGBA
mask = cv2.cvtColor(image, cv2.COLOR_RGBA2GRAY)
cv2.threshold(mask, threshold, 255, cv2.THRESH_BINARY, dst=mask)
else:
raise ImageNotSupported(f'shape={image.shape}')
# find bbox
contours, _ = cv2.findContours(mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
min_y, min_x = mask.shape
max_x = 0
max_y = 0
# all black
if not contours:
raise ImageNotSupported(f'Cannot get bbox from a pure black image')
for contour in contours:
# x, y, w, h
x1, y1, x2, y2 = cv2.boundingRect(contour)
x2 += x1
y2 += y1
if x1 < min_x:
min_x = x1
if y1 < min_y:
min_y = y1
if x2 > max_x:
max_x = x2
if y2 > max_y:
max_y = y2
if min_x < max_x and min_y < max_y:
return min_x, min_y, max_x, max_y
else:
# This shouldn't happen
raise ImageNotSupported(f'Empty bbox {(min_x, min_y, max_x, max_y)}')
def get_bbox_reversed(image, threshold=0):
def get_bbox_reversed(image, threshold=255):
"""
Similar to `get_bbox` but for black contents on white background.
Get outbound box of the content in image
A opencv implementation of the getbbox() in pillow
Args:
image (np.ndarray): Screenshot.
threshold (int): Color >= threshold will be considered white
image (np.ndarray):
threshold (int):
color < threshold will be considered as content
color >= threshold will be considered background
Returns:
tuple: (upper_left_x, upper_left_y, bottom_right_x, bottom_right_y)
tuple[int, int, int, int]: area
Raises:
ImageNotSupported: if failed to get bbox
"""
if image_channel(image) == 3:
image = np.min(image, axis=2)
x = np.where(np.min(image, axis=0) < threshold)[0]
y = np.where(np.min(image, axis=1) < threshold)[0]
return x[0], y[0], x[-1] + 1, y[-1] + 1
channel = image_channel(image)
# convert to grayscale
if channel == 3:
# RGB
mask = cv2.cvtColor(image, cv2.COLOR_RGB2GRAY)
cv2.threshold(mask, 0, threshold, cv2.THRESH_BINARY, dst=mask)
elif channel == 0:
# grayscale
mask = cv2.threshold(image, 0, threshold, cv2.THRESH_BINARY)
elif channel == 4:
# RGBA
mask = cv2.cvtColor(image, cv2.COLOR_RGBA2GRAY)
cv2.threshold(mask, 0, threshold, cv2.THRESH_BINARY, dst=mask)
else:
raise ImageNotSupported(f'shape={image.shape}')
# find bbox
contours, _ = cv2.findContours(mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
min_y, min_x = mask.shape
max_x = 0
max_y = 0
# all black
if not contours:
raise ImageNotSupported(f'Cannot get bbox from a pure black image')
for contour in contours:
# x, y, w, h
x1, y1, x2, y2 = cv2.boundingRect(contour)
x2 += x1
y2 += y1
if x1 < min_x:
min_x = x1
if y1 < min_y:
min_y = y1
if x2 > max_x:
max_x = x2
if y2 > max_y:
max_y = y2
if min_x < max_x and min_y < max_y:
return min_x, min_y, max_x, max_y
else:
# This shouldn't happen
raise ImageNotSupported(f'Empty bbox {(min_x, min_y, max_x, max_y)}')
def color_similarity(color1, color2):
@@ -729,13 +943,35 @@ def color_similarity(color1, color2):
Returns:
int:
"""
diff = np.array(color1).astype(int) - np.array(color2).astype(int)
diff = np.max(np.maximum(diff, 0)) - np.min(np.minimum(diff, 0))
# print(color1, color2)
# diff = np.array(color1).astype(int) - np.array(color2).astype(int)
# diff = np.max(np.maximum(diff, 0)) - np.min(np.minimum(diff, 0))
diff_r = color1[0] - color2[0]
diff_g = color1[1] - color2[1]
diff_b = color1[2] - color2[2]
max_positive = 0
max_negative = 0
if diff_r > max_positive:
max_positive = diff_r
elif diff_r < max_negative:
max_negative = diff_r
if diff_g > max_positive:
max_positive = diff_g
elif diff_g < max_negative:
max_negative = diff_g
if diff_b > max_positive:
max_positive = diff_b
elif diff_b < max_negative:
max_negative = diff_b
diff = max_positive - max_negative
return diff
def color_similar(color1, color2, threshold=10):
"""Consider two colors are similar, if tolerance lesser or equal threshold.
"""
Consider two colors are similar, if tolerance lesser or equal threshold.
Tolerance = Max(Positive(difference_rgb)) + Max(- Negative(difference_rgb))
The same as the tolerance in Photoshop.
@@ -748,8 +984,28 @@ def color_similar(color1, color2, threshold=10):
bool: True if two colors are similar.
"""
# print(color1, color2)
diff = np.array(color1).astype(int) - np.array(color2).astype(int)
diff = np.max(np.maximum(diff, 0)) - np.min(np.minimum(diff, 0))
# diff = np.array(color1).astype(int) - np.array(color2).astype(int)
# diff = np.max(np.maximum(diff, 0)) - np.min(np.minimum(diff, 0))
diff_r = color1[0] - color2[0]
diff_g = color1[1] - color2[1]
diff_b = color1[2] - color2[2]
max_positive = 0
max_negative = 0
if diff_r > max_positive:
max_positive = diff_r
elif diff_r < max_negative:
max_negative = diff_r
if diff_g > max_positive:
max_positive = diff_g
elif diff_g < max_negative:
max_negative = diff_g
if diff_b > max_positive:
max_positive = diff_b
elif diff_b < max_negative:
max_negative = diff_b
diff = max_positive - max_negative
return diff <= threshold
@@ -777,11 +1033,24 @@ def color_similarity_2d(image, color):
Returns:
np.ndarray: uint8
"""
r, g, b = cv2.split(cv2.subtract(image, (*color, 0)))
positive = cv2.max(cv2.max(r, g), b)
r, g, b = cv2.split(cv2.subtract((*color, 0), image))
negative = cv2.max(cv2.max(r, g), b)
return cv2.subtract(255, cv2.add(positive, negative))
# r, g, b = cv2.split(cv2.subtract(image, (*color, 0)))
# positive = cv2.max(cv2.max(r, g), b)
# r, g, b = cv2.split(cv2.subtract((*color, 0), image))
# negative = cv2.max(cv2.max(r, g), b)
# return cv2.subtract(255, cv2.add(positive, negative))
diff = cv2.subtract(image, (*color, 0))
r, g, b = cv2.split(diff)
cv2.max(r, g, dst=r)
cv2.max(r, b, dst=r)
positive = r
cv2.subtract((*color, 0), image, dst=diff)
r, g, b = cv2.split(diff)
cv2.max(r, g, dst=r)
cv2.max(r, b, dst=r)
negative = r
cv2.add(positive, negative, dst=positive)
cv2.subtract(255, positive, dst=positive)
return positive
def extract_letters(image, letter=(255, 255, 255), threshold=128):
@@ -795,11 +1064,25 @@ def extract_letters(image, letter=(255, 255, 255), threshold=128):
Returns:
np.ndarray: Shape (height, width)
"""
r, g, b = cv2.split(cv2.subtract(image, (*letter, 0)))
positive = cv2.max(cv2.max(r, g), b)
r, g, b = cv2.split(cv2.subtract((*letter, 0), image))
negative = cv2.max(cv2.max(r, g), b)
return cv2.multiply(cv2.add(positive, negative), 255.0 / threshold)
# r, g, b = cv2.split(cv2.subtract(image, (*letter, 0)))
# positive = cv2.max(cv2.max(r, g), b)
# r, g, b = cv2.split(cv2.subtract((*letter, 0), image))
# negative = cv2.max(cv2.max(r, g), b)
# return cv2.multiply(cv2.add(positive, negative), 255.0 / threshold)
diff = cv2.subtract(image, (*letter, 0))
r, g, b = cv2.split(diff)
cv2.max(r, g, dst=r)
cv2.max(r, b, dst=r)
positive = r
cv2.subtract((*letter, 0), image, dst=diff)
r, g, b = cv2.split(diff)
cv2.max(r, g, dst=r)
cv2.max(r, b, dst=r)
negative = r
cv2.add(positive, negative, dst=positive)
if threshold != 255:
cv2.convertScaleAbs(positive, alpha=255.0 / threshold, dst=positive)
return positive
def extract_white_letters(image, threshold=128):
@@ -813,10 +1096,23 @@ def extract_white_letters(image, threshold=128):
Returns:
np.ndarray: Shape (height, width)
"""
# minimum = cv2.min(cv2.min(r, g), b)
# maximum = cv2.max(cv2.max(r, g), b)
# return cv2.multiply(cv2.add(maximum, cv2.subtract(maximum, minimum)), 255.0 / threshold)
r, g, b = cv2.split(cv2.subtract((255, 255, 255, 0), image))
minimum = cv2.min(cv2.min(r, g), b)
maximum = cv2.max(cv2.max(r, g), b)
return cv2.multiply(cv2.add(maximum, cv2.subtract(maximum, minimum)), 255.0 / threshold)
maximum = cv2.max(r, g)
cv2.min(r, g, dst=r)
cv2.max(maximum, b, dst=maximum)
cv2.min(r, b, dst=r)
# minimum = r
cv2.convertScaleAbs(maximum, alpha=0.5, dst=maximum)
cv2.convertScaleAbs(r, alpha=0.5, dst=r)
cv2.subtract(maximum, r, dst=r)
cv2.add(maximum, r, dst=maximum)
if threshold != 255:
cv2.convertScaleAbs(maximum, alpha=255.0 / threshold, dst=maximum)
return maximum
def color_mapping(image, max_multiply=2):
@@ -835,7 +1131,9 @@ def color_mapping(image, max_multiply=2):
low, high = np.min(image), np.max(image)
multiply = min(255 / (high - low), max_multiply)
add = (255 - multiply * (low + high)) / 2
image = cv2.add(cv2.multiply(image, multiply), add)
# image = cv2.add(cv2.multiply(image, multiply), add)
cv2.multiply(image, multiply, dst=image)
cv2.add(image, add, dst=image)
image[image > 255] = 255
image[image < 0] = 0
return image.astype(np.uint8)
@@ -895,7 +1193,7 @@ def color_bar_percentage(image, area, prev_color, reverse=False, starter=0, thre
Returns:
float: 0 to 1.
"""
image = crop(image, area)
image = crop(image, area, copy=False)
image = image[:, ::-1, :] if reverse else image
length = image.shape[1]
prev_index = starter
@@ -914,6 +1212,9 @@ def color_bar_percentage(image, area, prev_color, reverse=False, starter=0, thre
prev_row = bar[:, prev_index] > 255 - threshold
if not prev_row.size:
return prev_index / length
prev_color = np.mean(image[:, prev_index], axis=0)
# Look back 5px to get average color
left = max(prev_index - 5, 0)
mask = np.where(bar[:, left:prev_index + 1] > 255 - threshold)
prev_color = np.mean(image[:, left:prev_index + 1][mask], axis=0)
return 0.
+14 -26
View File
@@ -41,9 +41,10 @@
"aScreenCap_nc",
"DroidCast",
"DroidCast_raw",
"scrcpy"
],
"display": "hide"
"scrcpy",
"nemu_ipc",
"ldopengl"
]
},
"ControlMethod": {
"type": "select",
@@ -74,6 +75,7 @@
"LDPlayer3",
"LDPlayer4",
"LDPlayer9",
"LDPlayer14",
"MuMuPlayer",
"MuMuPlayerX",
"MuMuPlayer12",
@@ -130,6 +132,7 @@
"stay_there",
"goto_main",
"close_game",
"close_emulator",
"exit_aas",
"exit_emulator",
"exit_aas_emulator",
@@ -770,7 +773,8 @@
6,
7,
8,
9
9,
10
]
},
"Count": {
@@ -792,7 +796,8 @@
6,
7,
8,
9
9,
10
]
},
"Count": {
@@ -814,7 +819,8 @@
6,
7,
8,
9
9,
10
]
},
"Count": {
@@ -1059,28 +1065,10 @@
}
},
"Momotalk": {
"Scheduler": {
"Momotalk": {
"Enable": {
"type": "checkbox",
"value": false,
"option": [
true,
false
]
},
"NextRun": {
"type": "datetime",
"value": "2020-01-01 00:00:00",
"validate": "datetime"
},
"Command": {
"type": "input",
"value": "Momotalk",
"display": "hide"
},
"ServerUpdate": {
"type": "input",
"value": "04:00",
"value": true,
"display": "hide"
}
}
+13 -5
View File
@@ -26,7 +26,7 @@ Emulator:
option: [ auto, jp, en, zht ]
ScreenshotMethod:
value: auto
option: [ auto, ADB, ADB_nc, uiautomator2, aScreenCap, aScreenCap_nc, DroidCast, DroidCast_raw, scrcpy ]
option: [ auto, ADB, ADB_nc, uiautomator2, aScreenCap, aScreenCap_nc, DroidCast, DroidCast_raw, scrcpy, nemu_ipc, ldopengl, ]
ControlMethod:
value: MaaTouch
option: [ minitouch, MaaTouch ]
@@ -45,6 +45,7 @@ EmulatorInfo:
LDPlayer3,
LDPlayer4,
LDPlayer9,
LDPlayer14,
MuMuPlayer,
MuMuPlayerX,
MuMuPlayer12,
@@ -71,7 +72,7 @@ Optimization:
CombatScreenshotInterval: 1.0
WhenTaskQueueEmpty:
value: goto_main
option: [ stay_there, goto_main, close_game, exit_aas, exit_emulator, exit_aas_emulator, shutdown ]
option: [ stay_there, goto_main, close_game, close_emulator, exit_aas, exit_emulator, exit_aas_emulator, shutdown ]
# ==================== Daily ====================
@@ -156,17 +157,17 @@ Bounty:
Highway:
Stage:
value: 0
option: [ 0, 1, 2, 3, 4, 5, 6, 7, 8, 9 ]
option: [ 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10 ]
Count: 2
DesertRailroad:
Stage:
value: 0
option: [ 0, 1, 2, 3, 4, 5, 6, 7, 8, 9 ]
option: [ 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10 ]
Count: 2
Schoolhouse:
Stage:
value: 0
option: [ 0, 1, 2, 3, 4, 5, 6, 7, 8, 9 ]
option: [ 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10 ]
Count: 2
Scrimmage:
@@ -267,3 +268,10 @@ ItemStorage:
stored: StoredTacticalChallengeTicket
order: 6
color: "#7ac8e5"
# ==================== Momotalk ====================
Momotalk:
Enable:
value: true
display: hide
+3
View File
@@ -4,6 +4,9 @@
# ==================== Alas ====================
Alas:
Emulator:
ScreenshotMethod: scrcpy
Cafe:
Scheduler:
Enable: true
+1
View File
@@ -61,6 +61,7 @@ Dashboard:
HoursAgo:
DaysAgo:
LongTimeAgo:
EtaDays:
AddAlas:
PopupTitle:
+7 -1
View File
@@ -33,7 +33,13 @@
"tasks": [
"Circle",
"Task",
"Mail",
"Mail"
]
},
"Tool": {
"menu": "collapse",
"page": "tool",
"tasks": [
"Momotalk"
]
}
-1
View File
@@ -7,7 +7,6 @@
Alas:
Emulator:
ScreenshotMethod: scrcpy
ControlMethod: MaaTouch
Optimization:
ScreenshotInterval: 0.2
+8 -1
View File
@@ -80,5 +80,12 @@ Reward:
- Scheduler
Mail:
- Scheduler
# ==================== Tool ====================
Tool:
menu: 'collapse'
page: 'tool'
tasks:
Momotalk:
- Scheduler
- Momotalk
-236
View File
@@ -1,236 +0,0 @@
"""
Copy-pasted from
https://github.com/untitaker/python-atomicwrites
"""
import contextlib
import io
import os
import sys
import tempfile
try:
import fcntl
except ImportError:
fcntl = None
# `fspath` was added in Python 3.6
try:
from os import fspath
except ImportError:
fspath = None
__version__ = '1.4.1'
PY2 = sys.version_info[0] == 2
text_type = unicode if PY2 else str # noqa
def _path_to_unicode(x):
if not isinstance(x, text_type):
return x.decode(sys.getfilesystemencoding())
return x
DEFAULT_MODE = "wb" if PY2 else "w"
_proper_fsync = os.fsync
if sys.platform != 'win32':
if hasattr(fcntl, 'F_FULLFSYNC'):
def _proper_fsync(fd):
# https://lists.apple.com/archives/darwin-dev/2005/Feb/msg00072.html
# https://developer.apple.com/library/mac/documentation/Darwin/Reference/ManPages/man2/fsync.2.html
# https://github.com/untitaker/python-atomicwrites/issues/6
fcntl.fcntl(fd, fcntl.F_FULLFSYNC)
def _sync_directory(directory):
# Ensure that filenames are written to disk
fd = os.open(directory, 0)
try:
_proper_fsync(fd)
finally:
os.close(fd)
def _replace_atomic(src, dst):
os.rename(src, dst)
_sync_directory(os.path.normpath(os.path.dirname(dst)))
def _move_atomic(src, dst):
os.link(src, dst)
os.unlink(src)
src_dir = os.path.normpath(os.path.dirname(src))
dst_dir = os.path.normpath(os.path.dirname(dst))
_sync_directory(dst_dir)
if src_dir != dst_dir:
_sync_directory(src_dir)
else:
from ctypes import windll, WinError
_MOVEFILE_REPLACE_EXISTING = 0x1
_MOVEFILE_WRITE_THROUGH = 0x8
_windows_default_flags = _MOVEFILE_WRITE_THROUGH
def _handle_errors(rv):
if not rv:
raise WinError()
def _replace_atomic(src, dst):
_handle_errors(windll.kernel32.MoveFileExW(
_path_to_unicode(src), _path_to_unicode(dst),
_windows_default_flags | _MOVEFILE_REPLACE_EXISTING
))
def _move_atomic(src, dst):
_handle_errors(windll.kernel32.MoveFileExW(
_path_to_unicode(src), _path_to_unicode(dst),
_windows_default_flags
))
def replace_atomic(src, dst):
'''
Move ``src`` to ``dst``. If ``dst`` exists, it will be silently
overwritten.
Both paths must reside on the same filesystem for the operation to be
atomic.
'''
return _replace_atomic(src, dst)
def move_atomic(src, dst):
'''
Move ``src`` to ``dst``. There might a timewindow where both filesystem
entries exist. If ``dst`` already exists, :py:exc:`FileExistsError` will be
raised.
Both paths must reside on the same filesystem for the operation to be
atomic.
'''
return _move_atomic(src, dst)
class AtomicWriter(object):
'''
A helper class for performing atomic writes. Usage::
with AtomicWriter(path).open() as f:
f.write(...)
:param path: The destination filepath. May or may not exist.
:param mode: The filemode for the temporary file. This defaults to `wb` in
Python 2 and `w` in Python 3.
:param overwrite: If set to false, an error is raised if ``path`` exists.
Errors are only raised after the file has been written to. Either way,
the operation is atomic.
:param open_kwargs: Keyword-arguments to pass to the underlying
:py:func:`open` call. This can be used to set the encoding when opening
files in text-mode.
If you need further control over the exact behavior, you are encouraged to
subclass.
'''
def __init__(self, path, mode=DEFAULT_MODE, overwrite=False,
**open_kwargs):
if 'a' in mode:
raise ValueError(
'Appending to an existing file is not supported, because that '
'would involve an expensive `copy`-operation to a temporary '
'file. Open the file in normal `w`-mode and copy explicitly '
'if that\'s what you\'re after.'
)
if 'x' in mode:
raise ValueError('Use the `overwrite`-parameter instead.')
if 'w' not in mode:
raise ValueError('AtomicWriters can only be written to.')
# Attempt to convert `path` to `str` or `bytes`
if fspath is not None:
path = fspath(path)
self._path = path
self._mode = mode
self._overwrite = overwrite
self._open_kwargs = open_kwargs
def open(self):
'''
Open the temporary file.
'''
return self._open(self.get_fileobject)
@contextlib.contextmanager
def _open(self, get_fileobject):
f = None # make sure f exists even if get_fileobject() fails
try:
success = False
with get_fileobject(**self._open_kwargs) as f:
yield f
self.sync(f)
self.commit(f)
success = True
finally:
if not success:
try:
self.rollback(f)
except Exception:
pass
def get_fileobject(self, suffix="", prefix=tempfile.gettempprefix(),
dir=None, **kwargs):
'''Return the temporary file to use.'''
if dir is None:
dir = os.path.normpath(os.path.dirname(self._path))
descriptor, name = tempfile.mkstemp(suffix=suffix, prefix=prefix,
dir=dir)
# io.open() will take either the descriptor or the name, but we need
# the name later for commit()/replace_atomic() and couldn't find a way
# to get the filename from the descriptor.
os.close(descriptor)
kwargs['mode'] = self._mode
kwargs['file'] = name
return io.open(**kwargs)
def sync(self, f):
'''responsible for clearing as many file caches as possible before
commit'''
f.flush()
_proper_fsync(f.fileno())
def commit(self, f):
'''Move the temporary file to the target location.'''
if self._overwrite:
replace_atomic(f.name, self._path)
else:
move_atomic(f.name, self._path)
def rollback(self, f):
'''Clean up all temporary resources.'''
os.unlink(f.name)
def atomic_write(path, writer_cls=AtomicWriter, **cls_kwargs):
'''
Simple atomic writes. This wraps :py:class:`AtomicWriter`::
with atomic_write(path) as f:
f.write(...)
:param path: The target path to write to.
:param writer_cls: The writer class to use. This parameter is useful if you
subclassed :py:class:`AtomicWriter` to change some behavior and want to
use that new subclass.
Additional keyword arguments are passed to the writer class. See
:py:class:`AtomicWriter`.
'''
return writer_cls(path, **cls_kwargs).open()
+44 -32
View File
@@ -1,18 +1,17 @@
import copy
import datetime
import operator
import threading
import pywebio
from datetime import datetime, timedelta
from module.base.decorator import cached_property, del_cached_property
from module.base.filter import Filter
from module.config.config_generated import GeneratedConfig
from module.config.config_manual import ManualConfig
from module.config.config_updater import ConfigUpdater
from module.config.config_updater import ConfigUpdater, ensure_time, get_server_next_update, nearest_future
from module.config.deep import deep_get, deep_set
from module.config.stored.classes import iter_attribute
from module.config.stored.stored_generated import StoredGenerated
from module.config.utils import *
from module.config.utils import DEFAULT_TIME, dict_to_kv, filepath_config, path_to_arg
from module.config.watcher import ConfigWatcher
from module.exception import RequestHumanTakeover, ScriptError
from module.logger import logger
@@ -106,17 +105,22 @@ class AzurLaneConfig(ConfigUpdater, ManualConfig, GeneratedConfig, ConfigWatcher
logger.info("Using template config, which is read only")
self.auto_update = False
self.task = name_to_function("template")
self.init_task(task)
def init_task(self, task=None):
if self.is_template_config:
return
self.load()
if task is None:
# Bind `Alas` by default which includes emulator settings.
task = name_to_function("Alas")
else:
self.load()
if task is None:
# Bind `Alas` by default which includes emulator settings.
task = name_to_function("Alas")
else:
# Bind a specific task for debug purpose.
task = name_to_function(task)
self.bind(task)
self.task = task
self.save()
# Bind a specific task for debug purpose.
task = name_to_function(task)
self.bind(task)
self.task = task
self.save()
def load(self):
self.data = self.read_file(self.config_name)
@@ -129,13 +133,17 @@ class AzurLaneConfig(ConfigUpdater, ManualConfig, GeneratedConfig, ConfigWatcher
"""
Args:
func (str, Function): Function to run
func_list (set): Set of tasks to be bound
func_list (list[str]): List of tasks to be bound
"""
if func_list is None:
func_list = ["Alas"]
if isinstance(func, Function):
func = func.command
func_list.append(func)
# func_list: ["Alas", <task>, *func_list]
if func_list is None:
func_list = []
if func not in func_list:
func_list.insert(0, func)
if "Alas" not in func_list:
func_list.insert(0, "Alas")
logger.info(f"Bind task {func_list}")
# Bind arguments
@@ -172,6 +180,10 @@ class AzurLaneConfig(ConfigUpdater, ManualConfig, GeneratedConfig, ConfigWatcher
self.data, keys="Alas.Optimization.CloseGameDuringWait", default=False
)
@property
def is_actual_task(self):
return self.task.command.lower() not in ['alas', 'template']
@cached_property
def stored(self) -> StoredGenerated:
stored = StoredGenerated()
@@ -279,6 +291,7 @@ class AzurLaneConfig(ConfigUpdater, ManualConfig, GeneratedConfig, ConfigWatcher
deep_set(self.data, keys=f"{task}.Scheduler.NextRun", value=now)
limit_next_run(['BattlePass'], limit=now + timedelta(days=31, seconds=-1))
limit_next_run(['Weekly'], limit=now + timedelta(days=7, seconds=-1))
limit_next_run(self.args.keys(), limit=now + timedelta(hours=24, seconds=-1))
def override(self, **kwargs):
@@ -485,19 +498,18 @@ class AzurLaneConfig(ConfigUpdater, ManualConfig, GeneratedConfig, ConfigWatcher
def is_task_enabled(self, task):
return bool(self.cross_get(keys=[task, 'Scheduler', 'Enable'], default=False))
def update_daily_quests(self):
"""
Raises:
TaskEnd: Call task `DailyQuest` and stop current task
"""
if self.stored.DailyActivity.is_expired():
logger.info('DailyActivity expired, call task to update')
self.task_call('DailyQuest')
self.task_stop()
if self.stored.DailyQuest.is_expired():
logger.info('DailyQuest expired, call task to update')
self.task_call('DailyQuest')
self.task_stop()
# def update_daily_quests(self):
# """
# Raises:
# TaskEnd: Call task `DailyQuest` and stop current task
# """
# with self.multi_set():
# if self.stored.DailyActivity.is_expired():
# logger.info('DailyActivity expired')
# self.stored.DailyActivity.clear()
# if self.stored.DailyQuest.is_expired():
# logger.info('DailyQuest expired')
# self.stored.DailyQuest.clear()
@property
def DEVICE_SCREENSHOT_METHOD(self):
+9 -6
View File
@@ -19,12 +19,12 @@ class GeneratedConfig:
Emulator_Serial = 'auto'
Emulator_PackageName = 'auto' # auto, JP-Official, OVERSEA-TWHKMO, OVERSEA-Korea, OVERSEA-Asia, OVERSEA-America, OVERSEA-Global
Emulator_GameLanguage = 'auto' # auto, jp, en, zht
Emulator_ScreenshotMethod = 'auto' # auto, ADB, ADB_nc, uiautomator2, aScreenCap, aScreenCap_nc, DroidCast, DroidCast_raw, scrcpy
Emulator_ScreenshotMethod = 'auto' # auto, ADB, ADB_nc, uiautomator2, aScreenCap, aScreenCap_nc, DroidCast, DroidCast_raw, scrcpy, nemu_ipc, ldopengl
Emulator_ControlMethod = 'MaaTouch' # minitouch, MaaTouch
Emulator_AdbRestart = False
# Group `EmulatorInfo`
EmulatorInfo_Emulator = 'auto' # auto, NoxPlayer, NoxPlayer64, BlueStacks4, BlueStacks5, BlueStacks4HyperV, BlueStacks5HyperV, LDPlayer3, LDPlayer4, LDPlayer9, MuMuPlayer, MuMuPlayerX, MuMuPlayer12, MEmuPlayer
EmulatorInfo_Emulator = 'auto' # auto, NoxPlayer, NoxPlayer64, BlueStacks4, BlueStacks5, BlueStacks4HyperV, BlueStacks5HyperV, LDPlayer3, LDPlayer4, LDPlayer9, LDPlayer14, MuMuPlayer, MuMuPlayerX, MuMuPlayer12, MEmuPlayer
EmulatorInfo_name = None
EmulatorInfo_path = None
@@ -37,7 +37,7 @@ class GeneratedConfig:
# Group `Optimization`
Optimization_ScreenshotInterval = 0.3
Optimization_CombatScreenshotInterval = 1.0
Optimization_WhenTaskQueueEmpty = 'goto_main' # stay_there, goto_main, close_game, exit_aas, exit_emulator, exit_aas_emulator, shutdown
Optimization_WhenTaskQueueEmpty = 'goto_main' # stay_there, goto_main, close_game, close_emulator, exit_aas, exit_emulator, exit_aas_emulator, shutdown
# Group `Cafe`
Cafe_Reward = True
@@ -87,15 +87,15 @@ class GeneratedConfig:
Bounty_OnError = 'skip' # stop, skip
# Group `Highway`
Highway_Stage = 0 # 0, 1, 2, 3, 4, 5, 6, 7, 8, 9
Highway_Stage = 0 # 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10
Highway_Count = 2
# Group `DesertRailroad`
DesertRailroad_Stage = 0 # 0, 1, 2, 3, 4, 5, 6, 7, 8, 9
DesertRailroad_Stage = 0 # 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10
DesertRailroad_Count = 2
# Group `Schoolhouse`
Schoolhouse_Stage = 0 # 0, 1, 2, 3, 4, 5, 6, 7, 8, 9
Schoolhouse_Stage = 0 # 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10
Schoolhouse_Count = 2
# Group `Scrimmage`
@@ -166,3 +166,6 @@ class GeneratedConfig:
ItemStorage_BountyTicket = {}
ItemStorage_ScrimmageTicket = {}
ItemStorage_TacticalChallengeTicket = {}
# Group `Momotalk`
Momotalk_Enable = True
+66 -6
View File
@@ -1,9 +1,11 @@
import typing as t
from copy import deepcopy
from cached_property import cached_property
from deploy.Windows.utils import DEPLOY_TEMPLATE, poor_yaml_read, poor_yaml_write
from module.base.timer import timer
from module.config.deep import deep_default, deep_get, deep_iter, deep_set
from module.config.server import VALID_SERVER
from module.config.utils import *
@@ -452,20 +454,19 @@ class ConfigUpdater:
dict:
"""
new = {}
type_lock = {'lock', 'state'}
type_stored = {'stored', 'dict'}
def deep_load(keys):
data = deep_get(self.args, keys=keys, default={})
for keys, data in deep_iter(self.args, depth=3):
value = deep_get(old, keys=keys, default=data['value'])
typ = data['type']
display = data.get('display')
if (is_template or value is None or value == ''
or typ in ['lock', 'state'] or (display == 'hide' and typ != 'stored')):
if is_template or value is None or value == '' \
or typ in type_lock or (display == 'hide' and typ not in type_stored):
value = data['value']
value = parse_value(value, data=data)
deep_set(new, keys=keys, value=value)
for path, _ in deep_iter(self.args, depth=3):
deep_load(path)
# if not is_template:
# new = self.config_redirect(old, new)
@@ -569,6 +570,65 @@ class ConfigUpdater:
# set_daily('Use_Consumables_1_time', 'achievable')
# return data
def save_callback(self, key: str, value: t.Any) -> t.Iterable[t.Tuple[str, t.Any]]:
"""
Args:
key: Key path in config json, such as "Main.Emotion.Fleet1Value"
value: Value set by user, such as "98"
Yields:
str: Key path to set config json, such as "Main.Emotion.Fleet1Record"
any: Value to set, such as "2020-01-01 00:00:00"
"""
# if key.startswith('Dungeon.Dungeon') or key.startswith('Dungeon.DungeonDaily'):
# from tasks.dungeon.keywords.dungeon import DungeonList
# from module.exception import ScriptError
# try:
# dungeon = DungeonList.find(value)
# except ScriptError:
# return
# if key.endswith('Name'):
# if dungeon.is_Calyx_Golden:
# yield 'Dungeon.Dungeon.NameAtDoubleCalyx', value
# yield 'Dungeon.DungeonDaily.CalyxGolden', value
# elif dungeon.is_Calyx_Crimson:
# yield 'Dungeon.Dungeon.NameAtDoubleCalyx', value
# yield 'Dungeon.DungeonDaily.CalyxCrimson', value
# elif dungeon.is_Stagnant_Shadow:
# yield 'Dungeon.DungeonDaily.StagnantShadow', value
# elif dungeon.is_Cavern_of_Corrosion:
# yield 'Dungeon.Dungeon.NameAtDoubleRelic', value
# yield 'Dungeon.DungeonDaily.CavernOfCorrosion', value
# elif key.endswith('NameAtDoubleCalyx'):
# if dungeon.is_Calyx_Golden:
# yield 'Dungeon.DungeonDaily.CalyxGolden', value
# elif dungeon.is_Calyx_Crimson:
# yield 'Dungeon.DungeonDaily.CalyxCrimson', value
# elif key.endswith('NameAtDoubleRelic'):
# yield 'Dungeon.DungeonDaily.CavernOfCorrosion', value
# elif key.endswith('CavernOfCorrosion'):
# yield 'Dungeon.Dungeon.NameAtDoubleRelic', value
yield '', None
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'
yield ''
def get_hidden_args(self, data) -> t.Set[str]:
"""
Return a set of hidden args
"""
out = list(self.iter_hidden_args(data))
return set(out)
def read_file(self, config_name, is_template=False):
"""
Read and update config file.
+533
View File
@@ -0,0 +1,533 @@
from collections import deque
# deep_* functions are used for access nested dictionary.
# They target for high performance so code are complicated to read
# In general performance practise, time costs are as below:
# - When key exists
# try: dict[key] except KeyError << dict.get(key) < if key in dict: dict[key]
# - When not key exists
# if key in dict: dict[key] < dict.get(key) <<< try: dict[key] except KeyError
OP_ADD = 'add'
OP_SET = 'set'
OP_DEL = 'del'
def deep_get(d, keys, default=None):
"""
Get value from nested dict and list
https://stackoverflow.com/questions/25833613/safe-method-to-get-value-of-nested-dictionary
Args:
d (dict):
keys (list[str], str): Such as ['Scheduler', 'NextRun', 'value']
default: Default return if key not found.
Returns:
Value on given keys
"""
# 240 + 30 * depth (ns)
if type(keys) is str:
keys = keys.split('.')
try:
for k in keys:
d = d[k]
return d
# No such key
except KeyError:
return default
# No such key
except IndexError:
return default
# Input `keys` is not iterable or input `d` is not dict
# list indices must be integers or slices, not str
except TypeError:
return default
def deep_get_with_error(d, keys):
"""
Get value from nested dict and list, raise KeyError if key not exists
Args:
d (dict):
keys (list[str], str): Such as ['Scheduler', 'NextRun', 'value']
Returns:
Value on given keys
Raises:
KeyError: If key not exists
"""
# 240 + 30 * depth (ns)
if type(keys) is str:
keys = keys.split('.')
try:
for k in keys:
d = d[k]
return d
# No such key
# except KeyError:
# raise
# No such key
except IndexError:
raise KeyError
# Input `keys` is not iterable or input `d` is not dict
# list indices must be integers or slices, not str
except TypeError:
raise KeyError
def deep_exist(d, keys):
"""
Check if keys exists in nested dict or list
Args:
d (dict):
keys (str, list): Such as `Scheduler.NextRun.value`
Returns:
bool: If key exists
"""
# 240 + 30 * depth (ns)
if type(keys) is str:
keys = keys.split('.')
try:
for k in keys:
d = d[k]
return True
# No such key
except KeyError:
return False
# No such key
except IndexError:
return False
# Input `keys` is not iterable or input `d` is not dict
# list indices must be integers or slices, not str
except TypeError:
return False
def deep_set(d, keys, value):
"""
Set value into nested dict safely, imitating deep_get().
Can only set dict
"""
# 150 * depth (ns)
if type(keys) is str:
keys = keys.split('.')
first = True
exist = True
prev_d = None
prev_k = None
prev_k2 = None
try:
for k in keys:
if first:
prev_d = d
prev_k = k
first = False
continue
try:
# if key in dict: dict[key] > dict.get > dict.setdefault > try dict[key] except
if exist and prev_k in d:
prev_d = d
d = d[prev_k]
else:
exist = False
new = {}
d[prev_k] = new
d = new
except TypeError:
# `d` is not dict
exist = False
d = {}
prev_d[prev_k2] = {prev_k: d}
prev_k2 = prev_k
prev_k = k
# prev_k2, prev_k = prev_k, k
# Input `keys` is not iterable
except TypeError:
return
# Last key, set value
try:
d[prev_k] = value
return
# Last value `d` is not dict
except TypeError:
prev_d[prev_k2] = {prev_k: value}
return
def deep_default(d, keys, value):
"""
Set value into nested dict safely, imitating deep_get().
Can only set dict
"""
# 150 * depth (ns)
if type(keys) is str:
keys = keys.split('.')
first = True
exist = True
prev_d = None
prev_k = None
prev_k2 = None
try:
for k in keys:
if first:
prev_d = d
prev_k = k
first = False
continue
try:
# if key in dict: dict[key] > dict.get > dict.setdefault > try dict[key] except
if exist and prev_k in d:
prev_d = d
d = d[prev_k]
else:
exist = False
new = {}
d[prev_k] = new
d = new
except TypeError:
# `d` is not dict
exist = False
d = {}
prev_d[prev_k2] = {prev_k: d}
prev_k2 = prev_k
prev_k = k
# prev_k2, prev_k = prev_k, k
# Input `keys` is not iterable
except TypeError:
return
# Last key, set value
try:
d.setdefault(prev_k, value)
return
# Last value `d` is not dict
except AttributeError:
prev_d[prev_k2] = {prev_k: value}
return
def deep_pop(d, keys, default=None):
"""
Pop value from nested dict and list
"""
if type(keys) is str:
keys = keys.split('.')
try:
for k in keys[:-1]:
d = d[k]
# No `pop(k, default)` so it can pop list
return d.pop(keys[-1])
# No such key
except KeyError:
return default
# Input `keys` is not iterable or input `d` is not dict
# list indices must be integers or slices, not str
except TypeError:
return default
# Input `keys` out of index
except IndexError:
return default
# Last `d` is not dict
except AttributeError:
return default
def deep_iter_depth1(data):
"""
Equivalent to data.items() but suppress error if data is not a dict
Args:
data:
Yields:
Any: Key
Any: Value
"""
try:
for k, v in data.items():
yield k, v
return
except AttributeError:
# `data` is not dict
return
def deep_iter_depth2(data):
"""
Iter key and value in nested dict of depth 2
A simplified deep_iter
Args:
data:
Yields:
Any: Key1
Any: Key2
Any: Value
"""
try:
for k1, v1 in data.items():
if type(v1) is dict:
for k2, v2 in v1.items():
yield k1, k2, v2
except AttributeError:
# `data` is not dict
return
def deep_iter(data, min_depth=None, depth=3):
"""
Iter key and value in nested dict
300us on alas.json depth=3 (530+ rows)
Can only iter dict
Args:
data:
min_depth:
depth:
Yields:
list[str]: Key path
Any: Value
"""
if min_depth is None:
min_depth = depth
assert 1 <= min_depth <= depth
# Equivalent to dict.items()
try:
if depth == 1:
for k, v in data.items():
yield [k], v
return
# Iter first depth
elif min_depth == 1:
q = deque()
for k, v in data.items():
key = [k]
if type(v) is dict:
q.append((key, v))
else:
yield key, v
# Iter target depth only
else:
q = deque()
for k, v in data.items():
key = [k]
if type(v) is dict:
q.append((key, v))
except AttributeError:
# `data` is not dict
return
# Iter depths
current = 2
while current <= depth:
new_q = deque()
# max depth
if current == depth:
for key, data in q:
for k, v in data.items():
yield key + [k], v
# in target depth
elif min_depth <= current < depth:
for key, data in q:
for k, v in data.items():
subkey = key + [k]
if type(v) is dict:
new_q.append((subkey, v))
else:
yield subkey, v
# Haven't reached min depth
else:
for key, data in q:
for k, v in data.items():
subkey = key + [k]
if type(v) is dict:
new_q.append((subkey, v))
q = new_q
current += 1
def deep_values(data, min_depth=None, depth=3):
"""
Iter value in nested dict
300us on alas.json depth=3 (530+ rows)
Can only iter dict
Args:
data:
min_depth:
depth:
Yields:
Any: Value
"""
if min_depth is None:
min_depth = depth
assert 1 <= min_depth <= depth
# Equivalent to dict.items()
try:
if depth == 1:
for v in data.values():
yield v
return
# Iter first depth
elif min_depth == 1:
q = deque()
for v in data.values():
if type(v) is dict:
q.append(v)
else:
yield v
# Iter target depth only
else:
q = deque()
for v in data.values():
if type(v) is dict:
q.append(v)
except AttributeError:
# `data` is not dict
return
# Iter depths
current = 2
while current <= depth:
new_q = deque()
# max depth
if current == depth:
for data in q:
for v in data.values():
yield v
# in target depth
elif min_depth <= current < depth:
for data in q:
for v in data.values():
if type(v) is dict:
new_q.append(v)
else:
yield v
# Haven't reached min depth
else:
for data in q:
for v in data.values():
if type(v) is dict:
new_q.append(v)
q = new_q
current += 1
def deep_iter_diff(before, after):
"""
Iter diff between 2 dict.
Pretty fast to compare 2 deeply nested dict,
time cost increases with the number of differences.
Args:
before:
after:
Yields:
list[str]: Key path
Any: Value in before, or None if not exists
Any: Value in after, or None if not exists
"""
if before == after:
return
if type(before) is not dict or type(after) is not dict:
yield [], before, after
return
queue = deque([([], before, after)])
while True:
new_queue = deque()
for path, d1, d2 in queue:
keys1 = set(d1.keys())
keys2 = set(d2.keys())
for key in keys1.union(keys2):
try:
val2 = d2[key]
except KeyError:
# Safe to access d1[key], because key came from the union of both
# If it's not in d2 then it's in d1
yield path + [key], d1[key], None
continue
try:
val1 = d1[key]
except KeyError:
yield path + [key], None, val2
continue
# Compare dict first, which is pretty fast
if val1 != val2:
if type(val1) is dict and type(val2) is dict:
new_queue.append((path + [key], val1, val2))
else:
yield path + [key], val1, val2
queue = new_queue
if not queue:
break
def deep_iter_patch(before, after):
"""
Iter patch event from before to after, like creating a json-patch
Pretty fast to compare 2 deeply nested dict,
time cost increases with the number of differences.
Args:
before:
after:
Yields:
str: OP_ADD, OP_SET, OP_DEL
list[str]: Key path
Any: Value in after,
or None of event is OP_DEL
"""
if before == after:
return
if type(before) is not dict or type(after) is not dict:
yield OP_SET, [], after
return
queue = deque([([], before, after)])
while True:
new_queue = deque()
for path, d1, d2 in queue:
keys1 = set(d1.keys())
keys2 = set(d2.keys())
for key in keys1.union(keys2):
try:
val2 = d2[key]
except KeyError:
yield OP_DEL, path + [key], None
continue
try:
val1 = d1[key]
except KeyError:
yield OP_ADD, path + [key], val2
continue
# Compare dict first, which is pretty fast
if val1 != val2:
if type(val1) is dict and type(val2) is dict:
new_queue.append((path + [key], val1, val2))
else:
yield OP_SET, path + [key], val2
queue = new_queue
if not queue:
break
+27 -5
View File
@@ -15,6 +15,10 @@
"Reward": {
"name": "Reward",
"help": ""
},
"Tool": {
"name": "Tools",
"help": ""
}
},
"Task": {
@@ -138,7 +142,9 @@
"aScreenCap_nc": "aScreenCap_nc",
"DroidCast": "DroidCast",
"DroidCast_raw": "DroidCast_raw",
"scrcpy": "scrcpy"
"scrcpy": "scrcpy",
"nemu_ipc": "nemu_ipc",
"ldopengl": "ldopengl"
},
"ControlMethod": {
"name": "Control Method",
@@ -169,6 +175,7 @@
"LDPlayer3": "LD Player 3",
"LDPlayer4": "LD Player 4",
"LDPlayer9": "LD Player 9",
"LDPlayer14": "LD Player 14",
"MuMuPlayer": "MuMu Player",
"MuMuPlayerX": "MuMu Player X",
"MuMuPlayer12": "MuMu Player 12",
@@ -226,6 +233,7 @@
"stay_there": "Stay There",
"goto_main": "Goto Main Page",
"close_game": "Close Game",
"close_emulator": "Close Emulator",
"exit_aas": "Exit AAS",
"exit_emulator": "Exit Emulator",
"exit_aas_emulator": "Exit AAS & Emulator",
@@ -502,7 +510,8 @@
"6": "06 - Overpass F",
"7": "07 - Overpass G",
"8": "08 - Overpass H",
"9": "09 - Overpass I"
"9": "09 - Overpass I",
"10": "10 - Overpass J"
},
"Count": {
"name": "Sweep X times",
@@ -526,7 +535,8 @@
"6": "06 - Abandoned Train F",
"7": "07 - Abandoned Train G",
"8": "08 - Abandoned Train H",
"9": "09 - Abandoned Train I"
"9": "09 - Abandoned Train I",
"10": "10 - Abandoned Train J"
},
"Count": {
"name": "Sweep X times",
@@ -550,7 +560,8 @@
"6": "06 - Besieged Classroom F",
"7": "07 - Besieged Classroom G",
"8": "08 - Besieged Classroom H",
"9": "09 - Besieged Classroom I"
"9": "09 - Besieged Classroom I",
"10": "10 - Besieged Classroom J"
},
"Count": {
"name": "Sweep X times",
@@ -846,6 +857,16 @@
"help": ""
}
},
"Momotalk": {
"_info": {
"name": "Momotalk",
"help": "Tools need to stop the scheduler and then run independently"
},
"Enable": {
"name": "",
"help": ""
}
},
"Gui": {
"Aside": {
"Install": "Install",
@@ -905,7 +926,8 @@
"MinutesAgo": "{time}min ago",
"HoursAgo": "{time}h ago",
"DaysAgo": "{time}d ago",
"LongTimeAgo": "long time ago"
"LongTimeAgo": "long time ago",
"EtaDays": "ETA {time}d"
},
"AddAlas": {
"PopupTitle": "Add new config",
+27 -5
View File
@@ -15,6 +15,10 @@
"Reward": {
"name": "收菜",
"help": ""
},
"Tool": {
"name": "工具",
"help": ""
}
},
"Task": {
@@ -138,7 +142,9 @@
"aScreenCap_nc": "aScreenCap_nc",
"DroidCast": "DroidCast",
"DroidCast_raw": "DroidCast_raw",
"scrcpy": "scrcpy"
"scrcpy": "scrcpy",
"nemu_ipc": "nemu_ipc",
"ldopengl": "ldopengl"
},
"ControlMethod": {
"name": "模拟器控制方案",
@@ -169,6 +175,7 @@
"LDPlayer3": "雷电模拟器3",
"LDPlayer4": "雷电模拟器4",
"LDPlayer9": "雷电模拟器9",
"LDPlayer14": "雷电模拟器14",
"MuMuPlayer": "MuMu模拟器",
"MuMuPlayerX": "MuMu模拟器X",
"MuMuPlayer12": "MuMu模拟器12",
@@ -226,6 +233,7 @@
"stay_there": "停在原处",
"goto_main": "前往主界面",
"close_game": "关闭游戏",
"close_emulator": "关闭模拟器",
"exit_aas": "关闭 AAS",
"exit_emulator": "关闭模拟器",
"exit_aas_emulator": "关闭 AAS 和模拟器",
@@ -502,7 +510,8 @@
"6": "06 - 高架公路 F",
"7": "07 - 高架公路 G",
"8": "08 - 高架公路 H",
"9": "09 - 高架公路 I"
"9": "09 - 高架公路 I",
"10": "10 - 高架公路 J"
},
"Count": {
"name": "扫荡次数",
@@ -526,7 +535,8 @@
"6": "06 - 被遗弃的列车 F",
"7": "07 - 被遗弃的列车 G",
"8": "08 - 被遗弃的列车 H",
"9": "09 - 被遗弃的列车 I"
"9": "09 - 被遗弃的列车 I",
"10": "10 - 被遗弃的列车 J"
},
"Count": {
"name": "扫荡次数",
@@ -550,7 +560,8 @@
"6": "06 - 被袭击的教室 F",
"7": "07 - 被袭击的教室 G",
"8": "08 - 被袭击的教室 H",
"9": "09 - 被袭击的教室 I"
"9": "09 - 被袭击的教室 I",
"10": "10 - 被袭击的教室 J"
},
"Count": {
"name": "扫荡次数",
@@ -846,6 +857,16 @@
"help": ""
}
},
"Momotalk": {
"_info": {
"name": "Momotalk",
"help": "工具需要停止调度器再单独运行"
},
"Enable": {
"name": "",
"help": ""
}
},
"Gui": {
"Aside": {
"Install": "安装",
@@ -905,7 +926,8 @@
"MinutesAgo": "{time}分钟前",
"HoursAgo": "{time}小时前",
"DaysAgo": "{time}天前",
"LongTimeAgo": "很久以前"
"LongTimeAgo": "很久以前",
"EtaDays": "剩余{time}天"
},
"AddAlas": {
"PopupTitle": "添加新配置",
+13 -5
View File
@@ -16,6 +16,11 @@ VALID_SERVER = {
}
VALID_PACKAGE = set(list(VALID_SERVER.values()))
DICT_PACKAGE_TO_ACTIVITY = {
'com.YostarJP.BlueArchive': 'com.yostarjp.bluearchive.MxUnityPlayerActivity',
'com.nexon.bluearchive': 'com.nexon.bluearchive.MxUnityPlayerActivity',
}
def set_lang(lang_: str):
"""
@@ -32,15 +37,18 @@ def set_lang(lang_: str):
release_resources()
def to_server(package_or_server: str) -> str:
def to_server(package_or_server: str, before: str = '') -> str:
"""
Convert package/server to server.
To unknown packages, consider they are a CN channel servers.
"""
# Can't distinguish different regions of oversea servers,
# assume it's 'OVERSEA-Asia'
# if package_or_server == 'com.HoYoverse.hkrpgoversea':
# return 'OVERSEA-Asia'
if package_or_server == 'com.nexon.bluearchive':
# Can't distinguish different regions of oversea servers, but we try to reuse old value
if before in ['OVERSEA-Asia', 'OVERSEA-America', 'OVERSEA-Korea', 'OVERSEA-TWHKMO', 'OVERSEA-Global']:
return before
else:
# otherwise assume it's 'OVERSEA-Asia'
return 'OVERSEA-Asia'
for key, value in VALID_SERVER.items():
if value == package_or_server:
+11 -1
View File
@@ -2,7 +2,8 @@ from datetime import datetime
from functools import cached_property as functools_cached_property
from module.base.decorator import cached_property
from module.config.utils import DEFAULT_TIME, deep_get, get_server_last_monday_update, get_server_last_update
from module.config.deep import deep_get
from module.config.utils import DEFAULT_TIME, get_server_last_monday_update, get_server_last_update
# from module.exception import ScriptError
@@ -134,6 +135,9 @@ class StoredExpiredAtMonday0400(StoredBase):
class StoredInt(StoredBase):
value = 0
def clear(self):
self.value = 0
class StoredCounter(StoredBase):
value = 0
@@ -148,6 +152,9 @@ class StoredCounter(StoredBase):
self.value = value
self.total = total
def clear(self):
self.value = 0
def to_counter(self) -> str:
return f'{self.value}/{self.total}'
@@ -157,6 +164,9 @@ class StoredCounter(StoredBase):
def get_remain(self) -> int:
return self.total - self.value
def add(self, value=1):
self.value += value
@cached_property
def _attrs(self) -> dict:
attrs = super()._attrs
+31 -145
View File
@@ -5,10 +5,9 @@ import string
from datetime import datetime, timedelta, timezone
import yaml
from filelock import FileLock
import module.config.server as server_
from module.config.atomicwrites import atomic_write
from deploy.Windows.atomic import atomic_read_bytes, atomic_read_text, atomic_write
LANGUAGES = ['zh-CN', 'en-US']
SERVER_TO_TIMEZONE = {
@@ -67,33 +66,23 @@ def read_file(file):
Returns:
dict, list:
"""
folder = os.path.dirname(file)
if not os.path.exists(folder):
os.mkdir(folder)
if not os.path.exists(file):
return {}
_, ext = os.path.splitext(file)
lock = FileLock(f"{file}.lock")
with lock:
print(f'read: {file}')
if ext == '.yaml':
with open(file, mode='r', encoding='utf-8') as f:
s = f.read()
data = list(yaml.safe_load_all(s))
if len(data) == 1:
data = data[0]
if not data:
data = {}
return data
elif ext == '.json':
with open(file, mode='r', encoding='utf-8') as f:
s = f.read()
return json.loads(s)
else:
print(f'Unsupported config file extension: {ext}')
print(f'read: {file}')
if file.endswith('.json'):
content = atomic_read_bytes(file)
if not content:
return {}
return json.loads(content)
elif file.endswith('.yaml'):
content = atomic_read_text(file)
data = list(yaml.safe_load_all(content))
if len(data) == 1:
data = data[0]
if not data:
data = {}
return data
else:
print(f'Unsupported config file extension: {file}')
return {}
def write_file(file, data):
@@ -104,28 +93,20 @@ def write_file(file, data):
file (str):
data (dict, list):
"""
folder = os.path.dirname(file)
if not os.path.exists(folder):
os.mkdir(folder)
_, ext = os.path.splitext(file)
lock = FileLock(f"{file}.lock")
with lock:
print(f'write: {file}')
if ext == '.yaml':
with atomic_write(file, overwrite=True, encoding='utf-8', newline='') as f:
if isinstance(data, list):
yaml.safe_dump_all(data, f, default_flow_style=False, encoding='utf-8', allow_unicode=True,
sort_keys=False)
else:
yaml.safe_dump(data, f, default_flow_style=False, encoding='utf-8', allow_unicode=True,
sort_keys=False)
elif ext == '.json':
with atomic_write(file, overwrite=True, encoding='utf-8', newline='') as f:
s = json.dumps(data, indent=2, ensure_ascii=False, sort_keys=False, default=str)
f.write(s)
print(f'write: {file}')
if file.endswith('.json'):
content = json.dumps(data, indent=2, ensure_ascii=False, sort_keys=False, default=str)
atomic_write(file, content)
elif file.endswith('.yaml'):
if isinstance(data, list):
content = yaml.safe_dump_all(
data, default_flow_style=False, encoding='utf-8', allow_unicode=True, sort_keys=False)
else:
print(f'Unsupported config file extension: {ext}')
content = yaml.safe_dump(
data, default_flow_style=False, encoding='utf-8', allow_unicode=True, sort_keys=False)
atomic_write(file, content)
else:
print(f'Unsupported config file extension: {file}')
def iter_folder(folder, is_dir=False, ext=None):
@@ -189,101 +170,6 @@ def alas_instance():
return out
def deep_get(d, keys, default=None):
"""
Get values in dictionary safely.
https://stackoverflow.com/questions/25833613/safe-method-to-get-value-of-nested-dictionary
Args:
d (dict):
keys (str, list): Such as `Scheduler.NextRun.value`
default: Default return if key not found.
Returns:
"""
if isinstance(keys, str):
keys = keys.split('.')
assert type(keys) is list
if d is None:
return default
if not keys:
return d
return deep_get(d.get(keys[0]), keys[1:], default)
def deep_set(d, keys, value):
"""
Set value into dictionary safely, imitating deep_get().
"""
if isinstance(keys, str):
keys = keys.split('.')
assert type(keys) is list
if not keys:
return value
if not isinstance(d, dict):
d = {}
d[keys[0]] = deep_set(d.get(keys[0], {}), keys[1:], value)
return d
def deep_pop(d, keys, default=None):
"""
Pop value from dictionary safely, imitating deep_get().
"""
if isinstance(keys, str):
keys = keys.split('.')
assert type(keys) is list
if not isinstance(d, dict):
return default
if not keys:
return default
elif len(keys) == 1:
return d.pop(keys[0], default)
return deep_pop(d.get(keys[0]), keys[1:], default)
def deep_default(d, keys, value):
"""
Set default value into dictionary safely, imitating deep_get().
Value is set only when the dict doesn't contain such keys.
"""
if isinstance(keys, str):
keys = keys.split('.')
assert type(keys) is list
if not keys:
if d:
return d
else:
return value
if not isinstance(d, dict):
d = {}
d[keys[0]] = deep_default(d.get(keys[0], {}), keys[1:], value)
return d
def deep_iter(data, depth=0, current_depth=1):
"""
Iter a dictionary safely.
Args:
data (dict):
depth (int): Maximum depth to iter
current_depth (int):
Returns:
list: Key path
Any:
"""
if isinstance(data, dict) \
and (depth and current_depth <= depth):
for key, value in data.items():
for child_path, child_value in deep_iter(value, depth=depth, current_depth=current_depth + 1):
yield [key] + child_path, child_value
else:
yield [], data
def parse_value(value, data):
"""
Convert a string to float, int, datetime, if possible.
@@ -543,7 +429,7 @@ def get_server_last_monday_update(daily_trigger):
Returns:
datetime.datetime
"""
update = get_server_next_update(daily_trigger)
update = get_server_last_update(daily_trigger)
diff = update.weekday()
update = update - timedelta(days=diff)
return update
+48 -21
View File
@@ -5,8 +5,7 @@ import numpy as np
from rich.table import Table
from rich.text import Text
from module.base.utils import float2str as float2str_
from module.base.utils import random_rectangle_point
from module.base.utils import float2str as float2str_, random_rectangle_point
from module.daemon.daemon_base import DaemonBase
from module.exception import RequestHumanTakeover
from module.logger import logger
@@ -38,7 +37,7 @@ class Benchmark(DaemonBase):
record = []
for n in range(1, self.TEST_TOTAL + 1):
start = time.time()
start = time.perf_counter()
try:
func(*args, **kwargs)
@@ -51,7 +50,7 @@ class Benchmark(DaemonBase):
logger.warning(f'Benchmark tests failed on func: {func.__name__}')
return 'Failed'
cost = time.time() - start
cost = time.perf_counter() - start
logger.attr(
f'{str(n).rjust(2, "0")}/{self.TEST_TOTAL}',
f'{float2str(cost)}'
@@ -68,17 +67,19 @@ class Benchmark(DaemonBase):
if not isinstance(cost, (float, int)):
return Text(cost, style="bold bright_red")
if cost < 0.10:
if cost < 0.025:
return Text('Insane Fast', style="bold bright_green")
if cost < 0.100:
return Text('Ultra Fast', style="bold bright_green")
if cost < 0.20:
if cost < 0.200:
return Text('Very Fast', style="bright_green")
if cost < 0.30:
if cost < 0.300:
return Text('Fast', style="green")
if cost < 0.50:
if cost < 0.500:
return Text('Medium', style="yellow")
if cost < 0.75:
if cost < 0.750:
return Text('Slow', style="red")
if cost < 1.00:
if cost < 1.000:
return Text('Very Slow', style="bright_red")
return Text('Ultra Slow', style="bold bright_red")
@@ -87,11 +88,11 @@ class Benchmark(DaemonBase):
if not isinstance(cost, (float, int)):
return Text(cost, style="bold bright_red")
if cost < 0.1:
if cost < 0.100:
return Text('Fast', style="bright_green")
if cost < 0.2:
if cost < 0.200:
return Text('Medium', style="yellow")
if cost < 0.4:
if cost < 0.400:
return Text('Slow', style="red")
return Text('Very Slow', style="bright_red")
@@ -162,22 +163,27 @@ class Benchmark(DaemonBase):
if click_result:
self.show(test='Control', data=click_result, evaluate_func=self.evaluate_click)
fastest = sorted(click_result, key=lambda item: compare(item))[0]
# Prefer MaaTouch if both minitouch and MaaTouch are fastest
if 'MaaTouch' in click and fastest[0] == 'minitouch':
fastest[0] = 'MaaTouch'
logger.info(f'Recommend control method: {fastest[0]} ({float2str(fastest[1])})')
fastest_click = fastest[0]
return fastest_screenshot, fastest_click
def get_test_methods(self) -> t.Tuple[t.Tuple[str], t.Tuple[str]]:
device = self.config.Benchmark_DeviceType
# device == 'emulator'
# device = self.config.Benchmark_DeviceType
device = 'emulator'
screenshot = ['ADB', 'ADB_nc', 'uiautomator2', 'aScreenCap', 'aScreenCap_nc', 'DroidCast', 'DroidCast_raw']
click = ['ADB', 'uiautomator2', 'minitouch']
click = ['ADB', 'uiautomator2', 'minitouch', 'maatouch']
def remove(*args):
return [l for l in screenshot if l not in args]
# No ascreencap on Android > 9
if device in ['emulator_android_12', 'android_phone_12']:
sdk = self.device.sdk_ver
logger.info(f'sdk_ver: {sdk}')
if not (21 <= sdk <= 28):
screenshot = remove('aScreenCap', 'aScreenCap_nc')
# No nc loopback
if device in ['plone_cloud_with_adb']:
@@ -186,8 +192,16 @@ class Benchmark(DaemonBase):
if device == 'android_phone_vmos':
screenshot = ['ADB', 'aScreenCap', 'DroidCast', 'DroidCast_raw']
click = ['ADB', 'Hermit', 'MaaTouch']
# Droidcast on SDK 23 (Android 6.0) to SDK 32 (Android 12)
if not (23 <= sdk <= 32):
screenshot = remove('DroidCast', 'DroidCast_raw')
scene = self.config.Benchmark_TestScene
if self.device.nemu_ipc_available():
screenshot.append('nemu_ipc')
if self.device.ldopengl_available():
screenshot.append('ldopengl')
scene = 'screenshot_click'
if 'screenshot' not in scene:
screenshot = []
if 'click' not in scene:
@@ -203,8 +217,8 @@ class Benchmark(DaemonBase):
logger.critical('Request human takeover')
return
logger.attr('DeviceType', self.config.Benchmark_DeviceType)
logger.attr('TestScene', self.config.Benchmark_TestScene)
# logger.attr('DeviceType', self.config.Benchmark_DeviceType)
# logger.attr('TestScene', self.config.Benchmark_TestScene)
screenshot, click = self.get_test_methods()
self.benchmark(screenshot, click)
@@ -224,6 +238,10 @@ class Benchmark(DaemonBase):
screenshot = remove('aScreenCap', 'aScreenCap_nc')
if self.device.is_chinac_phone_cloud:
screenshot = remove('ADB_nc', 'aScreenCap_nc')
if self.device.nemu_ipc_available():
screenshot.append('nemu_ipc')
if self.device.ldopengl_available():
screenshot.append('ldopengl')
screenshot = tuple(screenshot)
self.TEST_TOTAL = 3
@@ -233,6 +251,15 @@ class Benchmark(DaemonBase):
return method
def run_benchmark(config):
try:
Benchmark(config, task='Benchmark').run()
return True
except RequestHumanTakeover:
logger.critical('Request human takeover')
return False
if __name__ == '__main__':
b = Benchmark('alas', task='Benchmark')
b = Benchmark('src', task='Benchmark')
b.run()
+33 -7
View File
@@ -1,9 +1,11 @@
from lxml import etree
from module.base.timer import Timer
from module.device.method.adb import Adb
from module.device.method.uiautomator_2 import Uiautomator2
from module.device.method.utils import HierarchyButton
from module.device.method.wsa import WSA
from module.exception import ScriptError
from module.logger import logger
@@ -12,8 +14,9 @@ class AppControl(Adb, WSA, Uiautomator2):
# Use ADB for all
# See https://github.com/openatx/uiautomator2/issues/565
_app_u2_family = []
_hierarchy_interval = Timer(0.1)
def app_is_running(self) -> bool:
def app_current(self) -> str:
method = self.config.Emulator_ControlMethod
if self.is_wsa:
package = self.app_current_wsa()
@@ -21,8 +24,11 @@ class AppControl(Adb, WSA, Uiautomator2):
package = self.app_current_uiautomator2()
else:
package = self.app_current_adb()
package = package.strip(' \t\r\n')
return package
def app_is_running(self) -> bool:
package = self.app_current()
logger.attr('Package_name', package)
return package == self.package
@@ -44,16 +50,36 @@ class AppControl(Adb, WSA, Uiautomator2):
else:
self.app_stop_adb()
def hierarchy_timer_set(self, interval=None):
if interval is None:
interval = 0.1
elif isinstance(interval, (int, float)):
# No limitation for manual set in code
pass
else:
logger.warning(f'Unknown hierarchy interval: {interval}')
raise ScriptError(f'Unknown hierarchy interval: {interval}')
if interval != self._hierarchy_interval.limit:
logger.info(f'Hierarchy interval set to {interval}s')
self._hierarchy_interval.limit = interval
def dump_hierarchy(self) -> etree._Element:
"""
Returns:
etree._Element: Select elements with `self.hierarchy.xpath('//*[@text="Hermit"]')` for example.
"""
method = self.config.Emulator_ControlMethod
if method in AppControl._app_u2_family:
self.hierarchy = self.dump_hierarchy_uiautomator2()
else:
self.hierarchy = self.dump_hierarchy_adb()
self._hierarchy_interval.wait()
self._hierarchy_interval.reset()
# method = self.config.Emulator_ControlMethod
# if method in AppControl._app_u2_family:
# self.hierarchy = self.dump_hierarchy_uiautomator2()
# else:
# self.hierarchy = self.dump_hierarchy_adb()
# Using uiautomator2
self.hierarchy = self.dump_hierarchy_uiautomator2()
return self.hierarchy
def xpath_to_button(self, xpath: str) -> HierarchyButton:
+465 -87
View File
@@ -1,6 +1,6 @@
import ipaddress
import json
import logging
import platform
import re
import socket
import subprocess
@@ -12,12 +12,17 @@ from adbutils import AdbClient, AdbDevice, AdbTimeout, ForwardItem, ReverseItem
from adbutils.errors import AdbError
import module.config.server as server_
from module.base.decorator import Config, cached_property, del_cached_property
from module.base.decorator import Config, cached_property, del_cached_property, run_once
from module.base.timer import Timer
from module.base.utils import SelectedGrids, ensure_time
from module.config.deep import deep_get
from module.device.connection_attr import ConnectionAttr
from module.device.method.utils import (
PackageNotInstalled, RETRY_TRIES, get_serial_pair, handle_adb_error,
possible_reasons, random_port, recv_all, remove_shell_warning, retry_sleep)
from module.device.env import IS_LINUX, IS_MACINTOSH, IS_WINDOWS
from module.device.method.pool import WORKER_POOL
from module.device.method.remove_warning import remove_shell_warning
from module.device.method.utils import (PackageNotInstalled, RETRY_TRIES, get_serial_pair, handle_adb_error,
handle_unknown_host_service, possible_reasons, random_port, recv_all,
retry_sleep)
from module.exception import EmulatorNotRunningError, RequestHumanTakeover
from module.logger import logger
@@ -33,7 +38,7 @@ def retry(func):
for _ in range(RETRY_TRIES):
try:
if callable(init):
retry_sleep(_)
time.sleep(retry_sleep(_))
init()
return func(self, *args, **kwargs)
# Can't handle
@@ -50,6 +55,10 @@ def retry(func):
if handle_adb_error(e):
def init():
self.adb_reconnect()
elif handle_unknown_host_service(e):
def init():
self.adb_start_server()
self.adb_reconnect()
else:
break
# Package not installed
@@ -84,6 +93,18 @@ class AdbDeviceWithStatus(AdbDevice):
def __bool__(self):
return True
@cached_property
def port(self) -> int:
try:
return int(self.serial.split(':')[1])
except (IndexError, ValueError):
return 0
@cached_property
def may_mumu12_family(self):
# 127.0.0.1:16XXX
return 16384 <= self.port <= 17408
class Connection(ConnectionAttr):
def __init__(self, config):
@@ -96,7 +117,7 @@ class Connection(ConnectionAttr):
self.detect_device()
# Connect
self.adb_connect(self.serial)
self.adb_connect(wait_device=False)
logger.attr('AdbDevice', self.adb)
# Package
@@ -130,8 +151,18 @@ class Connection(ConnectionAttr):
"""
cmd = list(map(str, cmd))
cmd = [self.adb_binary, '-s', self.serial] + cmd
logger.info(f'Execute: {cmd}')
return self.subprocess_run(cmd, timeout=timeout)
def subprocess_run(self, cmd, timeout=10):
"""
Args:
cmd (list):
timeout (int):
Returns:
str:
"""
logger.info(f'Execute: {cmd}')
# Use shell=True to disable console window when using GUI.
# Although, there's still a window when you stop running in GUI, which cause by gooey.
# To disable it, edit gooey/gui/util/taskkill.py
@@ -148,11 +179,21 @@ class Connection(ConnectionAttr):
@Config.when(DEVICE_OVER_HTTP=True)
def adb_command(self, cmd, timeout=10):
logger.warning(
f'adb_command() is not available when connecting over http: {self.serial}, '
logger.critical(
f'Trying to execute {cmd}, '
f'but adb_command() is not available when connecting over http: {self.serial}, '
)
raise RequestHumanTakeover
def adb_start_server(self):
"""
Use `adb devices` as `adb start-server`, result is actually useless
Start ADB using subprocess instead of connecting via socket to kill the other ADBs
"""
stdout = self.subprocess_run([self.adb_binary, 'devices'])
logger.info(stdout)
return stdout
@Config.when(DEVICE_OVER_HTTP=False)
def adb_shell(self, cmd, stream=False, recvall=True, timeout=10, rstrip=True):
"""
@@ -233,6 +274,7 @@ class Connection(ConnectionAttr):
return self.adb_shell(['getprop', name]).strip()
@cached_property
@retry
def cpu_abi(self) -> str:
"""
Returns:
@@ -244,6 +286,7 @@ class Connection(ConnectionAttr):
return abi
@cached_property
@retry
def sdk_ver(self) -> int:
"""
Android SDK/API levels, see https://apilevels.com/
@@ -257,6 +300,7 @@ class Connection(ConnectionAttr):
return 0
@cached_property
@retry
def is_avd(self):
if get_serial_pair(self.serial)[0] is None:
return False
@@ -266,14 +310,72 @@ class Connection(ConnectionAttr):
return True
return False
@cached_property
@retry
def is_waydroid(self):
res = self.adb_getprop('ro.product.brand')
logger.attr('ro.product.brand', res)
return 'waydroid' in res.lower()
@cached_property
@retry
def is_bluestacks_air(self):
# BlueStacks Air is the Mac version of BlueStacks
if not IS_MACINTOSH:
return False
# 127.0.0.1:5555 + 10*n, assume 32 instances at max
if not (5555 <= self.port <= 5875):
return False
# [bst.installed_images]: [Tiramisu64]
# [bst.instance]: [Tiramisu64]
# Tiramisu64 is Android 13 and BlueStacks Air is the only BlueStacks version that uses Android 13
res = self.adb_getprop('bst.installed_images')
logger.attr('bst.installed_images', res)
if 'Tiramisu64' in res:
return True
return False
@cached_property
@retry
def is_mumu_pro(self):
# MuMU Pro is the Mac version of MuMu
if not IS_MACINTOSH:
return False
if not self.is_mumu_family:
return False
logger.attr('is_mumu_pro', True)
return True
@cached_property
@retry
def nemud_app_keep_alive(self) -> str:
res = self.adb_getprop('nemud.app_keep_alive')
logger.attr('nemud.app_keep_alive', res)
return res
@cached_property
@retry
def nemud_player_version(self) -> str:
# [nemud.player_product_version]: [3.8.27.2950]
res = self.adb_getprop('nemud.player_version')
logger.attr('nemud.player_version', res)
return res
@cached_property
@retry
def nemud_player_engine(self) -> str:
# NEMUX or MACPRO
res = self.adb_getprop('nemud.player_engine')
logger.attr('nemud.player_engine', res)
return res
def check_mumu_app_keep_alive(self):
if not self.is_mumu_family:
return False
res = self.adb_getprop('nemud.app_keep_alive')
logger.attr('nemud.app_keep_alive', res)
res = self.nemud_app_keep_alive
if res == '':
# Empry property, might not be a mumu emulator or might be an old mumu
# Empty property, probably MuMu6 or MuMu12 version < 3.5.6
return True
elif res == 'false':
# Disabled
@@ -286,6 +388,34 @@ class Connection(ConnectionAttr):
logger.warning(f'Invalid nemud.app_keep_alive value: {res}')
return False
@cached_property
def is_mumu_over_version_400(self) -> bool:
if not self.is_mumu_family:
return False
# >= 4.0 has no info in getprop
if self.nemud_player_version == '':
return True
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
MuMu PRO on mac has the same feature
"""
if not self.is_mumu_family:
return False
if self.is_mumu_over_version_400:
return True
if self.nemud_app_keep_alive != '':
return True
if IS_MACINTOSH:
if 'MACPRO' in self.nemud_player_engine:
return True
return False
@cached_property
def _nc_server_host_port(self):
"""
@@ -301,21 +431,26 @@ class Connection(ConnectionAttr):
return host, port, host, self.config.REVERSE_SERVER_PORT
# For emulators, listen on current host
if self.is_emulator or self.is_over_http:
# Mac emulators
if self.is_bluestacks_air or self.is_mumu_pro:
logger.info(f'Connecting to local emulator, using host 127.0.0.1')
port = random_port(self.config.FORWARD_PORT_RANGE)
return '127.0.0.1', port, "10.0.2.2", port
# Get host IP
try:
host = socket.gethostbyname(socket.gethostname())
except socket.gaierror as e:
logger.error(e)
logger.error(f'Unknown host name: {socket.gethostname()}')
host = '127.0.0.1'
if platform.system() == 'Linux' and host == '127.0.1.1':
# Fixup linux AVD host
if IS_LINUX and host == '127.0.1.1':
host = '127.0.0.1'
logger.info(f'Connecting to local emulator, using host {host}')
port = random_port(self.config.FORWARD_PORT_RANGE)
# For AVD instance
if self.is_avd:
return host, port, "10.0.2.2", port
return host, port, host, port
# For local network devices, listen on the host under the same network as target device
if self.is_network_device:
@@ -355,15 +490,21 @@ class Connection(ConnectionAttr):
Returns:
list[str]: ['nc'] or ['busybox', 'nc']
"""
sdk = self.sdk_ver
logger.info(f'sdk_ver: {sdk}')
if sdk >= 28:
# Android 9 emulators does not have `nc`, try `busybox nc`
# BlueStacks Pie (Android 9) has `nc` but cannot send data, try `busybox nc` first
trial = [
['busybox', 'nc'],
['nc'],
]
if self.is_emulator:
sdk = self.sdk_ver
logger.info(f'sdk_ver: {sdk}')
if sdk >= 28:
# LD Player 9 does not have `nc`, try `busybox nc`
# BlueStacks Pie (Android 9) has `nc` but cannot send data, try `busybox nc` first
trial = [
['busybox', 'nc'],
['nc'],
]
else:
trial = [
['nc'],
['busybox', 'nc'],
]
else:
trial = [
['nc'],
@@ -371,8 +512,9 @@ class Connection(ConnectionAttr):
]
for command in trial:
# About 3ms
result = self.adb_shell(command)
# Result should be command help if success
# nc: bad argument count (see "nc --help")
result = self.adb_shell(command)
# `/system/bin/sh: nc: not found`
if 'not found' in result:
continue
@@ -459,6 +601,22 @@ class Connection(ConnectionAttr):
self.adb.forward(forward.local, forward.remote)
return port
def _adb_reverse_transport(self, remote: str, local: str, norebind: bool = False):
"""
Backport fixes from https://github.com/openatx/adbutils/pull/116
Don't use self.adb.reverse(), use this method.
"""
args = ["reverse:forward"]
if norebind:
args.append("norebind")
args.append(remote + ";" + local)
cmd = ":".join(args)
with self.adb_client._connect() as c:
c.send_command(f'host:transport:{self.serial}')
c.check_okay()
c.send_command(cmd)
c.check_okay()
def adb_reverse(self, remote):
port = 0
for reverse in self.adb.reverse_list():
@@ -468,45 +626,66 @@ class Connection(ConnectionAttr):
port = int(reverse.local[4:])
else:
logger.info(f'Remove redundant forward: {reverse}')
self.adb_forward_remove(reverse.local)
self.adb_reverse_remove(reverse.remote)
if port:
return port
else:
# Create new reverse
port = random_port(self.config.FORWARD_PORT_RANGE)
reverse = ReverseItem(f'tcp:{port}', remote)
reverse = ReverseItem(remote, f'tcp:{port}')
logger.info(f'Create reverse: {reverse}')
self.adb.reverse(reverse.local, reverse.remote)
self._adb_reverse_transport(reverse.remote, reverse.local)
return port
def adb_forward_remove(self, local):
"""
Equivalent to `adb -s <serial> forward --remove <local>`
No error raised when removing a non-existent forward
More about the commands send to ADB server, see:
https://cs.android.com/android/platform/superproject/+/master:packages/modules/adb/SERVICES.TXT
Args:
local (str): Such as 'tcp:2437'
"""
with self.adb_client._connect() as c:
list_cmd = f"host-serial:{self.serial}:killforward:{local}"
c.send_command(list_cmd)
c.check_okay()
try:
with self.adb_client._connect() as c:
list_cmd = f"host-serial:{self.serial}:killforward:{local}"
c.send_command(list_cmd)
c.check_okay()
except AdbError as e:
# No error raised when removing a non-existed forward
# adbutils.errors.AdbError: listener 'tcp:8888' not found
msg = str(e)
if re.search(r'listener .*? not found', msg):
logger.warning(f'{type(e).__name__}: {msg}')
else:
raise
def adb_reverse_remove(self, local):
"""
Equivalent to `adb -s <serial> reverse --remove <local>`
No error raised when removing a non-existent reverse
Args:
local (str): Such as 'tcp:2437'
"""
with self.adb_client._connect() as c:
c.send_command(f"host:transport:{self.serial}")
c.check_okay()
list_cmd = f"reverse:killforward:{local}"
c.send_command(list_cmd)
c.check_okay()
try:
with self.adb_client._connect() as c:
c.send_command(f"host:transport:{self.serial}")
c.check_okay()
list_cmd = f"reverse:killforward:{local}"
c.send_command(list_cmd)
c.check_okay()
except AdbError as e:
# No error raised when removing a non-existed forward
# adbutils.errors.AdbError: listener 'tcp:8888' not found
msg = str(e)
if re.search(r'listener .*? not found', msg):
logger.warning(f'{type(e).__name__}: {msg}')
else:
raise
def adb_push(self, local, remote):
"""
@@ -520,8 +699,40 @@ class Connection(ConnectionAttr):
cmd = ['push', local, remote]
return self.adb_command(cmd)
def _wait_device_appear(self, serial, first_devices=None):
"""
Args:
serial:
first_devices (list[AdbDeviceWithStatus]):
Returns:
bool: If appear
"""
# Wait a little longer than 5s
timeout = Timer(5.2).start()
first_log = True
while 1:
if first_devices is not None:
devices = first_devices
first_devices = None
else:
devices = self.list_device()
# Check if device appear
for device in devices:
if device.serial == serial and device.status == 'device':
return True
# Delay and check later
if timeout.reached():
break
if first_log:
logger.info(f'Waiting device appear: {serial}')
first_log = False
time.sleep(0.05)
return False
@Config.when(DEVICE_OVER_HTTP=False)
def adb_connect(self, serial):
def adb_connect(self, wait_device=True):
"""
Connect to a serial, try 3 times at max.
If there's an old ADB server running while Alas is using a newer one, which happens on Chinese emulators,
@@ -529,70 +740,150 @@ class Connection(ConnectionAttr):
Args:
serial (str):
wait_device: True to wait emulator-* and android devices appear
Returns:
bool: If success
"""
# Disconnect offline device before connecting
for device in self.list_device():
devices = self.list_device()
for device in devices:
if device.status == 'offline':
logger.warning(f'Device {serial} is offline, disconnect it before connecting')
self.adb_disconnect(serial)
logger.warning(f'Device {device.serial} is offline, disconnect it before connecting')
msg = self.adb_client.disconnect(device.serial)
if msg:
logger.info(msg)
elif device.status == 'unauthorized':
logger.error(f'Device {serial} is unauthorized, please accept ADB debugging on your device')
logger.error(f'Device {device.serial} is unauthorized, please accept ADB debugging on your device')
elif device.status == 'device':
pass
else:
logger.warning(f'Device {serial} is is having a unknown status: {device.status}')
logger.warning(f'Device {device.serial} is is having a unknown status: {device.status}')
# Skip for emulator-5554
if 'emulator-' in serial:
logger.info(f'"{serial}" is a `emulator-*` serial, skip adb connect')
# Skip connecting emulator-5554 and android phones, as they should be auto connected once plugged in
if 'emulator-' in self.serial:
if wait_device:
if self._wait_device_appear(self.serial, first_devices=devices):
logger.info(f'Serial {self.serial} connected')
return True
else:
logger.info(f'Serial {self.serial} is not connected')
logger.info(f'"{self.serial}" is a `emulator-*` serial, skip adb connect')
return True
if re.match(r'^[a-zA-Z0-9]+$', serial):
logger.info(f'"{serial}" seems to be a Android serial, skip adb connect')
if re.match(r'^[a-zA-Z0-9]+$', self.serial):
if wait_device:
if self._wait_device_appear(self.serial, first_devices=devices):
logger.info(f'Serial {self.serial} connected')
return True
else:
logger.info(f'Serial {self.serial} is not connected')
logger.info(f'"{self.serial}" seems to be a Android serial, skip adb connect')
return True
# Try to connect
for _ in range(3):
msg = self.adb_client.connect(serial)
msg = self.adb_client.connect(self.serial)
logger.info(msg)
# Connected to 127.0.0.1:59865
# Already connected to 127.0.0.1:59865
if 'connected' in msg:
# Connected to 127.0.0.1:59865
# Already connected to 127.0.0.1:59865
return True
# bad port number '598265' in '127.0.0.1:598265'
elif 'bad port' in msg:
# bad port number '598265' in '127.0.0.1:598265'
logger.error(msg)
possible_reasons('Serial incorrect, might be a typo')
raise RequestHumanTakeover
# cannot connect to 127.0.0.1:55555:
# No connection could be made because the target machine actively refused it. (10061)
elif '(10061)' in msg:
# cannot connect to 127.0.0.1:55555:
# No connection could be made because the target machine actively refused it. (10061)
logger.info(msg)
# MuMu12 may switch serial if port is occupied
# Brute force connect nearby ports to handle serial switches
if self.is_mumu12_family:
before = self.serial
serial_list = [self.serial.replace(str(self.port), str(self.port + offset))
for offset in [1, -1, 2, -2]]
self.adb_brute_force_connect(serial_list)
self.detect_device()
if self.serial != before:
return True
run_once(self.check_mumu_bridge_network)()
# No such device
logger.warning('No such device exists, please restart the emulator or set a correct serial')
raise EmulatorNotRunningError
# Failed to connect
logger.warning(f'Failed to connect {serial} after 3 trial, assume connected')
logger.warning(f'Failed to connect {self.serial} after 3 trial, assume connected')
self.detect_device()
return False
def adb_brute_force_connect(self, serial_list):
"""
Args:
serial_list (list[str]):
"""
def connect(s):
try:
msg = self.adb_client.connect(s)
except Exception:
return ''
logger.info(msg)
return msg
with WORKER_POOL.wait_jobs() as pool:
for serial in serial_list:
pool.start_thread_soon(connect, serial)
def check_mumu_bridge_network(self):
"""
Returns:
bool: True if success to check, False if check is skipped
"""
if not self.is_mumu12_family:
return True
if not hasattr(self, 'find_emulator_instance'):
return False
# Assume PlatformBase inherited this class
instance = self.find_emulator_instance(
serial=self.serial,
)
if instance is None:
logger.warning(f'Failed to check check_mumu_bridge_network, emulator instance not found')
return False
file = instance.mumu_vms_config('customer_config.json')
try:
with open(file, mode='r', encoding='utf-8') as f:
s = f.read()
data = json.loads(s)
except FileNotFoundError:
logger.warning(f'Failed to check check_mumu_bridge_network, file {file} not exists')
return False
value = deep_get(data, keys='customer.network_bridge_opened', default=None)
logger.attr('customer.network_bridge_opened', value)
if str(value).lower() == 'true':
logger.critical('Please turn off "Network Bridging" in the settings of MuMuPlayer')
logger.critical('请在MuMU模拟器设置中关闭 网络桥接')
raise RequestHumanTakeover
return True
@Config.when(DEVICE_OVER_HTTP=True)
def adb_connect(self, serial):
def adb_connect(self, wait_device=True):
# No adb connect if over http
return True
def adb_disconnect(self, serial):
msg = self.adb_client.disconnect(serial)
if msg:
logger.info(msg)
def release_resource(self):
del_cached_property(self, 'hermit_session')
del_cached_property(self, 'droidcast_session')
del_cached_property(self, 'minitouch_builder')
del_cached_property(self, '_minitouch_builder')
del_cached_property(self, '_maatouch_builder')
del_cached_property(self, 'reverse_server')
def adb_disconnect(self):
msg = self.adb_client.disconnect(self.serial)
if msg:
logger.info(msg)
self.release_resource()
def adb_restart(self):
"""
Reboot adb client
@@ -602,6 +893,7 @@ class Connection(ConnectionAttr):
self.adb_client.server_kill()
# Init adb client
del_cached_property(self, 'adb_client')
self.release_resource()
_ = self.adb_client
@Config.when(DEVICE_OVER_HTTP=False)
@@ -613,11 +905,11 @@ class Connection(ConnectionAttr):
# Restart Adb
self.adb_restart()
# Connect to device
self.adb_connect(self.serial)
self.adb_connect()
self.detect_device()
else:
self.adb_disconnect(self.serial)
self.adb_connect(self.serial)
self.adb_disconnect()
self.adb_connect()
self.detect_device()
@Config.when(DEVICE_OVER_HTTP=True)
@@ -751,23 +1043,45 @@ class Connection(ConnectionAttr):
If serial=='auto' and only 1 device detected, use it
"""
logger.hr('Detect device')
logger.info('Here are the available devices, '
'copy to Alas.Emulator.Serial to use it or set Alas.Emulator.Serial="auto"')
devices = self.list_device()
available = SelectedGrids([])
devices = SelectedGrids([])
# Show available devices
available = devices.select(status='device')
for device in available:
logger.info(device.serial)
if not len(available):
logger.info('No available devices')
@run_once
def brute_force_connect():
logger.info('Brute force connect')
from deploy.Windows.emulator import EmulatorManager
manager = EmulatorManager()
manager.brute_force_connect()
# Show unavailable devices if having any
unavailable = devices.delete(available)
if len(unavailable):
logger.info('Here are the devices detected but unavailable')
for device in unavailable:
logger.info(f'{device.serial} ({device.status})')
for _ in range(2):
logger.info('Here are the available devices, '
'copy to Alas.Emulator.Serial to use it or set Alas.Emulator.Serial="auto"')
devices = self.list_device()
# Show available devices
available = devices.select(status='device')
for device in available:
logger.info(device.serial)
if not len(available):
logger.info('No available devices')
# Show unavailable devices if having any
unavailable = devices.delete(available)
if len(unavailable):
logger.info('Here are the devices detected but unavailable')
for device in unavailable:
logger.info(f'{device.serial} ({device.status})')
# brute_force_connect
if self.config.Emulator_Serial == 'auto' and available.count == 0:
logger.warning(f'No available device found')
if IS_WINDOWS:
brute_force_connect()
continue
else:
break
else:
break
# Auto device detection
if self.config.Emulator_Serial == 'auto':
@@ -777,7 +1091,16 @@ class Connection(ConnectionAttr):
raise RequestHumanTakeover
elif available.count == 1:
logger.info(f'Auto device detection found only one device, using it')
self.serial = devices[0].serial
self.config.Emulator_Serial = self.serial = available[0].serial
del_cached_property(self, 'adb')
elif available.count == 2 \
and available.select(serial='127.0.0.1:7555') \
and available.select(may_mumu12_family=True):
logger.info(f'Auto device detection found MuMu12 device, using it')
# For MuMu12 serials like 127.0.0.1:7555 and 127.0.0.1:16384
# ignore 7555 use 16384
remain = available.select(may_mumu12_family=True).first_or_none()
self.config.Emulator_Serial = self.serial = remain.serial
del_cached_property(self, 'adb')
else:
logger.critical('Multiple devices found, auto device detection cannot decide which to choose, '
@@ -786,6 +1109,7 @@ class Connection(ConnectionAttr):
# Handle LDPlayer
# LDPlayer serial jumps between `127.0.0.1:5555+{X}` and `emulator-5554+{X}`
# No config write since it's dynamic
port_serial, emu_serial = get_serial_pair(self.serial)
if port_serial and emu_serial:
# Might be LDPlayer, check connected devices
@@ -812,6 +1136,58 @@ class Connection(ConnectionAttr):
f'Using serial: {emu_serial}')
self.serial = emu_serial
# Redirect MuMu12 from 127.0.0.1:7555 to 127.0.0.1:16xxx
if self.serial == '127.0.0.1:7555':
for _ in range(2):
mumu12 = available.select(may_mumu12_family=True)
if mumu12.count == 1:
emu_serial = mumu12.first_or_none().serial
logger.warning(f'Redirect MuMu12 {self.serial} to {emu_serial}')
self.config.Emulator_Serial = self.serial = emu_serial
break
elif mumu12.count >= 2:
logger.warning(f'Multiple MuMu12 serial found, cannot redirect')
break
else:
# Only 127.0.0.1:7555
if self.is_mumu_over_version_356:
# is_mumu_over_version_356 and nemud_app_keep_alive was cached
# Acceptable since it's the same device
logger.warning(f'Device {self.serial} is MuMu12 but corresponding port not found')
if IS_WINDOWS:
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 serial switched {self.serial} -> {device.serial}')
del_cached_property(self, 'port')
del_cached_property(self, 'is_mumu12_family')
del_cached_property(self, 'is_mumu_family')
self.serial = device.serial
break
@retry
def list_package(self, show_log=True):
"""
@@ -833,7 +1209,7 @@ class Connection(ConnectionAttr):
packages = re.findall(r'package:([^\s]+)', output)
return packages
def list_azurlane_packages(self, show_log=True):
def list_known_packages(self, show_log=True):
"""
Args:
show_log:
@@ -850,7 +1226,7 @@ class Connection(ConnectionAttr):
Show all possible packages with the given keyword on this device.
"""
logger.hr('Detect package')
packages = self.list_azurlane_packages()
packages = self.list_known_packages()
# Show packages
logger.info(f'Here are the available packages in device "{self.serial}", '
@@ -871,10 +1247,12 @@ class Connection(ConnectionAttr):
self.package = packages[0]
# Set config
if set_config:
self.config.Emulator_PackageName = server_.to_server(self.package)
self.config.Emulator_PackageName = server_.to_server(
self.package, before=self.config.Emulator_PackageName)
# Set server
# logger.info('Server changed, release resources')
# set_server(self.package)
return
else:
logger.critical(
f'Multiple Blue Archive packages found, auto package detection cannot decide which to choose, '
+66 -12
View File
@@ -7,7 +7,7 @@ from adbutils import AdbClient, AdbDevice
from module.base.decorator import cached_property
from module.config.config import AzurLaneConfig
from module.config.utils import deep_iter
from module.device.method.utils import get_serial_pair
from module.exception import RequestHumanTakeover
from module.logger import logger
@@ -49,27 +49,47 @@ class ConnectionAttr:
self.serial_check()
self.config.DEVICE_OVER_HTTP = self.is_over_http
@staticmethod
def revise_serial(serial):
serial = serial.replace(' ', '')
def revise_serial(serial: str):
"""
Tons of fool-proof fixes to handle manual serial input
To load a serial:
serial = SerialStr.revise_serial(serial)
"""
serial = serial.strip().replace(' ', '')
# 127。0。0。1:5555
serial = serial.replace('。', '.').replace(',', '.').replace(',', '.').replace(':', ':')
# 127.0.0.1.5555
serial = serial.replace('127.0.0.1.', '127.0.0.1:')
# 5555,16384 (actually "5555.16384" because replace(',', '.'))
if '.' in serial:
left, _, right = serial.partition('.')
try:
left = int(left)
right = int(right)
if 5500 < left < 6000 and 16300 < right < 20000:
serial = str(right)
except ValueError:
pass
# 16384
try:
port = int(serial)
if 1000 < port < 65536:
serial = f'127.0.0.1:{port}'
except ValueError:
pass
if serial.isdigit():
try:
port = int(serial)
if 1000 < port < 65536:
serial = f'127.0.0.1:{port}'
except ValueError:
pass
# 夜神模拟器 127.0.0.1:62001
# MuMu模拟器12127.0.0.1:16384
if '模拟' in serial:
import re
res = re.search(r'(127\.\d+\.\d+\.\d+:\d+)', serial)
if res:
serial = res.group(1)
# 12127.0.0.1:16384
serial = serial.replace('12127.0.0.1', '127.0.0.1')
# auto127.0.0.1:16384
serial = serial.replace('auto127.0.0.1', '127.0.0.1').replace('autoemulator', 'emulator')
return str(serial)
def serial_check(self):
@@ -123,11 +143,40 @@ class ConnectionAttr:
def is_wsa(self):
return bool(re.match(r'^wsa', self.serial))
@cached_property
def port(self) -> int:
port_serial, _ = get_serial_pair(self.serial)
if port_serial is None:
port_serial = self.serial
try:
return int(port_serial.split(':')[1])
except (IndexError, ValueError):
return 0
@cached_property
def is_mumu12_family(self):
# 127.0.0.1:16384 + 32*n, assume 32 instances at max
return 16384 <= self.port <= 17408
@cached_property
def is_mumu_family(self):
# 127.0.0.1:7555
# 127.0.0.1:16384 + 32*n
return self.serial == '127.0.0.1:7555' or self.serial.startswith('127.0.0.1:16')
return self.serial == '127.0.0.1:7555' or self.is_mumu12_family
@cached_property
def is_ldplayer_bluestacks_family(self):
# Note that LDPlayer and BlueStacks have the same serial range
# 127.0.0.1:5555 + 2*n, assume 32 instances at max
return self.serial.startswith('emulator-') or 5555 <= self.port <= 5619
@cached_property
def is_nox_family(self):
return 62001 <= self.port <= 63025
@cached_property
def is_vmos(self):
return 5667 <= self.port <= 5699
@cached_property
def is_emulator(self):
@@ -137,6 +186,10 @@ class ConnectionAttr:
def is_network_device(self):
return bool(re.match(r'\d+\.\d+\.\d+\.\d+:\d+', self.serial))
@cached_property
def is_local_network_device(self):
return bool(re.match(r'192\.168\.\d+\.\d+:\d+', self.serial))
@cached_property
def is_over_http(self):
return bool(re.match(r"^https?://", self.serial))
@@ -173,7 +226,8 @@ class ConnectionAttr:
rf"SOFTWARE\BlueStacks_bgp64_hyperv\Guests\{folder_name}\Config") as key:
port = QueryValueEx(key, "BstAdbPort")[0]
except FileNotFoundError:
logger.error(rf'Unable to find registry HKEY_LOCAL_MACHINE\SOFTWARE\BlueStacks_bgp64_hyperv\Guests\{folder_name}\Config')
logger.error(
rf'Unable to find registry HKEY_LOCAL_MACHINE\SOFTWARE\BlueStacks_bgp64_hyperv\Guests\{folder_name}\Config')
logger.error('Please confirm that your are using BlueStack 4 hyper-v and not regular BlueStacks 4')
logger.error(r'Please check if there is any other emulator instances under '
r'registry HKEY_LOCAL_MACHINE\SOFTWARE\BlueStacks_bgp64_hyperv\Guests')

Some files were not shown because too many files have changed in this diff Show More