302 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
YoursFunny 19272d6cbe fix(cafe): correct invitation detection of Cafe No.2 in OVERSEA server 2025-03-08 18:43:25 +08:00
YoursFunny f268cfeda8 fix: update assets file 2025-01-29 16:41:31 +08:00
YoursFunny 37b5d4e4d2 fix(cafe): update ui assets for zht 2025-01-29 16:40:32 +08:00
YoursFunny 29bd71ccc8 fix(cafe): adjust timer of Cafe No.2 2025-01-22 15:01:49 +08:00
YoursFunny d5f4891d4e fix(cafe): adjust click template offset 2025-01-22 15:01:20 +08:00
YoursFunny 9a500b9191 fix: update assets file 2025-01-20 23:20:29 +08:00
YoursFunny 180fa6aa7d fix(cafe): update ui assets for jp 2025-01-20 23:19:46 +08:00
YoursFunny 4325118562 fix(cafe): update latest Cafe No.2 switch for jp 2025-01-20 23:19:45 +08:00
YoursFunny 2adf3f9efe fix: update assets file 2024-11-12 18:16:27 +08:00
YoursFunny 648bbb8a63 fix(cafe): update ui assets for zht 2024-11-12 18:16:05 +08:00
YoursFunny f73bba3e20 fix(cafe): update ui assets for en 2024-11-12 18:16:05 +08:00
YoursFunny e321fd7e28 lang(cafe): correct Cafe No.2 description 2024-11-12 18:16:05 +08:00
YoursFunny 6215f061c8 fix(cafe): enable Cafe No.2 for OVERSEA server 2024-11-12 18:09:13 +08:00
YoursFunny 72a948da68 fix: update assets file 2024-10-23 23:35:14 +08:00
YoursFunny c69a8543c6 fix: update ui assets for en
(cherry picked from commit a5d20a95be)
2024-10-23 23:34:54 +08:00
YoursFunny c16e50157e fix: update ui assets for zht 2024-10-23 23:29:51 +08:00
181 changed files with 8264 additions and 1867 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.6 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.8 KiB

After

Width:  |  Height:  |  Size: 4.5 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.

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.6 KiB

After

Width:  |  Height:  |  Size: 6.5 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,iVBORw0KGgoAAAANSUhEUgAAAMgAAADICAYAAACtWK6eAAAAAXNSR0IArs4c6QAADI1JREFUeF7tnVeTZlUVht9V5Y03lvdapVXeWMUfQL3BC2+8Qa0SkOyQMzPEGUKVoog5Y0TFnANmEQUVVBQjZhBMmAOCAdNrLd1Y37TTPeusc87X55z17tvZ65y9nrWfb+9ePd1t0BABEdiUgImNCIjA5gQkiHaHCGxBQIJoe4iABNEeEIEcAZ0gOW6KKkJAghQptNLMEZAgOW6KKkJAghQptNLMEZAgOW6KKkJAghQptNLMEZAgOW6KKkJAghQptNLMEZAgOW6KKkJAghQptNLMEZAgOW6KKkJAghQptNLMEZAgOW6KKkJAghQptNLMEZAgOW6KKkJAghQptNLMEZAgOW6KKkJAghQptNLMEZAgOW6KKkJAghQptNLMEZAgOW6KKkJAghQptNLMEZAgOW6KKkJAghQptNLMEZAgOW6KKkJAghQptNLMEZAgOW6KKkJAghQptNLMEZAgOW6KKkJAghQptNLMEZAgOW6KKkJAghQptNLMEZAgOW6KKkJAghQptNLMEZAgOW6KKkJAghQptNLMEZAgOW6KKkJAghQptNLMEZAgOW6KKkJAghQptNLMEZAgOW6KKkJAghQptNLMEZAgOW6KKkJAghQptNLMEZAgOW6KKkJAghQptNLMEZAgOW6KKkJAghQptNLMEZAgOW6KKkJAghQptNLMEZAgOW6KKkJAghQptNLMEZAgOW6KKkJAghQptNLMEZAgOW6KKkJAghQptNLMEZisICQfD+AhAB4M4HYA15rZPbk0FTV3AiQfBOBRAA4EcAOAW9axHyYpCMlPAzhoQ1FvBrDHzK6be7G1/m4ESO4EsAvAQ1ci7wTwVDO7vtvTus2enCAkbwPwiC3SOMTM3tUtTc2eKwGS7wTw5C3Wf4CZfXus/CYlCMnLAewOJHuomTk4jQUTIPkOAIfsJ8W7ADxuLEmmJsitAA4I1vwwM3OAGgskQPLtAA4NprbbzK4Izu00bWqC/BnAAztkIEk6wJrL1I5yeFrXmNnBY+Q3NUHuAPDwjok+xcz800ZjAQRIvg3AYR1TudLMTusYE5o+NUHeC+CJoZXvPelwM3OwGjMmkJTDMx7tQ3JqgjwSwCfb9z+6llqSdCU2ofkk3+obPbGk683ssYm4UMikBPEVk/SWXrZDdYSZOWiNGREg+RYAhyeXfLCZXZOM3W/Y5ARpknhrL9uhOtLMHLjGDAj0kIPeAjazd4+Z5iQFkSRjlnw6zyb5ZgBHJFa0Fjl8XZMVpEniffBsh+ooM/MCaEyQAMk3ATgysbR/tZPjPYnYziGTFqRJ4i2/bIfqaDPzQmhMiEAPOf7Z5PBu51rG5AWRJGvZB2t7Cck3Ajgq8cK1yzH5K9YqRJLeAsx2qI4xMy+MxjYSIHk1gKMTS/hHOznel4jtFTKLE+T+DEl6KzDboZIkvbZKv+Aecvzd/0+Wma1djlmdIANJcqyZ+aeYxhoJkHwDgGMSr3Q5vJX7/kTsICGzOkFWJPHWYLZD5T9k4wXTWAMBkq8HcGziVX9rcnwgETtYyCwF8exJeosw26GSJINtoc0f1EOO+9q1alvlmOUVa7UcPSXZYWb+6aYxAgGSr/MfiU082uXwa9Vo/32ky5pme4KsXLe8ZZjtUB1nZl5IjQEJ9JDjr02ODw64nF6Pmr0g7bolSXptg+GCSV4FYEfiiX9p16rJyDH7K9aG65b317MdquPNzAur0YMAydcCOC7xCJfDr1UfSsSOGrKIE2TluuWtxGyH6gQz8wJrJAj0kMN/zNrl+HDitaOHLEqQdt2SJKNvm71fQPI1AI5PvPZP7Vo1STkWdcXacN3yvnu2Q3WimXnBNQIESL4awAmBqRunuBx+cnwkEbu2kMWdICvXLW8xZjtUJ5mZF15jCwI95Li3yfHRqQNerCDtuiVJRtqBJF8F4MTE42cjx2KvWBuuW95yzHaoTjYz3wgaKwRIvhLASQko/svH/Vr1sUTstoQs+gRZuW556zHboZIkw8jxx/YF+WzkKHGCDCTJKWbmn5qlB8lXADg5AcHl8JPj44nYbQ0pcYKsSOKtyGyH6lQz8w1ScvSQ4+4mxyfmCK6UIO0Ld29JZjtUJSUheSWAUxIb/A/tWjVLOUpdsTZ84d5HktPMzDdMiUHy5QBOTSTrcvi16tpE7GRCyp0gK9ctb1FmO1Snm5lvnEWPHnL8vsnhv0Z21qOsIO26JUk22b4kXwYg8xvTf9euVbOXo+wVa8N1y/v52Q7VGWbmG2lRg+RLAZyeSMrl8GvVYv6OZOkTZOW65a3LbIfqTDPzDbWI0UOO3zY5PrUIEC0JCdJAkCwvCcmXADgjscF/065Vi5JDV6wNO4GktzKzHaqzzMw32CwHyRcDODOxeJfDr1X+p7sXN3SC/L8k3tLMdqjONjPfaLMaPeT4dZNj1L9Vvp0wJcg+6JMsIwnJFwE4K7EJFy+Hrlhb7AqS3uLMdqh2mplvvEkPki8EcHZikb9qJ8cNidhZhegE2VoSb3VmO1STlqSHHL9sX5AvXg6dIIHPMpJ9JNllZv4pPalB8gUAdiYW5XL4F+SfScTOMkQnSKBsJL31me1QnWNmviEnMXrI8Ysmx2cnkciaFiFBgqBJegs026GahCQknw9gVzDl1Wk/b9eqUnLoitVxp/SU5Fwz8w26LYPk8wCck3i5y+HXqs8lYmcfohOkYwlJeks026E6z8x8o6519JDjribHjWtd8IReJkESxZiTJCSfC+DcRJo/a9eqsnLoipXYNfeHkPTvH2Q7VOebmW/cUQfJ5wA4L/ESl8OvVTclYhcVohOkRzlJeqs026G6wMx8A48yesjx0ybH50dZ2MweKkF6FmyKkpB8NoDzE6n9pF2rJEeDJ0ESu2hjCElvnWY7VBeamW/oQQbJKwBckHiYy+HXqi8kYhcbIkEGKi1Jb6FmO1SDSNJDjh+3k0NybNgPEmQgQfwxPSXZbWb+6Z8aJJ8F4MJE8I+aHF9MxC4+RIIMXGKS3lLNdqj2mJlv9E6D5OUAdncK+u9kl8OvVTcnYkuESJARykzSW6vZDlUnSXrIcWc7OSTHFntAgowgSLtu9ZHkIjPzU2HLQfKZAPbsb94+/t3l8JPjS4nYUiESZMRyk/RWa7ZDdbGZuQD7HCSfAeCixPLvaHJ8ORFbLkSCjFzyMSTpIccP27VKcgTrLkGCoPpMI+nfl8h2qC4xMz8t/jNIXgbg4sR6XA6/Vt2SiC0bIkHWVHqS3oLt3KFqy7vUzC7rIcftTY6vrCndxbxGgqyxlD0l8V+tc1Biube1a5XkSMCTIAlofUJI+vcr9tuh6vOOlViXw69VXx3oeeUeI0G2oeQkvTW7aYdqoCX9oMnxtYGeV/IxEmSbyj6yJN9v1yrJ0bO+EqQnwD7hJP37GP/rUPV51kqsy+HXqq8P9LzSj5Eg21x+kt6y9dbtEON77eSQHEPQBCBBBgLZ5zEDSfLdJsc3+qxFsXsTkCAT2REkLwHw9ORyXA6/Vn0zGa+wTQhIkAltDZKXAnhaxyV9p50ckqMjuMh0CRKhtMY5HSXxVu6TzOzWNS6x1KskyATLHfylC3cDeIyZfWuCKSxmSRJkoqUkeRWAHZss714AB0qO8YsnQcZnnH4DyUcDuBrAwwA8AMA9APyXuT3BzO5LP1iBYQISJIxKEysSkCAVq66cwwQkSBiVJlYkIEEqVl05hwlIkDAqTaxIQIJUrLpyDhOQIGFUmliRgASpWHXlHCYgQcKoNLEiAQlSserKOUxAgoRRaWJFAhKkYtWVc5iABAmj0sSKBCRIxaor5zABCRJGpYkVCUiQilVXzmECEiSMShMrEpAgFauunMMEJEgYlSZWJCBBKlZdOYcJSJAwKk2sSECCVKy6cg4TkCBhVJpYkYAEqVh15RwmIEHCqDSxIgEJUrHqyjlMQIKEUWliRQISpGLVlXOYgAQJo9LEigQkSMWqK+cwAQkSRqWJFQlIkIpVV85hAhIkjEoTKxKQIBWrrpzDBCRIGJUmViQgQSpWXTmHCUiQMCpNrEhAglSsunIOE5AgYVSaWJGABKlYdeUcJiBBwqg0sSIBCVKx6so5TECChFFpYkUCEqRi1ZVzmIAECaPSxIoEJEjFqivnMAEJEkaliRUJSJCKVVfOYQISJIxKEysSkCAVq66cwwQkSBiVJlYkIEEqVl05hwlIkDAqTaxIQIJUrLpyDhOQIGFUmliRgASpWHXlHCYgQcKoNLEiAQlSserKOUxAgoRRaWJFAhKkYtWVc5iABAmj0sSKBCRIxaor5zCBfwOoUQv2LE3rzAAAAABJRU5ErkJggg==");
}
.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,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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 {
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: 5.6 KiB

After

Width:  |  Height:  |  Size: 5.1 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.8 KiB

After

Width:  |  Height:  |  Size: 4.8 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.

Before

Width:  |  Height:  |  Size: 10 KiB

After

Width:  |  Height:  |  Size: 9.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.3 KiB

After

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.0 KiB

After

Width:  |  Height:  |  Size: 6.7 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: 7.1 KiB

After

Width:  |  Height:  |  Size: 6.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.6 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: 5.8 KiB

After

Width:  |  Height:  |  Size: 4.6 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.

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: 14 KiB

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

After

Width:  |  Height:  |  Size: 7.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.2 KiB

After

Width:  |  Height:  |  Size: 6.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.6 KiB

After

Width:  |  Height:  |  Size: 6.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.0 KiB

After

Width:  |  Height:  |  Size: 6.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.7 KiB

After

Width:  |  Height:  |  Size: 4.8 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

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