diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..88bc480 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,15 @@ +* text=auto +*.jpg -text +*.png -text +*.bat text eol=crlf +*.txt text eol=lf +*.sh text eol=lf +*.js text eol=lf +*.ts text eol=lf +*.py eol=lf +*.vue text eol=lf +*.css text eol=lf +*.yml text eol=lf +*.yaml text eol=lf +*.json text eol=lf +*.html text eol=lf \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..5a75e60 --- /dev/null +++ b/.gitignore @@ -0,0 +1,251 @@ +### AzurLaneAutoScript +log +config/backup/* +config/*.ini +!config/template.ini +config/*.yaml +!config/deploy.*.yaml +config/*.json +config/tmp* +!config/template*.json +*.pyw +dev_tools/debug_tools +.idea +/screenshots +/campaign/test/ +/toolkit +*.lock +config/reloadflag +config/reloadalas +test.py +test/ +note.md + +# Created by .ignore support plugin (hsz.mobi) + +### JetBrains template +# Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio and WebStorm +# Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 + +# User-specific stuff +.idea/**/workspace.xml +.idea/**/tasks.xml +.idea/**/usage.statistics.xml +.idea/**/dictionaries +.idea/**/shelf + +# Generated files +.idea/**/contentModel.xml + +# Sensitive or high-churn files +.idea/**/dataSources/ +.idea/**/dataSources.ids +.idea/**/dataSources.local.xml +.idea/**/sqlDataSources.xml +.idea/**/dynamic.xml +.idea/**/uiDesigner.xml +.idea/**/dbnavigator.xml + +# Gradle +.idea/**/gradle.xml +.idea/**/libraries + +# Gradle and Maven with auto-import +# When using Gradle or Maven with auto-import, you should exclude module files, +# since they will be recreated, and may cause churn. Uncomment if using +# auto-import. +# .idea/modules.xml +# .idea/*.iml +# .idea/modules +# *.iml +# *.ipr + +# CMake +cmake-build-*/ + +# Mongo Explorer plugin +.idea/**/mongoSettings.xml + +# File-based project format +*.iws + +# IntelliJ +out/ + +# mpeltonen/sbt-idea plugin +.idea_modules/ + +# JIRA plugin +atlassian-ide-plugin.xml + +# Cursive Clojure plugin +.idea/replstate.xml + +# Crashlytics plugin (for Android Studio and IntelliJ) +com_crashlytics_export_strings.xml +crashlytics.properties +crashlytics-build.properties +fabric.properties + +# Editor-based Rest Client +.idea/httpRequests + +# Android studio 3.1+ serialized cache file +.idea/caches/build_file_checksums.ser + +### Python template +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +pip-wheel-metadata/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +.hypothesis/ +.pytest_cache/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +.python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +#Pipfile.lock + +# celery beat schedule file +celerybeat-schedule + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +### Windows template +# Windows thumbnail cache files +Thumbs.db +Thumbs.db:encryptable +ehthumbs.db +ehthumbs_vista.db + +# Dump file +*.stackdump + +# Folder config file +[Dd]esktop.ini + +# Recycle Bin used on file shares +$RECYCLE.BIN/ + +# Windows Installer files +*.cab +*.msi +*.msix +*.msm +*.msp + +# Windows shortcuts +*.lnk +adb_port.txt +adb_port.ini +*.exe +!popup.exe +*.zip +.vscode/* diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..f288702 --- /dev/null +++ b/LICENSE @@ -0,0 +1,674 @@ + GNU GENERAL PUBLIC LICENSE + Version 3, 29 June 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU General Public License is a free, copyleft license for +software and other kinds of works. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +the GNU General Public License is intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. We, the Free Software Foundation, use the +GNU General Public License for most of our software; it applies also to +any other work released this way by its authors. You can apply it to +your programs, too. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + To protect your rights, we need to prevent others from denying you +these rights or asking you to surrender the rights. Therefore, you have +certain responsibilities if you distribute copies of the software, or if +you modify it: responsibilities to respect the freedom of others. + + For example, if you distribute copies of such a program, whether +gratis or for a fee, you must pass on to the recipients the same +freedoms that you received. You must make sure that they, too, receive +or can get the source code. And you must show them these terms so they +know their rights. + + Developers that use the GNU GPL protect your rights with two steps: +(1) assert copyright on the software, and (2) offer you this License +giving you legal permission to copy, distribute and/or modify it. + + For the developers' and authors' protection, the GPL clearly explains +that there is no warranty for this free software. For both users' and +authors' sake, the GPL requires that modified versions be marked as +changed, so that their problems will not be attributed erroneously to +authors of previous versions. + + Some devices are designed to deny users access to install or run +modified versions of the software inside them, although the manufacturer +can do so. This is fundamentally incompatible with the aim of +protecting users' freedom to change the software. The systematic +pattern of such abuse occurs in the area of products for individuals to +use, which is precisely where it is most unacceptable. Therefore, we +have designed this version of the GPL to prohibit the practice for those +products. If such problems arise substantially in other domains, we +stand ready to extend this provision to those domains in future versions +of the GPL, as needed to protect the freedom of users. + + Finally, every program is threatened constantly by software patents. +States should not allow patents to restrict development and use of +software on general-purpose computers, but in those that do, we wish to +avoid the special danger that patents applied to a free program could +make it effectively proprietary. To prevent this, the GPL assures that +patents cannot be used to render the program non-free. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Use with the GNU Affero General Public License. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU Affero General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the special requirements of the GNU Affero General Public License, +section 13, concerning interaction through a network will apply to the +combination as such. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU General Public License from time to time. Such new versions will +be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + + If the program does terminal interaction, make it output a short +notice like this when it starts in an interactive mode: + + Copyright (C) + This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. + This is free software, and you are welcome to redistribute it + under certain conditions; type `show c' for details. + +The hypothetical commands `show w' and `show c' should show the appropriate +parts of the General Public License. Of course, your program's commands +might be different; for a GUI interface, you would use an "about box". + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU GPL, see +. + + The GNU General Public License does not permit incorporating your program +into proprietary programs. If your program is a subroutine library, you +may consider it more useful to permit linking proprietary applications with +the library. If this is what you want to do, use the GNU Lesser General +Public License instead of this License. But first, please read +. diff --git a/aas.py b/aas.py new file mode 100644 index 0000000..0088a0d --- /dev/null +++ b/aas.py @@ -0,0 +1,49 @@ +from module.alas import AzurLaneAutoScript +from module.logger import logger + + +class ArisuAutoSweeper(AzurLaneAutoScript): + def restart(self): + from tasks.login.login import Login + Login(self.config, device=self.device).app_restart() + + def start(self): + from tasks.login.login import Login + Login(self.config, device=self.device).app_start() + + def goto_main(self): + from tasks.login.login import Login + from tasks.base.ui import UI + if self.device.app_is_running(): + logger.info('App is already running, goto main page') + UI(self.config, device=self.device).ui_goto_main() + else: + logger.info('App is not running, start app and goto main page') + Login(self.config, device=self.device).app_start() + UI(self.config, device=self.device).ui_goto_main() + + def cafe(self): + from tasks.cafe.cafe import Cafe + Cafe(config=self.config, device=self.device).run() + + def circle(self): + from tasks.circle.circle import Circle + Circle(config=self.config, device=self.device).run() + + def mail(self): + from tasks.mail.mail import Mail + Mail(config=self.config, device=self.device).run() + + def tactical_challenge(self): + from tasks.tactical_challenge.tactical_challenge import TacticalChallenge + TacticalChallenge(config=self.config, device=self.device).run() + + def data_update(self): + from tasks.item.data_update import DataUpdate + DataUpdate(config=self.config, device=self.device).run() + + +if __name__ == '__main__': + aas = ArisuAutoSweeper('aas') + aas.loop() + # aas.goto_main() diff --git a/assets/gui/css/alas-mobile.css b/assets/gui/css/alas-mobile.css new file mode 100644 index 0000000..3e3d6f7 --- /dev/null +++ b/assets/gui/css/alas-mobile.css @@ -0,0 +1,69 @@ +.container-menu-collapsed { + display: none; +} + +.container-content-collapsed { + display: none; +} + +#pywebio-scope-log { + font-size: .75rem !important; +} + +#pywebio-scope-content { + padding: .25rem; + margin: 0 +} + +#pywebio-scope-navigator { + display: none; +} + +[id^="pywebio-scope-arg_container-"] { + grid-auto-flow: row; + grid-template-rows: auto auto; +} + +[id^="pywebio-scope-arg_container-checkbox-"] { + grid-auto-flow: column; + grid-template-columns: 1fr 8rem; + grid-template-rows: unset; +} + +[id^="pywebio-scope-arg_container-storage-"] { + grid-auto-flow: column; + grid-template-columns: 1fr auto; + grid-template-rows: 1fr auto; +} + +#pywebio-scope-_groups { + grid-template-columns: 0fr 1fr; +} + +#pywebio-scope-overview { + grid-auto-flow: row; + grid-template-rows: 100% 100%; +} + +#pywebio-scope-daemon-overview { + grid-auto-flow: row; + grid-template-rows: auto auto auto 1fr; +} + +#pywebio-scope-schedulers { + grid-auto-flow: row; +} + +#pywebio-scope-running, +#pywebio-scope-pending, +#pywebio-scope-waiting, +#pywebio-scope-log { + overflow-y: auto; +} + +[id^="pywebio-scope-dashboard-row-"] { + display: flex; + flex-grow: 1; + min-width: 4rem; + max-width: 50%; +} diff --git a/assets/gui/css/alas-pc.css b/assets/gui/css/alas-pc.css new file mode 100644 index 0000000..a3873f4 --- /dev/null +++ b/assets/gui/css/alas-pc.css @@ -0,0 +1,57 @@ +[id^="pywebio-scope-arg_container-"] { + grid-auto-flow: column; + grid-template-columns: 1fr 13rem; +} + +[id^="pywebio-scope-arg_container-storage-"] { + grid-auto-flow: column; + grid-template-columns: 1fr auto; + grid-template-rows: 1fr auto; +} + +#pywebio-scope-overview { + grid-auto-flow: column; + grid-template-columns: minmax(16rem, 20rem) minmax(24rem, 1fr); +} + +#pywebio-scope-daemon-overview { + grid-auto-flow: column; + grid-template-columns: 1fr minmax(25rem, 6fr) 1fr; +} + +#pywebio-scope-schedulers { + grid-auto-flow: row; + grid-template-rows: auto 7.75rem minmax(7.75rem, 13rem) minmax(7.75rem, 1fr); + height: 100%; + overflow-y: auto; +} + +#pywebio-scope-log-bar { + display: flex; + flex-direction: column; + height: 11.5rem; +} + +#pywebio-scope-dashboard { + flex: 1; +} + +#pywebio-scope-log-bar, +#pywebio-scope-log, +#pywebio-scope-daemon-overview #pywebio-scope-groups { + overflow-y: auto; +} + +#pywebio-scope-_daemon { + display: grid; + grid-auto-flow: row; + grid-template-rows: auto minmax(6rem, auto) minmax(15rem, 1fr); + height: 100%; + overflow-y: auto; +} + +#pywebio-scope-_daemon_upper { + display: grid; + grid-auto-flow: column; + grid-template-columns: auto auto; +} diff --git a/assets/gui/css/alas.css b/assets/gui/css/alas.css new file mode 100644 index 0000000..465e02e --- /dev/null +++ b/assets/gui/css/alas.css @@ -0,0 +1,568 @@ +details { + border: unset !important; + padding-bottom: unset !important; + margin-bottom: .25rem !important; +} + +details[open] > summary { + border-bottom: unset !important; +} + +details[open] > div { + margin-left: 0.625rem; +} + +summary { + background-color: transparent !important; + font-weight: 500; +} + +body { + margin-top: 0; + margin-bottom: 0; +} + +footer { + display: none; +} + +.btn:focus { + box-shadow: unset; +} + +.btn-menu { + font-weight: 400; + background-color: transparent; + padding: .0625rem .75rem; + border-radius: 0; + border: 0 solid; + transition: border .05s ease-in-out, padding .05s ease-in-out; + white-space: pre-wrap; + text-align: left; +} + +.btn-menu:hover, +.btn-menu-active { + font-weight: bold; + border-left: .125rem solid; + padding-right: .625rem; + border-left: 3px solid; +} + +.btn-aside { + width: 4rem; + font-weight: 400; + font-size: .8rem; + background-color: transparent; + padding: 32px 0 0 7px; + border-radius: 0; + border: 0 solid; + transition: border .1s ease-in-out, padding .1s ease-in-out +} + +.btn-aside:hover, +.btn-aside-active { + border-left: 4px solid; + padding-left: 3px; + font-weight: bold; +} + +.btn-off, +.btn-on { + border-radius: 0; + margin: 0; +} + +.btn-navigator { + border-radius: 0; + margin: 0 !important; + width: 100%; + text-align: left; + transition: color 0s ease-in-out; +} + +.btn-navigator:hover { + font-weight: bold; +} + +.toastify-center, +.toastify-right, +.toastify-left { + margin-top: 3.3125rem; +} + +.pywebio { + padding-top: 0; + padding-bottom: 0; + min-height: unset; +} + +#input-container { + margin-bottom: 0; +} + +#output-container { + padding-left: 0; + padding-right: 0; + margin-bottom: 0; + max-width: initial +} + +.container { + max-width: 100vh; +} + +.hr-group { + margin-top: .25rem !important; + margin-bottom: .25rem !important; +} + +.hr-task-group-box { + display: flex; + align-items: center; + margin-bottom: 0.5rem; +} + +.hr-task-group-line { + border-top: 0.125rem solid; + flex-grow: 1; +} + +.hr-task-group-text { + margin: 0 0.5rem; + font-size: 0.875rem; +} + +input[type="checkbox"] { + width: 1.25rem; + height: 1.25rem; +} + +label { + display: inline; +} + +.form-control { + background-color: unset; + border-radius: initial !important; + border-width: 0; + padding: 0 .5rem 0; + margin-top: .125rem; + height: auto !important; +} + +.form-control[readonly] { + pointer-events: none; + border-bottom-color: transparent; +} + +.form-control:focus { + border-color: unset; +} + +.form-control.is-invalid:focus { + box-shadow: 0 0.06rem 0 #dc3545; +} + +select { + appearance: none; + -webkit-appearance: none; + -moz-appearance: none; + background-attachment: scroll; + background-position: right, center; + background-repeat: no-repeat; + background-size: 1rem; +} + +select.form-control { + padding-right: 1rem; +} + +select.form-control.is-invalid { + padding-right: 3rem !important; + background-position: right 1.5rem center; +} + +button.btn.dropdown-toggle { + display: none; +} + +.bootstrap-select > select { + position: unset !important; + bottom: unset !important; + left: unset !important; + width: 100% !important; + height: unset !important; + padding: 0 1rem 0 0.5rem !important; + opacity: 1 !important; + z-index: auto !important; +} + +.invalid-feedback { + margin-top: 0; +} + +.CodeMirror { + height: auto !important; +} + +.CodeMirror-line { + font-family: SFMono-Regular, SF Mono, Menlo, Consolas, Liberation Mono, monospace !important; +} + +.form-group { + margin-bottom: 0 !important; +} + +.alas-icon, +.alas-icon > image { + width: 42px; + height: 42px; +} + +.aside-icon { + width: 2rem; + height: 2rem; +} + +.container-log { + border-radius: 0 !important; + margin: .375rem !important; + padding: 1rem !important; +} + +code.rich-traceback { + padding: 0; +} + +pre.rich-traceback-code { + padding-top: 0; + padding-bottom: 0; + font-family: Menlo, consolas, DejaVu Sans Mono, Courier New, monospace; + font-size: 0.85rem; + line-height: 1.2; +} + +#pywebio-scope-ROOT { + height: 100vh; + display: grid; + grid-auto-flow: row; + grid-template-rows: auto 1fr; +} + +#pywebio-scope-aside { + z-index: 91; + padding-left: .125rem; + padding-right: .325rem; + padding-top: 1rem; + overflow-y: auto; + flex-shrink: 0; +} + +#pywebio-scope-menu { + z-index: 90; + padding-left: .5rem; + padding-right: .5rem; + padding-top: 1.2rem; + overflow-y: auto; + width: 12rem; + flex-shrink: 0; +} + +#pywebio-scope-content { + overflow: auto; + padding: .625rem; + flex-grow: 1; +} + +#pywebio-scope-header { + z-index: 100; + display: grid; + grid-auto-flow: column; + grid-template-columns: 4.4rem 4rem auto 1fr !important; +} + +*[style*="--header-icon--"] { + margin: .25rem auto .25rem; + border-radius: 1.5rem; + height: 3.5em; +} + +*[style*="--header-text--"] { + font-size: 1.5rem; + font-weight: bold; + margin: auto !important; +} + +#pywebio-scope-header_title { + margin: auto; +} + +#pywebio-scope-header_title > p { + font-size: 1.2rem; + margin: auto; + overflow: hidden; + text-align: center; +} + +#pywebio-scope-header_status { + padding-top: 3px; + margin-top: auto; + margin-bottom: auto; +} + +#pywebio-scope-header_status > div > div + div + p { + margin: 0; +} + +#pywebio-scope-contents { + margin-top: 0; + overflow-y: auto; + display: flex; +} + +#pywebio-scope-_groups { + height: 100%; + display: grid; + grid-auto-flow: column; + + grid-template-columns: 1fr minmax(25rem, 5fr) 2fr; +} + +#pywebio-scope-group__info > p { + font-size: .80rem !important; + font-weight: 400; +} + +[id^="pywebio-scope-group_"] { + margin-top: .5rem; + margin-bottom: .5rem; + padding: 1rem; +} + +[id^="pywebio-scope-group_"] > p { + font-size: 1.25rem; + font-weight: 500; + margin: 0 .25rem 0 .25rem !important; +} + +[id^="pywebio-scope-group_"] > p + p { + font-size: .80rem; + margin: .2rem .25rem .1rem .25rem !important; +} + +#pywebio-scope-groups { + overflow-y: auto; +} + +#pywebio-scope-groups:not(:has(*)) { + display: none; +} + +#pywebio-scope-navigator { + margin: .5rem 1rem .5rem; + height: min-content; + max-width: 15rem; +} + +#pywebio-scope-overview { + height: 100%; + overflow: auto; + display: grid; +} + +#pywebio-scope-running, +#pywebio-scope-pending, +#pywebio-scope-waiting, +#pywebio-scope-scheduler-bar, +#pywebio-scope-log-bar, +#pywebio-scope-log, +#pywebio-scope-daemon-overview #pywebio-scope-groups { + font-weight: 500; + margin: 0.3125rem; + padding: 0.625rem; +} + +#pywebio-scope-scheduler-bar, +#pywebio-scope-log-title { + display: flex; + align-items: center; + justify-content: space-between; +} + +#pywebio-scope-log-title-btns { + display: grid; + grid-auto-flow: column; +} + +#pywebio-scope-dashboard { + display: flex; + align-content: space-between; + justify-content: flex-start; + flex-flow: row wrap; + overflow: auto; + margin-top: .5rem; +} + +#pywebio-scope-dashboard > i { + flex-grow: 1; + align-self: flex-end; + width: 10rem; +} + +[id^="pywebio-scope-dashboard-row-"] { + display: flex; + flex-grow: 1; + width: 10rem; +} + +.dashboard-icon { + margin: .6rem .8rem 0 .6rem; + width: .5rem; + height: .5rem; + border-radius: 50%; +} + +*[style*="--dashboard-value--"] { + font-size: 1rem; + font-weight: 500; + overflow-wrap: break-word; +} + +*[style*="--dashboard-time--"] { + font-size: 0.8rem; + font-weight: 400; + overflow-wrap: break-word; +} + +[id^="pywebio-scope-dashboard-row-"] p { + margin-bottom: 0; +} + +[id^="pywebio-scope-dashboard-value-"] { + display: flex; + align-items: flex-end; + height: 1.5rem; +} + + +#pywebio-scope-log { + line-height: 1.2; + font-size: 0.85rem; + font-family: Menlo, consolas, DejaVu Sans Mono, Courier New, monospace; + white-space: pre; +} + +#pywebio-scope-running, +#pywebio-scope-pending, +#pywebio-scope-waiting { + display: grid; + grid-auto-flow: row; + grid-template-rows: auto auto 1fr; +} + +#pywebio-scope-running > p, +#pywebio-scope-pending > p, +#pywebio-scope-waiting > p { + font-size: 1.25rem; + font-weight: 500; + margin: 0 0.625rem 0 !important; +} + +#pywebio-scope-running_tasks, +#pywebio-scope-pending_tasks, +#pywebio-scope-waiting_tasks { + overflow-y: auto; + height: 100%; +} + +#pywebio-scope-logs { + display: grid; + grid-auto-flow: column; + + height: 100%; + overflow-y: auto; + grid-template-rows: auto 1fr; + +} + +[id^="pywebio-scope-overview-task_"] { + display: grid; + grid-auto-flow: column; + grid-template-columns: 1fr auto; + margin: .125rem .625rem .125rem .375rem; +} + +#pywebio-scope-daemon-overview { + display: grid; + height: 100%; + overflow-y: auto; +} + +#pywebio-scope-daemon-overview [id^="pywebio-scope-group_"] { + margin-top: 0; + margin-bottom: 0; + padding: 0.3125rem; +} + +#pywebio-scope-schedulers { + display: grid; +} + +.bs-title-option, +.form-check-input[id*="ch_S"] { + display: none; +} + +[id^="pywebio-scope-arg_container-"] { + display: grid; + margin: .125rem 0; +} + +[id^="pywebio-scope-arg_container-checkbox-"], +[id^="pywebio-scope-arg_container-storage-"] { + display: grid; + margin: .375rem 0; +} + +*[style*="--arg-title--"] { + font-size: 1rem; + font-weight: 500; + margin: 0 .25rem !important; + overflow-wrap: break-word; +} + +*[style*="--arg-help--"] { + font-size: .8rem; + margin: .2rem .25rem .1rem !important; + overflow-wrap: break-word; +} + +*[style*="--overview-notask-text--"] { + text-align: center; + font-size: 0.875rem; + color: darkgrey; +} + +*[style*="--input--"] { + margin: 0; + padding-right: .25rem; +} + +*[style*="--loading-grow--"] { + width: 1.5rem; + height: 1.5rem; +} + +*[style*="--loading-border--"] { + width: 1.5rem; + height: 1.5rem; + border: .2em solid currentColor; + border-right-color: transparent; +} + +*[style*="--loading-border-fill--"] { + width: 1.5rem; + height: 1.5rem; + border: .2em solid currentColor; +} \ No newline at end of file diff --git a/assets/gui/css/dark-alas.css b/assets/gui/css/dark-alas.css new file mode 100644 index 0000000..c2d76e7 --- /dev/null +++ b/assets/gui/css/dark-alas.css @@ -0,0 +1,159 @@ +.modal-body { + background-color: #2f3136; +} + +.btn-menu:hover, +.btn-menu-active { + border-color: #7a77bb; + color: #7a77bb; +} + +.btn-aside:hover, +.btn-aside-active { + border-color: #7a77bb; + color: #7a77bb; +} + +.btn-off { + background-color: #36393f; + border: 1px solid #202225; +} + +.btn-on { + background-color: #7a77bb; + border: 1px solid #202225; +} + +.btn-navigator { + background-color: #2f3136; +} + +.btn-navigator:hover { + color: #7a77bb; +} + +.hr-group { + background-color: #40444b !important; +} + +.hr-task-group-line { + border-top-color: #adb5bd; +} + +.hr-task-group-text { + color: #adb5bd; +} + +.form-control, +.bootstrap-select > select { + border-bottom: .125rem solid #7a77bb; +} + +.form-control:focus { + background-color: #2f3136; + border-color: #7a77bb; + box-shadow: 0 0.06rem 0 #7a77bb; +} + +input[type="checkbox"] { + accent-color: #7a77bb; +} + +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 { + border-bottom: 0; + background-image: none; + pointer-events: none; +} + +.state-bold > select { + font-weight: bold; + color: #7a77bb; +} + +.state-light > select { + color: #777777; +} + +[id^="pywebio-scope-arg_stored-stored-value-"] > div > input { + border-bottom: none; + background-color: transparent !important; +} + +textarea { + border: 1px solid #21262d; +} + +.CodeMirror-wrap { + border: 1px solid #21262d; +} + +.aside-icon > path { + fill: #c9d1d9; +} + +.container-log { + background-color: #2f3136 !important; + border: 1px solid #21262d; +} + +pre.rich-traceback-code { + color: #cccccc; + background-color: #1e1e1e; +} + +#pywebio-scope-content { + background-color: #36393f; +} + +[id^="pywebio-scope-group_"] { + background-color: #2f3136; + border: 1px solid #21262d; +} + +#pywebio-scope-daemon-overview [id^="pywebio-scope-group_"] { + border: 0; +} + +#pywebio-scope-aside { + background-color: #202225; + border-right: 1px solid #21262d; +} + +#pywebio-scope-menu { + background-color: #2f3136; + border-right: 1px solid #21262d; +} + +#pywebio-scope-navigator { + border: 1px solid #21262d; + color: #c9d1d9; +} + +#pywebio-scope-scheduler-bar, +#pywebio-scope-log-bar, +#pywebio-scope-log, +#pywebio-scope-running, +#pywebio-scope-pending, +#pywebio-scope-waiting, +#pywebio-scope-daemon-overview #pywebio-scope-groups { + background-color: #2f3136; + border: 1px solid #21262d; +} + +#pywebio-scope-header { + background-color: #202225; + border-bottom: 1px solid #36393f; +} + +*[style*="--arg-help--"], +[id^="pywebio-scope-group_"] > p + p { + color: #adb5bd; +} + +*[style*="--dashboard-time--"] { + color: #adb5bd; +} \ No newline at end of file diff --git a/assets/gui/css/light-alas.css b/assets/gui/css/light-alas.css new file mode 100644 index 0000000..171d565 --- /dev/null +++ b/assets/gui/css/light-alas.css @@ -0,0 +1,158 @@ +.btn-menu:hover, +.btn-menu-active { + border-color: #4e4c97; + color: #4e4c97; +} + +.btn-aside:hover, +.btn-aside-active { + border-color: #4e4c97; + color: #4e4c97; +} + +.btn-off { + background-color: white; + border: 1px solid lightgrey; +} + +.btn-on { + background-color: #4e4c97; + border: 1px solid lightgrey; + color: white; +} + +.btn-on:hover { + color: white; +} + +.btn-navigator { + background-color: white; +} + +.btn-navigator:hover { + color: #4e4c97; +} + +.hr-group { + background-color: #eaecef !important; +} + +.hr-task-group-line { + border-top-color: #d0d0d0; +} + +.hr-task-group-text { + color: #777777; +} + +.form-control, +.bootstrap-select > select { + border-bottom: .125rem solid #4e4c97; +} + +.form-control:focus { + background-color: white; + border-color: #4e4c97; + box-shadow: 0 0.06rem 0 #4e4c97; +} + +input[type="checkbox"] { + accent-color: #7a77bb; +} + +select { + background-image: url("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAMgAAADICAYAAACtWK6eAAAAAXNSR0IArs4c6QAAC85JREFUeF7tnUezdVURhh+qmDixnGOVVjmxyj+AOsGBEyeGKrMYMGfBCIYqE2LOWVTMOeeMGRQjZhBMmAOCAXO9sE7V+eC711699z5n791vj3vtu/vp9Zy1bh+43zE4TMAEDiRwjNmYgAkcTMCCeHeYwCEELIi3hwlYEO8BE8gR8AmS4+ZVRQhYkCKNdpk5AhYkx82rihCwIEUa7TJzBCxIjptXFSFgQYo02mXmCFiQHDevKkLAghRptMvMEbAgOW5eVYSABSnSaJeZI2BBcty8qggBC1Kk0S4zR8CC5Lh5VRECFqRIo11mjoAFyXHzqiIELEiRRrvMHAELkuPmVUUIWJAijXaZOQIWJMfNq4oQsCBFGu0ycwQsSI6bVxUhYEGKNNpl5ghYkBw3rypCwIIUabTLzBGwIDluXlWEgAUp0miXmSNgQXLcvKoIAQtSpNEuM0fAguS4eVURAhakSKNdZo6ABclx86oiBCxIkUa7zBwBC5Lj5lVFCFiQIo12mTkCFiTHzauKELAgRRrtMnMELEiOm1cVIWBBijTaZeYIWJAcN68qQsCCFGm0y8wRsCA5bl5VhIAFKdJol5kjYEFy3LyqCAELUqTRLjNHwILkuHlVEQIWpEijXWaOgAXJcfOqIgQsSJFGu8wcAQuS4+ZVRQhYkCKNdpk5AhYkx82rihCwIEUa7TJzBCxIjptXFSFgQYo02mXmCFiQHDevKkLAghRptMvMEZizILcAjgOuA1wIfBy4LFemV62AwLWBGwPHA2cD5+1iP8xVkE8DJ1ytqecCpwGfXEGzXUIfgZOBU4Drbi27GLgH8Jm+R/Vlz1GQC4AbHFLG7YC395Xp7AUTeBtw20Pe/0bAd6eqb26CnA6cGij29oDAOdZN4K2APhAPi0uAm08lydwEOR/QJ0Ik7gAIoGOdBN4C6IMwEvpQPSOS2JszN0H+ClyrowhL0gFrQak9cqis9wG3nKK+uQlyEXD9zkLvCAioYx0E3gzog68nXgI8sGdBNHdugrwLuHX05bfy7gQIrGPZBDJyqOLJPiTnJsgNgU+07z96W21JeonNK/9NbaP3vpXGvDfrXRTNn5sgem+N9LITqjsDAu1YFoE3AvqAy4R+99DvIJPEHAVRoRrtZSdUdwEE3LEMAlk5/tv2yTumLHOugliSKbs+n2e/AdCp3xs7kUMvNWdB9H6ag2cnVCcCaoBjngReD+i0743/tJPjnb0LM/lzF0Q1aeSXnVDdFVAjHPMikJXj300OTTt3EksQxJLsZCvs7Ie8DtDp3hs7l2MJV6xtiJp1ZydUdwPUGMd+CZwF6FTvjX+1k+PdvQuH5i/lBNnUqVFgdkJlSYbulmHrs3L8s/0uunM5lnaCjCHJ3QE1yrFbAq8F9AHVG5JDI//39C4cK39pJ8imbo0GsxMq/U82aphjNwReA+iDqTf+0eR4b+/CMfOXKogYaESYnVBZkjF30cHPyspxRbtW7VWOpV6xttsxRJKTADXQMQ2BV7f/Jbb36ZJD16rJ/vORnhda8gmyqVMjw+yE6p6AGukYl0BWjr83Od4/7uvkn7YGQVS9JcnvgbFXngnodO6Nv7Vr1WzkWMMVa7sJmq9nJ1T3AtRYxzACrwJ0KveG5NC16gO9C6fOX8sJsuGkUWJ2QnVvQA125Ahk5dD/Zi05Ppj7sdOuWpsgomVJpt0zR3v6KwGdwr3xl3atmqUca7tibTdHc/fshOo+gBruiBF4BaDTtzckh06OD/Uu3GX+Gk+QDT9915GdUN0XUOMdhxPIynF5k+PDcwe8ZkHE3pJMtwNfDui07Y3FyLHmK9Z20zRyzE6o7gdoIziOJPAyQKdsb+iPj+ta9ZHehfvKX/sJsuGq0WN2QmVJxpHjz+0X8sXIUeUEGUOS+wP61KweLwX0gdEbkkMnx0d7F+47v8oJsuGsUWR2QvUAQBukamTluLTJ8bElgqsmiHqkkWR2QlVVEv1pT52ivfGndq1apBzVrljbzR0iif4GrDZMlXgxoA+G3pAculbpXwZbbFQ8QTbN0ogyO6F6EKCNs/bIyvHHJof+jOyio7IgapwlOXj7vij5F9P/0K5Vi5ej8hVre1tonp+dUD0Y0EZaW7wQ0CnZG5JD16rV/DuS1U+QzQbQ6DI7oXoIoA21lsjK8fsmx6fWAsInyJGdtCTwAkCnYm/8rl2rViWHBbnmNtAoMzuheihXbbClxvMBnYa9ITl0rdI/3b268BXrmi3VSDM7oXoYoI22tMjK8dsmx6T/Vvk+YVqQo9OvJMnzAJ1+vbF6OXzFOnxL6AvB7ITqZEAbb+7xXECnXm/8pp0cZ/cuXFq+T5DDO6ZRZ3ZCNXdJsnL8uv1Cvno5fILEPs6GSHIKoI04t3gOIIF7Q3LoF/LP9i5car5PkFjnNPrMTqgeDmhDziWycvyqyfG5uRSyi/ewIHHKGoFmJ1RzkeTZgE613vhlu1aVksNXrN5tctX3BFlJHgFog+4rngVI1N6QHLpWfb534RryfYL0d1Ej0eyE6pGANuquIyvHJU2OL+z6hefy8yxIrhNLkuSZgE6v3vhFu1aVlcNXrN4tc2S+vj/ITqgeBWjjTh3PAHRq9Ybk0LXqi70L15bvE2RYRzUqzU6oHg1oA08VWTl+3uT40lQvtqTnWpDh3ZqjJE8HdEr1xs/atcpyNHIWpHcLHT1fo9PshOoxgDb0WHEGoNOpNySHrlVf7l245nwLMl53NULNTqjGkiQrx0/byWE5rrYfLMh4guhJQyQ5FdAGz8bTAInWGz9pcpzTu7BCvgUZv8saqWYnVKcB2ui9cTogwXpDcuhadW7vwir5FmSaTmu0mp1Q9UqSlePidnJYjkP2gAWZRhA9dYgkjwW08f9fPBWQUL0hOXRyfKV3YbV8CzJtxzVqzU6oHgdIgIPiKYBE6o2Lmhxf7V1YMd+CTN/1KSTJyvHjdq2yHMG+W5AgqIFp+l4iO6F6PCAhNvFkQKdLb0gOXavO611YOd+C7K77GsFmJlR6wycAEiMrx4VNjq/trtx1/CQLsts+DpFEf1rnhMTrXtCuVZYjAc+CJKANXKLvKyITqoE/5srlkkPXqq+P8bCKz7Ag++m6RrOHTajGeKsfNTm+McbDqj7Dguyv81NK8sN2rbIcA/trQQYCHLhc32NsT6gGPu7K5ZJD16pvjvGw6s+wIPvfARrZajo1RvygnRyWYwyagAUZCeTAx4whyfebHN8a+C5evkXAgsxnO+gLwSclX0dy6Fr17eR6LzuAgAWZ19bQF4JP7Hyl77WTw3J0goukW5AIpd3m9EiiUe5tgPN3+4p1fpoFmWevI3904VLgpsB35lnCOt7Kgsy3j2cCJx3wepcDx1uO6ZtnQaZnPOQn3AQ4C7gecCxwWftjbrcCrhjyYK+NEbAgMU7OKkrAghRtvMuOEbAgMU7OKkrAghRtvMuOEbAgMU7OKkrAghRtvMuOEbAgMU7OKkrAghRtvMuOEbAgMU7OKkrAghRtvMuOEbAgMU7OKkrAghRtvMuOEbAgMU7OKkrAghRtvMuOEbAgMU7OKkrAghRtvMuOEbAgMU7OKkrAghRtvMuOEbAgMU7OKkrAghRtvMuOEbAgMU7OKkrAghRtvMuOEbAgMU7OKkrAghRtvMuOEbAgMU7OKkrAghRtvMuOEbAgMU7OKkrAghRtvMuOEbAgMU7OKkrAghRtvMuOEbAgMU7OKkrAghRtvMuOEbAgMU7OKkrAghRtvMuOEbAgMU7OKkrAghRtvMuOEbAgMU7OKkrAghRtvMuOEbAgMU7OKkrAghRtvMuOEbAgMU7OKkrAghRtvMuOEbAgMU7OKkrAghRtvMuOEbAgMU7OKkrAghRtvMuOEbAgMU7OKkrAghRtvMuOEbAgMU7OKkrAghRtvMuOEbAgMU7OKkrAghRtvMuOEbAgMU7OKkrAghRtvMuOEbAgMU7OKkrAghRtvMuOEbAgMU7OKkrAghRtvMuOEbAgMU7OKkrAghRtvMuOEbAgMU7OKkrAghRtvMuOEfgf9hZk2PNubUAAAAAASUVORK5CYII="); +} + +.state > select { + border-bottom: 0; + background-image: none; + pointer-events: none; +} + +.state-bold > select { + font-weight: bold; + color: #7a77bb; +} + +.state-light > select { + color: #777777; +} + +[id^="pywebio-scope-arg_stored-stored-value-"] > div > input { + border-bottom: none; + background-color: transparent !important; +} + +textarea { + border: 1px solid lightgrey; +} + +.CodeMirror-wrap { + border: 1px solid lightgrey; +} + +.aside-icon > path { + fill: #2c2c2c; +} + +.container-log { + background-color: white !important; + border: 1px solid lightgrey; +} + +pre.rich-traceback-code { + background-color: #ffffff; + color: #616161; +} + +#pywebio-scope-content { + background-color: #f9f9f9; +} + +[id^="pywebio-scope-group_"] { + background-color: white; + border: 1px solid lightgrey; +} + +#pywebio-scope-daemon-overview [id^="pywebio-scope-group_"] { + border: 0; +} + +#pywebio-scope-aside { + background-color: white; + border-right: 1px solid lightgrey; +} + +#pywebio-scope-menu { + box-shadow: 0 0 8px rgba(0, 0, 0, .1); + background-color: white; +} + +#pywebio-scope-navigator { + 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-daemon-overview #pywebio-scope-groups { + background-color: white; + border: 1px solid lightgrey; +} + +#pywebio-scope-header { + box-shadow: 0 0 8px rgba(0, 0, 0, .2); +} + +*[style*="--arg-help--"], +[id^="pywebio-scope-group_"] > p + p { + color: #777777; +} + +*[style*="--dashboard-time--"] { + color: #777777; +} \ No newline at end of file diff --git a/assets/gui/icon/add.svg b/assets/gui/icon/add.svg new file mode 100644 index 0000000..344503e --- /dev/null +++ b/assets/gui/icon/add.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/assets/gui/icon/alas.svg b/assets/gui/icon/alas.svg new file mode 100644 index 0000000..a09327a --- /dev/null +++ b/assets/gui/icon/alas.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/assets/gui/icon/develop.svg b/assets/gui/icon/develop.svg new file mode 100644 index 0000000..c734fa6 --- /dev/null +++ b/assets/gui/icon/develop.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/assets/gui/icon/run.svg b/assets/gui/icon/run.svg new file mode 100644 index 0000000..d3b16bc --- /dev/null +++ b/assets/gui/icon/run.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/assets/gui/icon/setting.svg b/assets/gui/icon/setting.svg new file mode 100644 index 0000000..ccfb004 --- /dev/null +++ b/assets/gui/icon/setting.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/assets/jp/base/page/ACCOUNT_INFO_CHECK.png b/assets/jp/base/page/ACCOUNT_INFO_CHECK.png new file mode 100644 index 0000000..0bce627 Binary files /dev/null and b/assets/jp/base/page/ACCOUNT_INFO_CHECK.png differ diff --git a/assets/jp/base/page/BACK.png b/assets/jp/base/page/BACK.png new file mode 100644 index 0000000..e36fd57 Binary files /dev/null and b/assets/jp/base/page/BACK.png differ diff --git a/assets/jp/base/page/BOUNTY_CHECK.png b/assets/jp/base/page/BOUNTY_CHECK.png new file mode 100644 index 0000000..5b38fa5 Binary files /dev/null and b/assets/jp/base/page/BOUNTY_CHECK.png differ diff --git a/assets/jp/base/page/CAFE_CHECK.png b/assets/jp/base/page/CAFE_CHECK.png new file mode 100644 index 0000000..9701c4e Binary files /dev/null and b/assets/jp/base/page/CAFE_CHECK.png differ diff --git a/assets/jp/base/page/CIRCLE_CHECK.png b/assets/jp/base/page/CIRCLE_CHECK.png new file mode 100644 index 0000000..4377823 Binary files /dev/null and b/assets/jp/base/page/CIRCLE_CHECK.png differ diff --git a/assets/jp/base/page/COMMISSIONS_CHECK.png b/assets/jp/base/page/COMMISSIONS_CHECK.png new file mode 100644 index 0000000..6565f83 Binary files /dev/null and b/assets/jp/base/page/COMMISSIONS_CHECK.png differ diff --git a/assets/jp/base/page/CRAFTING_CHECK.png b/assets/jp/base/page/CRAFTING_CHECK.png new file mode 100644 index 0000000..2e93590 Binary files /dev/null and b/assets/jp/base/page/CRAFTING_CHECK.png differ diff --git a/assets/jp/base/page/GACHA_CHECK.png b/assets/jp/base/page/GACHA_CHECK.png new file mode 100644 index 0000000..cace0fb Binary files /dev/null and b/assets/jp/base/page/GACHA_CHECK.png differ diff --git a/assets/jp/base/page/HOME.png b/assets/jp/base/page/HOME.png new file mode 100644 index 0000000..6cc85e1 Binary files /dev/null and b/assets/jp/base/page/HOME.png differ diff --git a/assets/jp/base/page/LOADING_CHECK.png b/assets/jp/base/page/LOADING_CHECK.png new file mode 100644 index 0000000..25fed20 Binary files /dev/null and b/assets/jp/base/page/LOADING_CHECK.png differ diff --git a/assets/jp/base/page/MAIL_CHECK.png b/assets/jp/base/page/MAIL_CHECK.png new file mode 100644 index 0000000..f7bb708 Binary files /dev/null and b/assets/jp/base/page/MAIL_CHECK.png differ diff --git a/assets/jp/base/page/MAIN_GO_TO_CAFE.png b/assets/jp/base/page/MAIN_GO_TO_CAFE.png new file mode 100644 index 0000000..5547831 Binary files /dev/null and b/assets/jp/base/page/MAIN_GO_TO_CAFE.png differ diff --git a/assets/jp/base/page/MAIN_GO_TO_CIRCLE.png b/assets/jp/base/page/MAIN_GO_TO_CIRCLE.png new file mode 100644 index 0000000..e4d1169 Binary files /dev/null and b/assets/jp/base/page/MAIN_GO_TO_CIRCLE.png differ diff --git a/assets/jp/base/page/MAIN_GO_TO_CRAFTING.png b/assets/jp/base/page/MAIN_GO_TO_CRAFTING.png new file mode 100644 index 0000000..e607c6a Binary files /dev/null and b/assets/jp/base/page/MAIN_GO_TO_CRAFTING.png differ diff --git a/assets/jp/base/page/MAIN_GO_TO_GACHA.png b/assets/jp/base/page/MAIN_GO_TO_GACHA.png new file mode 100644 index 0000000..a7e8d33 Binary files /dev/null and b/assets/jp/base/page/MAIN_GO_TO_GACHA.png differ diff --git a/assets/jp/base/page/MAIN_GO_TO_MAIL.png b/assets/jp/base/page/MAIN_GO_TO_MAIL.png new file mode 100644 index 0000000..202ae87 Binary files /dev/null and b/assets/jp/base/page/MAIN_GO_TO_MAIL.png differ diff --git a/assets/jp/base/page/MAIN_GO_TO_MOMOTALK.png b/assets/jp/base/page/MAIN_GO_TO_MOMOTALK.png new file mode 100644 index 0000000..58d3cf1 Binary files /dev/null and b/assets/jp/base/page/MAIN_GO_TO_MOMOTALK.png differ diff --git a/assets/jp/base/page/MAIN_GO_TO_PURCHASE.png b/assets/jp/base/page/MAIN_GO_TO_PURCHASE.png new file mode 100644 index 0000000..4e365b3 Binary files /dev/null and b/assets/jp/base/page/MAIN_GO_TO_PURCHASE.png differ diff --git a/assets/jp/base/page/MAIN_GO_TO_SCHEDULE.png b/assets/jp/base/page/MAIN_GO_TO_SCHEDULE.png new file mode 100644 index 0000000..146282b Binary files /dev/null and b/assets/jp/base/page/MAIN_GO_TO_SCHEDULE.png differ diff --git a/assets/jp/base/page/MAIN_GO_TO_SHOP.png b/assets/jp/base/page/MAIN_GO_TO_SHOP.png new file mode 100644 index 0000000..a8e8d89 Binary files /dev/null and b/assets/jp/base/page/MAIN_GO_TO_SHOP.png differ diff --git a/assets/jp/base/page/MAIN_GO_TO_TASK.png b/assets/jp/base/page/MAIN_GO_TO_TASK.png new file mode 100644 index 0000000..7dae9e8 Binary files /dev/null and b/assets/jp/base/page/MAIN_GO_TO_TASK.png differ diff --git a/assets/jp/base/page/MAIN_GO_TO_WORK.png b/assets/jp/base/page/MAIN_GO_TO_WORK.png new file mode 100644 index 0000000..5b5560b Binary files /dev/null and b/assets/jp/base/page/MAIN_GO_TO_WORK.png differ diff --git a/assets/jp/base/page/MISSION_CHECK.png b/assets/jp/base/page/MISSION_CHECK.png new file mode 100644 index 0000000..d060b68 Binary files /dev/null and b/assets/jp/base/page/MISSION_CHECK.png differ diff --git a/assets/jp/base/page/MOMOTALK_CHECK.png b/assets/jp/base/page/MOMOTALK_CHECK.png new file mode 100644 index 0000000..455678a Binary files /dev/null and b/assets/jp/base/page/MOMOTALK_CHECK.png differ diff --git a/assets/jp/base/page/MOMOTALK_GO_TO_MAIN.png b/assets/jp/base/page/MOMOTALK_GO_TO_MAIN.png new file mode 100644 index 0000000..6eda2d7 Binary files /dev/null and b/assets/jp/base/page/MOMOTALK_GO_TO_MAIN.png differ diff --git a/assets/jp/base/page/SCHEDULE_CHECK.png b/assets/jp/base/page/SCHEDULE_CHECK.png new file mode 100644 index 0000000..459f48a Binary files /dev/null and b/assets/jp/base/page/SCHEDULE_CHECK.png differ diff --git a/assets/jp/base/page/SCHOOL_EXCHANGE_CHECK.png b/assets/jp/base/page/SCHOOL_EXCHANGE_CHECK.png new file mode 100644 index 0000000..bd3e72a Binary files /dev/null and b/assets/jp/base/page/SCHOOL_EXCHANGE_CHECK.png differ diff --git a/assets/jp/base/page/SHOP_CHECK.png b/assets/jp/base/page/SHOP_CHECK.png new file mode 100644 index 0000000..ac88ce0 Binary files /dev/null and b/assets/jp/base/page/SHOP_CHECK.png differ diff --git a/assets/jp/base/page/STORY_CHECK.png b/assets/jp/base/page/STORY_CHECK.png new file mode 100644 index 0000000..5f9931c Binary files /dev/null and b/assets/jp/base/page/STORY_CHECK.png differ diff --git a/assets/jp/base/page/TACTICAL_CHALLENGE_CHECK.png b/assets/jp/base/page/TACTICAL_CHALLENGE_CHECK.png new file mode 100644 index 0000000..df8619e Binary files /dev/null and b/assets/jp/base/page/TACTICAL_CHALLENGE_CHECK.png differ diff --git a/assets/jp/base/page/TASK_CHECK.png b/assets/jp/base/page/TASK_CHECK.png new file mode 100644 index 0000000..3802c91 Binary files /dev/null and b/assets/jp/base/page/TASK_CHECK.png differ diff --git a/assets/jp/base/page/WORK_CHECK.png b/assets/jp/base/page/WORK_CHECK.png new file mode 100644 index 0000000..3d7ee2b Binary files /dev/null and b/assets/jp/base/page/WORK_CHECK.png differ diff --git a/assets/jp/base/page/WORK_GO_TO_BOUNTY.png b/assets/jp/base/page/WORK_GO_TO_BOUNTY.png new file mode 100644 index 0000000..71f03e1 Binary files /dev/null and b/assets/jp/base/page/WORK_GO_TO_BOUNTY.png differ diff --git a/assets/jp/base/page/WORK_GO_TO_COMMISSIONS.png b/assets/jp/base/page/WORK_GO_TO_COMMISSIONS.png new file mode 100644 index 0000000..256a5ef Binary files /dev/null and b/assets/jp/base/page/WORK_GO_TO_COMMISSIONS.png differ diff --git a/assets/jp/base/page/WORK_GO_TO_MISSION.png b/assets/jp/base/page/WORK_GO_TO_MISSION.png new file mode 100644 index 0000000..226c930 Binary files /dev/null and b/assets/jp/base/page/WORK_GO_TO_MISSION.png differ diff --git a/assets/jp/base/page/WORK_GO_TO_SCHOOL_EXCHANGE.png b/assets/jp/base/page/WORK_GO_TO_SCHOOL_EXCHANGE.png new file mode 100644 index 0000000..8f48008 Binary files /dev/null and b/assets/jp/base/page/WORK_GO_TO_SCHOOL_EXCHANGE.png differ diff --git a/assets/jp/base/page/WORK_GO_TO_STORY.png b/assets/jp/base/page/WORK_GO_TO_STORY.png new file mode 100644 index 0000000..7275e22 Binary files /dev/null and b/assets/jp/base/page/WORK_GO_TO_STORY.png differ diff --git a/assets/jp/base/page/WORK_GO_TO_TACTICAL_CHALLENGE.png b/assets/jp/base/page/WORK_GO_TO_TACTICAL_CHALLENGE.png new file mode 100644 index 0000000..44b0fe6 Binary files /dev/null and b/assets/jp/base/page/WORK_GO_TO_TACTICAL_CHALLENGE.png differ diff --git a/assets/jp/base/popup/AFFECTION_LEVEL_UP.BUTTON.png b/assets/jp/base/popup/AFFECTION_LEVEL_UP.BUTTON.png new file mode 100644 index 0000000..a331e9a Binary files /dev/null and b/assets/jp/base/popup/AFFECTION_LEVEL_UP.BUTTON.png differ diff --git a/assets/jp/base/popup/AFFECTION_LEVEL_UP.png b/assets/jp/base/popup/AFFECTION_LEVEL_UP.png new file mode 100644 index 0000000..1895ab4 Binary files /dev/null and b/assets/jp/base/popup/AFFECTION_LEVEL_UP.png differ diff --git a/assets/jp/base/popup/DAILY_NEWS.BUTTON.png b/assets/jp/base/popup/DAILY_NEWS.BUTTON.png new file mode 100644 index 0000000..33a3f89 Binary files /dev/null and b/assets/jp/base/popup/DAILY_NEWS.BUTTON.png differ diff --git a/assets/jp/base/popup/DAILY_NEWS.png b/assets/jp/base/popup/DAILY_NEWS.png new file mode 100644 index 0000000..2f9da4b Binary files /dev/null and b/assets/jp/base/popup/DAILY_NEWS.png differ diff --git a/assets/jp/base/popup/DAILY_REWARD.BUTTON.png b/assets/jp/base/popup/DAILY_REWARD.BUTTON.png new file mode 100644 index 0000000..1989880 Binary files /dev/null and b/assets/jp/base/popup/DAILY_REWARD.BUTTON.png differ diff --git a/assets/jp/base/popup/DAILY_REWARD.png b/assets/jp/base/popup/DAILY_REWARD.png new file mode 100644 index 0000000..094b5b6 Binary files /dev/null and b/assets/jp/base/popup/DAILY_REWARD.png differ diff --git a/assets/jp/base/popup/GET_NEW_STUDENT.BUTTON.png b/assets/jp/base/popup/GET_NEW_STUDENT.BUTTON.png new file mode 100644 index 0000000..da6e87e Binary files /dev/null and b/assets/jp/base/popup/GET_NEW_STUDENT.BUTTON.png differ diff --git a/assets/jp/base/popup/GET_NEW_STUDENT.png b/assets/jp/base/popup/GET_NEW_STUDENT.png new file mode 100644 index 0000000..7e9fe54 Binary files /dev/null and b/assets/jp/base/popup/GET_NEW_STUDENT.png differ diff --git a/assets/jp/base/popup/GET_REWARD.BUTTON.png b/assets/jp/base/popup/GET_REWARD.BUTTON.png new file mode 100644 index 0000000..13245a1 Binary files /dev/null and b/assets/jp/base/popup/GET_REWARD.BUTTON.png differ diff --git a/assets/jp/base/popup/GET_REWARD.png b/assets/jp/base/popup/GET_REWARD.png new file mode 100644 index 0000000..4c2247e Binary files /dev/null and b/assets/jp/base/popup/GET_REWARD.png differ diff --git a/assets/jp/base/popup/GET_REWARD_SKIP.png b/assets/jp/base/popup/GET_REWARD_SKIP.png new file mode 100644 index 0000000..abfdfed Binary files /dev/null and b/assets/jp/base/popup/GET_REWARD_SKIP.png differ diff --git a/assets/jp/base/popup/NETWORK_RECONNECT.BUTTON.png b/assets/jp/base/popup/NETWORK_RECONNECT.BUTTON.png new file mode 100644 index 0000000..6d48c8b Binary files /dev/null and b/assets/jp/base/popup/NETWORK_RECONNECT.BUTTON.png differ diff --git a/assets/jp/base/popup/NETWORK_RECONNECT.png b/assets/jp/base/popup/NETWORK_RECONNECT.png new file mode 100644 index 0000000..27fe648 Binary files /dev/null and b/assets/jp/base/popup/NETWORK_RECONNECT.png differ diff --git a/assets/jp/cafe/BOX_CAFE.png b/assets/jp/cafe/BOX_CAFE.png new file mode 100644 index 0000000..6a883b1 Binary files /dev/null and b/assets/jp/cafe/BOX_CAFE.png differ diff --git a/assets/jp/cafe/CHECK_REWARD.BUTTON.png b/assets/jp/cafe/CHECK_REWARD.BUTTON.png new file mode 100644 index 0000000..516bad7 Binary files /dev/null and b/assets/jp/cafe/CHECK_REWARD.BUTTON.png differ diff --git a/assets/jp/cafe/CHECK_REWARD.png b/assets/jp/cafe/CHECK_REWARD.png new file mode 100644 index 0000000..6e991d5 Binary files /dev/null and b/assets/jp/cafe/CHECK_REWARD.png differ diff --git a/assets/jp/cafe/CLICKABLE_TEMPLATE.png b/assets/jp/cafe/CLICKABLE_TEMPLATE.png new file mode 100644 index 0000000..570afc7 Binary files /dev/null and b/assets/jp/cafe/CLICKABLE_TEMPLATE.png differ diff --git a/assets/jp/cafe/GET_REWARD.BUTTON.png b/assets/jp/cafe/GET_REWARD.BUTTON.png new file mode 100644 index 0000000..0625fb2 Binary files /dev/null and b/assets/jp/cafe/GET_REWARD.BUTTON.png differ diff --git a/assets/jp/cafe/GET_REWARD.png b/assets/jp/cafe/GET_REWARD.png new file mode 100644 index 0000000..ad660e5 Binary files /dev/null and b/assets/jp/cafe/GET_REWARD.png differ diff --git a/assets/jp/cafe/GET_REWARD_CLOSE.png b/assets/jp/cafe/GET_REWARD_CLOSE.png new file mode 100644 index 0000000..1254761 Binary files /dev/null and b/assets/jp/cafe/GET_REWARD_CLOSE.png differ diff --git a/assets/jp/cafe/GOT_REWARD.png b/assets/jp/cafe/GOT_REWARD.png new file mode 100644 index 0000000..9662792 Binary files /dev/null and b/assets/jp/cafe/GOT_REWARD.png differ diff --git a/assets/jp/cafe/OCR_CAFE.png b/assets/jp/cafe/OCR_CAFE.png new file mode 100644 index 0000000..080ba07 Binary files /dev/null and b/assets/jp/cafe/OCR_CAFE.png differ diff --git a/assets/jp/cafe/STUDENT_LIST.BUTTON.png b/assets/jp/cafe/STUDENT_LIST.BUTTON.png new file mode 100644 index 0000000..927eb9b Binary files /dev/null and b/assets/jp/cafe/STUDENT_LIST.BUTTON.png differ diff --git a/assets/jp/cafe/STUDENT_LIST.png b/assets/jp/cafe/STUDENT_LIST.png new file mode 100644 index 0000000..e55289c Binary files /dev/null and b/assets/jp/cafe/STUDENT_LIST.png differ diff --git a/assets/jp/circle/GET_REWARD_AP.BUTTON.png b/assets/jp/circle/GET_REWARD_AP.BUTTON.png new file mode 100644 index 0000000..220bc2a Binary files /dev/null and b/assets/jp/circle/GET_REWARD_AP.BUTTON.png differ diff --git a/assets/jp/circle/GET_REWARD_AP.png b/assets/jp/circle/GET_REWARD_AP.png new file mode 100644 index 0000000..89d74d2 Binary files /dev/null and b/assets/jp/circle/GET_REWARD_AP.png differ diff --git a/assets/jp/login/LOGIN_CONFIRM.BUTTON.png b/assets/jp/login/LOGIN_CONFIRM.BUTTON.png new file mode 100644 index 0000000..359f813 Binary files /dev/null and b/assets/jp/login/LOGIN_CONFIRM.BUTTON.png differ diff --git a/assets/jp/login/LOGIN_CONFIRM.png b/assets/jp/login/LOGIN_CONFIRM.png new file mode 100644 index 0000000..c657014 Binary files /dev/null and b/assets/jp/login/LOGIN_CONFIRM.png differ diff --git a/assets/jp/login/LOGIN_LOADING.png b/assets/jp/login/LOGIN_LOADING.png new file mode 100644 index 0000000..3d4424c Binary files /dev/null and b/assets/jp/login/LOGIN_LOADING.png differ diff --git a/assets/jp/mail/MAIL_RECEIVE.BUTTON.png b/assets/jp/mail/MAIL_RECEIVE.BUTTON.png new file mode 100644 index 0000000..85cd5d3 Binary files /dev/null and b/assets/jp/mail/MAIL_RECEIVE.BUTTON.png differ diff --git a/assets/jp/mail/MAIL_RECEIVE.png b/assets/jp/mail/MAIL_RECEIVE.png new file mode 100644 index 0000000..64aa00c Binary files /dev/null and b/assets/jp/mail/MAIL_RECEIVE.png differ diff --git a/assets/jp/mail/MAIL_RECEIVED.png b/assets/jp/mail/MAIL_RECEIVED.png new file mode 100644 index 0000000..af7f232 Binary files /dev/null and b/assets/jp/mail/MAIL_RECEIVED.png differ diff --git a/assets/jp/schedule/SCROLL.png b/assets/jp/schedule/SCROLL.png new file mode 100644 index 0000000..02308f9 Binary files /dev/null and b/assets/jp/schedule/SCROLL.png differ diff --git a/assets/jp/tactical_challenge/CHALLENGE_LOSE.BUTTON.png b/assets/jp/tactical_challenge/CHALLENGE_LOSE.BUTTON.png new file mode 100644 index 0000000..f0a00d7 Binary files /dev/null and b/assets/jp/tactical_challenge/CHALLENGE_LOSE.BUTTON.png differ diff --git a/assets/jp/tactical_challenge/CHALLENGE_LOSE.png b/assets/jp/tactical_challenge/CHALLENGE_LOSE.png new file mode 100644 index 0000000..0f5aba9 Binary files /dev/null and b/assets/jp/tactical_challenge/CHALLENGE_LOSE.png differ diff --git a/assets/jp/tactical_challenge/CHALLENGE_WIN.BUTTON.png b/assets/jp/tactical_challenge/CHALLENGE_WIN.BUTTON.png new file mode 100644 index 0000000..34e7256 Binary files /dev/null and b/assets/jp/tactical_challenge/CHALLENGE_WIN.BUTTON.png differ diff --git a/assets/jp/tactical_challenge/CHALLENGE_WIN.png b/assets/jp/tactical_challenge/CHALLENGE_WIN.png new file mode 100644 index 0000000..2295dc2 Binary files /dev/null and b/assets/jp/tactical_challenge/CHALLENGE_WIN.png differ diff --git a/assets/jp/tactical_challenge/GET_REWARD_CREDIT.png b/assets/jp/tactical_challenge/GET_REWARD_CREDIT.png new file mode 100644 index 0000000..be4daee Binary files /dev/null and b/assets/jp/tactical_challenge/GET_REWARD_CREDIT.png differ diff --git a/assets/jp/tactical_challenge/GET_REWARD_DAILY.png b/assets/jp/tactical_challenge/GET_REWARD_DAILY.png new file mode 100644 index 0000000..239332f Binary files /dev/null and b/assets/jp/tactical_challenge/GET_REWARD_DAILY.png differ diff --git a/assets/jp/tactical_challenge/GOT_REWARD_CREDIT.png b/assets/jp/tactical_challenge/GOT_REWARD_CREDIT.png new file mode 100644 index 0000000..e06e26c Binary files /dev/null and b/assets/jp/tactical_challenge/GOT_REWARD_CREDIT.png differ diff --git a/assets/jp/tactical_challenge/GOT_REWARD_DAILY.png b/assets/jp/tactical_challenge/GOT_REWARD_DAILY.png new file mode 100644 index 0000000..a9d827f Binary files /dev/null and b/assets/jp/tactical_challenge/GOT_REWARD_DAILY.png differ diff --git a/assets/jp/tactical_challenge/OCR_TICKET.png b/assets/jp/tactical_challenge/OCR_TICKET.png new file mode 100644 index 0000000..f269ecb Binary files /dev/null and b/assets/jp/tactical_challenge/OCR_TICKET.png differ diff --git a/assets/jp/tactical_challenge/PLAYER_SELECT_FIRST.BUTTON.png b/assets/jp/tactical_challenge/PLAYER_SELECT_FIRST.BUTTON.png new file mode 100644 index 0000000..00ae01a Binary files /dev/null and b/assets/jp/tactical_challenge/PLAYER_SELECT_FIRST.BUTTON.png differ diff --git a/assets/jp/tactical_challenge/PLAYER_SELECT_FIRST.png b/assets/jp/tactical_challenge/PLAYER_SELECT_FIRST.png new file mode 100644 index 0000000..fa4383b Binary files /dev/null and b/assets/jp/tactical_challenge/PLAYER_SELECT_FIRST.png differ diff --git a/assets/jp/tactical_challenge/PLAYER_SELECT_SECOND.BUTTON.png b/assets/jp/tactical_challenge/PLAYER_SELECT_SECOND.BUTTON.png new file mode 100644 index 0000000..ed66719 Binary files /dev/null and b/assets/jp/tactical_challenge/PLAYER_SELECT_SECOND.BUTTON.png differ diff --git a/assets/jp/tactical_challenge/PLAYER_SELECT_SECOND.png b/assets/jp/tactical_challenge/PLAYER_SELECT_SECOND.png new file mode 100644 index 0000000..23a87c8 Binary files /dev/null and b/assets/jp/tactical_challenge/PLAYER_SELECT_SECOND.png differ diff --git a/assets/jp/tactical_challenge/PLAYER_SELECT_THIRD.BUTTON.png b/assets/jp/tactical_challenge/PLAYER_SELECT_THIRD.BUTTON.png new file mode 100644 index 0000000..370db5e Binary files /dev/null and b/assets/jp/tactical_challenge/PLAYER_SELECT_THIRD.BUTTON.png differ diff --git a/assets/jp/tactical_challenge/PLAYER_SELECT_THIRD.png b/assets/jp/tactical_challenge/PLAYER_SELECT_THIRD.png new file mode 100644 index 0000000..4f42af9 Binary files /dev/null and b/assets/jp/tactical_challenge/PLAYER_SELECT_THIRD.png differ diff --git a/assets/jp/tactical_challenge/PREPARE_CHALLENGE.BUTTON.png b/assets/jp/tactical_challenge/PREPARE_CHALLENGE.BUTTON.png new file mode 100644 index 0000000..535eca3 Binary files /dev/null and b/assets/jp/tactical_challenge/PREPARE_CHALLENGE.BUTTON.png differ diff --git a/assets/jp/tactical_challenge/PREPARE_CHALLENGE.png b/assets/jp/tactical_challenge/PREPARE_CHALLENGE.png new file mode 100644 index 0000000..2af90bc Binary files /dev/null and b/assets/jp/tactical_challenge/PREPARE_CHALLENGE.png differ diff --git a/assets/jp/tactical_challenge/SKIP_OFF.BUTTON.png b/assets/jp/tactical_challenge/SKIP_OFF.BUTTON.png new file mode 100644 index 0000000..2360ca5 Binary files /dev/null and b/assets/jp/tactical_challenge/SKIP_OFF.BUTTON.png differ diff --git a/assets/jp/tactical_challenge/SKIP_OFF.png b/assets/jp/tactical_challenge/SKIP_OFF.png new file mode 100644 index 0000000..859c6e5 Binary files /dev/null and b/assets/jp/tactical_challenge/SKIP_OFF.png differ diff --git a/assets/jp/tactical_challenge/SKIP_ON.BUTTON.png b/assets/jp/tactical_challenge/SKIP_ON.BUTTON.png new file mode 100644 index 0000000..2e7ab7f Binary files /dev/null and b/assets/jp/tactical_challenge/SKIP_ON.BUTTON.png differ diff --git a/assets/jp/tactical_challenge/SKIP_ON.png b/assets/jp/tactical_challenge/SKIP_ON.png new file mode 100644 index 0000000..5869e23 Binary files /dev/null and b/assets/jp/tactical_challenge/SKIP_ON.png differ diff --git a/assets/jp/tactical_challenge/START_CHALLENGE.BUTTON.png b/assets/jp/tactical_challenge/START_CHALLENGE.BUTTON.png new file mode 100644 index 0000000..7903709 Binary files /dev/null and b/assets/jp/tactical_challenge/START_CHALLENGE.BUTTON.png differ diff --git a/assets/jp/tactical_challenge/START_CHALLENGE.png b/assets/jp/tactical_challenge/START_CHALLENGE.png new file mode 100644 index 0000000..49c55ca Binary files /dev/null and b/assets/jp/tactical_challenge/START_CHALLENGE.png differ diff --git a/assets/share/item/data/OCR_AP.png b/assets/share/item/data/OCR_AP.png new file mode 100644 index 0000000..44cf99a Binary files /dev/null and b/assets/share/item/data/OCR_AP.png differ diff --git a/assets/share/item/data/OCR_DATA.png b/assets/share/item/data/OCR_DATA.png new file mode 100644 index 0000000..0e60519 Binary files /dev/null and b/assets/share/item/data/OCR_DATA.png differ diff --git a/bin/DroidCast/DroidCast-debug-1.1.0.apk b/bin/DroidCast/DroidCast-debug-1.1.0.apk new file mode 100644 index 0000000..a53654f Binary files /dev/null and b/bin/DroidCast/DroidCast-debug-1.1.0.apk differ diff --git a/bin/DroidCast/DroidCastS-release-1.1.5.apk b/bin/DroidCast/DroidCastS-release-1.1.5.apk new file mode 100644 index 0000000..50608a2 Binary files /dev/null and b/bin/DroidCast/DroidCastS-release-1.1.5.apk differ diff --git a/bin/MaaTouch/maatouch b/bin/MaaTouch/maatouch new file mode 100644 index 0000000..3673f3f Binary files /dev/null and b/bin/MaaTouch/maatouch differ diff --git a/bin/ascreencap/Android_5.x-7.x/arm64-v8a/ascreencap b/bin/ascreencap/Android_5.x-7.x/arm64-v8a/ascreencap new file mode 100644 index 0000000..3738bf2 Binary files /dev/null and b/bin/ascreencap/Android_5.x-7.x/arm64-v8a/ascreencap differ diff --git a/bin/ascreencap/Android_5.x-7.x/armeabi-v7a/ascreencap b/bin/ascreencap/Android_5.x-7.x/armeabi-v7a/ascreencap new file mode 100644 index 0000000..acd5bec Binary files /dev/null and b/bin/ascreencap/Android_5.x-7.x/armeabi-v7a/ascreencap differ diff --git a/bin/ascreencap/Android_5.x-7.x/x86/ascreencap b/bin/ascreencap/Android_5.x-7.x/x86/ascreencap new file mode 100644 index 0000000..4eb60eb Binary files /dev/null and b/bin/ascreencap/Android_5.x-7.x/x86/ascreencap differ diff --git a/bin/ascreencap/Android_5.x-7.x/x86_64/ascreencap b/bin/ascreencap/Android_5.x-7.x/x86_64/ascreencap new file mode 100644 index 0000000..99e023f Binary files /dev/null and b/bin/ascreencap/Android_5.x-7.x/x86_64/ascreencap differ diff --git a/bin/ascreencap/Android_8.x/arm64-v8a/ascreencap b/bin/ascreencap/Android_8.x/arm64-v8a/ascreencap new file mode 100644 index 0000000..3bb69d6 Binary files /dev/null and b/bin/ascreencap/Android_8.x/arm64-v8a/ascreencap differ diff --git a/bin/ascreencap/Android_8.x/armeabi-v7a/ascreencap b/bin/ascreencap/Android_8.x/armeabi-v7a/ascreencap new file mode 100644 index 0000000..f486e50 Binary files /dev/null and b/bin/ascreencap/Android_8.x/armeabi-v7a/ascreencap differ diff --git a/bin/ascreencap/Android_8.x/x86/ascreencap b/bin/ascreencap/Android_8.x/x86/ascreencap new file mode 100644 index 0000000..ec66c99 Binary files /dev/null and b/bin/ascreencap/Android_8.x/x86/ascreencap differ diff --git a/bin/ascreencap/Android_8.x/x86_64/ascreencap b/bin/ascreencap/Android_8.x/x86_64/ascreencap new file mode 100644 index 0000000..c11aa1c Binary files /dev/null and b/bin/ascreencap/Android_8.x/x86_64/ascreencap differ diff --git a/bin/ascreencap/Android_9.x/arm64-v8a/ascreencap b/bin/ascreencap/Android_9.x/arm64-v8a/ascreencap new file mode 100644 index 0000000..4a5a511 Binary files /dev/null and b/bin/ascreencap/Android_9.x/arm64-v8a/ascreencap differ diff --git a/bin/ascreencap/Android_9.x/armeabi-v7a/ascreencap b/bin/ascreencap/Android_9.x/armeabi-v7a/ascreencap new file mode 100644 index 0000000..fc66846 Binary files /dev/null and b/bin/ascreencap/Android_9.x/armeabi-v7a/ascreencap differ diff --git a/bin/ascreencap/Android_9.x/x86/ascreencap b/bin/ascreencap/Android_9.x/x86/ascreencap new file mode 100644 index 0000000..18c6a2d Binary files /dev/null and b/bin/ascreencap/Android_9.x/x86/ascreencap differ diff --git a/bin/ascreencap/Android_9.x/x86_64/ascreencap b/bin/ascreencap/Android_9.x/x86_64/ascreencap new file mode 100644 index 0000000..3d91a0d Binary files /dev/null and b/bin/ascreencap/Android_9.x/x86_64/ascreencap differ diff --git a/bin/scrcpy/scrcpy-server-v1.20.jar b/bin/scrcpy/scrcpy-server-v1.20.jar new file mode 100644 index 0000000..d1d8016 Binary files /dev/null and b/bin/scrcpy/scrcpy-server-v1.20.jar differ diff --git a/bin/scrcpy/scrcpy-server-v1.25.jar b/bin/scrcpy/scrcpy-server-v1.25.jar new file mode 100644 index 0000000..5f55d0a Binary files /dev/null and b/bin/scrcpy/scrcpy-server-v1.25.jar differ diff --git a/config/deploy.template-cn.yaml b/config/deploy.template-cn.yaml new file mode 100644 index 0000000..4afd7c6 --- /dev/null +++ b/config/deploy.template-cn.yaml @@ -0,0 +1,165 @@ +Deploy: + Git: + # URL of AzurLaneAutoScript repository + # [CN user] Use 'cn' to get update from git-over-cdn service + # [Other] Use 'global' to get update from https://github.com/LmeSzinc/StarRailCopilot + Repository: cn + # Branch of Alas + # [Developer] Use 'dev', 'app', etc, to try new features + # [Other] Use 'master', the stable branch + Branch: master + # Filepath of git executable `git.exe` + # [Easy installer] Use './toolkit/Git/mingw64/bin/git.exe' + # [Other] Use you own git + GitExecutable: ./toolkit/Git/mingw64/bin/git.exe + # Set git proxy + # [CN user] Use your local http proxy (http://127.0.0.1:{port}) or socks5 proxy (socks5://127.0.0.1:{port}) + # [Other] Use null + GitProxy: null + # Set SSL Verify + # [In most cases] Use true + # [Other] Use false to when connected to an untrusted network + SSLVerify: true + # Update Alas at startup + # [In most cases] Use true + AutoUpdate: true + # Whether to keep local changes during update + # User settings, logs and screenshots will be kept, no mather this is true or false + # [Developer] Use true, if you modified the code + # [Other] Use false + KeepLocalChanges: false + + Python: + # Filepath of python executable `python.exe` + # [Easy installer] Use './toolkit/python.exe' + # [Other] Use you own python, and its version should be 3.7.6 64bit + PythonExecutable: ./toolkit/python.exe + # URL of pypi mirror + # [CN user] Use 'https://pypi.tuna.tsinghua.edu.cn/simple' for faster and more stable download + # [Other] Use null + PypiMirror: https://pypi.tuna.tsinghua.edu.cn/simple + # Install dependencies at startup + # [In most cases] Use true + InstallDependencies: true + # Path to requirements.txt + # [In most cases] Use 'requirements.txt' + # [In AidLux] Use './deploy/AidLux/{version}/requirements.txt', version is default to 0.92 + RequirementsFile: requirements.txt + + Adb: + # Filepath of ADB executable `adb.exe` + # [Easy installer] Use './toolkit/Lib/site-packages/adbutils/binaries/adb.exe' + # [Other] Use you own latest ADB, but not the ADB in your emulator + AdbExecutable: ./toolkit/Lib/site-packages/adbutils/binaries/adb.exe + # Whether to replace ADB + # Chinese emulators (NoxPlayer, LDPlayer, MemuPlayer, MuMuPlayer) use their own ADB, instead of the latest. + # Different ADB servers will terminate each other at startup, resulting in disconnection. + # For compatibility, we have to replace them all. + # This will do: + # 1. Terminate current ADB server + # 2. Rename ADB from all emulators to *.bak and replace them by the AdbExecutable set above + # 3. Brute-force connect to all available emulator instances + # [In most cases] Use true + # [In few cases] Use false, if you have other programs using ADB. + ReplaceAdb: true + # Brute-force connect to all available emulator instances + # [In most cases] Use true + AutoConnect: true + # Re-install uiautomator2 + # [In most cases] Use true + InstallUiautomator2: true + + Ocr: + # Run Ocr as a service, can reduce memory usage by not import mxnet everytime you start an alas instance + + # Whether to use ocr server + # [Default] false + UseOcrServer: false + # Whether to start ocr server when start GUI + # [Default] false + StartOcrServer: false + # Port of ocr server runs by GUI + # [Default] 22268 + OcrServerPort: 22268 + # Address of ocr server for alas instance to connect + # [Default] 127.0.0.1:22268 + OcrClientAddress: 127.0.0.1:22268 + + Update: + # Use auto update and builtin updater feature + # This may cause problem https://github.com/LmeSzinc/AzurLaneAutoScript/issues/876 + EnableReload: true + # Check update every X minute + # [Disable] 0 + # [Default] 5 + CheckUpdateInterval: 5 + # Scheduled restart time + # If there are updates, Alas will automatically restart and update at this time every day + # and run all alas instances that running before restarted + # [Disable] null + # [Default] 03:50 + AutoRestartTime: 03:50 + + Misc: + # Enable discord rich presence + DiscordRichPresence: false + + RemoteAccess: + # Enable remote access (using ssh reverse tunnel serve by https://github.com/wang0618/localshare) + # ! You need to set Password below to enable remote access since everyone can access to your alas if they have your url. + # See here (http://app.azurlane.cloud/en.html) for more infomation. + EnableRemoteAccess: false + # Username when login into ssh server + # [Default] null (will generate a random one when startup) + SSHUser: null + # Server to connect + # [Default] null + # [Format] host:port + SSHServer: null + # Filepath of SSH executable `ssh.exe` + # [Default] ssh (find ssh in system PATH) + # If you don't have one, install OpenSSH or download it here (https://github.com/PowerShell/Win32-OpenSSH/releases) + SSHExecutable: ssh + + Webui: + # --host. Host to listen + # [Use IPv6] '::' + # [In most cases] Default to '0.0.0.0' + WebuiHost: 0.0.0.0 + # --port. Port to listen + # You will be able to access webui via `http://{host}:{port}` + # [In most cases] Default to 22367 + WebuiPort: 22367 + # Language to use on web ui + # 'zh-CN' for Chinese simplified + # 'en-US' for English + # 'ja-JP' for Japanese + # 'zh-TW' for Chinese traditional + Language: zh-CN + # Theme of web ui + # 'default' for light theme + # 'dark' for dark theme + Theme: default + # Follow system DPI scaling + # [In most cases] true + # [In few cases] false to make Alas smaller, if you have a low resolution but high DPI scaling. + DpiScaling: true + # --key. Password of web ui + # Useful when expose Alas to the public network + Password: null + # --cdn. Use jsdelivr cdn for pywebio static files (css, js). + # 'true' for jsdelivr cdn + # 'false' for self host cdn (automatically) + # 'https://path.to.your/cdn' to use custom cdn + CDN: false + # --run. Auto-run specified config when startup + # 'null' default no specified config + # '["alas"]' specified "alas" config + # '["alas","alas2"]' specified "alas" "alas2" configs + Run: null + # To update app.asar + # [In most cases] true + AppAsarUpdate: true + # --no-sandbox. https://github.com/electron/electron/issues/30966 + # Some Windows systems cannot call the GPU normally for virtualization, and you need to manually turn off sandbox mode + NoSandbox: false diff --git a/config/deploy.template.yaml b/config/deploy.template.yaml new file mode 100644 index 0000000..cbeee0c --- /dev/null +++ b/config/deploy.template.yaml @@ -0,0 +1,165 @@ +Deploy: + Git: + # URL of AzurLaneAutoScript repository + # [CN user] Use 'cn' to get update from git-over-cdn service + # [Other] Use 'global' to get update from https://github.com/LmeSzinc/StarRailCopilot + Repository: global + # Branch of Alas + # [Developer] Use 'dev', 'app', etc, to try new features + # [Other] Use 'master', the stable branch + Branch: master + # Filepath of git executable `git.exe` + # [Easy installer] Use './toolkit/Git/mingw64/bin/git.exe' + # [Other] Use you own git + GitExecutable: ./toolkit/Git/mingw64/bin/git.exe + # Set git proxy + # [CN user] Use your local http proxy (http://127.0.0.1:{port}) or socks5 proxy (socks5://127.0.0.1:{port}) + # [Other] Use null + GitProxy: null + # Set SSL Verify + # [In most cases] Use true + # [Other] Use false to when connected to an untrusted network + SSLVerify: true + # Update Alas at startup + # [In most cases] Use true + AutoUpdate: true + # Whether to keep local changes during update + # User settings, logs and screenshots will be kept, no mather this is true or false + # [Developer] Use true, if you modified the code + # [Other] Use false + KeepLocalChanges: false + + Python: + # Filepath of python executable `python.exe` + # [Easy installer] Use './toolkit/python.exe' + # [Other] Use you own python, and its version should be 3.7.6 64bit + PythonExecutable: ./toolkit/python.exe + # URL of pypi mirror + # [CN user] Use 'https://pypi.tuna.tsinghua.edu.cn/simple' for faster and more stable download + # [Other] Use null + PypiMirror: null + # Install dependencies at startup + # [In most cases] Use true + InstallDependencies: true + # Path to requirements.txt + # [In most cases] Use 'requirements.txt' + # [In AidLux] Use './deploy/AidLux/{version}/requirements.txt', version is default to 0.92 + RequirementsFile: requirements.txt + + Adb: + # Filepath of ADB executable `adb.exe` + # [Easy installer] Use './toolkit/Lib/site-packages/adbutils/binaries/adb.exe' + # [Other] Use you own latest ADB, but not the ADB in your emulator + AdbExecutable: ./toolkit/Lib/site-packages/adbutils/binaries/adb.exe + # Whether to replace ADB + # Chinese emulators (NoxPlayer, LDPlayer, MemuPlayer, MuMuPlayer) use their own ADB, instead of the latest. + # Different ADB servers will terminate each other at startup, resulting in disconnection. + # For compatibility, we have to replace them all. + # This will do: + # 1. Terminate current ADB server + # 2. Rename ADB from all emulators to *.bak and replace them by the AdbExecutable set above + # 3. Brute-force connect to all available emulator instances + # [In most cases] Use true + # [In few cases] Use false, if you have other programs using ADB. + ReplaceAdb: true + # Brute-force connect to all available emulator instances + # [In most cases] Use true + AutoConnect: true + # Re-install uiautomator2 + # [In most cases] Use true + InstallUiautomator2: true + + Ocr: + # Run Ocr as a service, can reduce memory usage by not import mxnet everytime you start an alas instance + + # Whether to use ocr server + # [Default] false + UseOcrServer: false + # Whether to start ocr server when start GUI + # [Default] false + StartOcrServer: false + # Port of ocr server runs by GUI + # [Default] 22268 + OcrServerPort: 22268 + # Address of ocr server for alas instance to connect + # [Default] 127.0.0.1:22268 + OcrClientAddress: 127.0.0.1:22268 + + Update: + # Use auto update and builtin updater feature + # This may cause problem https://github.com/LmeSzinc/AzurLaneAutoScript/issues/876 + EnableReload: true + # Check update every X minute + # [Disable] 0 + # [Default] 5 + CheckUpdateInterval: 5 + # Scheduled restart time + # If there are updates, Alas will automatically restart and update at this time every day + # and run all alas instances that running before restarted + # [Disable] null + # [Default] 03:50 + AutoRestartTime: 03:50 + + Misc: + # Enable discord rich presence + DiscordRichPresence: false + + RemoteAccess: + # Enable remote access (using ssh reverse tunnel serve by https://github.com/wang0618/localshare) + # ! You need to set Password below to enable remote access since everyone can access to your alas if they have your url. + # See here (http://app.azurlane.cloud/en.html) for more infomation. + EnableRemoteAccess: false + # Username when login into ssh server + # [Default] null (will generate a random one when startup) + SSHUser: null + # Server to connect + # [Default] null + # [Format] host:port + SSHServer: null + # Filepath of SSH executable `ssh.exe` + # [Default] ssh (find ssh in system PATH) + # If you don't have one, install OpenSSH or download it here (https://github.com/PowerShell/Win32-OpenSSH/releases) + SSHExecutable: ssh + + Webui: + # --host. Host to listen + # [Use IPv6] '::' + # [In most cases] Default to '0.0.0.0' + WebuiHost: 0.0.0.0 + # --port. Port to listen + # You will be able to access webui via `http://{host}:{port}` + # [In most cases] Default to 22367 + WebuiPort: 22367 + # Language to use on web ui + # 'zh-CN' for Chinese simplified + # 'en-US' for English + # 'ja-JP' for Japanese + # 'zh-TW' for Chinese traditional + Language: en-US + # Theme of web ui + # 'default' for light theme + # 'dark' for dark theme + Theme: default + # Follow system DPI scaling + # [In most cases] true + # [In few cases] false to make Alas smaller, if you have a low resolution but high DPI scaling. + DpiScaling: true + # --key. Password of web ui + # Useful when expose Alas to the public network + Password: null + # --cdn. Use jsdelivr cdn for pywebio static files (css, js). + # 'true' for jsdelivr cdn + # 'false' for self host cdn (automatically) + # 'https://path.to.your/cdn' to use custom cdn + CDN: false + # --run. Auto-run specified config when startup + # 'null' default no specified config + # '["alas"]' specified "alas" config + # '["alas","alas2"]' specified "alas" "alas2" configs + Run: null + # To update app.asar + # [In most cases] true + AppAsarUpdate: true + # --no-sandbox. https://github.com/electron/electron/issues/30966 + # Some Windows systems cannot call the GPU normally for virtualization, and you need to manually turn off sandbox mode + NoSandbox: false diff --git a/config/template.json b/config/template.json new file mode 100644 index 0000000..30f79a1 --- /dev/null +++ b/config/template.json @@ -0,0 +1,89 @@ +{ + "Alas": { + "Emulator": { + "Serial": "auto", + "PackageName": "auto", + "GameLanguage": "auto", + "ScreenshotMethod": "scrcpy", + "ControlMethod": "MaaTouch", + "AdbRestart": false + }, + "EmulatorInfo": { + "Emulator": "auto", + "name": null, + "path": null + }, + "Error": { + "Restart": "game", + "SaveError": true, + "ScreenshotLength": 1, + "OnePushConfig": "provider: null" + }, + "Optimization": { + "ScreenshotInterval": 0.2, + "CombatScreenshotInterval": 1.0, + "WhenTaskQueueEmpty": "goto_main" + } + }, + "Restart": { + "Scheduler": { + "Enable": true, + "NextRun": "2020-01-01 00:00:00", + "Command": "Restart", + "ServerUpdate": "04:00" + } + }, + "Cafe": { + "Scheduler": { + "Enable": true, + "NextRun": "2020-01-01 00:00:00", + "Command": "Cafe", + "ServerUpdate": "04:00, 16:00" + }, + "Cafe": { + "Reward": true, + "Touch": true, + "AutoAdjust": true + } + }, + "Mail": { + "Scheduler": { + "Enable": true, + "NextRun": "2020-01-01 00:00:00", + "Command": "Mail", + "ServerUpdate": "04:00" + } + }, + "Circle": { + "Scheduler": { + "Enable": true, + "NextRun": "2020-01-01 00:00:00", + "Command": "Circle", + "ServerUpdate": "04:00" + } + }, + "TacticalChallenge": { + "Scheduler": { + "Enable": true, + "NextRun": "2020-01-01 00:00:00", + "Command": "TacticalChallenge", + "ServerUpdate": "15:00" + }, + "TacticalChallenge": { + "PlayerSelect": 0 + } + }, + "DataUpdate": { + "Scheduler": { + "Enable": true, + "NextRun": "2020-01-01 00:00:00", + "Command": "DataUpdate", + "ServerUpdate": "04:00" + }, + "ItemStorage": { + "AP": {}, + "Credit": {}, + "Pyroxene": {} + } + } +} \ No newline at end of file diff --git a/console.bat b/console.bat new file mode 100644 index 0000000..dcc1656 --- /dev/null +++ b/console.bat @@ -0,0 +1,29 @@ +@rem +@echo off + +set "_root=%~dp0" +set "_root=%_root:~0,-1%" +%~d0 +cd "%_root%" + +color F0 + +set "_pyBin=%_root%\toolkit" +set "_GitBin=%_root%\toolkit\Git\mingw64\bin" +set "_adbBin=%_root%\toolkit\Lib\site-packages\adbutils\binaries" +set "PATH=%_root%\toolkit\alias;%_root%\toolkit\command;%_pyBin%;%_pyBin%\Scripts;%_GitBin%;%_adbBin%;%PATH%" +set "ELECTRON_ENABLE_LOGGING=1" + +title Alas Console Debugger +echo This is an console to run adb, git, python and pip. +echo adb devices +echo git log +echo python -V +echo pip -V +echo. & echo ----- & echo. +echo. +) +echo. + +PROMPT $P$_$G$G$G +cmd /Q /K diff --git a/deploy/Readme.md b/deploy/Readme.md new file mode 100644 index 0000000..0a471e7 --- /dev/null +++ b/deploy/Readme.md @@ -0,0 +1,14 @@ +# Deploy + +This directory holds the Alas installer. + +Install Alas by running `python -m deploy.installer` in Alas root folder. + + + +# Launcher + +Launcher `Alas.exe` is a `.bat` file converted to `.exe` file by [Bat To Exe Converter](https://f2ko.de/programme/bat-to-exe-converter/). + +If you have warnings from your anti-virus software, replace `alas.exe` with `deploy/launcher/Alas.bat`. They should do the same thing. + diff --git a/deploy/Windows/adb.py b/deploy/Windows/adb.py new file mode 100644 index 0000000..d995b6b --- /dev/null +++ b/deploy/Windows/adb.py @@ -0,0 +1,74 @@ +import logging +import os + +from deploy.Windows.emulator import EmulatorManager +from deploy.Windows.logger import Progress, logger + + +def show_fix_tip(module): + logger.info(f""" + To fix this: + 1. Open console.bat + 2. Execute the following commands: + pip uninstall -y {module} + pip install --no-cache-dir {module} + 3. Re-open Alas.exe + """) + + +class AdbManager(EmulatorManager): + def adb_install(self): + logger.hr('Start ADB service', 0) + + if self.ReplaceAdb: + logger.hr('Replace ADB', 1) + self.adb_replace() + Progress.AdbReplace() + if self.AutoConnect: + logger.hr('ADB Connect', 1) + self.brute_force_connect() + Progress.AdbConnect() + + if False: + logger.hr('Uiautomator2 Init', 1) + try: + import adbutils + from uiautomator2 import init + except ModuleNotFoundError as e: + message = str(e) + for module in ['apkutils2', 'progress']: + # ModuleNotFoundError: No module named 'apkutils2' + # ModuleNotFoundError: No module named 'progress.bar' + if module in message: + show_fix_tip(module) + exit(1) + raise + + # Remove global proxies, or uiautomator2 will go through it + for k in list(os.environ.keys()): + if k.lower().endswith('_proxy'): + del os.environ[k] + + for device in adbutils.adb.iter_device(): + initer = init.Initer(device, loglevel=logging.DEBUG) + # MuMu X has no ro.product.cpu.abi, pick abi from ro.product.cpu.abilist + if initer.abi not in ['x86_64', 'x86', 'arm64-v8a', 'armeabi-v7a', 'armeabi']: + initer.abi = initer.abis[0] + initer.set_atx_agent_addr('127.0.0.1:7912') + + for _ in range(2): + try: + initer.install() + break + except AssertionError: + logger.info(f'AssertionError when installing uiautomator2 on device {device.serial}') + logger.info('If you are using BlueStacks or LD player or WSA, ' + 'please enable ADB in the settings of your emulator') + exit(1) + except ConnectionError: + if _ == 1: + raise + init.GITHUB_BASEURL = 'http://tool.appetizer.io/openatx' + + initer._device.shell(["rm", "/data/local/tmp/minicap"]) + initer._device.shell(["rm", "/data/local/tmp/minicap.so"]) diff --git a/deploy/Windows/alas.py b/deploy/Windows/alas.py new file mode 100644 index 0000000..9f88739 --- /dev/null +++ b/deploy/Windows/alas.py @@ -0,0 +1,80 @@ +import os +import time +import typing as t + +from deploy.Windows.config import DeployConfig +from deploy.Windows.logger import Progress, logger +from deploy.Windows.utils import DataProcessInfo, cached_property, iter_process + + +class AlasManager(DeployConfig): + @cached_property + def alas_folder(self): + return [ + self.filepath(self.PythonExecutable), + self.root_filepath + ] + + @cached_property + def self_pid(self): + return os.getpid() + + def list_process(self) -> t.List[DataProcessInfo]: + logger.info('List process') + process = list(iter_process()) + logger.info(f'Found {len(process)} processes') + return process + + def iter_process_by_names(self, names, in_alas=False) -> t.Iterable[DataProcessInfo]: + """ + Args: + names (str, list[str]): process name, such as 'alas.exe' + in_alas (bool): If the output process must in Alas + + Yields: + DataProcessInfo: + """ + if not isinstance(names, list): + names = [names] + try: + for proc in self.list_process(): + + 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("\\", "/") + for folder in self.alas_folder: + if folder in cmdline: + yield proc + else: + yield proc + 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 alas_kill(self): + for _ in range(10): + logger.hr(f'Kill existing Alas', 0) + proc_list = list(self.iter_process_by_names(['python.exe'], in_alas=True)) + if not len(proc_list): + Progress.KillExisting() + return True + for proc in proc_list: + logger.info(proc) + self.kill_process(proc) + + logger.warning('Unable to kill existing Alas, skip') + Progress.KillExisting() + return False + + +if __name__ == '__main__': + self = AlasManager() + start = time.time() + self.alas_kill() + print(time.time() - start) diff --git a/deploy/Windows/app.py b/deploy/Windows/app.py new file mode 100644 index 0000000..750a50d --- /dev/null +++ b/deploy/Windows/app.py @@ -0,0 +1,57 @@ +import filecmp +import os +import shutil + +from deploy.Windows.config import DeployConfig +from deploy.Windows.logger import Progress, logger + + +class AppManager(DeployConfig): + @staticmethod + def app_asar_replace(folder, path='./toolkit/WebApp/resources/app.asar'): + """ + Args: + folder (str): Path to AzurLaneAutoScript + path (str): Path from AzurLaneAutoScript to app.asar + + Returns: + bool: If updated. + """ + source = os.path.abspath(os.path.join(folder, path)) + logger.info(f'Old file: {source}') + + try: + import alas_webapp + except ImportError: + logger.info(f'Dependency alas_webapp not exists, skip updating') + return False + + update = alas_webapp.app_file() + logger.info(f'New version: {alas_webapp.__version__}') + logger.info(f'New file: {update}') + + if os.path.exists(source): + if filecmp.cmp(source, update, shallow=True): + logger.info('app.asar is already up to date') + return False + else: + # Keyword "Update app.asar" is used in AlasApp + # to determine whether there is a hot update + logger.info(f'Update app.asar {update} -----> {source}') + os.remove(source) + shutil.copy(update, source) + return True + else: + logger.info(f'{source} not exists, skip updating') + return False + + def app_update(self): + logger.hr(f'Update app', 0) + + if not self.AppAsarUpdate: + logger.info('AppAsarUpdate is disabled, skip') + Progress.UpdateAlasApp() + return False + + # self.app_asar_replace(os.getcwd()) + # Progress.UpdateAlasApp() diff --git a/deploy/Windows/config.py b/deploy/Windows/config.py new file mode 100644 index 0000000..b53570d --- /dev/null +++ b/deploy/Windows/config.py @@ -0,0 +1,226 @@ +import copy +import os +import subprocess +from typing import Optional, Union + +from deploy.Windows.logger import logger +from deploy.Windows.utils import DEPLOY_CONFIG, DEPLOY_TEMPLATE, cached_property, poor_yaml_read, poor_yaml_write + + +class ExecutionError(Exception): + pass + + +class ConfigModel: + # Git + Repository: str = "https://github.com/LmeSzinc/AzurLaneAutoScript" + Branch: str = "master" + GitExecutable: str = "./toolkit/Git/mingw64/bin/git.exe" + GitProxy: Optional[str] = None + SSLVerify: bool = False + AutoUpdate: bool = True + KeepLocalChanges: bool = False + + # Python + PythonExecutable: str = "./toolkit/python.exe" + PypiMirror: Optional[str] = None + InstallDependencies: bool = True + RequirementsFile: str = "requirements.txt" + + # Adb + AdbExecutable: str = "./toolkit/Lib/site-packages/adbutils/binaries/adb.exe" + ReplaceAdb: bool = True + AutoConnect: bool = True + InstallUiautomator2: bool = True + + # Ocr + UseOcrServer: bool = False + StartOcrServer: bool = False + OcrServerPort: int = 22268 + OcrClientAddress: str = "127.0.0.1:22268" + + # Update + EnableReload: bool = True + CheckUpdateInterval: int = 5 + AutoRestartTime: str = "03:50" + + # Misc + DiscordRichPresence: bool = False + + # Remote Access + EnableRemoteAccess: bool = False + SSHUser: Optional[str] = None + SSHServer: Optional[str] = None + SSHExecutable: Optional[str] = None + + # Webui + WebuiHost: str = "0.0.0.0" + WebuiPort: int = 22367 + Language: str = "en-US" + Theme: str = "default" + DpiScaling: bool = True + Password: Optional[str] = None + CDN: Union[str, bool] = False + Run: Optional[str] = None + AppAsarUpdate: bool = True + NoSandbox: bool = True + + # Dynamic + GitOverCdn: bool = False + + +class DeployConfig(ConfigModel): + def __init__(self, file=DEPLOY_CONFIG): + """ + Args: + file (str): User deploy config. + """ + self.file = file + self.config = {} + self.config_template = {} + self.read() + + # Bypass webui.config.DeployConfig.__setattr__() + # Don't write these into deploy.yaml + super().__setattr__('GitOverCdn', self.Repository in ['cn']) + if self.Repository in ['global', 'cn']: + super().__setattr__('Repository', 'https://github.com/TheFunny/ArisuAutoSweeper') + + self.write() + self.show_config() + + 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: + continue + logger.info(f"{k}: {v}") + + logger.info(f"Rest of the configs are the same as default") + + def read(self): + self.config = poor_yaml_read(DEPLOY_TEMPLATE) + self.config_template = copy.deepcopy(self.config) + self.config.update(poor_yaml_read(self.file)) + + for key, value in self.config.items(): + if hasattr(self, key): + super().__setattr__(key, value) + + def write(self): + poor_yaml_write(self.config, self.file) + + def filepath(self, path): + """ + Args: + path (str): + + Returns: + str: Absolute filepath. + """ + if os.path.isabs(path): + return path + + return ( + os.path.abspath(os.path.join(self.root_filepath, path)) + .replace(r"\\", "/") + .replace("\\", "/") + ) + + @cached_property + def root_filepath(self): + return ( + os.path.abspath(os.path.join(os.path.dirname(__file__), "../../")) + .replace(r"\\", "/") + .replace("\\", "/") + ) + + @cached_property + def adb(self) -> str: + exe = self.filepath(self.AdbExecutable) + if os.path.exists(exe): + return exe + + logger.warning(f'AdbExecutable: {exe} does not exists, use `adb` instead') + return 'adb' + + @cached_property + def git(self) -> str: + exe = self.filepath(self.GitExecutable) + if os.path.exists(exe): + return exe + + logger.warning(f'GitExecutable: {exe} does not exists, use `git` instead') + return 'git' + + @cached_property + def python(self) -> str: + return self.filepath(self.PythonExecutable) + + @cached_property + def requirements_file(self) -> str: + if self.RequirementsFile == 'requirements.txt': + return 'requirements.txt' + else: + return self.filepath(self.RequirementsFile) + + def execute(self, command, allow_failure=False, output=True): + """ + Args: + command (str): + allow_failure (bool): + output(bool): + + Returns: + bool: If success. + Terminate installation if failed to execute and not allow_failure. + """ + command = command.replace(r"\\", "/").replace("\\", "/").replace('"', '"') + if not output: + command = command + ' >nul 2>nul' + logger.info(command) + error_code = os.system(command) + if error_code: + if allow_failure: + logger.info(f"[ allowed failure ], error_code: {error_code}") + return False + else: + logger.info(f"[ failure ], error_code: {error_code}") + self.show_error(command) + raise ExecutionError + else: + logger.info(f"[ success ]") + return True + + def subprocess_execute(self, cmd, timeout=10): + """ + Args: + cmd (list[str]): + timeout: + + Returns: + str: + """ + logger.info(' '.join(cmd)) + process = subprocess.Popen(cmd, stdout=subprocess.PIPE, shell=True) + try: + stdout, stderr = process.communicate(timeout=timeout) + process.kill() + except subprocess.TimeoutExpired: + process.kill() + stdout, stderr = process.communicate() + logger.info(f'TimeoutExpired, stdout={stdout}, stderr={stderr}') + return stdout.decode() + + def show_error(self, command=None): + logger.hr("Update failed", 0) + self.show_config() + logger.info("") + logger.info(f"Last command: {command}") + logger.info( + "Please check your deploy settings in config/deploy.yaml " + "and re-open Alas.exe" + ) + logger.info("Take the screenshot of entire window if you need help") diff --git a/deploy/Windows/emulator.py b/deploy/Windows/emulator.py new file mode 100644 index 0000000..e01d59c --- /dev/null +++ b/deploy/Windows/emulator.py @@ -0,0 +1,169 @@ +import asyncio +import filecmp +import os +import shutil +import typing as t +from dataclasses import dataclass + +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()) + + +@dataclass +class DataAdbDevice: + serial: str + status: str + + +class EmulatorManager(AlasManager): + @cached_property + def emulator_manager(self): + from module.device.platform.emulator_windows import EmulatorManager + return EmulatorManager() + + def adb_kill(self): + # Just kill it, because some adb don't obey. + logger.hr('Kill all known ADB', level=2) + for proc in self.iter_process_by_names([ + # Most emulator use this + 'adb.exe', + # NoxPlayer 夜神模拟器 + 'nox_adb.exe', + # MumuPlayer MuMu模拟器 + 'adb_server.exe', + # Bluestacks 蓝叠模拟器 + 'HD-Adb.exe' + ]): + logger.info(proc) + self.kill_process(proc) + + def adb_devices(self): + """ + Returns: + list[DataAdbDevice]: Connected devices in adb + """ + logger.hr('Adb deivces', level=2) + result = self.subprocess_execute([self.adb, 'devices']) + devices = [] + for line in result.replace('\r\r\n', '\n').replace('\r\n', '\n').split('\n'): + if line.startswith('List') or '\t' not in line: + continue + serial, status = line.split('\t') + device = DataAdbDevice( + serial=serial, + status=status, + ) + devices.append(device) + logger.info(device) + return devices + + def brute_force_connect(self): + """ + Brute-force connect all available emulator instances + """ + devices = self.adb_devices() + + # Disconnect offline devices + for device in devices: + if device.status == 'offline': + self.subprocess_execute([self.adb, 'disconnect', device.serial]) + + # Get serial + list_serial = self.emulator_manager.all_emulator_serials + + logger.hr('Brute force connect', level=2) + + async def _connect(serial): + try: + await asyncio.create_subprocess_exec(self.adb, 'connect', serial) + except Exception as e: + logger.info(e) + + async def connect(): + await asyncio.gather( + *[_connect(serial) for serial in list_serial] + ) + + asyncio.run(connect()) + + return self.adb_devices() + + @staticmethod + def adb_path_to_backup(adb, new_backup=True): + """ + Args: + adb (str): Filepath to an adb binary + new_backup (bool): True to return a new backup path, + False to return an existing backup + + Returns: + str: Filepath to its backup file + """ + for n in range(10): + backup = f'{adb}.bak{n}' if n else f'{adb}.bak' + if os.path.exists(backup): + if new_backup: + continue + else: + return backup + else: + if new_backup: + return backup + else: + continue + + # Too many backups, override the first one + return f'{adb}.bak' + + def iter_adb_to_replace(self) -> t.Iterable[str]: + for adb in self.emulator_manager.all_adb_binaries: + if filecmp.cmp(adb, self.adb, shallow=True): + logger.info(f'{adb} is same as {self.adb}, skip') + continue + else: + yield adb + + def adb_replace(self): + """ + Backup the adb in emulator folder to xxx.bak, replace it with your adb. + `adb kill-server` must be called before replacing. + """ + replace = list(self.iter_adb_to_replace()) + if not replace: + logger.info('No need to replace') + return + + self.adb_kill() + 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) + + def adb_recover(self): + """ + Revert `adb_replace()` + """ + for adb in self.emulator_manager.all_adb_binaries: + logger.info(f'Recovering {adb}') + bak = self.adb_path_to_backup(adb, new_backup=False) + if os.path.exists(bak): + logger.info(f'Delete {adb}') + if os.path.exists(adb): + os.remove(adb) + logger.info(f'{bak} -----> {adb}') + shutil.move(bak, adb) + else: + logger.info('No backup available, skip') + continue + + +if __name__ == '__main__': + os.chdir(os.path.join(os.path.dirname(__file__), '../../')) + self = EmulatorManager() + self.brute_force_connect() diff --git a/deploy/Windows/git.py b/deploy/Windows/git.py new file mode 100644 index 0000000..c873f6b --- /dev/null +++ b/deploy/Windows/git.py @@ -0,0 +1,162 @@ +import configparser +import os + +from deploy.Windows.config import DeployConfig +from deploy.Windows.logger import Progress, logger +from deploy.Windows.utils import cached_property +from deploy.git_over_cdn.client import GitOverCdnClient + + +class GitConfigParser(configparser.ConfigParser): + def check(self, section, option, value): + result = self.get(section, option, fallback=None) + if result == value: + logger.info(f'Git config {section}.{option} = {value}') + return True + else: + return False + + +class GitOverCdnClientWindows(GitOverCdnClient): + def update(self, *args, **kwargs): + Progress.GitInit() + _ = super().update(*args, **kwargs) + Progress.GitShowVersion() + return _ + + @cached_property + def latest_commit(self) -> str: + _ = super().latest_commit + Progress.GitLatestCommit() + return _ + + def download_pack(self): + _ = super().download_pack() + Progress.GitDownloadPack() + return _ + + +class GitManager(DeployConfig): + @staticmethod + def remove(file): + try: + os.remove(file) + logger.info(f'Removed file: {file}') + except FileNotFoundError: + logger.info(f'File not found: {file}') + + @cached_property + def git_config(self): + conf = GitConfigParser() + conf.read('./.git/config') + return conf + + def git_repository_init( + self, repo, source='origin', branch='master', + proxy='', ssl_verify=True, keep_changes=False + ): + logger.hr('Git Init', 1) + if not self.execute(f'"{self.git}" init', allow_failure=True): + self.remove('./.git/config') + self.remove('./.git/index') + self.remove('./.git/HEAD') + self.remove('./.git/ORIG_HEAD') + self.execute(f'"{self.git}" init') + Progress.GitInit() + + logger.hr('Set Git Proxy', 1) + if proxy: + if not self.git_config.check('http', 'proxy', value=proxy): + self.execute(f'"{self.git}" config --local http.proxy {proxy}') + if not self.git_config.check('https', 'proxy', value=proxy): + self.execute(f'"{self.git}" config --local https.proxy {proxy}') + else: + if not self.git_config.check('http', 'proxy', value=None): + self.execute(f'"{self.git}" config --local --unset http.proxy', allow_failure=True) + if not self.git_config.check('https', 'proxy', value=None): + self.execute(f'"{self.git}" config --local --unset https.proxy', allow_failure=True) + + if ssl_verify: + if not self.git_config.check('http', 'sslVerify', value='true'): + self.execute(f'"{self.git}" config --local http.sslVerify true', allow_failure=True) + else: + if not self.git_config.check('http', 'sslVerify', value='false'): + self.execute(f'"{self.git}" config --local http.sslVerify false', allow_failure=True) + Progress.GitSetConfig() + + logger.hr('Set Git Repository', 1) + if not self.git_config.check(f'remote "{source}"', 'url', value=repo): + if not self.execute(f'"{self.git}" remote set-url {source} {repo}', allow_failure=True): + self.execute(f'"{self.git}" remote add {source} {repo}') + Progress.GitSetRepo() + + logger.hr('Fetch Repository Branch', 1) + self.execute(f'"{self.git}" fetch {source} {branch}') + Progress.GitFetch() + + logger.hr('Pull Repository Branch', 1) + # Remove git lock + for lock_file in [ + './.git/index.lock', + './.git/HEAD.lock', + './.git/refs/heads/master.lock', + ]: + if os.path.exists(lock_file): + logger.info(f'Lock file {lock_file} exists, removing') + os.remove(lock_file) + if keep_changes: + if self.execute(f'"{self.git}" stash', allow_failure=True): + self.execute(f'"{self.git}" pull --ff-only {source} {branch}') + if self.execute(f'"{self.git}" stash pop', allow_failure=True): + pass + else: + # No local changes to existing files, untracked files not included + logger.info('Stash pop failed, there seems to be no local changes, skip instead') + else: + logger.info('Stash failed, this may be the first installation, drop changes instead') + self.execute(f'"{self.git}" reset --hard {source}/{branch}') + self.execute(f'"{self.git}" pull --ff-only {source} {branch}') + else: + self.execute(f'"{self.git}" reset --hard {source}/{branch}') + Progress.GitReset() + # Since `git fetch` is already called, checkout is faster + if not self.execute(f'"{self.git}" checkout {branch}', allow_failure=True): + self.execute(f'"{self.git}" pull --ff-only {source} {branch}') + Progress.GitCheckout() + + logger.hr('Show Version', 1) + self.execute(f'"{self.git}" --no-pager log --no-merges -1') + Progress.GitShowVersion() + + @property + def goc_client(self): + client = GitOverCdnClient( + url='https://vip.123pan.cn/1815343254/pack/LmeSzinc_StarRailCopilot_master', + folder=self.root_filepath, + source='origin', + branch='master', + git=self.git, + ) + client.logger = logger + return client + + def git_install(self): + logger.hr('Update Alas', 0) + + if not self.AutoUpdate: + logger.info('AutoUpdate is disabled, skip') + Progress.GitShowVersion() + return + + if self.GitOverCdn: + if self.goc_client.update(keep_changes=self.KeepLocalChanges): + return + + self.git_repository_init( + repo=self.Repository, + source='origin', + branch=self.Branch, + proxy=self.GitProxy, + ssl_verify=self.SSLVerify, + keep_changes=self.KeepLocalChanges, + ) diff --git a/deploy/Windows/installer_test.py b/deploy/Windows/installer_test.py new file mode 100644 index 0000000..a3995e5 --- /dev/null +++ b/deploy/Windows/installer_test.py @@ -0,0 +1,117 @@ +import time + +from deploy.Windows.logger import logger + +output = r""" +Process: [ 0% ] +./toolkit/Lib/site-packages/requests/sessions.py trust_env already patched +./toolkit/Lib/site-packages/pip/_vendor/requests/sessions.py trust_env already patched +./toolkit/Lib/site-packages/uiautomator2/init.py minicap_urls no need to patch +./toolkit/Lib/site-packages/uiautomator2/init.py appdir already patched +./toolkit/Lib/site-packages/adbutils/mixin.py apkutils2 no need to patch +Process: [ 5% ] +==================== SHOW DEPLOY CONFIG ==================== +Repository: https://e.coding.net/llop18870/alas/AzurLaneAutoScript.git +Branch: feature +PypiMirror: https://pypi.tuna.tsinghua.edu.cn/simple +Language: zh-CN +Rest of the configs are the same as default +Process: [ 10% ] ++---------------------------------------------------+ +| UPDATE ALAS | ++---------------------------------------------------+ +==================== GIT INIT ==================== +"D:/AlasRelease/AzurLaneAutoScript/toolkit/Git/mingw64/bin/git.exe" init +Reinitialized existing Git repository in D:/AlasRelease/AzurLaneAutoScript/.git/ +[ success ] +Process: [ 15% ] +==================== SET GIT PROXY ==================== +Git config http.proxy = None +Git config https.proxy = None +Git config http.sslVerify = true +Process: [ 18% ] +==================== SET GIT REPOSITORY ==================== +Git config remote "origin".url = https://e.coding.net/llop18870/alas/AzurLaneAutoScript.git +Process: [ 20% ] +==================== FETCH REPOSITORY BRANCH ==================== +"D:/AlasRelease/AzurLaneAutoScript/toolkit/Git/mingw64/bin/git.exe" fetch origin feature +From https://e.coding.net/llop18870/alas/AzurLaneAutoScript + * branch feature -> FETCH_HEAD +[ success ] +Process: [ 40% ] +==================== PULL REPOSITORY BRANCH ==================== +"D:/AlasRelease/AzurLaneAutoScript/toolkit/Git/mingw64/bin/git.exe" reset --hard origin/feature +HEAD is now at 11595208 Fix: No process cache since it's fast already +[ success ] +Process: [ 45% ] +"D:/AlasRelease/AzurLaneAutoScript/toolkit/Git/mingw64/bin/git.exe" checkout feature +Already on 'feature' +Your branch is up to date with 'origin/feature'. +[ success ] +Process: [ 48% ] +==================== SHOW VERSION ==================== +"D:/AlasRelease/AzurLaneAutoScript/toolkit/Git/mingw64/bin/git.exe" --no-pager log --no-merges -1 +commit 11595208afe1ca1b3d48f5722795ce2387bccd87 (HEAD -> feature, origin/feature) +Author: LmeSzinc <37934724+LmeSzinc@users.noreply.github.com> +Date: Tue Apr 4 01:17:09 2023 +0800 + + Fix: No process cache since it's fast already +[ success ] +Process: [ 50% ] ++----------------------------------------------------------+ +| KILL EXISTING ALAS | ++----------------------------------------------------------+ +List process +Found 264 processes +Process: [ 60% ] ++-----------------------------------------------------------+ +| UPDATE DEPENDENCIES | ++-----------------------------------------------------------+ +All dependencies installed +Process: [ 70% ] ++--------------------------------------------------+ +| UPDATE APP | ++--------------------------------------------------+ +Old file: D:\AlasRelease\AzurLaneAutoScript\toolkit\WebApp\resources\app.asar +New version: 0.3.7 +New file: D:\AlasRelease\AzurLaneAutoScript\toolkit\lib\site-packages\alas_webapp\app.asar +app.asar is already up to date +Process: [ 75% ] ++---------------------------------------------------------+ +| START ADB SERVICE | ++---------------------------------------------------------+ +==================== REPLACE ADB ==================== +No need to replace +Process: [ 90% ] +==================== ADB CONNECT ==================== +-------------------- ADB DEIVCES -------------------- +D:/AlasRelease/AzurLaneAutoScript/toolkit/Lib/site-packages/adbutils/binaries/adb.exe devices +DataAdbDevice(serial='127.0.0.1:16384', status='device') +DataAdbDevice(serial='127.0.0.1:16480', status='device') +DataAdbDevice(serial='127.0.0.1:7555', status='device') +Process: [ 92% ] +-------------------- BRUTE FORCE CONNECT -------------------- +already connected to 127.0.0.1:7555 +already connected to 127.0.0.1:16384 +already connected to 127.0.0.1:16480 +already connected to 127.0.0.1:7555 +Process: [ 98% ] +-------------------- ADB DEIVCES -------------------- +D:/AlasRelease/AzurLaneAutoScript/toolkit/Lib/site-packages/adbutils/binaries/adb.exe devices +DataAdbDevice(serial='127.0.0.1:16384', status='device') +DataAdbDevice(serial='127.0.0.1:16480', status='device') +DataAdbDevice(serial='127.0.0.1:7555', status='device') +Process: [ 100% ] +中文测试,!@#nfoir +""" + + +def run(): + for row in output.split('\n'): + time.sleep(0.05) + if row: + logger.info(row) + + +if __name__ == '__main__': + run() diff --git a/deploy/Windows/logger.py b/deploy/Windows/logger.py new file mode 100644 index 0000000..a0607dc --- /dev/null +++ b/deploy/Windows/logger.py @@ -0,0 +1,75 @@ +import logging +import os +import sys + +os.chdir(os.path.join(os.path.dirname(__file__), '../../')) + +logger = logging.getLogger("deploy") +_logger = logger + +formatter = logging.Formatter(fmt="%(message)s") +hdlr = logging.StreamHandler(stream=sys.stdout) +hdlr.setFormatter(formatter) +logger.addHandler(hdlr) +logger.setLevel(logging.INFO) + + +def hr(title, level=3): + if logger is not _logger: + return logger.hr(title, level) + + title = str(title).upper() + if level == 0: + middle = "|" + " " * 20 + title + " " * 20 + "|" + border = "+" + "-" * (len(middle) - 2) + "+" + logger.info(border) + logger.info(middle) + logger.info(border) + if level == 1: + logger.info("=" * 20 + " " + title + " " + "=" * 20) + if level == 2: + logger.info("-" * 20 + " " + title + " " + "-" * 20) + if level == 3: + logger.info(f"<<< {title} >>>") + + +def attr(name, text): + print(f'[{name}] {text}') + + +logger.hr = hr +logger.attr = attr + + +class Percentage: + def __init__(self, progress): + self.progress = progress + + def __call__(self, *args, **kwargs): + logger.info(f'Process: [ {self.progress}% ]') + + +class Progress: + Start = Percentage(0) + ShowDeployConfig = Percentage(10) + + GitInit = Percentage(12) + GitSetConfig = Percentage(13) + GitSetRepo = Percentage(15) + GitFetch = Percentage(40) + GitReset = Percentage(45) + GitCheckout = Percentage(48) + GitShowVersion = Percentage(50) + + GitLatestCommit = Percentage(25) + GitDownloadPack = Percentage(40) + + KillExisting = Percentage(60) + UpdateDependency = Percentage(70) + UpdateAlasApp = Percentage(75) + + AdbReplace = Percentage(80) + AdbConnect = Percentage(95) + + # Must have a 100% + Finish = Percentage(100) diff --git a/deploy/Windows/patch.py b/deploy/Windows/patch.py new file mode 100644 index 0000000..a1d3be0 --- /dev/null +++ b/deploy/Windows/patch.py @@ -0,0 +1,154 @@ +import os +import re + +from deploy.Windows.logger import logger + + +def patch_trust_env(file): + """ + People use proxies, but they never realize that proxy software leaves a + global proxy pointing to itself even when the software is not running. + In most situations we set `session.trust_env = False` in requests, but this + does not effect the `pip` command. + + To handle untrusted user environment for good. We patch the code file in + requests directly. Of course, the patch only effect the python env inside + Alas. + + Returns: + bool: If patched. + """ + try: + with open(file, 'r', encoding='utf-8') as f: + content = f.read() + except FileNotFoundError: + logger.info(f'{file} trust_env not exist') + return + + if re.search('self.trust_env = True', content): + content = re.sub('self.trust_env = True', 'self.trust_env = False', content) + with open(file, 'w', encoding='utf-8') as f: + f.write(content) + logger.info(f'{file} trust_env patched') + elif re.search('self.trust_env = False', content): + logger.info(f'{file} trust_env already patched') + else: + logger.info(f'{file} trust_env is not in the file') + + +def check_running_directory(): + """ + An fool-proof mechanism. + Show error if user is running Easy Install in compressing software, + since Alas can't install in temp directories. + """ + file = __file__.replace(r"\\", "/").replace("\\", "/") + # C:/Users//AppData/Local/Temp/360zip$temp/360$3/AzurLaneAutoScript + if 'Temp/360zip' in file: + logger.critical('请先解压Alas的压缩包,再安装Alas') + exit(1) + # C:/Users//AppData/Local/Temp/Rar$EXa9248.23428/AzurLaneAutoScript + if 'Temp/Rar' in file or 'Local/Temp' in file: + logger.critical('Please unzip ALAS installer first') + exit(1) + + +def patch_uiautomator2(): + """ + uiautomator2 download assets from https://tool.appetizer.io first then fallback to https://github.com/openatx. + 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`. + 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. + for url in self.minicap_urls: + self.push_url(url) + to: + for url in []: + self.push_url(url) + """ + init_file = './toolkit/Lib/site-packages/uiautomator2/init.py' + cache_dir = './toolkit/Lib/site-packages/uiautomator2cache/cache' + appdir = "os.path.join(__file__, '../../uiautomator2cache')" + + modified = False + try: + with open(init_file, 'r', encoding='utf-8') as f: + content = f.read() + except FileNotFoundError: + logger.info(f'{init_file} not exist') + return + + # Patch minicap_urls + res = re.search(r'self.minicap_urls', content) + if res: + content = re.sub(r'self.minicap_urls', '[]', content) + modified = True + logger.info(f'{init_file} minicap_urls patched') + else: + logger.info(f'{init_file} minicap_urls no need to patch') + + # Patch appdir + if os.path.exists(cache_dir): + res = re.search(r'appdir ?=(.*)\n', content) + if res: + prev = res.group(1).strip() + if prev == appdir: + logger.info(f'{init_file} appdir already patched') + else: + content = re.sub(r'appdir ?=.*\n', f'appdir = {appdir}\n', content) + modified = True + logger.info(f'{init_file} appdir patched') + else: + logger.info(f'{init_file} appdir not found') + else: + logger.info('uiautomator2cache is not installed skip patching') + + # Save file + if modified: + with open(init_file, 'w', encoding='utf-8') as f: + f.write(content) + logger.info(f'{init_file} content saved') + + +def patch_apkutils2(): + """ + `adbutils/mixin.py` `ShellMixin.install` imports `apkutils2`, but `apkutils2` does not provide wheel files, + it may failed to install for unknown reasons. Since we never used that method, we just remove the import. + """ + mixin = './toolkit/Lib/site-packages/adbutils/mixin.py' + + try: + with open(mixin, 'r', encoding='utf-8') as f: + content = f.read() + except FileNotFoundError: + logger.info(f'{mixin} not exist') + return + + res = re.search(r'import apkutils2', content) + if res: + content = re.sub(r'import apkutils2', '', content) + with open(mixin, 'w', encoding='utf-8') as f: + f.write(content) + logger.info(f'{mixin} apkutils2 patched') + else: + logger.info(f'{mixin} apkutils2 no need to patch') + + +def pre_checks(): + check_running_directory() + + # patch_trust_env + patch_trust_env('./toolkit/Lib/site-packages/requests/sessions.py') + patch_trust_env('./toolkit/Lib/site-packages/pip/_vendor/requests/sessions.py') + + patch_uiautomator2() + patch_apkutils2() + + +if __name__ == '__main__': + pre_checks() diff --git a/deploy/Windows/pip.py b/deploy/Windows/pip.py new file mode 100644 index 0000000..cedb0a1 --- /dev/null +++ b/deploy/Windows/pip.py @@ -0,0 +1,132 @@ +import os +import re +import typing as t +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.utils import cached_property + + +@dataclass +class DataDependency: + name: str + version: str + + def __post_init__(self): + # uvicorn[standard] -> uvicorn + self.name = re.sub(r'\[.*\]', '', self.name) + # opencv_python -> opencv-python + self.name = self.name.replace('_', '-').strip() + # PyYaml -> pyyaml + self.name = self.name.lower() + self.version = self.version.strip() + self.version = re.sub(r'\.0$', '', self.version) + + @cached_property + def pretty_name(self): + return f'{self.name}=={self.version}' + + def __str__(self): + return self.pretty_name + + __repr__ = __str__ + + def __eq__(self, other): + return str(self) == str(other) + + def __hash__(self): + return hash(str(self)) + + +class PipManager(DeployConfig): + @cached_property + def pip(self): + 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("\\", "/") + + @cached_property + def set_installed_dependency(self) -> t.Set[DataDependency]: + data = [] + regex = re.compile(r'(.*)-(.*).dist-info') + try: + for name in os.listdir(self.python_site_packages): + res = regex.search(name) + if res: + dep = DataDependency(name=res.group(1), version=res.group(2)) + data.append(dep) + except FileNotFoundError: + logger.info(f'Directory not found: {self.python_site_packages}') + return set(data) + + @cached_property + def set_required_dependency(self) -> t.Set[DataDependency]: + data = [] + regex = re.compile('(.*)==(.*)[ ]*#') + file = self.filepath('./requirements.txt') + try: + with open(file, 'r', encoding='utf-8') as f: + for line in f.readlines(): + res = regex.search(line) + if res: + dep = DataDependency(name=res.group(1), version=res.group(2)) + data.append(dep) + except FileNotFoundError: + logger.info(f'File not found: {file}') + return set(data) + + @cached_property + def set_dependency_to_install(self) -> t.Set[DataDependency]: + """ + A poor dependency comparison, but much much faster than `pip install` and `pip list` + """ + data = [] + for dep in self.set_required_dependency: + if dep not in self.set_installed_dependency: + data.append(dep) + return set(data) + + def pip_install(self): + logger.hr('Update Dependencies', 0) + + if not self.InstallDependencies: + logger.info('InstallDependencies is disabled, skip') + Progress.UpdateDependency() + return + + if not len(self.set_dependency_to_install): + logger.info('All dependencies installed') + Progress.UpdateDependency() + return + else: + logger.info(f'Dependencies to install: {self.set_dependency_to_install}') + + # Install + logger.hr('Check Python', 1) + self.execute(f'"{self.python}" --version') + + arg = [] + if self.PypiMirror: + mirror = self.PypiMirror + arg += ['-i', mirror] + # Trust http mirror or skip ssl verify + if 'http:' in mirror or not self.SSLVerify: + arg += ['--trusted-host', urlparse(mirror).hostname] + elif not self.SSLVerify: + arg += ['--trusted-host', 'pypi.org'] + arg += ['--trusted-host', 'files.pythonhosted.org'] + + # Don't update pip, just leave it. + # logger.hr('Update pip', 1) + # self.execute(f'"{self.pip}" install --upgrade pip{arg}') + arg += ['--disable-pip-version-check'] + + logger.hr('Update Dependencies', 1) + arg = ' ' + ' '.join(arg) if arg else '' + self.execute(f'{self.pip} install -r {self.requirements_file}{arg}') + Progress.UpdateDependency() diff --git a/deploy/Windows/template.yaml b/deploy/Windows/template.yaml new file mode 100644 index 0000000..045b076 --- /dev/null +++ b/deploy/Windows/template.yaml @@ -0,0 +1,165 @@ +Deploy: + Git: + # URL of AzurLaneAutoScript repository + # [CN user] Use 'cn' to get update from git-over-cdn service + # [Other] Use 'global' to get update from https://github.com/LmeSzinc/StarRailCopilot + Repository: 'global' + # Branch of Alas + # [Developer] Use 'dev', 'app', etc, to try new features + # [Other] Use 'master', the stable branch + Branch: 'master' + # Filepath of git executable `git.exe` + # [Easy installer] Use './toolkit/Git/mingw64/bin/git.exe' + # [Other] Use you own git + GitExecutable: './toolkit/Git/mingw64/bin/git.exe' + # Set git proxy + # [CN user] Use your local http proxy (http://127.0.0.1:{port}) or socks5 proxy (socks5://127.0.0.1:{port}) + # [Other] Use null + GitProxy: null + # Set SSL Verify + # [In most cases] Use true + # [Other] Use false to when connected to an untrusted network + SSLVerify: true + # Update Alas at startup + # [In most cases] Use true + AutoUpdate: true + # Whether to keep local changes during update + # User settings, logs and screenshots will be kept, no mather this is true or false + # [Developer] Use true, if you modified the code + # [Other] Use false + KeepLocalChanges: false + + Python: + # Filepath of python executable `python.exe` + # [Easy installer] Use './toolkit/python.exe' + # [Other] Use you own python, and its version should be 3.7.6 64bit + PythonExecutable: './toolkit/python.exe' + # URL of pypi mirror + # [CN user] Use 'https://pypi.tuna.tsinghua.edu.cn/simple' for faster and more stable download + # [Other] Use null + PypiMirror: null + # Install dependencies at startup + # [In most cases] Use true + InstallDependencies: true + # Path to requirements.txt + # [In most cases] Use 'requirements.txt' + # [In AidLux] Use './deploy/AidLux/{version}/requirements.txt', version is default to 0.92 + RequirementsFile: 'requirements.txt' + + Adb: + # Filepath of ADB executable `adb.exe` + # [Easy installer] Use './toolkit/Lib/site-packages/adbutils/binaries/adb.exe' + # [Other] Use you own latest ADB, but not the ADB in your emulator + AdbExecutable: './toolkit/Lib/site-packages/adbutils/binaries/adb.exe' + # Whether to replace ADB + # Chinese emulators (NoxPlayer, LDPlayer, MemuPlayer, MuMuPlayer) use their own ADB, instead of the latest. + # Different ADB servers will terminate each other at startup, resulting in disconnection. + # For compatibility, we have to replace them all. + # This will do: + # 1. Terminate current ADB server + # 2. Rename ADB from all emulators to *.bak and replace them by the AdbExecutable set above + # 3. Brute-force connect to all available emulator instances + # [In most cases] Use true + # [In few cases] Use false, if you have other programs using ADB. + ReplaceAdb: true + # Brute-force connect to all available emulator instances + # [In most cases] Use true + AutoConnect: true + # Re-install uiautomator2 + # [In most cases] Use true + InstallUiautomator2: true + + Ocr: + # Run Ocr as a service, can reduce memory usage by not import mxnet everytime you start an alas instance + + # Whether to use ocr server + # [Default] false + UseOcrServer: false + # Whether to start ocr server when start GUI + # [Default] false + StartOcrServer: false + # Port of ocr server runs by GUI + # [Default] 22268 + OcrServerPort: 22268 + # Address of ocr server for alas instance to connect + # [Default] 127.0.0.1:22268 + OcrClientAddress: 127.0.0.1:22268 + + Update: + # Use auto update and builtin updater feature + # This may cause problem https://github.com/LmeSzinc/AzurLaneAutoScript/issues/876 + EnableReload: true + # Check update every X minute + # [Disable] 0 + # [Default] 5 + CheckUpdateInterval: 5 + # Scheduled restart time + # If there are updates, Alas will automatically restart and update at this time every day + # and run all alas instances that running before restarted + # [Disable] null + # [Default] 03:50 + AutoRestartTime: 03:50 + + Misc: + # Enable discord rich presence + DiscordRichPresence: false + + RemoteAccess: + # Enable remote access (using ssh reverse tunnel serve by https://github.com/wang0618/localshare) + # ! You need to set Password below to enable remote access since everyone can access to your alas if they have your url. + # See here (http://app.azurlane.cloud/en.html) for more infomation. + EnableRemoteAccess: false + # Username when login into ssh server + # [Default] null (will generate a random one when startup) + SSHUser: null + # Server to connect + # [Default] null + # [Format] host:port + SSHServer: null + # Filepath of SSH executable `ssh.exe` + # [Default] ssh (find ssh in system PATH) + # If you don't have one, install OpenSSH or download it here (https://github.com/PowerShell/Win32-OpenSSH/releases) + SSHExecutable: ssh + + Webui: + # --host. Host to listen + # [Use IPv6] '::' + # [In most cases] Default to '0.0.0.0' + WebuiHost: 0.0.0.0 + # --port. Port to listen + # You will be able to access webui via `http://{host}:{port}` + # [In most cases] Default to 22367 + WebuiPort: 22367 + # Language to use on web ui + # 'zh-CN' for Chinese simplified + # 'en-US' for English + # 'ja-JP' for Japanese + # 'zh-TW' for Chinese traditional + Language: en-US + # Theme of web ui + # 'default' for light theme + # 'dark' for dark theme + Theme: default + # Follow system DPI scaling + # [In most cases] true + # [In few cases] false to make Alas smaller, if you have a low resolution but high DPI scaling. + DpiScaling: true + # --key. Password of web ui + # Useful when expose Alas to the public network + Password: null + # --cdn. Use jsdelivr cdn for pywebio static files (css, js). + # 'true' for jsdelivr cdn + # 'false' for self host cdn (automatically) + # 'https://path.to.your/cdn' to use custom cdn + CDN: false + # --run. Auto-run specified config when startup + # 'null' default no specified config + # '["alas"]' specified "alas" config + # '["alas","alas2"]' specified "alas" "alas2" configs + Run: null + # To update app.asar + # [In most cases] true + AppAsarUpdate: true + # --no-sandbox. https://github.com/electron/electron/issues/30966 + # Some Windows systems cannot call the GPU normally for virtualization, and you need to manually turn off sandbox mode + NoSandbox: false diff --git a/deploy/Windows/utils.py b/deploy/Windows/utils.py new file mode 100644 index 0000000..6b27a3a --- /dev/null +++ b/deploy/Windows/utils.py @@ -0,0 +1,166 @@ +import os +import re +from dataclasses import dataclass +from typing import Callable, Generic, Iterable, TypeVar + +T = TypeVar("T") + +DEPLOY_CONFIG = './config/deploy.yaml' +DEPLOY_TEMPLATE = './deploy/Windows/template.yaml' + + +class cached_property(Generic[T]): + """ + cached-property from https://github.com/pydanny/cached-property + Add typing support + + A property that is only computed once per instance and then replaces itself + with an ordinary attribute. Deleting the attribute resets the property. + Source: https://github.com/bottlepy/bottle/commit/fa7733e075da0d790d809aa3d2f53071897e6f76 + """ + + def __init__(self, func: Callable[..., T]): + self.func = func + + def __get__(self, obj, cls) -> T: + if obj is None: + return self + + value = obj.__dict__[self.func.__name__] = self.func(obj) + return value + + +def iter_folder(folder, is_dir=False, ext=None): + """ + Args: + folder (str): + is_dir (bool): True to iter directories only + ext (str): File extension, such as `.yaml` + + Yields: + str: Absolute path of files + """ + for file in os.listdir(folder): + sub = os.path.join(folder, file) + if is_dir: + if os.path.isdir(sub): + yield sub.replace('\\\\', '/').replace('\\', '/') + elif ext is not None: + if not os.path.isdir(sub): + _, extension = os.path.splitext(file) + if extension == ext: + yield os.path.join(folder, file).replace('\\\\', '/').replace('\\', '/') + else: + yield os.path.join(folder, file).replace('\\\\', '/').replace('\\', '/') + + +def poor_yaml_read(file): + """ + Poor implementation to load yaml without pyyaml dependency, but with re + + Args: + file (str): + + Returns: + dict: + """ + if not os.path.exists(file): + return {} + + 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 + + return data + + +def poor_yaml_write(data, file, template_file=DEPLOY_TEMPLATE): + """ + Args: + data (dict): + file (str): + template_file (str): + """ + with open(template_file, 'r', encoding='utf-8') as f: + text = f.read().replace('\\', '/') + + for key, value in data.items(): + if value is None: + value = 'null' + elif value is True: + value = "true" + elif value is False: + 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) + + +@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]: + try: + import psutil + except ModuleNotFoundError: + return + + if psutil.WINDOWS: + # Since this is a one-time-usage, we access psutil._psplatform.Process directly + # to bypass the call of psutil.Process.is_running(). + # This only costs about 0.017s. + for pid in psutil.pids(): + proc = psutil._psplatform.Process(pid) + 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, + ) diff --git a/deploy/git_over_cdn/client.py b/deploy/git_over_cdn/client.py new file mode 100644 index 0000000..bebe199 --- /dev/null +++ b/deploy/git_over_cdn/client.py @@ -0,0 +1,266 @@ +import io +import json +import os +import re +import shutil +import subprocess +import zipfile +from typing import Callable, Generic, TypeVar + +import requests +from requests.adapters import HTTPAdapter + +T = TypeVar("T") + +TEMPLATE_FILE = './config/template.yaml' + + +class cached_property(Generic[T]): + """ + cached-property from https://github.com/pydanny/cached-property + Add typing support + + A property that is only computed once per instance and then replaces itself + with an ordinary attribute. Deleting the attribute resets the property. + Source: https://github.com/bottlepy/bottle/commit/fa7733e075da0d790d809aa3d2f53071897e6f76 + """ + + def __init__(self, func: Callable[..., T]): + self.func = func + + def __get__(self, obj, cls) -> T: + if obj is None: + return self + + value = obj.__dict__[self.func.__name__] = self.func(obj) + return value + + +class PrintLogger: + info = print + warning = print + error = print + + @staticmethod + def attr(name, text): + print(f'[{name}] {text}') + + +class GitOverCdnClient: + logger = PrintLogger() + + def __init__(self, url, folder, source='origin', branch='master', git='git'): + """ + Args: + url: http://127.0.0.1:22251/pack/LmeSzinc_AzurLaneAutoScript_master/ + folder: D:/AzurLaneAutoScript + """ + self.url = url.strip('/') + self.folder = folder.replace('\\', '/') + self.source = source + self.branch = branch + self.git = git + + def filepath(self, path): + path = os.path.join(self.folder, '.git', path) + return os.path.abspath(path).replace('\\', '/') + + def urlpath(self, path): + return f'{self.url}{path}' + + @cached_property + def current_commit(self) -> str: + for file in [ + f'./refs/remotes/{self.source}/{self.branch}', + f'./refs/heads/{self.branch}', + 'ORIG_HEAD', + ]: + file = self.filepath(file) + try: + with open(file, 'r', encoding='utf-8') as f: + commit = f.read() + res = re.search(r'([0-9a-f]{40})', commit) + if res: + commit = res.group(1) + self.logger.attr('CurrentCommit', commit) + return commit + except FileNotFoundError as e: + self.logger.error(f'Failed to get local commit: {e}') + except Exception as e: + self.logger.error(f'Failed to get local commit: {e}') + return '' + + @property + def session(self): + session = requests.Session() + session.trust_env = False + session.mount('http://', HTTPAdapter(max_retries=3)) + session.mount('https://', HTTPAdapter(max_retries=3)) + return session + + @cached_property + def latest_commit(self) -> str: + try: + url = self.urlpath('/latest.json') + self.logger.info(f'Fetch url: {url}') + resp = self.session.get(url, timeout=3) + except Exception as e: + self.logger.error(f'Failed to get remote commit: {e}') + return '' + + if resp.status_code == 200: + try: + info = json.loads(resp.text) + commit = info['commit'] + self.logger.attr('LatestCommit', commit) + return commit + except json.JSONDecodeError: + self.logger.error(f'Failed to get remote commit, response is not a json: {resp.text}') + return '' + except KeyError: + self.logger.error(f'Failed to get remote commit, key "commit" is not found: {resp.text}') + return '' + else: + self.logger.error(f'Failed to get remote commit, status={resp.status_code}, text={resp.text}') + return '' + + def download_pack(self): + try: + url = self.urlpath(f'/{self.latest_commit}/{self.current_commit}.zip') + self.logger.info(f'Fetch url: {url}') + resp = self.session.get(url, timeout=20) + except Exception as e: + self.logger.error(f'Failed to download pack: {e}') + return False + + if resp.status_code == 200: + try: + zipped = zipfile.ZipFile(io.BytesIO(resp.content)) + for file in [f'pack-{self.latest_commit}.pack', f'pack-{self.latest_commit}.idx']: + self.logger.info(f'Unzip {file}') + member = zipped.getinfo(file) + tmp = self.filepath(f'./objects/pack/{file}.tmp') + out = self.filepath(f'./objects/pack/{file}') + with zipped.open(member) as source, open(tmp, "wb") as target: + shutil.copyfileobj(source, target) + os.replace(tmp, out) + return True + except zipfile.BadZipFile as e: + # File is not a zip file + self.logger.error(e) + return False + except KeyError as e: + # There is no item named 'xxx.idx' in the archive + self.logger.error(e) + return False + except Exception as e: + self.logger.error(e) + return False + elif resp.status_code == 404: + self.logger.error(f'Failed to download pack, status={resp.status_code}, no such pack files provided') + return False + else: + self.logger.error(f'Failed to download pack, status={resp.status_code}, text={resp.text}') + return False + + def update_refs(self): + file = self.filepath(f'./refs/remotes/{self.source}/{self.branch}') + text = f'{self.latest_commit}\n' + self.logger.info(f'Update refs: {file}') + os.makedirs(os.path.dirname(file), exist_ok=True) + try: + with open(file, 'w', encoding='utf-8', newline='') as f: + f.write(text) + return True + except FileNotFoundError as e: + self.logger.error(f'Failed to get local commit: {e}') + except Exception as e: + self.logger.error(f'Failed to get local commit: {e}') + + return False + + def git_command(self, *args, timeout=300): + """ + Execute ADB commands in a subprocess, + usually to be used when pulling or pushing large files. + + Args: + timeout (int): + + Returns: + str: + """ + os.chdir(self.folder) + cmd = list(map(str, args)) + cmd = [self.git] + cmd + self.logger.info(f'Execute: {cmd}') + + process = subprocess.Popen(cmd, stdout=subprocess.PIPE, shell=False) + try: + stdout, stderr = process.communicate(timeout=timeout) + except subprocess.TimeoutExpired: + process.kill() + stdout, stderr = process.communicate() + self.logger.warning(f'TimeoutExpired when calling {cmd}, stdout={stdout}, stderr={stderr}') + return stdout.decode() + + def git_reset(self, keep_changes=False): + """ + git reset --hard + """ + if keep_changes: + self.git_command('stash') + self.git_command('reset', '--hard', f'{self.source}/{self.branch}') + self.git_command('stash', 'pop') + else: + self.git_command('reset', '--hard', f'{self.source}/{self.branch}') + + def get_status(self): + """ + Returns: + str: 'uptodate' if repo is up-to-date + 'behind' if repos is not up-to-date + 'failed' if failed + """ + _ = self.current_commit + _ = self.latest_commit + if not self.current_commit: + self.logger.error('Failed to get current commit') + return 'failed' + if not self.latest_commit: + self.logger.error('Failed to get latest commit') + return 'failed' + if self.current_commit == self.latest_commit: + self.logger.info('Already up to date') + return 'uptodate' + self.logger.info('Current repo is behind remote') + return 'behind' + + def update(self, keep_changes=False): + """ + Args: + keep_changes: + + Returns: + bool: If repo is up-to-date + """ + _ = self.current_commit + _ = self.latest_commit + if not self.current_commit: + self.logger.error('Failed to get current commit') + return False + if not self.latest_commit: + self.logger.error('Failed to get latest commit') + return False + if self.current_commit == self.latest_commit: + self.logger.info('Already up to date') + self.git_reset(keep_changes=keep_changes) + return True + + if not self.download_pack(): + return False + if not self.update_refs(): + return False + self.git_reset(keep_changes=keep_changes) + self.logger.info('Update success') + return True diff --git a/deploy/installer.py b/deploy/installer.py new file mode 100644 index 0000000..9bfad00 --- /dev/null +++ b/deploy/installer.py @@ -0,0 +1,34 @@ +from deploy.Windows.logger import Progress, logger +from deploy.Windows.patch import pre_checks + +pre_checks() + +from deploy.Windows.adb import AdbManager +from deploy.Windows.alas import AlasManager +from deploy.Windows.app import AppManager +from deploy.Windows.config import ExecutionError +from deploy.Windows.git import GitManager +from deploy.Windows.pip import PipManager + + +class Installer(GitManager, PipManager, AdbManager, AppManager, AlasManager): + def install(self): + try: + self.git_install() + self.alas_kill() + self.pip_install() + self.app_update() + self.adb_install() + except ExecutionError: + exit(1) + + +def run(): + Progress.Start() + installer = Installer() + Progress.ShowDeployConfig() + + installer.install() + + logger.info('Finish') + Progress.Finish() diff --git a/deploy/set.py b/deploy/set.py new file mode 100644 index 0000000..c72a2c0 --- /dev/null +++ b/deploy/set.py @@ -0,0 +1,46 @@ +import sys +import typing as t + +from deploy.Windows.utils import poor_yaml_read, poor_yaml_write, DEPLOY_TEMPLATE + +""" +Set config/deploy.yaml with commands like + +python -m deploy.set GitExecutable=/usr/bin/git PythonExecutable=/usr/bin/python3.8 +""" + + +def get_args() -> t.Dict[str, str]: + args = {} + for arg in sys.argv[1:]: + if '=' not in arg: + continue + k, v = arg.split('=') + k, v = k.strip(), v.strip() + args[k] = v + return args + + +def config_set(modify: t.Dict[str, str], output='./config/deploy.yaml') -> t.Dict[str, str]: + """ + Args: + modify: A dict of key-value in deploy.yaml + output: + + Returns: + The updated key-value in deploy.yaml + """ + data = poor_yaml_read(DEPLOY_TEMPLATE) + data.update(poor_yaml_read(output)) + for k, v in modify.items(): + if k in data: + print(f'Key "{k}" set') + data[k] = v + else: + print(f'Key "{k}" not exist') + poor_yaml_write(data, file=output) + return data + + +if __name__ == '__main__': + config_set(get_args()) diff --git a/dev_tools/button_extract.py b/dev_tools/button_extract.py new file mode 100644 index 0000000..ecc35ca --- /dev/null +++ b/dev_tools/button_extract.py @@ -0,0 +1,248 @@ +import os +import re +import typing as t +from dataclasses import dataclass + +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.config.config_manual import ManualConfig as AzurLaneConfig +from module.config.server import VALID_LANG +from module.config.utils import deep_get, deep_iter, deep_set, iter_folder +from module.logger import logger + +SHARE_SERVER = 'share' +ASSET_SERVER = [SHARE_SERVER] + VALID_LANG + + +class AssetsImage: + REGEX_ASSETS = re.compile( + f'^{AzurLaneConfig.ASSETS_FOLDER}/' + f'(?P{"|".join(ASSET_SERVER).lower()})/' + f'(?P[a-zA-Z0-9_/]+?)/' + f'(?P\w+)' + f'(?P\.\d+)?' + f'(?P\.AREA|\.SEARCH|\.COLOR|\.BUTTON)?' + f'\.png$' + ) + + def __init__(self, file: str): + """ + Args: + file: ./assets///...png + Example: ./assets/cn/ui/login/LOGIN_CONFIRM.2.BUTTON.png + then, server="cn", module="ui/login", assets="LOGIN_CONFIRM", frame=2, attr="BUTTON" + and are optional. + """ + self.file: str = file + prefix = AzurLaneConfig.ASSETS_FOLDER + res = AssetsImage.REGEX_ASSETS.match(file) + + self.valid = False + self.server = '' + self.module = '' + self.assets = '' + self.frame = 1 + self.attr = '' + + if res: + self.valid = True + self.server = res.group('server') + self.module = res.group('module') + self.assets = res.group('assets') + if res.group('frame'): + self.frame = int(res.group('frame').strip('.')) + else: + self.frame = 1 + if res.group('attr'): + self.attr = res.group('attr').strip('.') + else: + self.attr = '' + self.parent_file = f'{prefix}{res.group(1)}.png' + else: + logger.info(f'Invalid assets name: {self.file}') + + self.bbox: t.Tuple = () + self.mean: t.Tuple = () + + def parse(self): + image = load_image(self.file) + + size = image_size(image) + if size != AzurLaneConfig.ASSETS_RESOLUTION: + logger.warning(f'{self.file} has wrong resolution: {size}') + # self.valid = False + bbox = get_bbox(image) + mean = get_color(image=image, area=bbox) + mean = tuple(np.rint(mean).astype(int)) + self.bbox = bbox + self.mean = mean + return bbox, mean + + def __str__(self): + if self.valid: + return f'AssetsImage(module={self.module}, assets={self.assets}, server={self.server}, frame={self.frame}, attr={self.attr})' + else: + return f'AssetsImage(file={self.file}, valid={self.valid})' + + +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) + + +@dataclass +class DataAssets: + module: str + assets: str + server: str + frame: int + file: str = '' + 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] = () + + @staticmethod + def area_to_search(area): + area = area_pad(area, pad=-20) + area = area_limit(area, (0, 0, *AzurLaneConfig.ASSETS_RESOLUTION)) + return area + + @classmethod + def product(cls, image: AssetsImage): + """ + 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): + if image.attr == '': + self.file = image.file + self.area = image.bbox + self.color = image.mean + self.button = image.bbox + elif image.attr == 'AREA': + self.area = image.bbox + elif image.attr == 'SEARCH': + self.search = image.bbox + elif image.attr == 'COLOR': + self.color = image.mean + elif image.attr == 'BUTTON': + self.button = image.bbox + else: + logger.warning(f'Trying to load an image with unknown attribute: {image}') + + def generate_code(self): + return f'Assets(file="{self.file}", area={self.area}, search={self.search}, color={self.color}, button={self.button})' + + +def iter_assets(): + images = list(iter_images()) + + # parse images, this may take a while + for image in tqdm(images): + image.parse() + + # 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.attr == 'SEARCH' and image.frame > 1: + logger.warning(f'Attribute SEARCH with frame > 1 is not allowed: {image.file}') + image.valid = False + images = images.select(valid=True).sort('module', 'assets', 'server', 'frame') + + # Convert to DataAssets + data = {} + for image in images: + if image.attr == '': + 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 != '': + row = deep_get(data, keys=[image.module, image.assets, image.server, image.frame]) + row.load_image(image) + # Apply `search` of the first frame to all + for path, frames in deep_iter(data, depth=3): + print(path, frames) + first = frames[1] + search = first.search if first.search else DataAssets.area_to_search(first.area) + for frame in frames.values(): + frame.search = search + + return data + + +def generate_code(): + all = iter_assets() + for module, module_data in all.items(): + path = os.path.join(AzurLaneConfig.ASSETS_MODULE, module.split('/', maxsplit=1)[0]) + output = os.path.join(path, 'assets.py') + if os.path.exists(output): + os.remove(output) + output = os.path.join(path, 'assets') + os.makedirs(output, exist_ok=True) + for prev in iter_folder(output, ext='.py'): + if os.path.basename(prev) == '__init__.py': + continue + os.remove(prev) + + for module, module_data in all.items(): + path = os.path.join(AzurLaneConfig.ASSETS_MODULE, module.split('/', maxsplit=1)[0]) + output = os.path.join(path, 'assets') + gen = CodeGenerator() + gen.Import(""" + from module.base.button import Button, ButtonWrapper + """) + gen.CommentAutoGenerage('dev_tools.button_extract') + for assets, assets_data in module_data.items(): + has_share = SHARE_SERVER in assets_data + with gen.Object(key=assets, object_class='ButtonWrapper'): + gen.ObjectAttr(key='name', value=assets) + if has_share: + servers = assets_data.keys() + else: + servers = VALID_LANG + for server in servers: + frames = list(assets_data.get(server, {}).values()) + if len(frames) > 1: + with gen.ObjectAttr(key=server, value=gen.List()): + for index, frame in enumerate(frames): + with gen.ListItem(gen.Object(object_class='Button')): + gen.ObjectAttr(key='file', value=frame.file) + gen.ObjectAttr(key='area', value=frame.area) + gen.ObjectAttr(key='search', value=frame.search) + gen.ObjectAttr(key='color', value=frame.color) + gen.ObjectAttr(key='button', value=frame.button) + elif len(frames) == 1: + frame = frames[0] + with gen.ObjectAttr(key=server, value=gen.Object(object_class='Button')): + gen.ObjectAttr(key='file', value=frame.file) + gen.ObjectAttr(key='area', value=frame.area) + gen.ObjectAttr(key='search', value=frame.search) + gen.ObjectAttr(key='color', value=frame.color) + gen.ObjectAttr(key='button', value=frame.button) + else: + gen.ObjectAttr(key=server, value=None) + gen.write(os.path.join(output, f'assets_{module.replace("/", "_")}.py')) + + +if __name__ == '__main__': + generate_code() diff --git a/dev_tools/keyword_extract.py b/dev_tools/keyword_extract.py new file mode 100644 index 0000000..7fb4eb8 --- /dev/null +++ b/dev_tools/keyword_extract.py @@ -0,0 +1,172 @@ +import os +import re +import typing as t +from functools import cached_property + +from module.base.code_generator import CodeGenerator +from module.config.utils import read_file +from module.logger import logger + +UI_LANGUAGES = ['jp'] + + +def text_to_variable(text): + text = re.sub("'s |s' ", '_', text) + text = re.sub('[ \-—:\'/•.]+', '_', text) + text = re.sub(r'[(),#"?!&]|', '', text) + # text = re.sub(r'[#_]?\d+(_times?)?', '', text) + return text + + +class TextMap: + DATA_FOLDER = '' + + def __init__(self, lang: str): + self.lang = lang + + @cached_property + def data(self) -> dict[int, str]: + if not os.path.exists(TextMap.DATA_FOLDER): + logger.critical('`TextMap.DATA_FOLDER` does not exist, please set it to your path to StarRailData') + exit(1) + file = os.path.join(TextMap.DATA_FOLDER, 'TextMap', f'TextMap{self.lang.upper()}.json') + data = {} + for id_, text in read_file(file).items(): + text = text.replace('\u00A0', '') + text = text.replace(r'{NICKNAME}', 'Trailblazer') + data[int(id_)] = text + return data + + def find(self, name: t.Union[int, str]) -> tuple[int, str]: + """ + Args: + name: + + Returns: + text id (hash in TextMap) + text + """ + if isinstance(name, int) or (isinstance(name, str) and name.isdigit()): + name = int(name) + try: + return name, self.data[name] + except KeyError: + pass + + name = str(name) + for row_id, row_name in self.data.items(): + if row_id >= 0 and row_name == name: + return row_id, row_name + for row_id, row_name in self.data.items(): + if row_name == name: + return row_id, row_name + logger.error(f'Cannot find name: "{name}" in language {self.lang}') + return 0, '' + + +def replace_templates(text: str) -> str: + """ + Replace templates in data to make sure it equals to what is shown in game + + Examples: + replace_templates("Complete Echo of War #4 time(s)") + == "Complete Echo of War 1 time(s)" + """ + text = re.sub(r'#4', '1', text) + text = re.sub(r'', '', text) + return text + + +class KeywordExtract: + def __init__(self): + self.text_map: dict[str, TextMap] = {lang: TextMap(lang) for lang in UI_LANGUAGES} + self.text_map['cn'] = TextMap('chs') + self.keywords_id: list[int] = [] + + def find_keyword(self, keyword, lang) -> tuple[int, str]: + """ + Args: + keyword: text string or text id + lang: Language to find + + Returns: + text id (hash in TextMap) + text + """ + text_map = self.text_map[lang] + return text_map.find(keyword) + + def load_keywords(self, keywords: list[str | int], lang='cn'): + text_map = self.text_map[lang] + keywords_id = [text_map.find(keyword)[0] for keyword in keywords] + self.keywords_id = [keyword for keyword in keywords_id if keyword != 0] + + def clear_keywords(self): + self.keywords_id = [] + + def write_keywords( + self, + keyword_class, + output_file: str = '', + text_convert=text_to_variable, + generator: CodeGenerator = None, + extra_attrs: dict[str, dict] = None + ): + """ + Args: + keyword_class: + output_file: + text_convert: + generator: Reuse an existing code generator + extra_attrs: Extra attributes write in keywords + """ + if generator is None: + gen = CodeGenerator() + gen.Import(f""" + from .classes import {keyword_class} + """) + gen.CommentAutoGenerage('dev_tools.keyword_extract') + else: + gen = generator + + last_id = getattr(gen, 'last_id', 0) + if extra_attrs: + keyword_num = len(self.keywords_id) + for attr_key, attr_value in extra_attrs.items(): + if len(attr_value) != keyword_num: + print(f"Extra attribute {attr_key} does not match the size of keywords") + return + for index, keyword in enumerate(self.keywords_id): + _, name = self.find_keyword(keyword, lang='en') + name = text_convert(replace_templates(name)) + with gen.Object(key=name, object_class=keyword_class): + gen.ObjectAttr(key='id', value=index + last_id + 1) + gen.ObjectAttr(key='name', value=name) + for lang in UI_LANGUAGES: + gen.ObjectAttr(key=lang, value=replace_templates(self.find_keyword(keyword, lang=lang)[1])) + if extra_attrs: + for attr_key, attr_value in extra_attrs.items(): + gen.ObjectAttr(key=attr_key, value=attr_value[keyword]) + gen.last_id = index + last_id + 1 + + if output_file: + print(f'Write {output_file}') + gen.write(output_file) + self.clear_keywords() + return gen + + def generate(self): + self.load_keywords(['模拟宇宙', '拟造花萼(金)', '拟造花萼(赤)', '凝滞虚影', '侵蚀隧洞', '历战余响', '忘却之庭']) + self.write_keywords(keyword_class='DungeonNav', output_file='./tasks/dungeon/keywords/nav.py') + self.load_keywords(['行动摘要', '生存索引', '每日实训']) + self.write_keywords(keyword_class='DungeonTab', output_file='./tasks/dungeon/keywords/tab.py') + self.load_keywords(['前往', '领取', '进行中', '已领取', '本日活跃度已满']) + self.write_keywords(keyword_class='DailyQuestState', output_file='./tasks/daily/keywords/daily_quest_state.py') + self.load_keywords(['领取', '追踪']) + self.write_keywords(keyword_class='BattlePassQuestState', + output_file='./tasks/battle_pass/keywords/quest_state.py') + + +if __name__ == '__main__': + TextMap.DATA_FOLDER = '../StarRailData' + KeywordExtract().generate() diff --git a/dev_tools/route_extract.py b/dev_tools/route_extract.py new file mode 100644 index 0000000..dedc2e5 --- /dev/null +++ b/dev_tools/route_extract.py @@ -0,0 +1,91 @@ +import os +import re +from dataclasses import dataclass, fields + +from module.base.code_generator import CodeGenerator + + +@dataclass +class RouteData: + name: str + route: str + plane: str + floor: str = 'F1' + position: tuple = None + + +class RouteExtract: + def __init__(self, folder): + self.folder = folder + + def iter_files(self): + for path, folders, files in os.walk(self.folder): + path = path.replace('\\', '/') + for file in files: + if file.endswith('.py'): + yield f'{path}/{file}' + + def extract_route(self, file): + print(f'Extract {file}') + with open(file, 'r', encoding='utf-8') as f: + content = f.read() + + """ + def route_item_enemy(self): + self.enter_himeko_trial() + self.map_init(plane=Jarilo_BackwaterPass, position=(519.9, 361.5)) + """ + regex = re.compile( + r'def (?P[a-zA-Z0-9_]*?)\(self\):.*?' + r'self\.map_init\((.*?)\)' + , re.DOTALL) + file = file.replace(self.folder, '').replace('.py', '').replace('/', '_').strip('_') + module = f"{self.folder.strip('./').replace('/', '.')}.{file.replace('_', '.')}" + + for result in regex.findall(content): + func, data = result + + res = re.search(r'plane=([a-zA-Z_]*)', data) + if res: + plane = res.group(1) + else: + # Must contain plane + continue + res = re.search(r'floor=([\'"a-zA-Z0-9_]*)', data) + if res: + floor = res.group(1).strip('"\'') + else: + floor = 'F1' + res = re.search(r'position=\(([0-9.]*)[, ]+([0-9.]*)', data) + if res: + position = (float(res.group(1)), float(res.group(2))) + else: + position = None + + yield RouteData( + name=f'{file}__{func}', + route=f'{module}:{func}', + plane=plane, + floor=floor, + position=position, + ) + + def write(self, file): + gen = CodeGenerator() + gen.Import(""" + from tasks.map.route.base import RouteData + """) + gen.CommentAutoGenerage('dev_tools.route_extract') + + for f in self.iter_files(): + for row in self.extract_route(f): + with gen.Object(key=row.name, object_class='RouteData'): + for key in fields(row): + value = getattr(row, key.name) + gen.ObjectAttr(key.name, value) + gen.write(file) + + +if __name__ == '__main__': + os.chdir(os.path.join(os.path.dirname(__file__), '../')) + RouteExtract('./route/daily').write('./tasks/map/route/route/daily.py') diff --git a/dev_tools/screenshot.py b/dev_tools/screenshot.py new file mode 100644 index 0000000..90ce49b --- /dev/null +++ b/dev_tools/screenshot.py @@ -0,0 +1,108 @@ +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 +from module.device.device import Device +from module.logger import logger + +""" +A tool to take screenshots on device + +Usage: + python -m dev_tools.screenshot +""" + + +class EmptyConnection(Connection): + def __init__(self): + ConnectionAttr.__init__(self, AzurLaneConfig('template')) + + logger.hr('Detect device') + print() + print('这里是你本机可用的模拟器serial:') + devices = self.list_device() + + # Show available devices + available = devices.select(status='device') + for device in available: + print(device.serial) + if not len(available): + print('No available devices') + + # Show unavailable devices if having any + unavailable = devices.delete(available) + if len(unavailable): + print('Here are the devices detected but unavailable') + for device in unavailable: + print(f'{device.serial} ({device.status})') + + +def handle_sensitive_info(image): + # Paint UID to black + image[680:720, 0:180, :] = 0 + return image + + +_ = EmptyConnection() +name = input( + '输入alas配置文件名称,或者模拟器serial,或者模拟器端口号: (默认输入 "alas"):\n' + '例如:"alas", "127.0.0.1:16384", "7555"\n' +) +name = name.strip().strip('"').strip() +if not name: + name = 'alas' +if name.isdigit(): + name = f'127.0.0.1:{name}' +if name in alas_instance(): + print(f'{name} is an existing config file') + device = Device(name) +else: + print(f'{name} is a device serial') + config = AzurLaneConfig('template') + config.override( + Emulator_Serial=name, + Emulator_PackageName='com.miHoYo.hkrpg', + Emulator_ScreenshotMethod='adb_nc', + ) + device = Device(config) + +output = './screenshots/dev_screenshots' +os.makedirs(output, exist_ok=True) +device.disable_stuck_detection() +device.screenshot_interval_set(0.) +print('') +print(f'截图将保存到: {output}') + + +def screenshot(): + print(f'截图中...') + image = device.screenshot() + now = datetime.strftime(datetime.now(), '%Y-%m-%d_%H-%M-%S-%f') + file = f'{output}/{now}.png' + image = handle_sensitive_info(image) + print(f'截图中...') + Image.fromarray(image).save(file) + print(f'截图已保存到: {file}') + + +# Bind global shortcut +GLOBAL_KEY = 'F3' + + +def on_press(key): + if str(key) == f'Key.{GLOBAL_KEY.lower()}': + screenshot() + + +listener = keyboard.Listener(on_press=on_press) +listener.start() + +while 1: + print() + _ = input(f'按 <回车键> 或者按快捷键 <{GLOBAL_KEY}> 截一张图(快捷键全局生效):') + screenshot() diff --git a/gui.py b/gui.py new file mode 100644 index 0000000..2ecc6b2 --- /dev/null +++ b/gui.py @@ -0,0 +1,91 @@ +import threading +from multiprocessing import Event, Process + +from module.logger import logger +from module.webui.setting import State + + +def func(ev: threading.Event): + import argparse + import asyncio + import sys + + import uvicorn + + if sys.platform.startswith("win"): + asyncio.set_event_loop_policy(asyncio.WindowsProactorEventLoopPolicy()) + + State.restart_event = ev + + parser = argparse.ArgumentParser(description="Alas web service") + parser.add_argument( + "--host", + type=str, + help="Host to listen. Default to WebuiHost in deploy setting", + ) + parser.add_argument( + "-p", + "--port", + type=int, + help="Port to listen. Default to WebuiPort in deploy setting", + ) + parser.add_argument( + "-k", "--key", type=str, help="Password of alas. No password by default" + ) + parser.add_argument( + "--cdn", + action="store_true", + help="Use jsdelivr cdn for pywebio static files (css, js). Self host cdn by default.", + ) + parser.add_argument( + "--electron", action="store_true", help="Runs by electron client." + ) + parser.add_argument( + "--run", + nargs="+", + type=str, + help="Run alas by config names on startup", + ) + args, _ = parser.parse_known_args() + + host = args.host or State.deploy_config.WebuiHost or "0.0.0.0" + port = args.port or int(State.deploy_config.WebuiPort) or 22367 + State.electron = args.electron + + logger.hr("Launcher config") + logger.attr("Host", host) + logger.attr("Port", port) + logger.attr("Electron", args.electron) + logger.attr("Reload", ev is not None) + + if State.electron: + # https://github.com/LmeSzinc/AzurLaneAutoScript/issues/2051 + logger.info("Electron detected, remove log output to stdout") + from module.logger.logger import console_hdlr + logger.removeHandler(console_hdlr) + + uvicorn.run("module.webui.app:app", host=host, port=port, factory=True) + + +if __name__ == "__main__": + if State.deploy_config.EnableReload: + should_exit = False + while not should_exit: + event = Event() + process = Process(target=func, args=(event,)) + process.start() + while not should_exit: + try: + b = event.wait(1) + except KeyboardInterrupt: + should_exit = True + break + if b: + process.kill() + break + elif process.is_alive(): + continue + else: + should_exit = True + else: + func(None) diff --git a/module/alas.py b/module/alas.py new file mode 100644 index 0000000..56c0193 --- /dev/null +++ b/module/alas.py @@ -0,0 +1,314 @@ +import os +import re +import threading +import time +from datetime import datetime, timedelta + +import inflection +from cached_property import cached_property + +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.exception import * +from module.logger import logger +from module.notify import handle_notify + + +class AzurLaneAutoScript: + stop_event: threading.Event = None + + def __init__(self, config_name='alas'): + logger.hr('Start', level=0) + self.config_name = config_name + # Skip first restart + self.is_first_task = True + # Failure count of tasks + # Key: str, task name, value: int, failure count + self.failure_record = {} + + @cached_property + def config(self): + try: + config = AzurLaneConfig(config_name=self.config_name) + return config + except RequestHumanTakeover: + logger.critical('Request human takeover') + exit(1) + except Exception as e: + logger.exception(e) + exit(1) + + @cached_property + def device(self): + try: + from module.device.device import Device + device = Device(config=self.config) + return device + except RequestHumanTakeover: + logger.critical('Request human takeover') + exit(1) + except Exception as e: + logger.exception(e) + exit(1) + + @cached_property + def checker(self): + try: + from module.server_checker import ServerChecker + checker = ServerChecker(server=self.config.Emulator_PackageName) + return checker + except Exception as e: + logger.exception(e) + exit(1) + + def run(self, command): + try: + self.device.screenshot() + self.__getattribute__(command)() + return True + except TaskEnd: + return True + except GameNotRunningError as e: + logger.warning(e) + self.config.task_call('Restart') + return True + 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') + 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(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') + 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", + content=f"<{self.config_name}> GamePageUnknownError", + ) + exit(1) + else: + self.checker.wait_until_available() + return False + except ScriptError as e: + logger.critical(e) + logger.critical('This is likely to be a mistake of developers, but sometimes just random issues') + handle_notify( + self.config.Error_OnePushConfig, + title=f"Alas <{self.config_name}> crashed", + content=f"<{self.config_name}> ScriptError", + ) + exit(1) + except RequestHumanTakeover: + logger.critical('Request human takeover') + handle_notify( + self.config.Error_OnePushConfig, + title=f"Alas <{self.config_name}> crashed", + content=f"<{self.config_name}> RequestHumanTakeover", + ) + exit(1) + except Exception as e: + logger.exception(e) + self.save_error_log() + handle_notify( + self.config.Error_OnePushConfig, + title=f"Alas <{self.config_name}> crashed", + content=f"<{self.config_name}> Exception occured", + ) + exit(1) + + def save_error_log(self): + """ + Save last 60 screenshots in ./log/error/ + Save logs to ./log/error//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) + + def wait_until(self, future): + """ + Wait until a specific time. + + Args: + future (datetime): + + Returns: + bool: True if wait finished, False if config changed. + """ + future = future + timedelta(seconds=1) + self.config.start_watching() + while 1: + if datetime.now() > future: + return True + if self.stop_event is not None: + if self.stop_event.is_set(): + logger.info("Update event detected") + logger.info(f"[{self.config_name}] exited. Reason: Update") + exit(0) + + time.sleep(5) + + if self.config.should_reload(): + return False + + def get_next_task(self): + """ + Returns: + str: Name of the next task. + """ + while 1: + task = self.config.get_next() + self.config.task = task + self.config.bind(task) + + from module.base.resource import release_resources + if self.config.task.command != 'Alas': + release_resources(next_task=task.command) + + if task.next_run > datetime.now(): + logger.info(f'Wait until {task.next_run} for task `{task.command}`') + self.is_first_task = False + method = self.config.Optimization_WhenTaskQueueEmpty + if method == 'close_game': + logger.info('Close game during wait') + self.device.app_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') + elif method == 'goto_main': + logger.info('Goto main page during wait') + self.run('goto_main') + release_resources() + self.device.release_during_wait() + if not self.wait_until(task.next_run): + del_cached_property(self, 'config') + continue + elif method == 'stay_there': + logger.info('Stay there during wait') + release_resources() + self.device.release_during_wait() + if not self.wait_until(task.next_run): + del_cached_property(self, 'config') + continue + else: + logger.warning(f'Invalid Optimization_WhenTaskQueueEmpty: {method}, fallback to stay_there') + release_resources() + self.device.release_during_wait() + if not self.wait_until(task.next_run): + del_cached_property(self, 'config') + continue + break + + AzurLaneConfig.is_hoarding_task = False + return task.command + + def loop(self): + logger.set_file_logger(self.config_name) + logger.info(f'Start scheduler loop: {self.config_name}') + + while 1: + # Check update event from GUI + 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.") + break + # Check game server maintenance + self.checker.wait_until_available() + if self.checker.is_recovered(): + # There is an accidental bug hard to reproduce + # Sometimes, config won't be updated due to blocking + # even though it has been changed + # So update it once recovered + del_cached_property(self, 'config') + logger.info('Server or network is recovered. Restart game client') + self.config.task_call('Restart') + # Get task + task = self.get_next_task() + # Init device and change server + _ = self.device + # Skip first restart + if self.is_first_task and task == 'Restart': + logger.info('Skip task `Restart` at scheduler start') + self.config.task_delay(server_update=True) + del_cached_property(self, 'config') + continue + + # Run + logger.info(f'Scheduler: Start task `{task}`') + self.device.stuck_record_clear() + self.device.click_record_clear() + logger.hr(task, level=0) + success = self.run(inflection.underscore(task)) + logger.info(f'Scheduler: End task `{task}`') + self.is_first_task = False + + # Check failures + failed = deep_get(self.failure_record, keys=task, default=0) + failed = 0 if success else failed + 1 + deep_set(self.failure_record, keys=task, value=failed) + if failed >= 3: + logger.critical(f"Task `{task}` failed 3 or more times.") + logger.critical("Possible reason #1: You haven't used it correctly. " + "Please read the help text of the options.") + logger.critical("Possible reason #2: There is a problem with this task. " + "Please contact developers or try to fix it yourself.") + logger.critical('Request human takeover') + handle_notify( + self.config.Error_OnePushConfig, + title=f"Alas <{self.config_name}> crashed", + content=f"<{self.config_name}> RequestHumanTakeover\nTask `{task}` failed 3 or more times.", + ) + exit(1) + + if success: + del_cached_property(self, 'config') + continue + else: + # self.config.task_delay(success=False) + del_cached_property(self, 'config') + self.checker.check_now() + continue + + +if __name__ == '__main__': + alas = AzurLaneAutoScript() + alas.loop() diff --git a/module/base/base.py b/module/base/base.py new file mode 100644 index 0000000..496e55b --- /dev/null +++ b/module/base/base.py @@ -0,0 +1,276 @@ +import module.config.server as server_ +from module.base.button import Button, ButtonWrapper, ClickButton, match_template +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.logger import logger + + +class ModuleBase: + config: AzurLaneConfig + device: Device + + def __init__(self, config, device=None, task=None): + """ + Args: + config (AzurLaneConfig, str): + Name of the user config under ./config + device (Device, str): + To reuse a device. + If None, create a new Device object. + If str, create a new Device object and use the given device as serial. + task (str): + Bind a task only for dev purpose. Usually to be None for auto task scheduling. + If None, use default configs. + """ + if isinstance(config, AzurLaneConfig): + self.config = config + elif isinstance(config, str): + self.config = AzurLaneConfig(config, task=task) + else: + logger.warning('Alas ModuleBase received an unknown config, assume it is AzurLaneConfig') + self.config = config + + if isinstance(device, Device): + self.device = device + elif device is None: + self.device = Device(config=self.config) + elif isinstance(device, str): + self.config.override(Emulator_Serial=device) + self.device = Device(config=self.config) + else: + logger.warning('Alas ModuleBase received an unknown device, assume it is Device') + self.device = device + + self.interval_timer = {} + + def match_template(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(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: + button (ButtonWrapper): + interval (int, float): interval between two active events. + threshold (int): 0 to 255, smaller means more similar + + Returns: + bool: + """ + self.device.stuck_record_add(button) + + if interval and not self.interval_is_reached(button, interval=interval): + return False + + appear = button.match_color(self.device.image, threshold=threshold) + + if appear and interval: + self.interval_reset(button, interval=interval) + + return appear + + def match_template_color(self, button, interval=0, similarity=0.85, threshold=30): + """ + Args: + button (ButtonWrapper): + interval (int, float): interval between two active events. + similarity (int, float): 0 to 1. + threshold (int): 0 to 255, smaller means more similar + + Returns: + bool: + """ + self.device.stuck_record_add(button) + + if interval and not self.interval_is_reached(button, interval=interval): + return False + + appear = button.match_template_color(self.device.image, similarity=similarity, threshold=threshold) + + if appear and interval: + self.interval_reset(button, interval=interval) + + return appear + + appear = match_template + + def appear_then_click(self, button, interval=5, similarity=0.85): + appear = self.appear(button, interval=interval, similarity=similarity) + if appear: + self.device.click(button) + return appear + + def wait_until_stable(self, button, timer=Timer(0.3, count=1), timeout=Timer(5, count=10)): + """ + A terrible method, don't rely too much on it. + """ + logger.info(f'Wait until stable: {button}') + prev_image = self.image_crop(button) + timer.reset() + timeout.reset() + while 1: + self.device.screenshot() + + if timeout.reached(): + logger.warning(f'wait_until_stable({button}) timeout') + break + + image = self.image_crop(button) + if match_template(image, prev_image): + if timer.reached(): + logger.info(f'{button} stabled') + break + else: + prev_image = image + timer.reset() + + def image_crop(self, button, copy=True): + """Extract the area from image. + + Args: + button(Button, tuple): Button instance or area tuple. + copy: + """ + if isinstance(button, Button): + return crop(self.device.image, button.area, copy=copy) + elif isinstance(button, ButtonWrapper): + return crop(self.device.image, button.area, copy=copy) + elif hasattr(button, 'area'): + return crop(self.device.image, button.area, copy=copy) + else: + return crop(self.device.image, button, copy=copy) + + def image_color_count(self, button, color, threshold=221, count=50): + """ + Args: + button (Button, tuple): Button instance or area. + color (tuple): RGB. + threshold: 255 means colors are the same, the lower the worse. + count (int): Pixels count. + + Returns: + bool: + """ + if isinstance(button, np.ndarray): + image = button + else: + image = self.image_crop(button, copy=False) + mask = color_similarity_2d(image, color=color) + cv2.inRange(mask, threshold, 255, dst=mask) + sum_ = cv2.countNonZero(mask) + return sum_ > count + + def image_color_button(self, area, color, color_threshold=250, encourage=5, name='COLOR_BUTTON'): + """ + Find an area with pure color on image, convert into a Button. + + Args: + area (tuple[int]): Area to search from + color (tuple[int]): Target color + color_threshold (int): 0-255, 255 means exact match + encourage (int): Radius of button + name (str): Name of the button + + Returns: + Button: Or None if nothing matched. + """ + image = color_similarity_2d(self.image_crop(area), color=color) + points = np.array(np.where(image > color_threshold)).T[:, ::-1] + if points.shape[0] < encourage ** 2: + # Not having enough pixels to match + return None + + 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) + + def interval_reset(self, button, interval=5): + if isinstance(button, (list, tuple)): + for b in button: + self.interval_reset(b, interval) + 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() + + def interval_clear(self, button, interval=5): + if isinstance(button, (list, tuple)): + for b in button: + self.interval_clear(b, interval) + 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() + + 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() + + _image_file = '' + + @property + def image_file(self): + return self._image_file + + @image_file.setter + def image_file(self, value): + """ + For development. + Load image from local file system and set it to self.device.image + Test an image without taking a screenshot from emulator. + """ + if isinstance(value, Image.Image): + value = np.array(value) + elif isinstance(value, str): + value = load_image(value) + + self.device.image = value + + def set_lang(self, lang): + """ + For development. + Change lang and affect globally, + including assets and server specific methods. + """ + server_.set_lang(lang) + logger.attr('Lang', self.config.LANG) diff --git a/module/base/button.py b/module/base/button.py new file mode 100644 index 0000000..a690888 --- /dev/null +++ b/module/base/button.py @@ -0,0 +1,268 @@ +import module.config.server as server +from module.base.decorator import cached_property, del_cached_property +from module.base.resource import Resource +from module.base.utils import * +from module.exception import ScriptError + + +class Button(Resource): + def __init__(self, file, area, search, color, button): + """ + Args: + file: Filepath to an assets + area: Area to crop template + search: Area to search from, 20px larger than `area` by default + color: Average color of assets + button: Area to click if assets appears on the image + """ + self.file: str = file + self.area: t.Tuple[int, int, int, int] = area + 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.resource_add(self.file) + self._button_offset: t.Tuple[int, int] = (0, 0) + + @property + def button(self): + return area_offset(self._button, self._button_offset) + + def load_offset(self, button): + self._button_offset = button._button_offset + + def clear_offset(self): + self._button_offset = (0, 0) + + @cached_property + def image(self): + return load_image(self.file, self.area) + + def resource_release(self): + del_cached_property(self, 'image') + self.clear_offset() + + def __str__(self): + return self.file + + __repr__ = __str__ + + def __eq__(self, other): + return str(self) == str(other) + + def __hash__(self): + return hash(self.file) + + def __bool__(self): + return True + + def match_color(self, image, threshold=10) -> bool: + """ + Check if the button appears on the image, using average color + + Args: + image (np.ndarray): Screenshot. + threshold (int): Default to 10. + + Returns: + bool: True if button appears on screenshot. + """ + color = get_color(image, self.area) + return color_similar( + color1=color, + color2=self.color, + threshold=threshold + ) + + def match_template(self, image, similarity=0.85) -> 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. + + Returns: + bool. + """ + 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_color(self, image, similarity=0.85, threshold=30) -> bool: + """ + Template match first, color match then + + Args: + image: Screenshot. + similarity (float): 0-1. + threshold (int): Default to 10. + + Returns: + + """ + matched = self.match_template(image, similarity=similarity) + if not matched: + return False + + area = area_offset(self.area, offset=self._button_offset) + color = get_color(image, area) + return color_similar( + color1=color, + color2=self.color, + threshold=threshold + ) + + +class ButtonWrapper(Resource): + def __init__(self, name='MULTI_ASSETS', **kwargs): + self.name = name + self.data_buttons = kwargs + self._matched_button: t.Optional[Button] = None + self.resource_add(self.name) + + def resource_release(self): + del_cached_property(self, 'assets') + self._matched_button = None + + def __str__(self): + return self.name + + __repr__ = __str__ + + def __eq__(self, other): + return str(self) == str(other) + + def __hash__(self): + return hash(self.name) + + def __bool__(self): + return True + + @cached_property + def buttons(self) -> t.List[Button]: + # for trial in [server.lang, 'share', 'cn']: + for trial in [server.lang, 'share', 'jp']: + assets = self.data_buttons.get(trial, None) + if assets is not None: + if isinstance(assets, Button): + return [assets] + elif isinstance(assets, list): + return assets + + raise ScriptError(f'ButtonWrapper({self}) on server {server.lang} has no fallback button') + + def match_color(self, image, threshold=10) -> bool: + for assets in self.buttons: + if assets.match_color(image, threshold=threshold): + self._matched_button = assets + return True + return False + + def match_template(self, image, similarity=0.85) -> bool: + for assets in self.buttons: + if assets.match_template(image, similarity=similarity): + self._matched_button = assets + return True + return False + + def match_template_color(self, image, similarity=0.85, threshold=30) -> bool: + for assets in self.buttons: + if assets.match_template_color(image, similarity=similarity, threshold=threshold): + self._matched_button = assets + return True + return False + + @property + def matched_button(self) -> Button: + if self._matched_button is None: + return self.buttons[0] + else: + return self._matched_button + + @property + def area(self) -> tuple[int, int, int, int]: + return self.matched_button.area + + @property + def search(self) -> tuple[int, int, int, int]: + return self.matched_button.search + + @property + def color(self) -> tuple[int, int, int]: + return self.matched_button.color + + @property + def button(self) -> tuple[int, int, int, int]: + return self.matched_button.button + + @property + def button_offset(self) -> tuple[int, int]: + return self.matched_button._button_offset + + @property + def width(self) -> int: + return area_size(self.area)[0] + + @property + def height(self) -> int: + return area_size(self.area)[1] + + def load_offset(self, button): + """ + Load offset from another button. + + Args: + button (Button, ButtonWrapper): + """ + if isinstance(button, ButtonWrapper): + button = button.matched_button + for b in self.buttons: + b.load_offset(button) + + def clear_offset(self): + for b in self.buttons: + b.clear_offset() + + +class ClickButton: + def __init__(self, button, name='CLICK_BUTTON'): + self.area = button + self.button = button + self.name = name + + def __str__(self): + return self.name + + __repr__ = __str__ + + def __eq__(self, other): + return str(self) == str(other) + + def __hash__(self): + return hash(self.name) + + def __bool__(self): + return True + + +def match_template(image, template, similarity=0.85): + """ + Args: + image (np.ndarray): Screenshot + template (np.ndarray): + area (tuple): Crop area of image. + offset (int, tuple): Detection area offset. + similarity (float): 0-1. Similarity. Lower than this value will return float(0). + + Returns: + bool: + """ + res = cv2.matchTemplate(image, template, cv2.TM_CCOEFF_NORMED) + _, sim, _, point = cv2.minMaxLoc(res) + return sim > similarity diff --git a/module/base/code_generator.py b/module/base/code_generator.py new file mode 100644 index 0000000..097bb7f --- /dev/null +++ b/module/base/code_generator.py @@ -0,0 +1,181 @@ +import typing as t + + +class TabWrapper: + def __init__(self, generator, prefix='', suffix='', newline=True): + """ + Args: + generator (CodeGenerator): + """ + self.generator = generator + self.prefix = prefix + self.suffix = suffix + self.newline = newline + + self.nested = False + + def __enter__(self): + if not self.nested and self.prefix: + self.generator.add(self.prefix, newline=self.newline) + self.generator.tab_count += 1 + return self + + def __exit__(self, exc_type, exc_val, exc_tb): + self.generator.tab_count -= 1 + if self.suffix: + self.generator.add(self.suffix) + + def __repr__(self): + return self.prefix + + def set_nested(self, suffix=''): + self.nested = True + self.suffix += suffix + + +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 print(self): + lines = ''.join(self.lines) + print(lines) + + def write(self, file: str = None): + lines = ''.join(self.lines) + with open(file, 'w', encoding='utf-8', newline='') as f: + f.write(lines) + + def _line_with_tabs(self, line, comment=False, newline=True): + if comment: + line = '# ' + line + out = ' ' * self.tab_count + line + if newline: + out += '\n' + return out + + def _repr(self, obj): + if isinstance(obj, str): + if '\n' in obj: + out = '"""\n' + with self.tab(): + for line in obj.strip().split('\n'): + line = line.strip() + out += self._line_with_tabs(line) + out += self._line_with_tabs('"""', newline=False) + return out + return repr(obj) + + def tab(self): + return TabWrapper(self) + + def Empty(self): + self.add('') + + def Import(self, text, empty=2): + for line in text.strip().split('\n'): + line = line.strip() + self.add(line) + for _ in range(empty): + self.Empty() + + def Value(self, key=None, value=None, type_=None, **kwargs): + if key is not None: + if type_ is not None: + self.add(f'{key}: {type_} = {self._repr(value)}') + else: + self.add(f'{key} = {self._repr(value)}') + for key, value in kwargs.items(): + self.Value(key, value) + + def Comment(self, text): + for line in text.strip().split('\n'): + line = line.strip() + self.add(line, comment=True) + + def CommentAutoGenerage(self, file): + """ + Args: + file: dev_tools.button_extract + """ + # Only leave one blank line at above + if len(self.lines) >= 2: + if self.lines[-2:] == ['\n', '\n']: + self.lines.pop(-1) + self.Comment('This file was auto-generated, do not modify it manually. To generate:') + self.Comment(f'``` python -m {file} ```') + self.Empty() + + def List(self, key=None): + if key is not None: + return TabWrapper(self, prefix=str(key) + ' = [', suffix=']') + else: + return TabWrapper(self, prefix='[', suffix=']', newline=False) + + def ListItem(self, value): + if isinstance(value, TabWrapper): + value.set_nested(suffix=',') + self.add(f'{self._repr(value)}') + return value + else: + self.add(f'{self._repr(value)},') + + def Dict(self, key=None): + if key is not None: + return TabWrapper(self, prefix=str(key) + ' = {', suffix='}') + else: + return TabWrapper(self, prefix='{', suffix='}', newline=False) + + def DictItem(self, key=None, value=None): + if isinstance(value, TabWrapper): + value.set_nested(suffix=',') + if key is not None: + self.add(f'{self._repr(key)}: {self._repr(value)}') + return value + else: + if key is not None: + self.add(f'{self._repr(key)}: {self._repr(value)},') + + def Object(self, object_class, key=None): + 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) + + def ObjectAttr(self, key=None, value=None): + if isinstance(value, TabWrapper): + value.set_nested(suffix=',') + if key is None: + self.add(f'{self._repr(value)}') + else: + self.add(f'{key}={self._repr(value)}') + return value + else: + if key is None: + self.add(f'{self._repr(value)},') + else: + self.add(f'{key}={self._repr(value)},') + + def Class(self, name, inherit=None): + if inherit is not None: + return TabWrapper(self, prefix=f'class {name}({inherit}):') + else: + return TabWrapper(self, prefix=f'class {name}:') + + def Def(self, name, args=''): + return TabWrapper(self, prefix=f'def {name}({args}):') + + +generator = CodeGenerator() +Import = generator.Import +Value = generator.Value +Comment = generator.Comment +Dict = generator.Dict +DictItem = generator.DictItem diff --git a/module/base/decorator.py b/module/base/decorator.py new file mode 100644 index 0000000..f4d6937 --- /dev/null +++ b/module/base/decorator.py @@ -0,0 +1,196 @@ +import random +import re +from functools import wraps +from typing import Callable, Generic, TypeVar + +T = TypeVar("T") + + +class Config: + """ + Decorator that calls different function with a same name according to config. + + func_list likes: + func_list = { + 'func1': [ + {'options': {'ENABLE': True}, 'func': 1}, + {'options': {'ENABLE': False}, 'func': 1} + ] + } + """ + func_list = {} + + @classmethod + def when(cls, **kwargs): + """ + Args: + **kwargs: Any option in AzurLaneConfig. + + Examples: + @Config.when(USE_ONE_CLICK_RETIREMENT=True) + def retire_ships(self, amount=None, rarity=None): + pass + + @Config.when(USE_ONE_CLICK_RETIREMENT=False) + def retire_ships(self, amount=None, rarity=None): + pass + """ + from module.logger import logger + options = kwargs + + def decorate(func): + name = func.__name__ + data = {'options': options, 'func': func} + if name not in cls.func_list: + cls.func_list[name] = [data] + else: + override = False + for record in cls.func_list[name]: + if record['options'] == data['options']: + record['func'] = data['func'] + override = True + if not override: + cls.func_list[name].append(data) + + @wraps(func) + def wrapper(self, *args, **kwargs): + """ + Args: + self: ModuleBase instance. + *args: + **kwargs: + """ + for record in cls.func_list[name]: + + flag = [value is None or self.config.__getattribute__(key) == value + for key, value in record['options'].items()] + if not all(flag): + continue + + return record['func'](self, *args, **kwargs) + + logger.warning(f'No option fits for {name}, using the last define func.') + return func(self, *args, **kwargs) + + return wrapper + + return decorate + + +class cached_property(Generic[T]): + """ + cached-property from https://github.com/pydanny/cached-property + Add typing support + + A property that is only computed once per instance and then replaces itself + with an ordinary attribute. Deleting the attribute resets the property. + Source: https://github.com/bottlepy/bottle/commit/fa7733e075da0d790d809aa3d2f53071897e6f76 + """ + + def __init__(self, func: Callable[..., T]): + self.func = func + + def __get__(self, obj, cls) -> T: + if obj is None: + return self + + value = obj.__dict__[self.func.__name__] = self.func(obj) + return value + + +def del_cached_property(obj, name): + """ + Delete a cached property safely. + + Args: + obj: + name (str): + """ + try: + del obj.__dict__[name] + except KeyError: + pass + + +def has_cached_property(obj, name): + """ + Check if a property is cached. + + Args: + obj: + name (str): + """ + return name in obj.__dict__ + + +def function_drop(rate=0.5, default=None): + """ + Drop function calls to simulate random emulator stuck, for testing purpose. + + Args: + rate (float): 0 to 1. Drop rate. + default: Default value to return if dropped. + + Examples: + @function_drop(0.3) + def click(self, button, record_check=True): + pass + + 30% possibility: + INFO | Dropped: module.device.device.Device.click(REWARD_GOTO_MAIN, record_check=True) + 70% possibility: + INFO | Click (1091, 628) @ REWARD_GOTO_MAIN + """ + from module.logger import logger + + def decorate(func): + @wraps(func) + def wrapper(*args, **kwargs): + if random.uniform(0, 1) > rate: + return func(*args, **kwargs) + else: + cls = '' + arguments = [str(arg) for arg in args] + if len(arguments): + matched = re.search('<(.*?) object at', arguments[0]) + if matched: + cls = matched.group(1) + '.' + arguments.pop(0) + arguments += [f'{k}={v}' for k, v in kwargs.items()] + arguments = ', '.join(arguments) + logger.info(f'Dropped: {cls}{func.__name__}({arguments})') + return default + + return wrapper + + return decorate + + +def run_once(f): + """ + Run a function only once, no matter how many times it has been called. + + Examples: + @run_once + def my_function(foo, bar): + return foo + bar + + while 1: + my_function() + + Examples: + def my_function(foo, bar): + return foo + bar + + action = run_once(my_function) + while 1: + action() + """ + + def wrapper(*args, **kwargs): + if not wrapper.has_run: + wrapper.has_run = True + return f(*args, **kwargs) + + wrapper.has_run = False + return wrapper diff --git a/module/base/filter.py b/module/base/filter.py new file mode 100644 index 0000000..2f23325 --- /dev/null +++ b/module/base/filter.py @@ -0,0 +1,149 @@ +import re + +from module.logger import logger + + +class Filter: + def __init__(self, regex, attr, preset=()): + """ + Args: + regex: Regular expression. + attr: Attribute name. + preset: Build-in string preset. + """ + if isinstance(regex, str): + regex = re.compile(regex) + self.regex = regex + self.attr = attr + self.preset = tuple(list(p.lower() for p in preset)) + self.filter_raw = [] + self.filter = [] + + def load(self, string): + """ + Load a filter string, filters are connected with ">" + + There are also tons of unicode characters similar to ">" + > \u003E correct + > \uFF1E + ﹥ \uFE65 + › \u203a + ˃ \u02c3 + ᐳ \u1433 + ❯ \u276F + """ + string = str(string) + string = re.sub(r'[ \t\r\n]', '', string) + string = re.sub(r'[>﹥›˃ᐳ❯]', '>', string) + self.filter_raw = string.split('>') + self.filter = [self.parse_filter(f) for f in self.filter_raw] + + def is_preset(self, filter): + return len(filter) and filter.lower() in self.preset + + def apply(self, objs, func=None): + """ + Args: + objs (list): List of objects and strings + func (callable): A function to filter object. + Function should receive an object as arguments, and return a bool. + True means add it to output. + + Returns: + list: A list of objects and preset strings, such as [object, object, object, 'reset'] + """ + out = [] + for raw, filter in zip(self.filter_raw, self.filter): + if self.is_preset(raw): + raw = raw.lower() + if raw not in out: + out.append(raw) + else: + for index, obj in enumerate(objs): + if self.apply_filter_to_obj(obj=obj, filter=filter) and obj not in out: + out.append(obj) + + if func is not None: + objs, out = out, [] + for obj in objs: + if isinstance(obj, str): + out.append(obj) + elif func(obj): + out.append(obj) + else: + # Drop this object + pass + + return out + + def apply_filter_to_obj(self, obj, filter): + """ + Args: + obj (object): + filter (list[str]): + + Returns: + bool: If an object satisfy a filter. + """ + + for attr, value in zip(self.attr, filter): + if not value: + continue + if str(obj.__getattribute__(attr)).lower() != str(value): + return False + + return True + + def parse_filter(self, string): + """ + Args: + string (str): + + Returns: + list[strNone]: + """ + string = string.replace(' ', '').lower() + result = re.search(self.regex, string) + + if self.is_preset(string): + return [string] + + if result and len(string) and result.span()[1]: + return [result.group(index + 1) for index, attr in enumerate(self.attr)] + else: + logger.warning(f'Invalid filter: "{string}". This selector does not match the regex, nor a preset.') + # Invalid filter will be ignored. + # Return strange things and make it impossible to match + return ['1nVa1d'] + [None] * (len(self.attr) - 1) + + +class MultiLangFilter(Filter): + """ + To support multi-language, there might be different correct matches of same object. + """ + + def apply_filter_to_obj(self, obj, filter): + """ + Args: + obj (object): In this case, attributes of object are array (instead of plain string). + Any match of element in it will return True + filter (list[str]): + + Returns: + bool: If an object satisfy a filter. + """ + for attr, value in zip(self.attr, filter): + if not value: + continue + if not hasattr(obj, attr): + continue + + obj_value = obj.__getattribute__(attr) + if isinstance(obj_value, (str, int)): + if str(obj_value).lower() != str(value): + return False + if isinstance(obj_value, list): + if value not in obj_value: + return False + + return True diff --git a/module/base/resource.py b/module/base/resource.py new file mode 100644 index 0000000..40d930e --- /dev/null +++ b/module/base/resource.py @@ -0,0 +1,77 @@ +import re + +import module.config.server as server +from module.base.decorator import cached_property, del_cached_property + + +def get_assets_from_file(file, regex): + assets = set() + with open(file, 'r', encoding='utf-8') as f: + for row in f.readlines(): + result = regex.search(row) + if result: + assets.add(result.group(1)) + return assets + + +class PreservedAssets: + @cached_property + def ui(self): + assets = set() + assets |= get_assets_from_file( + file='./tasks/base/assets/assets_base_page.py', + regex=re.compile(r'^([A-Za-z][A-Za-z0-9_]+) = ') + ) + assets |= get_assets_from_file( + file='./tasks/base/assets/assets_base_popup.py', + regex=re.compile(r'^([A-Za-z][A-Za-z0-9_]+) = ') + ) + return assets + + +_preserved_assets = PreservedAssets() + + +class Resource: + # Class property, record all button and templates + instances = {} + + def resource_add(self, key): + Resource.instances[key] = self + + def resource_release(self): + pass + + @classmethod + def is_loaded(cls, obj): + if hasattr(obj, '_image') and obj._image is None: + return False + elif hasattr(obj, 'image') and obj.image is None: + return False + return True + + @classmethod + def resource_show(cls): + from module.logger import logger + logger.hr('Show resource') + for key, obj in cls.instances.items(): + if cls.is_loaded(obj): + continue + logger.info(f'{obj}: {key}') + + +def release_resources(next_task=''): + # Release assets cache + # module.ui has about 80 assets and takes about 3MB + # Alas has about 800 assets, but they are not all loaded. + # Template images take more, about 6MB each + for key, obj in Resource.instances.items(): + # Preserve assets for ui switching + if next_task and str(obj) in _preserved_assets.ui: + continue + # if Resource.is_loaded(obj): + # logger.info(f'Release {obj}') + obj.resource_release() + + # Useless in most cases, but just call it + # gc.collect() diff --git a/module/base/retry.py b/module/base/retry.py new file mode 100644 index 0000000..2106ae2 --- /dev/null +++ b/module/base/retry.py @@ -0,0 +1,123 @@ +import functools +import random +import time +from functools import partial + +from module.logger import logger as logging_logger + +""" +Copied from `retry`, but modified something. +""" + +try: + from decorator import decorator +except ImportError: + def decorator(caller): + """ Turns caller into a decorator. + Unlike decorator module, function signature is not preserved. + + :param caller: caller(f, *args, **kwargs) + """ + + def decor(f): + @functools.wraps(f) + def wrapper(*args, **kwargs): + return caller(f, *args, **kwargs) + + return wrapper + + return decor + + +def __retry_internal(f, exceptions=Exception, tries=-1, delay=0, max_delay=None, backoff=1, jitter=0, + logger=logging_logger): + """ + Executes a function and retries it if it failed. + + :param f: the function to execute. + :param exceptions: an exception or a tuple of exceptions to catch. default: Exception. + :param tries: the maximum number of attempts. default: -1 (infinite). + :param delay: initial delay between attempts. default: 0. + :param max_delay: the maximum value of delay. default: None (no limit). + :param backoff: multiplier applied to delay between attempts. default: 1 (no backoff). + :param jitter: extra seconds added to delay between attempts. default: 0. + fixed if a number, random if a range tuple (min, max) + :param logger: logger.warning(fmt, error, delay) will be called on failed attempts. + default: retry.logging_logger. if None, logging is disabled. + :returns: the result of the f function. + """ + _tries, _delay = tries, delay + while _tries: + try: + return f() + except exceptions as e: + _tries -= 1 + if not _tries: + # Difference, raise same exception + raise e + + if logger is not None: + # Difference, show exception + logger.exception(e) + logger.warning(f'{type(e).__name__}({e}), retrying in {_delay} seconds...') + + time.sleep(_delay) + _delay *= backoff + + if isinstance(jitter, tuple): + _delay += random.uniform(*jitter) + else: + _delay += jitter + + if max_delay is not None: + _delay = min(_delay, max_delay) + + +def retry(exceptions=Exception, tries=-1, delay=0, max_delay=None, backoff=1, jitter=0, logger=logging_logger): + """Returns a retry decorator. + + :param exceptions: an exception or a tuple of exceptions to catch. default: Exception. + :param tries: the maximum number of attempts. default: -1 (infinite). + :param delay: initial delay between attempts. default: 0. + :param max_delay: the maximum value of delay. default: None (no limit). + :param backoff: multiplier applied to delay between attempts. default: 1 (no backoff). + :param jitter: extra seconds added to delay between attempts. default: 0. + fixed if a number, random if a range tuple (min, max) + :param logger: logger.warning(fmt, error, delay) will be called on failed attempts. + default: retry.logging_logger. if None, logging is disabled. + :returns: a retry decorator. + """ + + @decorator + def retry_decorator(f, *fargs, **fkwargs): + args = fargs if fargs else list() + kwargs = fkwargs if fkwargs else dict() + return __retry_internal(partial(f, *args, **kwargs), exceptions, tries, delay, max_delay, backoff, jitter, + logger) + + return retry_decorator + + +def retry_call(f, fargs=None, fkwargs=None, exceptions=Exception, tries=-1, delay=0, max_delay=None, backoff=1, + jitter=0, + logger=logging_logger): + """ + Calls a function and re-executes it if it failed. + + :param f: the function to execute. + :param fargs: the positional arguments of the function to execute. + :param fkwargs: the named arguments of the function to execute. + :param exceptions: an exception or a tuple of exceptions to catch. default: Exception. + :param tries: the maximum number of attempts. default: -1 (infinite). + :param delay: initial delay between attempts. default: 0. + :param max_delay: the maximum value of delay. default: None (no limit). + :param backoff: multiplier applied to delay between attempts. default: 1 (no backoff). + :param jitter: extra seconds added to delay between attempts. default: 0. + fixed if a number, random if a range tuple (min, max) + :param logger: logger.warning(fmt, error, delay) will be called on failed attempts. + default: retry.logging_logger. if None, logging is disabled. + :returns: the result of the f function. + """ + args = fargs if fargs else list() + kwargs = fkwargs if fkwargs else dict() + return __retry_internal(partial(f, *args, **kwargs), exceptions, tries, delay, max_delay, backoff, jitter, logger) diff --git a/module/base/timer.py b/module/base/timer.py new file mode 100644 index 0000000..b849d1f --- /dev/null +++ b/module/base/timer.py @@ -0,0 +1,163 @@ +import time +from datetime import datetime, timedelta +from functools import wraps + + +def timer(function): + @wraps(function) + def function_timer(*args, **kwargs): + t0 = time.time() + + result = function(*args, **kwargs) + t1 = time.time() + print('%s: %s s' % (function.__name__, str(round(t1 - t0, 10)))) + return result + + return function_timer + + +def future_time(string): + """ + Args: + string (str): Such as 14:59. + + Returns: + datetime.datetime: Time with given hour, minute in the future. + """ + 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 + + +def past_time(string): + """ + Args: + string (str): Such as 14:59. + + Returns: + datetime.datetime: Time with given hour, minute in the past. + """ + 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] + + +class Timer: + def __init__(self, limit, count=0): + """ + 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. + """ + self.limit = limit + self.count = count + self._current = 0 + self._reach_count = count + + def start(self): + if not self.started(): + self._current = time.time() + self._reach_count = 0 + + return self + + def started(self): + return bool(self._current) + + def current(self): + """ + Returns: + float + """ + if self.started(): + return time.time() - self._current + else: + return 0. + + def set_current(self, current, count=0): + self._current = time.time() - current + self._reach_count = count + + def reached(self): + """ + Returns: + bool + """ + self._reach_count += 1 + return time.time() - self._current > self.limit and self._reach_count > self.count + + def reset(self): + self._current = time.time() + self._reach_count = 0 + return self + + def clear(self): + self._current = 0 + self._reach_count = self.count + return self + + def reached_and_reset(self): + """ + Returns: + bool: + """ + if self.reached(): + self.reset() + return True + else: + return False + + def wait(self): + """ + Wait until timer reached. + """ + diff = self._current + self.limit - time.time() + if diff > 0: + time.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})' + + __repr__ = __str__ diff --git a/module/base/utils/__init__.py b/module/base/utils/__init__.py new file mode 100644 index 0000000..0ebf2f7 --- /dev/null +++ b/module/base/utils/__init__.py @@ -0,0 +1,3 @@ +from .utils import * +from .grids import * +from .points import * diff --git a/module/base/utils/grids.py b/module/base/utils/grids.py new file mode 100644 index 0000000..69527c5 --- /dev/null +++ b/module/base/utils/grids.py @@ -0,0 +1,377 @@ +import operator +import typing as t + + +class SelectedGrids: + def __init__(self, grids): + self.grids = grids + self.indexes: t.Dict[tuple, SelectedGrids] = {} + + def __iter__(self): + return iter(self.grids) + + def __getitem__(self, item): + if isinstance(item, int): + return self.grids[item] + else: + return SelectedGrids(self.grids[item]) + + def __contains__(self, item): + return item in self.grids + + def __str__(self): + # return str([str(grid) for grid in self]) + return '[' + ', '.join([str(grid) for grid in self]) + ']' + + def __len__(self): + return len(self.grids) + + def __bool__(self): + return self.count > 0 + + # def __getattr__(self, item): + # return [grid.__getattribute__(item) for grid in self.grids] + + @property + def location(self): + """ + Returns: + list[tuple]: + """ + return [grid.location for grid in self.grids] + + @property + def cost(self): + """ + Returns: + list[int]: + """ + return [grid.cost for grid in self.grids] + + @property + def weight(self): + """ + Returns: + list[int]: + """ + return [grid.weight for grid in self.grids] + + @property + def count(self): + """ + Returns: + int: + """ + return len(self.grids) + + def select(self, **kwargs): + """ + Args: + **kwargs: Attributes of Grid. + + Returns: + SelectedGrids: + """ + def matched(obj): + flag = True + for k, v in kwargs.items(): + obj_v = obj.__getattribute__(k) + if type(obj_v) != type(v) or obj_v != v: + flag = False + return flag + + return SelectedGrids([grid for grid in self.grids if matched(grid)]) + + def create_index(self, *attrs): + indexes = {} + # index_keys = [(grid.__getattribute__(attr) for attr in attrs) for grid in self.grids] + for grid in self.grids: + k = tuple(grid.__getattribute__(attr) for attr in attrs) + try: + indexes[k].append(grid) + except KeyError: + indexes[k] = [grid] + + indexes = {k: SelectedGrids(v) for k, v in indexes.items()} + self.indexes = indexes + return indexes + + def indexed_select(self, *values): + return self.indexes.get(values, SelectedGrids([])) + + def left_join(self, right, on_attr, set_attr, default=None): + """ + Args: + right (SelectedGrids): Right table to join + on_attr: + set_attr: + default: + + Returns: + SelectedGrids: + """ + right.create_index(*on_attr) + for grid in self: + attr_value = tuple([grid.__getattribute__(attr) for attr in on_attr]) + right_grid = right.indexed_select(*attr_value).first_or_none() + if right_grid is not None: + for attr in set_attr: + grid.__setattr__(attr, right_grid.__getattribute__(attr)) + else: + for attr in set_attr: + grid.__setattr__(attr, default) + + return self + + def filter(self, func): + """ + Filter grids by a function. + + Args: + func (callable): Function should receive an grid as argument, and return a bool. + + Returns: + SelectedGrids: + """ + return SelectedGrids([grid for grid in self if func(grid)]) + + def set(self, **kwargs): + """ + Set attribute to each grid. + + Args: + **kwargs: + """ + for grid in self: + for key, value in kwargs.items(): + grid.__setattr__(key, value) + + def get(self, attr): + """ + Get an attribute from each grid. + + Args: + attr: Attribute name. + + Returns: + list: + """ + return [grid.__getattribute__(attr) for grid in self.grids] + + def call(self, func, **kwargs): + """ + Call a function in reach grid, and get results. + + Args: + func (str): Function name to call. + **kwargs: + + Returns: + list: + """ + return [grid.__getattribute__(func)(**kwargs) for grid in self] + + def first_or_none(self): + """ + Returns: + + """ + try: + return self.grids[0] + except IndexError: + return None + + def add(self, grids): + """ + Args: + grids(SelectedGrids): + + Returns: + SelectedGrids: + """ + return SelectedGrids(list(set(self.grids + grids.grids))) + + def add_by_eq(self, grids): + """ + Another `add()` method, but de-duplicates with `__eq__` instead of `__hash__`. + + Args: + grids(SelectedGrids): + + Returns: + SelectedGrids: + """ + new = [] + for grid in self.grids + grids.grids: + if grid not in new: + new.append(grid) + + return SelectedGrids(new) + + def intersect(self, grids): + """ + Args: + grids(SelectedGrids): + + Returns: + SelectedGrids: + """ + return SelectedGrids(list(set(self.grids).intersection(set(grids.grids)))) + + def intersect_by_eq(self, grids): + """ + Another `intersect()` method, but de-duplicates with `__eq__` instead of `__hash__`. + + Args: + grids(SelectedGrids): + + Returns: + SelectedGrids: + """ + new = [] + for grid in self.grids: + if grid in grids.grids: + new.append(grid) + + return SelectedGrids(new) + + def delete(self, grids): + """ + Args: + grids(SelectedGrids): + + Returns: + SelectedGrids: + """ + g = [grid for grid in self.grids if grid not in grids] + return SelectedGrids(g) + + def sort(self, *args): + """ + Args: + args (str): Attribute name to sort. + + Returns: + SelectedGrids: + """ + if not self: + return self + if len(args): + grids = sorted(self.grids, key=operator.attrgetter(*args)) + return SelectedGrids(grids) + else: + return self + + def sort_by_camera_distance(self, camera): + """ + Args: + camera (tuple): + + Returns: + SelectedGrids: + """ + import numpy as np + if not self: + return self + location = np.array(self.location) + diff = np.sum(np.abs(location - camera), axis=1) + # grids = [x for _, x in sorted(zip(diff, self.grids))] + grids = tuple(np.array(self.grids)[np.argsort(diff)]) + return SelectedGrids(grids) + + def sort_by_clock_degree(self, center=(0, 0), start=(0, 1), clockwise=True): + """ + Args: + center (tuple): Origin point. + start (tuple): Start coordinate, this point will be considered as theta=0. + clockwise (bool): True for clockwise, false for counterclockwise. + + Returns: + SelectedGrids: + """ + import numpy as np + if not self: + return self + vector = np.subtract(self.location, center) + theta = np.arctan2(vector[:, 1], vector[:, 0]) / np.pi * 180 + vector = np.subtract(start, center) + theta = theta - np.arctan2(vector[1], vector[0]) / np.pi * 180 + if not clockwise: + theta = -theta + theta[theta < 0] += 360 + grids = tuple(np.array(self.grids)[np.argsort(theta)]) + return SelectedGrids(grids) + + +class RoadGrids: + def __init__(self, grids): + """ + Args: + grids (list): + """ + self.grids = [] + for grid in grids: + if isinstance(grid, list): + self.grids.append(SelectedGrids(grids=grid)) + else: + self.grids.append(SelectedGrids(grids=[grid])) + + def __str__(self): + return str(' - '.join([str(grid) for grid in self.grids])) + + def roadblocks(self): + """ + Returns: + SelectedGrids: + """ + grids = [] + for block in self.grids: + if block.count == block.select(is_enemy=True).count: + grids += block.grids + return SelectedGrids(grids) + + def potential_roadblocks(self): + """ + Returns: + SelectedGrids: + """ + grids = [] + for block in self.grids: + if any([grid.is_fleet for grid in block]): + continue + if any([grid.is_cleared for grid in block]): + continue + if block.count - block.select(is_enemy=True).count == 1: + grids += block.select(is_enemy=True).grids + return SelectedGrids(grids) + + def first_roadblocks(self): + """ + Returns: + SelectedGrids: + """ + grids = [] + for block in self.grids: + if any([grid.is_fleet for grid in block]): + continue + if any([grid.is_cleared for grid in block]): + continue + if block.select(is_enemy=True).count >= 1: + grids += block.select(is_enemy=True).grids + return SelectedGrids(grids) + + def combine(self, road): + """ + Args: + road (RoadGrids): + + Returns: + RoadGrids: + """ + out = RoadGrids([]) + for select_1 in self.grids: + for select_2 in road.grids: + select = select_1.add(select_2) + out.grids.append(select) + + return out diff --git a/module/base/utils/points.py b/module/base/utils/points.py new file mode 100644 index 0000000..d8d6b5b --- /dev/null +++ b/module/base/utils/points.py @@ -0,0 +1,395 @@ +import numpy as np +from scipy import optimize + +from .utils import area_pad + + +class Points: + def __init__(self, points): + if points is None or len(points) == 0: + self._bool = False + self.points = None + else: + self._bool = True + self.points = np.array(points) + if len(self.points.shape) == 1: + self.points = np.array([self.points]) + self.x, self.y = self.points.T + + def __str__(self): + return str(self.points) + + __repr__ = __str__ + + def __iter__(self): + return iter(self.points) + + def __getitem__(self, item): + return self.points[item] + + def __len__(self): + if self: + return len(self.points) + else: + return 0 + + def __bool__(self): + return self._bool + + def link(self, point, is_horizontal=False): + if is_horizontal: + lines = [[y, np.pi / 2] for y in self.y] + return Lines(lines, is_horizontal=True) + else: + x, y = point + theta = -np.arctan((self.x - x) / (self.y - y)) + rho = self.x * np.cos(theta) + self.y * np.sin(theta) + lines = np.array([rho, theta]).T + return Lines(lines, is_horizontal=False) + + def mean(self): + if not self: + return None + + return np.round(np.mean(self.points, axis=0)).astype(int) + + def group(self, threshold=3): + if not self: + return np.array([]) + groups = [] + points = self.points + if len(points) == 1: + return np.array([points[0]]) + + while len(points): + p0, p1 = points[0], points[1:] + distance = np.sum(np.abs(p1 - p0), axis=1) + new = Points(np.append(p1[distance <= threshold], [p0], axis=0)).mean().tolist() + groups.append(new) + points = p1[distance > threshold] + + return np.array(groups) + + +class Lines: + MID_Y = 360 + + def __init__(self, lines, is_horizontal): + if lines is None or len(lines) == 0: + self._bool = False + self.lines = None + else: + self._bool = True + self.lines = np.array(lines) + if len(self.lines.shape) == 1: + self.lines = np.array([self.lines]) + self.rho, self.theta = self.lines.T + self.is_horizontal = is_horizontal + + def __str__(self): + return str(self.lines) + + __repr__ = __str__ + + def __iter__(self): + return iter(self.lines) + + def __getitem__(self, item): + return Lines(self.lines[item], is_horizontal=self.is_horizontal) + + def __len__(self): + if self: + return len(self.lines) + else: + return 0 + + def __bool__(self): + return self._bool + + @property + def sin(self): + return np.sin(self.theta) + + @property + def cos(self): + return np.cos(self.theta) + + @property + def mean(self): + if not self: + return None + if self.is_horizontal: + return np.mean(self.lines, axis=0) + else: + x = np.mean(self.mid) + theta = np.mean(self.theta) + rho = x * np.cos(theta) + self.MID_Y * np.sin(theta) + return np.array((rho, theta)) + + @property + def mid(self): + if not self: + return np.array([]) + if self.is_horizontal: + return self.rho + else: + return (self.rho - self.MID_Y * self.sin) / self.cos + + def get_x(self, y): + return (self.rho - y * self.sin) / self.cos + + def get_y(self, x): + return (self.rho - x * self.cos) / self.sin + + def add(self, other): + if not other: + return self + if not self: + return other + lines = np.append(self.lines, other.lines, axis=0) + return Lines(lines, is_horizontal=self.is_horizontal) + + def move(self, x, y): + if not self: + return self + if self.is_horizontal: + self.lines[:, 0] += y + else: + self.lines[:, 0] += x * self.cos + y * self.sin + return Lines(self.lines, is_horizontal=self.is_horizontal) + + def sort(self): + if not self: + return self + lines = self.lines[np.argsort(self.mid)] + return Lines(lines, is_horizontal=self.is_horizontal) + + def group(self, threshold=3): + if not self: + return self + lines = self.sort() + prev = 0 + regrouped = [] + group = [] + for mid, line in zip(lines.mid, lines.lines): + line = line.tolist() + if mid - prev > threshold: + if len(regrouped) == 0: + if len(group) != 0: + regrouped = [group] + else: + regrouped += [group] + group = [line] + else: + group.append(line) + prev = mid + regrouped += [group] + regrouped = np.vstack([Lines(r, is_horizontal=self.is_horizontal).mean for r in regrouped]) + return Lines(regrouped, is_horizontal=self.is_horizontal) + + def distance_to_point(self, point): + x, y = point + return self.rho - x * self.cos - y * self.sin + + @staticmethod + def cross_two_lines(lines1, lines2): + for rho1, sin1, cos1 in zip(lines1.rho, lines1.sin, lines1.cos): + for rho2, sin2, cos2 in zip(lines2.rho, lines2.sin, lines2.cos): + a = np.array([[cos1, sin1], [cos2, sin2]]) + b = np.array([rho1, rho2]) + yield np.linalg.solve(a, b) + + def cross(self, other): + points = np.vstack(self.cross_two_lines(self, other)) + points = Points(points) + return points + + def delete(self, other, threshold=3): + if not self: + return self + + other_mid = other.mid + lines = [] + for mid, line in zip(self.mid, self.lines): + if np.any(np.abs(other_mid - mid) < threshold): + continue + lines.append(line) + + return Lines(lines, is_horizontal=self.is_horizontal) + + +def area2corner(area): + """ + Args: + area: (x1, y1, x2, y2) + + Returns: + np.ndarray: [upper-left, upper-right, bottom-left, bottom-right] + """ + return np.array([[area[0], area[1]], [area[2], area[1]], [area[0], area[3]], [area[2], area[3]]]) + + +def corner2area(corner): + """ + Args: + corner: [upper-left, upper-right, bottom-left, bottom-right] + + Returns: + np.ndarray: (x1, y1, x2, y2) + """ + x, y = np.array(corner).T + return np.rint([np.min(x), np.min(y), np.max(x), np.max(y)]).astype(int) + + +def corner2inner(corner): + """ + The largest rectangle inscribed in trapezoid. + + Args: + corner: ((x0, y0), (x1, y1), (x2, y2), (x3, y3)) + + Returns: + tuple[int]: (upper_left_x, upper_left_y, bottom_right_x, bottom_right_y). + """ + x0, y0, x1, y1, x2, y2, x3, y3 = np.array(corner).flatten() + area = tuple(np.rint((max(x0, x2), max(y0, y1), min(x1, x3), min(y2, y3))).astype(int)) + return area + + +def corner2outer(corner): + """ + The smallest rectangle circumscribed by the trapezoid. + + Args: + corner: ((x0, y0), (x1, y1), (x2, y2), (x3, y3)) + + Returns: + tuple[int]: (upper_left_x, upper_left_y, bottom_right_x, bottom_right_y). + """ + x0, y0, x1, y1, x2, y2, x3, y3 = np.array(corner).flatten() + area = tuple(np.rint((min(x0, x2), min(y0, y1), max(x1, x3), max(y2, y3))).astype(int)) + return area + + +def trapezoid2area(corner, pad=0): + """ + Convert corners of a trapezoid to area. + + Args: + corner: ((x0, y0), (x1, y1), (x2, y2), (x3, y3)) + pad (int): + Positive value for inscribed area. + Negative value and 0 for circumscribed area. + + Returns: + tuple[int]: (upper_left_x, upper_left_y, bottom_right_x, bottom_right_y). + """ + if pad > 0: + return area_pad(corner2inner(corner), pad=pad) + elif pad < 0: + return area_pad(corner2outer(corner), pad=pad) + else: + return area_pad(corner2area(corner), pad=pad) + + +def points_to_area_generator(points, shape): + """ + Args: + points (np.ndarray): N x 2 array. + shape (tuple): (x, y). + + Yields: + tuple, np.ndarray: (x, y), [upper-left, upper-right, bottom-left, bottom-right] + """ + points = points.reshape(*shape[::-1], 2) + for y in range(shape[1] - 1): + for x in range(shape[0] - 1): + area = np.array([points[y, x], points[y, x + 1], points[y + 1, x], points[y + 1, x + 1]]) + yield ((x, y), area) + + +def get_map_inner(points): + """ + Args: + points (np.ndarray): N x 2 array. + + Yields: + np.ndarray: (x, y). + """ + points = np.array(points) + if len(points.shape) == 1: + points = np.array([points]) + + return np.mean(points, axis=0) + + +def separate_edges(edges, inner): + """ + Args: + edges: A iterate object which contains float ot integer. + inner (float, int): A inner point to separate edges. + + Returns: + float, float: Lower edge and upper edge. if not found, return None + """ + if len(edges) == 0: + return None, None + elif len(edges) == 1: + edge = edges[0] + return (None, edge) if edge > inner else (edge, None) + else: + lower = [edge for edge in edges if edge < inner] + upper = [edge for edge in edges if edge > inner] + lower = lower[0] if len(lower) else None + upper = upper[-1] if len(upper) else None + return lower, upper + + +def perspective_transform(points, data): + """ + Args: + points: A 2D array with shape (n, 2) + data: Perspective data, a 2D array with shape (3, 3), + see https://web.archive.org/web/20150222120106/xenia.media.mit.edu/~cwren/interpolator/ + + Returns: + np.ndarray: 2D array with shape (n, 2) + """ + points = np.pad(np.array(points), ((0, 0), (0, 1)), mode='constant', constant_values=1) + matrix = data.dot(points.T) + x, y = matrix[0] / matrix[2], matrix[1] / matrix[2] + points = np.array([x, y]).T + return points + + +def fit_points(points, mod, encourage=1): + """ + Get a closet point in a group of points with common difference. + Will ignore points in the distance. + + Args: + points: Points on image, a 2D array with shape (n, 2) + mod: Common difference of points, (x, y). + encourage (int, float): Describe how close to fit a group of points, in pixel. + Smaller means closer to local minimum, larger means closer to global minimum. + + Returns: + np.ndarray: (x, y) + """ + encourage = np.square(encourage) + mod = np.array(mod) + points = np.array(points) % mod + points = np.append(points - mod, points, axis=0) + + def cal_distance(point): + distance = np.linalg.norm(points - point, axis=1) + return np.sum(1 / (1 + np.exp(encourage / distance) / distance)) + + # Fast local minimizer + # result = optimize.minimize(cal_distance, np.mean(points, axis=0), method='SLSQP') + # return result['x'] % mod + + # Brute-force global minimizer + area = np.append(-mod - 10, mod + 10) + result = optimize.brute(cal_distance, ((area[0], area[2]), (area[1], area[3]))) + return result % mod diff --git a/module/base/utils/utils.py b/module/base/utils/utils.py new file mode 100644 index 0000000..ce29352 --- /dev/null +++ b/module/base/utils/utils.py @@ -0,0 +1,919 @@ +import re + +import cv2 +import numpy as np +from PIL import Image + +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. + + Args: + a (int): The minimum of the interval. + b (int): The maximum of the interval. + n (int): The amount of numbers in simulation. Default to 3. + + Returns: + int + """ + if a < b: + output = np.mean(np.random.randint(a, b, size=n)) + return int(output.round()) + else: + return b + + +def random_rectangle_point(area, n=3): + """Choose a random point in an area. + + Args: + area: (upper_left_x, upper_left_y, bottom_right_x, bottom_right_y). + n (int): The amount of numbers in simulation. Default to 3. + + Returns: + tuple(int): (x, y) + """ + x = random_normal_distribution_int(area[0], area[2], n=n) + y = random_normal_distribution_int(area[1], area[3], n=n) + return x, y + + +def random_rectangle_vector(vector, box, random_range=(0, 0, 0, 0), padding=15): + """Place a vector in a box randomly. + + Args: + vector: (x, y) + box: (upper_left_x, upper_left_y, bottom_right_x, bottom_right_y). + random_range (tuple): Add a random_range to vector. (x_min, y_min, x_max, y_max). + padding (int): + + Returns: + tuple(int), tuple(int): start_point, end_point. + """ + vector = np.array(vector) + random_rectangle_point(random_range) + vector = np.round(vector).astype(int) + half_vector = np.round(vector / 2).astype(int) + box = np.array(box) + np.append(np.abs(half_vector) + padding, -np.abs(half_vector) - padding) + center = random_rectangle_point(box) + start_point = center - half_vector + end_point = start_point + vector + return tuple(start_point), tuple(end_point) + + +def random_rectangle_vector_opted( + vector, box, random_range=(0, 0, 0, 0), padding=15, whitelist_area=None, blacklist_area=None): + """ + Place a vector in a box randomly. + + When emulator/game stuck, it treats a swipe as a click, clicking at the end of swipe path. + To prevent this, random results need to be filtered. + + Args: + vector: (x, y) + box: (upper_left_x, upper_left_y, bottom_right_x, bottom_right_y). + random_range (tuple): Add a random_range to vector. (x_min, y_min, x_max, y_max). + padding (int): + whitelist_area: (list[tuple[int]]): + A list of area that safe to click. Swipe path will end there. + blacklist_area: (list[tuple[int]]): + If none of the whitelist_area satisfies current vector, blacklist_area will be used. + Delete random path that ends in any blacklist_area. + + Returns: + tuple(int), tuple(int): start_point, end_point. + """ + vector = np.array(vector) + random_rectangle_point(random_range) + vector = np.round(vector).astype(int) + half_vector = np.round(vector / 2).astype(int) + box_pad = np.array(box) + np.append(np.abs(half_vector) + padding, -np.abs(half_vector) - padding) + box_pad = area_offset(box_pad, half_vector) + segment = int(np.linalg.norm(vector) // 70) + 1 + + def in_blacklist(end): + if not blacklist_area: + return False + for x in range(segment + 1): + point = - vector * x / segment + end + for area in blacklist_area: + if point_in_area(point, area, threshold=0): + return True + return False + + if whitelist_area: + for area in whitelist_area: + area = area_limit(area, box_pad) + if all([x > 0 for x in area_size(area)]): + end_point = random_rectangle_point(area) + for _ in range(10): + if in_blacklist(end_point): + continue + return point_limit(end_point - vector, box), point_limit(end_point, box) + + for _ in range(100): + end_point = random_rectangle_point(box_pad) + if in_blacklist(end_point): + continue + return point_limit(end_point - vector, box), point_limit(end_point, box) + + end_point = random_rectangle_point(box_pad) + return point_limit(end_point - vector, box), point_limit(end_point, box) + + +def random_line_segments(p1, p2, n, random_range=(0, 0, 0, 0)): + """Cut a line into multiple segments. + + Args: + p1: (x, y). + p2: (x, y). + n: Number of slice. + random_range: Add a random_range to points. + + Returns: + list[tuple]: [(x0, y0), (x1, y1), (x2, y2)] + """ + return [tuple((((n - index) * p1 + index * p2) / n).astype(int) + random_rectangle_point(random_range)) + for index in range(0, n + 1)] + + +def ensure_time(second, n=3, precision=3): + """Ensure to be time. + + Args: + second (int, float, tuple): time, such as 10, (10, 30), '10, 30' + n (int): The amount of numbers in simulation. Default to 5. + precision (int): Decimals. + + Returns: + float: + """ + if isinstance(second, tuple): + multiply = 10 ** precision + result = random_normal_distribution_int(second[0] * multiply, second[1] * multiply, n) / multiply + return round(result, precision) + elif isinstance(second, str): + if ',' in second: + lower, upper = second.replace(' ', '').split(',') + lower, upper = int(lower), int(upper) + return ensure_time((lower, upper), n=n, precision=precision) + if '-' in second: + lower, upper = second.replace(' ', '').split('-') + lower, upper = int(lower), int(upper) + return ensure_time((lower, upper), n=n, precision=precision) + else: + return int(second) + else: + return second + + +def ensure_int(*args): + """ + Convert all elements to int. + Return the same structure as nested objects. + + Args: + *args: + + Returns: + list: + """ + + def to_int(item): + try: + return int(item) + except TypeError: + result = [to_int(i) for i in item] + if len(result) == 1: + result = result[0] + return result + + return to_int(args) + + +def area_offset(area, offset): + """ + Move an area. + + Args: + area: (upper_left_x, upper_left_y, bottom_right_x, bottom_right_y). + offset: (x, y). + + Returns: + tuple: (upper_left_x, upper_left_y, bottom_right_x, bottom_right_y). + """ + upper_left_x, upper_left_y, bottom_right_x, bottom_right_y = area + x, y = offset + return upper_left_x + x, upper_left_y + y, bottom_right_x + x, bottom_right_y + y + + +def area_pad(area, pad=10): + """ + Inner offset an area. + + Args: + area: (upper_left_x, upper_left_y, bottom_right_x, bottom_right_y). + pad (int): + + Returns: + tuple: (upper_left_x, upper_left_y, bottom_right_x, bottom_right_y). + """ + upper_left_x, upper_left_y, bottom_right_x, bottom_right_y = area + return upper_left_x + pad, upper_left_y + pad, bottom_right_x - pad, bottom_right_y - pad + + +def limit_in(x, lower, upper): + """ + Limit x within range (lower, upper) + + Args: + x: + lower: + upper: + + Returns: + int, float: + """ + return max(min(x, upper), lower) + + +def area_limit(area1, area2): + """ + Limit an area in another area. + + Args: + area1: (upper_left_x, upper_left_y, bottom_right_x, bottom_right_y). + area2: (upper_left_x, upper_left_y, bottom_right_x, bottom_right_y). + + Returns: + tuple: (upper_left_x, upper_left_y, bottom_right_x, bottom_right_y). + """ + x_lower, y_lower, x_upper, y_upper = area2 + return ( + limit_in(area1[0], x_lower, x_upper), + limit_in(area1[1], y_lower, y_upper), + limit_in(area1[2], x_lower, x_upper), + limit_in(area1[3], y_lower, y_upper), + ) + + +def area_size(area): + """ + Area size or shape. + + Args: + area: (upper_left_x, upper_left_y, bottom_right_x, bottom_right_y). + + Returns: + tuple: (x, y). + """ + return ( + max(area[2] - area[0], 0), + max(area[3] - area[1], 0) + ) + + +def point_limit(point, area): + """ + Limit point in an area. + + Args: + point: (x, y). + area: (upper_left_x, upper_left_y, bottom_right_x, bottom_right_y). + + Returns: + tuple: (x, y). + """ + return ( + limit_in(point[0], area[0], area[2]), + limit_in(point[1], area[1], area[3]) + ) + + +def point_in_area(point, area, threshold=5): + """ + + Args: + point: (x, y). + area: (upper_left_x, upper_left_y, bottom_right_x, bottom_right_y). + threshold: int + + Returns: + bool: + """ + return area[0] - threshold < point[0] < area[2] + threshold and area[1] - threshold < point[1] < area[3] + threshold + + +def area_in_area(area1, area2, threshold=5): + """ + + Args: + area1: (upper_left_x, upper_left_y, bottom_right_x, bottom_right_y). + area2: (upper_left_x, upper_left_y, bottom_right_x, bottom_right_y). + threshold: int + + Returns: + bool: + """ + return area2[0] - threshold <= area1[0] \ + and area2[1] - threshold <= area1[1] \ + and area1[2] <= area2[2] + threshold \ + and area1[3] <= area2[3] + threshold + + +def area_cross_area(area1, area2, threshold=5): + """ + + Args: + area1: (upper_left_x, upper_left_y, bottom_right_x, bottom_right_y). + area2: (upper_left_x, upper_left_y, bottom_right_x, bottom_right_y). + threshold: int + + Returns: + bool: + """ + # https://www.yiiven.cn/rect-is-intersection.html + xa1, ya1, xa2, ya2 = area1 + xb1, yb1, xb2, yb2 = area2 + return abs(xb2 + xb1 - xa2 - xa1) <= xa2 - xa1 + xb2 - xb1 + threshold * 2 \ + and abs(yb2 + yb1 - ya2 - ya1) <= ya2 - ya1 + yb2 - yb1 + threshold * 2 + + +def float2str(n, decimal=3): + """ + Args: + n (float): + decimal (int): + + Returns: + str: + """ + return str(round(n, decimal)).ljust(decimal + 2, "0") + + +def point2str(x, y, length=4): + """ + Args: + x (int, float): + y (int, float): + length (int): Align length. + + Returns: + str: String with numbers right aligned, such as '( 100, 80)'. + """ + return '(%s, %s)' % (str(int(x)).rjust(length), str(int(y)).rjust(length)) + + +def col2name(col): + """ + Convert a zero indexed column cell reference to a string. + + Args: + col: The cell column. Int. + + Returns: + Column style string. + + Examples: + 0 -> A, 3 -> D, 35 -> AJ, -1 -> -A + """ + + col_neg = col < 0 + if col_neg: + col_num = -col + else: + col_num = col + 1 # Change to 1-index. + col_str = '' + + while col_num: + # Set remainder from 1 .. 26 + remainder = col_num % 26 + + if remainder == 0: + remainder = 26 + + # Convert the remainder to a character. + col_letter = chr(remainder + 64) + + # Accumulate the column letters, right to left. + col_str = col_letter + col_str + + # Get the next order of magnitude. + col_num = int((col_num - 1) / 26) + + if col_neg: + return '-' + col_str + else: + return col_str + + +def name2col(col_str): + """ + Convert a cell reference in A1 notation to a zero indexed row and column. + + Args: + col_str: A1 style string. + + Returns: + row, col: Zero indexed cell row and column indices. + """ + # Convert base26 column string to number. + expn = 0 + col = 0 + col_neg = col_str.startswith('-') + col_str = col_str.strip('-').upper() + + for char in reversed(col_str): + col += (ord(char) - 64) * (26 ** expn) + expn += 1 + + if col_neg: + return -col + else: + return col - 1 # Convert 1-index to zero-index + + +def node2location(node): + """ + See location2node() + + Args: + node (str): Example: 'E3' + + Returns: + tuple[int]: Example: (4, 2) + """ + res = REGEX_NODE.search(node) + if res: + x, y = res.group(1), res.group(2) + y = int(y) + if y > 0: + y -= 1 + return name2col(x), y + else: + # Whatever + return ord(node[0]) % 32 - 1, int(node[1:]) - 1 + + +def location2node(location): + """ + Convert location tuple to an Excel-like cell. + Accept negative values also. + + -2 -1 0 1 2 3 + -2 -B-2 -A-2 A-2 B-2 C-2 D-2 + -1 -B-1 -A-1 A-1 B-1 C-1 D-1 + 0 -B1 -A1 A1 B1 C1 D1 + 1 -B2 -A2 A2 B2 C2 D2 + 2 -B3 -A3 A3 B3 C3 D3 + 3 -B4 -A4 A4 B4 C4 D4 + + # To generate the table above + index = range(-2, 4) + row = ' ' + ' '.join([str(i).rjust(4) for i in index]) + print(row) + for y in index: + row = str(y).rjust(2) + ' ' + ' '.join([location2node((x, y)).rjust(4) for x in index]) + print(row) + + def check(node): + return point2str(*node2location(location2node(node)), length=2) + row = ' ' + ' '.join([str(i).rjust(8) for i in index]) + print(row) + for y in index: + row = str(y).rjust(2) + ' ' + ' '.join([check((x, y)).rjust(4) for x in index]) + print(row) + + Args: + location (tuple[int]): + + Returns: + str: + """ + x, y = location + if y >= 0: + y += 1 + return col2name(x) + str(y) + + +def load_image(file, area=None): + """ + Load an image like pillow and drop alpha channel. + + Args: + file (str): + area (tuple): + + 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() + return image + + +def save_image(image, file): + """ + Save an image like pillow. + + Args: + image (np.ndarray): + file (str): + """ + # image = cv2.cvtColor(image, cv2.COLOR_RGB2BGR) + # cv2.imwrite(file, image) + Image.fromarray(image).save(file) + + +def crop(image, area, copy=True): + """ + Crop image like pillow, when using opencv / numpy. + Provides a black background if cropping outside of image. + + Args: + image (np.ndarray): + area: + copy (bool): + + 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) + 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 + + +def resize(image, size): + """ + Resize image like pillow image.resize(), but implement in opencv. + Pillow uses PIL.Image.NEAREST by default. + + Args: + image (np.ndarray): + size: (x, y) + + Returns: + np.ndarray: + """ + return cv2.resize(image, size, interpolation=cv2.INTER_NEAREST) + + +def image_channel(image): + """ + Args: + image (np.ndarray): + + Returns: + int: 0 for grayscale, 3 for RGB. + """ + return image.shape[2] if len(image.shape) == 3 else 0 + + +def image_size(image): + """ + Args: + image (np.ndarray): + + Returns: + int, int: width, height + """ + shape = image.shape + return shape[1], shape[0] + + +def image_paste(image, background, origin): + """ + Paste an image on background. + This method does not return a value, but instead updates the array "background". + + Args: + image: + background: + origin: Upper-left corner, (x, y) + """ + x, y = origin + w, h = image_size(image) + background[y:y + h, x:x + w] = image + + +def rgb2gray(image): + """ + 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) + ) + + +def rgb2hsv(image): + """ + Convert RGB color space to HSV color space. + HSV is Hue Saturation Value. + + Args: + image (np.ndarray): Shape (height, width, channel) + + Returns: + 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) + return image + + +def rgb2yuv(image): + """ + Convert RGB to YUV color space. + + Args: + image (np.ndarray): Shape (height, width, channel) + + Returns: + np.ndarray: Shape (height, width) + """ + image = cv2.cvtColor(image, cv2.COLOR_RGB2YUV) + return image + + +def rgb2luma(image): + """ + Convert RGB to the Y channel (Luminance) in YUV color space. + + Args: + image (np.ndarray): Shape (height, width, channel) + + Returns: + np.ndarray: Shape (height, width) + """ + image = cv2.cvtColor(image, cv2.COLOR_RGB2YUV) + luma, _, _ = cv2.split(image) + return luma + + +def get_color(image, area): + """Calculate the average color of a particular area of the image. + + Args: + image (np.ndarray): Screenshot. + area (tuple): (upper_left_x, upper_left_y, bottom_right_x, bottom_right_y) + + Returns: + tuple: (r, g, b) + """ + temp = crop(image, area, copy=False) + color = cv2.mean(temp) + return color[:3] + + +def get_bbox(image, threshold=0): + """ + A numpy implementation of the getbbox() in pillow. + + Args: + image (np.ndarray): Screenshot. + threshold (int): Color <= threshold will be considered black + + Returns: + tuple: (upper_left_x, upper_left_y, bottom_right_x, bottom_right_y) + """ + 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 + + +def get_bbox_reversed(image, threshold=0): + """ + Similar to `get_bbox` but for black contents on white background. + + Args: + image (np.ndarray): Screenshot. + threshold (int): Color >= threshold will be considered white + + Returns: + tuple: (upper_left_x, upper_left_y, bottom_right_x, bottom_right_y) + """ + 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 + + +def color_similarity(color1, color2): + """ + Args: + color1 (tuple): (r, g, b) + color2 (tuple): (r, g, b) + + 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)) + return diff + + +def color_similar(color1, color2, threshold=10): + """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. + + Args: + color1 (tuple): (r, g, b) + color2 (tuple): (r, g, b) + threshold (int): Default to 10. + + Returns: + 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)) + return diff <= threshold + + +def color_similar_1d(image, color, threshold=10): + """ + Args: + image (np.ndarray): 1D array. + color: (r, g, b) + threshold(int): Default to 10. + + Returns: + np.ndarray: bool + """ + diff = image.astype(int) - color + diff = np.max(np.maximum(diff, 0), axis=1) - np.min(np.minimum(diff, 0), axis=1) + return diff <= threshold + + +def color_similarity_2d(image, color): + """ + Args: + image: 2D array. + color: (r, g, b) + + 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)) + + +def extract_letters(image, letter=(255, 255, 255), threshold=128): + """Set letter color to black, set background color to white. + + Args: + image: Shape (height, width, channel) + letter (tuple): Letter RGB. + threshold (int): + + 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) + + +def extract_white_letters(image, threshold=128): + """Set letter color to black, set background color to white. + This function will discourage color pixels (Non-gray pixels) + + Args: + image: Shape (height, width, channel) + threshold (int): + + Returns: + np.ndarray: Shape (height, width) + """ + 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) + + +def color_mapping(image, max_multiply=2): + """ + Mapping color to 0-255. + Minimum color to 0, maximum color to 255, multiply colors by 2 at max. + + Args: + image (np.ndarray): + max_multiply (int, float): + + Returns: + np.ndarray: + """ + image = image.astype(float) + 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[image > 255] = 255 + image[image < 0] = 0 + return image.astype(np.uint8) + + +def image_left_strip(image, threshold, length): + """ + In `DAILY:200/200` strip `DAILY:` and leave `200/200` + + Args: + image (np.ndarray): (height, width) + threshold (int): + 0-255 + The first column with brightness lower than this + will be considered as left edge. + length (int): + Strip this length of image after the left edge + + Returns: + np.ndarray: + """ + brightness = np.mean(image, axis=0) + match = np.where(brightness < threshold)[0] + + if len(match): + left = match[0] + length + total = image.shape[1] + if left < total: + image = image[:, left:] + return image + + +def red_overlay_transparency(color1, color2, red=247): + """Calculate the transparency of red overlay. + + Args: + color1: origin color. + color2: changed color. + red(int): red color 0-255. Default to 247. + + Returns: + float: 0-1 + """ + return (color2[0] - color1[0]) / (red - color1[0]) + + +def color_bar_percentage(image, area, prev_color, reverse=False, starter=0, threshold=30): + """ + Args: + image: + area: + prev_color: + reverse: True if bar goes from right to left. + starter: + threshold: + + Returns: + float: 0 to 1. + """ + image = crop(image, area) + image = image[:, ::-1, :] if reverse else image + length = image.shape[1] + prev_index = starter + + for _ in range(1280): + bar = color_similarity_2d(image, color=prev_color) + index = np.where(np.any(bar > 255 - threshold, axis=0))[0] + if not index.size: + return prev_index / length + else: + index = index[-1] + if index <= prev_index: + return index / length + prev_index = index + + 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) + + return 0. diff --git a/module/config/argument/args.json b/module/config/argument/args.json new file mode 100644 index 0000000..1c2b99b --- /dev/null +++ b/module/config/argument/args.json @@ -0,0 +1,348 @@ +{ + "Alas": { + "Emulator": { + "Serial": { + "type": "input", + "value": "auto", + "valuetype": "str" + }, + "PackageName": { + "type": "select", + "value": "auto", + "option": [ + "auto", + "JP-Official" + ] + }, + "GameLanguage": { + "type": "select", + "value": "auto", + "option": [ + "auto", + "jp" + ] + }, + "ScreenshotMethod": { + "type": "select", + "value": "scrcpy", + "option": [ + "auto", + "ADB", + "ADB_nc", + "uiautomator2", + "aScreenCap", + "aScreenCap_nc", + "DroidCast", + "DroidCast_raw", + "scrcpy" + ], + "display": "hide" + }, + "ControlMethod": { + "type": "select", + "value": "MaaTouch", + "option": [ + "minitouch", + "MaaTouch" + ], + "display": "hide" + }, + "AdbRestart": { + "type": "checkbox", + "value": false + } + }, + "EmulatorInfo": { + "Emulator": { + "type": "select", + "value": "auto", + "option": [ + "auto", + "NoxPlayer", + "NoxPlayer64", + "BlueStacks4", + "BlueStacks5", + "BlueStacks4HyperV", + "BlueStacks5HyperV", + "LDPlayer3", + "LDPlayer4", + "LDPlayer9", + "MuMuPlayer", + "MuMuPlayerX", + "MuMuPlayer12", + "MEmuPlayer" + ] + }, + "name": { + "type": "textarea", + "value": null + }, + "path": { + "type": "textarea", + "value": null + } + }, + "Error": { + "Restart": { + "type": "select", + "value": "game", + "option": [ + "game", + "game_emulator" + ] + }, + "SaveError": { + "type": "checkbox", + "value": true + }, + "ScreenshotLength": { + "type": "input", + "value": 1 + }, + "OnePushConfig": { + "type": "textarea", + "value": "provider: null", + "mode": "yaml" + } + }, + "Optimization": { + "ScreenshotInterval": { + "type": "input", + "value": 0.2, + "display": "hide" + }, + "CombatScreenshotInterval": { + "type": "input", + "value": 1.0, + "display": "hide" + }, + "WhenTaskQueueEmpty": { + "type": "select", + "value": "goto_main", + "option": [ + "stay_there", + "goto_main", + "close_game" + ] + } + } + }, + "Restart": { + "Scheduler": { + "Enable": { + "type": "state", + "value": true, + "option": [ + true + ], + "option_bold": [ + true + ] + }, + "NextRun": { + "type": "datetime", + "value": "2020-01-01 00:00:00", + "validate": "datetime" + }, + "Command": { + "type": "input", + "value": "Restart", + "display": "hide" + }, + "ServerUpdate": { + "type": "input", + "value": "04:00", + "display": "hide" + } + } + }, + "Cafe": { + "Scheduler": { + "Enable": { + "type": "checkbox", + "value": true, + "option": [ + true, + false + ] + }, + "NextRun": { + "type": "datetime", + "value": "2020-01-01 00:00:00", + "validate": "datetime" + }, + "Command": { + "type": "input", + "value": "Cafe", + "display": "hide" + }, + "ServerUpdate": { + "type": "input", + "value": "04:00, 16:00", + "display": "hide" + } + }, + "Cafe": { + "Reward": { + "type": "checkbox", + "value": true + }, + "Touch": { + "type": "checkbox", + "value": true + }, + "AutoAdjust": { + "type": "checkbox", + "value": true + } + } + }, + "Mail": { + "Scheduler": { + "Enable": { + "type": "checkbox", + "value": true, + "option": [ + true, + false + ] + }, + "NextRun": { + "type": "datetime", + "value": "2020-01-01 00:00:00", + "validate": "datetime" + }, + "Command": { + "type": "input", + "value": "Mail", + "display": "hide" + }, + "ServerUpdate": { + "type": "input", + "value": "04:00", + "display": "hide" + } + } + }, + "Circle": { + "Scheduler": { + "Enable": { + "type": "checkbox", + "value": true, + "option": [ + true, + false + ] + }, + "NextRun": { + "type": "datetime", + "value": "2020-01-01 00:00:00", + "validate": "datetime" + }, + "Command": { + "type": "input", + "value": "Circle", + "display": "hide" + }, + "ServerUpdate": { + "type": "input", + "value": "04:00", + "display": "hide" + } + } + }, + "TacticalChallenge": { + "Scheduler": { + "Enable": { + "type": "checkbox", + "value": true, + "option": [ + true, + false + ] + }, + "NextRun": { + "type": "datetime", + "value": "2020-01-01 00:00:00", + "validate": "datetime" + }, + "Command": { + "type": "input", + "value": "TacticalChallenge", + "display": "hide" + }, + "ServerUpdate": { + "type": "input", + "value": "15:00", + "display": "hide" + } + }, + "TacticalChallenge": { + "PlayerSelect": { + "type": "select", + "value": 0, + "option": [ + 0, + 1, + 2, + 3 + ] + } + } + }, + "DataUpdate": { + "Scheduler": { + "Enable": { + "type": "state", + "value": true, + "option": [ + true + ], + "option_bold": [ + true + ] + }, + "NextRun": { + "type": "datetime", + "value": "2020-01-01 00:00:00", + "validate": "datetime" + }, + "Command": { + "type": "input", + "value": "DataUpdate", + "display": "hide" + }, + "ServerUpdate": { + "type": "input", + "value": "04:00", + "display": "hide" + } + }, + "ItemStorage": { + "AP": { + "type": "stored", + "value": {}, + "display": "hide", + "stored": "StoredAP", + "order": 1, + "color": "#62ea6e" + }, + "Credit": { + "type": "stored", + "value": {}, + "display": "hide", + "stored": "StoredInt", + "order": 2, + "color": "#fdec00" + }, + "Pyroxene": { + "type": "stored", + "value": {}, + "display": "hide", + "stored": "StoredInt", + "order": 3, + "color": "#21befc" + } + } + } +} \ No newline at end of file diff --git a/module/config/argument/argument.yaml b/module/config/argument/argument.yaml new file mode 100644 index 0000000..2274f3e --- /dev/null +++ b/module/config/argument/argument.yaml @@ -0,0 +1,100 @@ +# -------------------- +# Define arguments. +# -------------------- + +# ==================== Alas ==================== + +Scheduler: + Enable: + type: checkbox + value: false + option: [ true, false ] + NextRun: 2020-01-01 00:00:00 + Command: Alas + ServerUpdate: + value: 04:00 + display: hide +Emulator: + Serial: + value: auto + valuetype: str + PackageName: + value: auto + option: [ auto, ] + GameLanguage: + value: auto + option: [ auto, jp ] + ScreenshotMethod: + value: auto + option: [ auto, ADB, ADB_nc, uiautomator2, aScreenCap, aScreenCap_nc, DroidCast, DroidCast_raw, scrcpy ] + ControlMethod: + value: MaaTouch + option: [ minitouch, MaaTouch ] + AdbRestart: false +EmulatorInfo: + Emulator: + value: auto + option: [ + auto, + NoxPlayer, + NoxPlayer64, + BlueStacks4, + BlueStacks5, + BlueStacks4HyperV, + BlueStacks5HyperV, + LDPlayer3, + LDPlayer4, + LDPlayer9, + MuMuPlayer, + MuMuPlayerX, + MuMuPlayer12, + MEmuPlayer, + ] + name: + value: null + type: textarea + path: + value: null + type: textarea +Error: + Restart: + value: game + option: [ game, game_emulator ] + SaveError: true + ScreenshotLength: 1 + OnePushConfig: + type: textarea + mode: yaml + value: 'provider: null' +Optimization: + ScreenshotInterval: 0.3 + CombatScreenshotInterval: 1.0 + WhenTaskQueueEmpty: + value: goto_main + option: [ stay_there, goto_main, close_game ] + +# ==================== Daily ==================== + +Cafe: + Reward: true + Touch: true + AutoAdjust: true + +TacticalChallenge: + PlayerSelect: + value: 0 + option: [ 0, 1, 2, 3 ] + +ItemStorage: + AP: + stored: StoredAP + order: 1 + color: "#62ea6e" + Credit: + stored: StoredInt + order: 2 + color: "#fdec00" + Pyroxene: + stored: StoredInt + order: 3 + color: "#21befc" diff --git a/module/config/argument/default.yaml b/module/config/argument/default.yaml new file mode 100644 index 0000000..4b1585f --- /dev/null +++ b/module/config/argument/default.yaml @@ -0,0 +1,18 @@ +# -------------------- +# Define default values +# -------------------- + +# ==================== Alas ==================== + +Cafe: + Scheduler: + Enable: true +Mail: + Scheduler: + Enable: true +Circle: + Scheduler: + Enable: true +TacticalChallenge: + Scheduler: + Enable: true \ No newline at end of file diff --git a/module/config/argument/gui.yaml b/module/config/argument/gui.yaml new file mode 100644 index 0000000..419167b --- /dev/null +++ b/module/config/argument/gui.yaml @@ -0,0 +1,103 @@ +# Translations web gui +# This will insert to `config/i18n/{lang}.json`, under key `Gui` + +Aside: + Install: + Home: + Develop: + Performance: + Setting: + AddAlas: + +Button: + Start: + Stop: + ScrollON: + ScrollOFF: + ClearLog: + Setting: + CheckUpdate: + ClickToUpdate: + RetryUpdate: + CancelUpdate: + +Toast: + DisableTranslateMode: + ConfigSaved: + AlasIsRunning: + ClickToUpdate: + +Status: + Running: + Inactive: + Warning: + Updating: + +MenuAlas: + Overview: + Log: + +MenuDevelop: + HomePage: + Translate: + Update: + Remote: + Utils: + +Overview: + Scheduler: + Log: + Running: + Pending: + Waiting: + NoTask: + +Dashboard: + # From lang.readable_time() + NoData: + TimeError: + JustNow: + MinutesAgo: + HoursAgo: + DaysAgo: + LongTimeAgo: + +AddAlas: + PopupTitle: + NewName: + CopyFrom: + Confirm: + FileExist: + InvalidChar: + InvalidPrefixTemplate: + +Update: + UpToDate: + HaveUpdate: + UpdateStart: + UpdateWait: + UpdateRun: + UpdateSuccess: + UpdateFailed: + UpdateChecking: + UpdateCancel: + UpdateFinish: + Local: + Upstream: + Author: + Time: + Message: + DisabledWarn: + DetailedHistory: + +Remote: + Running: + NotRunning: + NotEnable: + EntryPoint: + ConfigureHint: + SSHNotInstall: + +Text: + InvalidFeedBack: + Clear: \ No newline at end of file diff --git a/module/config/argument/menu.json b/module/config/argument/menu.json new file mode 100644 index 0000000..71fa952 --- /dev/null +++ b/module/config/argument/menu.json @@ -0,0 +1,21 @@ +{ + "Alas": { + "menu": "list", + "page": "setting", + "tasks": [ + "Alas", + "Restart" + ] + }, + "Daily": { + "menu": "list", + "page": "setting", + "tasks": [ + "Cafe", + "Mail", + "Circle", + "TacticalChallenge", + "DataUpdate" + ] + } +} \ No newline at end of file diff --git a/module/config/argument/override.yaml b/module/config/argument/override.yaml new file mode 100644 index 0000000..c9bfe23 --- /dev/null +++ b/module/config/argument/override.yaml @@ -0,0 +1,40 @@ +# -------------------- +# Define non-modifiable values +# -------------------- + + +# ==================== Alas ==================== + +Alas: + Emulator: + ScreenshotMethod: scrcpy + ControlMethod: MaaTouch + Optimization: + ScreenshotInterval: 0.2 + CombatScreenshotInterval: 1.0 +Restart: + Scheduler: + Enable: + type: state + value: true + option: [ true, ] + option_bold: [ true, ] + ServerUpdate: 04:00 + +# ==================== Daily ==================== + +Cafe: + Scheduler: + ServerUpdate: "04:00, 16:00" + +TacticalChallenge: + Scheduler: + ServerUpdate: "15:00" + +DataUpdate: + Scheduler: + Enable: + type: state + value: true + option: [ true, ] + option_bold: [ true, ] diff --git a/module/config/argument/stored.json b/module/config/argument/stored.json new file mode 100644 index 0000000..950d84b --- /dev/null +++ b/module/config/argument/stored.json @@ -0,0 +1,39 @@ +{ + "AP": { + "name": "AP", + "path": "DataUpdate.ItemStorage.AP", + "i18n": "ItemStorage.AP.name", + "stored": "StoredAP", + "attrs": { + "time": "2020-01-01 00:00:00", + "total": 0, + "value": 0 + }, + "order": 1, + "color": "#62ea6e" + }, + "Credit": { + "name": "Credit", + "path": "DataUpdate.ItemStorage.Credit", + "i18n": "ItemStorage.Credit.name", + "stored": "StoredInt", + "attrs": { + "time": "2020-01-01 00:00:00", + "value": 0 + }, + "order": 2, + "color": "#fdec00" + }, + "Pyroxene": { + "name": "Pyroxene", + "path": "DataUpdate.ItemStorage.Pyroxene", + "i18n": "ItemStorage.Pyroxene.name", + "stored": "StoredInt", + "attrs": { + "time": "2020-01-01 00:00:00", + "value": 0 + }, + "order": 3, + "color": "#21befc" + } +} \ No newline at end of file diff --git a/module/config/argument/task.yaml b/module/config/argument/task.yaml new file mode 100644 index 0000000..ccb7176 --- /dev/null +++ b/module/config/argument/task.yaml @@ -0,0 +1,37 @@ +# -------------------- +# Define argument group of tasks. +# -------------------- + +# ==================== Alas ==================== + +Alas: + menu: 'list' + page: 'setting' + tasks: + Alas: + - Emulator + - EmulatorInfo + - Error + - Optimization + Restart: + - Scheduler + +# ==================== Daily ==================== + +Daily: + menu: 'list' + page: 'setting' + tasks: + Cafe: + - Scheduler + - Cafe + Mail: + - Scheduler + Circle: + - Scheduler + TacticalChallenge: + - Scheduler + - TacticalChallenge + DataUpdate: + - Scheduler + - ItemStorage \ No newline at end of file diff --git a/module/config/atomicwrites.py b/module/config/atomicwrites.py new file mode 100644 index 0000000..9922f1a --- /dev/null +++ b/module/config/atomicwrites.py @@ -0,0 +1,236 @@ +""" +Copy-pasted from +https://github.com/untitaker/python-atomicwrites +""" +import contextlib +import io +import os +import sys +import tempfile + +try: + import fcntl +except ImportError: + fcntl = None + +# `fspath` was added in Python 3.6 +try: + from os import fspath +except ImportError: + fspath = None + +__version__ = '1.4.1' + +PY2 = sys.version_info[0] == 2 + +text_type = unicode if PY2 else str # noqa + + +def _path_to_unicode(x): + if not isinstance(x, text_type): + return x.decode(sys.getfilesystemencoding()) + return x + + +DEFAULT_MODE = "wb" if PY2 else "w" + +_proper_fsync = os.fsync + +if sys.platform != 'win32': + if hasattr(fcntl, 'F_FULLFSYNC'): + def _proper_fsync(fd): + # https://lists.apple.com/archives/darwin-dev/2005/Feb/msg00072.html + # https://developer.apple.com/library/mac/documentation/Darwin/Reference/ManPages/man2/fsync.2.html + # https://github.com/untitaker/python-atomicwrites/issues/6 + fcntl.fcntl(fd, fcntl.F_FULLFSYNC) + + + def _sync_directory(directory): + # Ensure that filenames are written to disk + fd = os.open(directory, 0) + try: + _proper_fsync(fd) + finally: + os.close(fd) + + + def _replace_atomic(src, dst): + os.rename(src, dst) + _sync_directory(os.path.normpath(os.path.dirname(dst))) + + + def _move_atomic(src, dst): + os.link(src, dst) + os.unlink(src) + + src_dir = os.path.normpath(os.path.dirname(src)) + dst_dir = os.path.normpath(os.path.dirname(dst)) + _sync_directory(dst_dir) + if src_dir != dst_dir: + _sync_directory(src_dir) +else: + from ctypes import windll, WinError + + _MOVEFILE_REPLACE_EXISTING = 0x1 + _MOVEFILE_WRITE_THROUGH = 0x8 + _windows_default_flags = _MOVEFILE_WRITE_THROUGH + + + def _handle_errors(rv): + if not rv: + raise WinError() + + + def _replace_atomic(src, dst): + _handle_errors(windll.kernel32.MoveFileExW( + _path_to_unicode(src), _path_to_unicode(dst), + _windows_default_flags | _MOVEFILE_REPLACE_EXISTING + )) + + + def _move_atomic(src, dst): + _handle_errors(windll.kernel32.MoveFileExW( + _path_to_unicode(src), _path_to_unicode(dst), + _windows_default_flags + )) + + +def replace_atomic(src, dst): + ''' + Move ``src`` to ``dst``. If ``dst`` exists, it will be silently + overwritten. + + Both paths must reside on the same filesystem for the operation to be + atomic. + ''' + return _replace_atomic(src, dst) + + +def move_atomic(src, dst): + ''' + Move ``src`` to ``dst``. There might a timewindow where both filesystem + entries exist. If ``dst`` already exists, :py:exc:`FileExistsError` will be + raised. + + Both paths must reside on the same filesystem for the operation to be + atomic. + ''' + return _move_atomic(src, dst) + + +class AtomicWriter(object): + ''' + A helper class for performing atomic writes. Usage:: + + with AtomicWriter(path).open() as f: + f.write(...) + + :param path: The destination filepath. May or may not exist. + :param mode: The filemode for the temporary file. This defaults to `wb` in + Python 2 and `w` in Python 3. + :param overwrite: If set to false, an error is raised if ``path`` exists. + Errors are only raised after the file has been written to. Either way, + the operation is atomic. + :param open_kwargs: Keyword-arguments to pass to the underlying + :py:func:`open` call. This can be used to set the encoding when opening + files in text-mode. + + If you need further control over the exact behavior, you are encouraged to + subclass. + ''' + + def __init__(self, path, mode=DEFAULT_MODE, overwrite=False, + **open_kwargs): + if 'a' in mode: + raise ValueError( + 'Appending to an existing file is not supported, because that ' + 'would involve an expensive `copy`-operation to a temporary ' + 'file. Open the file in normal `w`-mode and copy explicitly ' + 'if that\'s what you\'re after.' + ) + if 'x' in mode: + raise ValueError('Use the `overwrite`-parameter instead.') + if 'w' not in mode: + raise ValueError('AtomicWriters can only be written to.') + + # Attempt to convert `path` to `str` or `bytes` + if fspath is not None: + path = fspath(path) + + self._path = path + self._mode = mode + self._overwrite = overwrite + self._open_kwargs = open_kwargs + + def open(self): + ''' + Open the temporary file. + ''' + return self._open(self.get_fileobject) + + @contextlib.contextmanager + def _open(self, get_fileobject): + f = None # make sure f exists even if get_fileobject() fails + try: + success = False + with get_fileobject(**self._open_kwargs) as f: + yield f + self.sync(f) + self.commit(f) + success = True + finally: + if not success: + try: + self.rollback(f) + except Exception: + pass + + def get_fileobject(self, suffix="", prefix=tempfile.gettempprefix(), + dir=None, **kwargs): + '''Return the temporary file to use.''' + if dir is None: + dir = os.path.normpath(os.path.dirname(self._path)) + descriptor, name = tempfile.mkstemp(suffix=suffix, prefix=prefix, + dir=dir) + # io.open() will take either the descriptor or the name, but we need + # the name later for commit()/replace_atomic() and couldn't find a way + # to get the filename from the descriptor. + os.close(descriptor) + kwargs['mode'] = self._mode + kwargs['file'] = name + return io.open(**kwargs) + + def sync(self, f): + '''responsible for clearing as many file caches as possible before + commit''' + f.flush() + _proper_fsync(f.fileno()) + + def commit(self, f): + '''Move the temporary file to the target location.''' + if self._overwrite: + replace_atomic(f.name, self._path) + else: + move_atomic(f.name, self._path) + + def rollback(self, f): + '''Clean up all temporary resources.''' + os.unlink(f.name) + + +def atomic_write(path, writer_cls=AtomicWriter, **cls_kwargs): + ''' + Simple atomic writes. This wraps :py:class:`AtomicWriter`:: + + with atomic_write(path) as f: + f.write(...) + + :param path: The target path to write to. + :param writer_cls: The writer class to use. This parameter is useful if you + subclassed :py:class:`AtomicWriter` to change some behavior and want to + use that new subclass. + + Additional keyword arguments are passed to the writer class. See + :py:class:`AtomicWriter`. + ''' + return writer_cls(path, **cls_kwargs).open() diff --git a/module/config/config.py b/module/config/config.py new file mode 100644 index 0000000..61cf350 --- /dev/null +++ b/module/config/config.py @@ -0,0 +1,583 @@ +import copy +import datetime +import operator +import threading + +import pywebio + +from module.base.decorator import cached_property, del_cached_property +from module.base.filter import Filter +from module.config.config_generated import GeneratedConfig +from module.config.config_manual import ManualConfig +from module.config.config_updater import ConfigUpdater +from module.config.stored.classes import iter_attribute +from module.config.stored.stored_generated import StoredGenerated +from module.config.utils import * +from module.config.watcher import ConfigWatcher +from module.exception import RequestHumanTakeover, ScriptError +from module.logger import logger + + +class TaskEnd(Exception): + pass + + +class Function: + def __init__(self, data): + self.enable = deep_get(data, keys="Scheduler.Enable", default=False) + self.command = deep_get(data, keys="Scheduler.Command", default="Unknown") + self.next_run = deep_get(data, keys="Scheduler.NextRun", default=DEFAULT_TIME) + + def __str__(self): + enable = "Enable" if self.enable else "Disable" + return f"{self.command} ({enable}, {str(self.next_run)})" + + __repr__ = __str__ + + def __eq__(self, other): + if not isinstance(other, Function): + return False + + if self.command == other.command and self.next_run == other.next_run: + return True + else: + return False + + +def name_to_function(name): + """ + Args: + name (str): + + Returns: + Function: + """ + function = Function({}) + function.command = name + function.enable = True + return function + + +class AzurLaneConfig(ConfigUpdater, ManualConfig, GeneratedConfig, ConfigWatcher): + stop_event: threading.Event = None + bound = {} + + # Class property + is_hoarding_task = True + + def __setattr__(self, key, value): + if key in self.bound: + path = self.bound[key] + self.modified[path] = value + if self.auto_update: + self.update() + else: + super().__setattr__(key, value) + + def __init__(self, config_name, task=None): + logger.attr("Lang", self.LANG) + # This will read ./config/.json + self.config_name = config_name + # Raw json data in yaml file. + self.data = {} + # Modified arguments. Key: Argument path in yaml file. Value: Modified value. + # All variable modifications will be record here and saved in method `save()`. + self.modified = {} + # Key: Argument name in GeneratedConfig. Value: Path in `data`. + self.bound = {} + # If write after every variable modification. + self.auto_update = True + # Force override variables + # Key: Argument name in GeneratedConfig. Value: Modified value. + self.overridden = {} + # Scheduler queue, will be updated in `get_next_task()`, list of Function objects + # pending_task: Run time has been reached, but haven't been run due to task scheduling. + # waiting_task: Run time haven't been reached, wait needed. + self.pending_task = [] + self.waiting_task = [] + # Task to run and bind. + # Task means the name of the function to run in AzurLaneAutoScript class. + self.task: Function + # Template config is used for dev tools + self.is_template_config = config_name.startswith("template") + + if self.is_template_config: + # For dev tools + logger.info("Using template config, which is read only") + self.auto_update = False + self.task = name_to_function("template") + else: + self.load() + if task is None: + # Bind `Alas` by default which includes emulator settings. + task = name_to_function("Alas") + else: + # Bind a specific task for debug purpose. + task = name_to_function(task) + self.bind(task) + self.task = task + self.save() + + def load(self): + self.data = self.read_file(self.config_name) + self.config_override() + + for path, value in self.modified.items(): + deep_set(self.data, keys=path, value=value) + + def bind(self, func, func_list=None): + """ + Args: + func (str, Function): Function to run + func_list (set): Set of tasks to be bound + """ + if func_list is None: + func_list = ["Alas"] + if isinstance(func, Function): + func = func.command + func_list.append(func) + logger.info(f"Bind task {func_list}") + + # Bind arguments + visited = set() + self.bound.clear() + for func in func_list: + func_data = self.data.get(func, {}) + for group, group_data in func_data.items(): + for arg, value in group_data.items(): + path = f"{group}.{arg}" + if path in visited: + continue + arg = path_to_arg(path) + super().__setattr__(arg, value) + self.bound[arg] = f"{func}.{path}" + visited.add(path) + + # Override arguments + for arg, value in self.overridden.items(): + super().__setattr__(arg, value) + + @property + def hoarding(self): + minutes = int( + deep_get( + self.data, keys="Alas.Optimization.TaskHoardingDuration", default=0 + ) + ) + return timedelta(minutes=max(minutes, 0)) + + @property + def close_game(self): + return deep_get( + self.data, keys="Alas.Optimization.CloseGameDuringWait", default=False + ) + + @cached_property + def stored(self) -> StoredGenerated: + stored = StoredGenerated() + # Bind config + for _, value in iter_attribute(stored): + value._bind(self) + del_cached_property(value, '_stored') + return stored + + def get_next_task(self): + """ + Calculate tasks, set pending_task and waiting_task + """ + pending = [] + waiting = [] + error = [] + now = datetime.now() + if AzurLaneConfig.is_hoarding_task: + now -= self.hoarding + for func in self.data.values(): + func = Function(func) + if not func.enable: + continue + if not isinstance(func.next_run, datetime): + error.append(func) + elif func.next_run < now: + pending.append(func) + else: + waiting.append(func) + + f = Filter(regex=r"(.*)", attr=["command"]) + f.load(self.SCHEDULER_PRIORITY) + if pending: + pending = f.apply(pending) + if waiting: + waiting = f.apply(waiting) + waiting = sorted(waiting, key=operator.attrgetter("next_run")) + if error: + pending = error + pending + + self.pending_task = pending + self.waiting_task = waiting + + def get_next(self): + """ + Returns: + Function: Command to run + """ + self.get_next_task() + + if self.pending_task: + AzurLaneConfig.is_hoarding_task = False + logger.info(f"Pending tasks: {[f.command for f in self.pending_task]}") + task = self.pending_task[0] + logger.attr("Task", task) + return task + else: + AzurLaneConfig.is_hoarding_task = True + + if self.waiting_task: + logger.info("No task pending") + task = copy.deepcopy(self.waiting_task[0]) + task.next_run = (task.next_run + self.hoarding).replace(microsecond=0) + logger.attr("Task", task) + return task + else: + logger.critical("No task waiting or pending") + logger.critical("Please enable at least one task") + raise RequestHumanTakeover + + def save(self, mod_name='alas'): + if not self.modified: + return False + + for path, value in self.modified.items(): + deep_set(self.data, keys=path, value=value) + + logger.info( + f"Save config {filepath_config(self.config_name, mod_name)}, {dict_to_kv(self.modified)}" + ) + # Don't use self.modified = {}, that will create a new object. + self.modified.clear() + del_cached_property(self, 'stored') + self.write_file(self.config_name, data=self.data) + + def update(self): + self.load() + self.config_override() + self.bind(self.task) + self.save() + + def config_override(self): + now = datetime.now().replace(microsecond=0) + limited = set() + + def limit_next_run(tasks, limit): + for task in tasks: + if task in limited: + continue + limited.add(task) + next_run = deep_get( + self.data, keys=f"{task}.Scheduler.NextRun", default=None + ) + if isinstance(next_run, datetime) and next_run > limit: + deep_set(self.data, keys=f"{task}.Scheduler.NextRun", value=now) + + limit_next_run(['BattlePass'], limit=now + timedelta(days=31, seconds=-1)) + limit_next_run(self.args.keys(), limit=now + timedelta(hours=24, seconds=-1)) + + def override(self, **kwargs): + """ + Override anything you want. + Variables stall remain overridden even config is reloaded from yaml file. + Note that this method is irreversible. + """ + for arg, value in kwargs.items(): + self.overridden[arg] = value + super().__setattr__(arg, value) + + def set_record(self, **kwargs): + """ + Args: + **kwargs: For example, `Emotion1_Value=150` + will set `Emotion1_Value=150` and `Emotion1_Record=now()` + """ + with self.multi_set(): + for arg, value in kwargs.items(): + record = arg.replace("Value", "Record") + self.__setattr__(arg, value) + self.__setattr__(record, datetime.now().replace(microsecond=0)) + + def multi_set(self): + """ + Set multiple arguments but save once. + + Examples: + with self.config.multi_set(): + self.config.foo1 = 1 + self.config.foo2 = 2 + """ + return MultiSetWrapper(main=self) + + def cross_get(self, keys, default=None): + """ + Get configs from other tasks. + + Args: + keys (str, list[str]): Such as `{task}.Scheduler.Enable` + default: + + Returns: + Any: + """ + return deep_get(self.data, keys=keys, default=default) + + def cross_set(self, keys, value): + """ + Set configs to other tasks. + + Args: + keys (str, list[str]): Such as `{task}.Scheduler.Enable` + value (Any): + + Returns: + Any: + """ + self.modified[keys] = value + if self.auto_update: + self.update() + + def task_delay(self, success=None, server_update=None, target=None, minute=None, task=None): + """ + Set Scheduler.NextRun + Should set at least one arguments. + If multiple arguments are set, use the nearest. + + Args: + success (bool): + If True, delay Scheduler.SuccessInterval + If False, delay Scheduler.FailureInterval + server_update (bool, list, str): + If True, delay to nearest Scheduler.ServerUpdate + If type is list or str, delay to such server update + target (datetime.datetime, str, list): + Delay to such time. + minute (int, float, tuple): + Delay several minutes. + task (str): + Set across task. None for current task. + """ + + def ensure_delta(delay): + return timedelta(seconds=int(ensure_time(delay, precision=3) * 60)) + + run = [] + if success is not None: + interval = ( + 120 + if success + else 30 + ) + run.append(datetime.now() + ensure_delta(interval)) + if server_update is not None: + if server_update is True: + server_update = self.Scheduler_ServerUpdate + run.append(get_server_next_update(server_update)) + if target is not None: + target = [target] if not isinstance(target, list) else target + target = nearest_future(target) + run.append(target) + if minute is not None: + run.append(datetime.now() + ensure_delta(minute)) + + if len(run): + run = min(run).replace(microsecond=0) + kv = dict_to_kv( + { + "success": success, + "server_update": server_update, + "target": target, + "minute": minute, + }, + allow_none=False, + ) + if task is None: + task = self.task.command + logger.info(f"Delay task `{task}` to {run} ({kv})") + self.modified[f'{task}.Scheduler.NextRun'] = run + self.update() + else: + raise ScriptError( + "Missing argument in delay_next_run, should set at least one" + ) + + def task_call(self, task, force_call=True): + """ + Call another task to run. + + That task will run when current task finished. + But it might not be run because: + - Other tasks should run first according to SCHEDULER_PRIORITY + - Task is disabled by user + + Args: + task (str): Task name to call, such as `Restart` + force_call (bool): + + Returns: + bool: If called. + """ + if deep_get(self.data, keys=f"{task}.Scheduler.NextRun", default=None) is None: + raise ScriptError(f"Task to call: `{task}` does not exist in user config") + + if force_call or self.is_task_enabled(task): + logger.info(f"Task call: {task}") + self.modified[f"{task}.Scheduler.NextRun"] = datetime.now().replace( + microsecond=0 + ) + self.modified[f"{task}.Scheduler.Enable"] = True + if self.auto_update: + self.update() + return True + else: + logger.info(f"Task call: {task} (skipped because disabled by user)") + return False + + @staticmethod + def task_stop(message=""): + """ + Stop current task. + + Raises: + TaskEnd: + """ + if message: + raise TaskEnd(message) + else: + raise TaskEnd + + def task_switched(self): + """ + Check if needs to switch task. + + Raises: + bool: If task switched + """ + # Update event + if self.stop_event is not None: + if self.stop_event.is_set(): + return True + prev = self.task + self.load() + new = self.get_next() + if prev == new: + logger.info(f"Continue task `{new}`") + return False + else: + logger.info(f"Switch task `{prev}` to `{new}`") + return True + + def check_task_switch(self, message=""): + """ + Stop current task when task switched. + + Raises: + TaskEnd: + """ + if self.task_switched(): + self.task_stop(message=message) + + def is_task_enabled(self, task): + return bool(self.cross_get(keys=[task, 'Scheduler', 'Enable'], default=False)) + + def update_daily_quests(self): + """ + Raises: + TaskEnd: Call task `DailyQuest` and stop current task + """ + if self.stored.DailyActivity.is_expired(): + logger.info('DailyActivity expired, call task to update') + self.task_call('DailyQuest') + self.task_stop() + if self.stored.DailyQuest.is_expired(): + logger.info('DailyQuest expired, call task to update') + self.task_call('DailyQuest') + self.task_stop() + + def update_battle_pass_quests(self): + """ + Raises: + TaskEnd: Call task `BattlePass` and stop current task + """ + if self.stored.BattlePassTodayQuest.is_expired(): + if self.stored.BattlePassLevel.is_full(): + logger.info('BattlePassLevel full, no updates') + else: + logger.info('BattlePassTodayQuest expired, call task to update') + self.task_call('BattlePass') + self.task_stop() + + @property + def DEVICE_SCREENSHOT_METHOD(self): + return self.Emulator_ScreenshotMethod + + @property + def DEVICE_CONTROL_METHOD(self): + return self.Emulator_ControlMethod + + def temporary(self, **kwargs): + """ + Cover some settings, and recover later. + + Usage: + backup = self.config.cover(ENABLE_DAILY_REWARD=False) + # do_something() + backup.recover() + + Args: + **kwargs: + + Returns: + ConfigBackup: + """ + backup = ConfigBackup(config=self) + backup.cover(**kwargs) + return backup + + +class ConfigBackup: + def __init__(self, config): + """ + Args: + config (AzurLaneConfig): + """ + self.config = config + self.backup = {} + self.kwargs = {} + + def cover(self, **kwargs): + self.kwargs = kwargs + for key, value in kwargs.items(): + self.backup[key] = self.config.__getattribute__(key) + self.config.__setattr__(key, value) + + def recover(self): + for key, value in self.backup.items(): + self.config.__setattr__(key, value) + + +class MultiSetWrapper: + def __init__(self, main): + """ + Args: + main (AzurLaneConfig): + """ + self.main = main + self.in_wrapper = False + + def __enter__(self): + if self.main.auto_update: + self.main.auto_update = False + else: + self.in_wrapper = True + return self + + def __exit__(self, exc_type, exc_val, exc_tb): + if not self.in_wrapper: + self.main.update() + self.main.auto_update = True diff --git a/module/config/config_generated.py b/module/config/config_generated.py new file mode 100644 index 0000000..26caec6 --- /dev/null +++ b/module/config/config_generated.py @@ -0,0 +1,53 @@ +import datetime + +# This file was automatically generated by module/config/config_updater.py. +# Don't modify it manually. + + +class GeneratedConfig: + """ + Auto generated configuration + """ + + # Group `Scheduler` + Scheduler_Enable = False # True, False + Scheduler_NextRun = datetime.datetime(2020, 1, 1, 0, 0) + Scheduler_Command = 'Alas' + Scheduler_ServerUpdate = '04:00' + + # Group `Emulator` + Emulator_Serial = 'auto' + Emulator_PackageName = 'auto' # auto, JP-Official + Emulator_GameLanguage = 'auto' # auto, jp + Emulator_ScreenshotMethod = 'auto' # auto, ADB, ADB_nc, uiautomator2, aScreenCap, aScreenCap_nc, DroidCast, DroidCast_raw, scrcpy + Emulator_ControlMethod = 'MaaTouch' # minitouch, MaaTouch + Emulator_AdbRestart = False + + # Group `EmulatorInfo` + EmulatorInfo_Emulator = 'auto' # auto, NoxPlayer, NoxPlayer64, BlueStacks4, BlueStacks5, BlueStacks4HyperV, BlueStacks5HyperV, LDPlayer3, LDPlayer4, LDPlayer9, MuMuPlayer, MuMuPlayerX, MuMuPlayer12, MEmuPlayer + EmulatorInfo_name = None + EmulatorInfo_path = None + + # Group `Error` + Error_Restart = 'game' # game, game_emulator + Error_SaveError = True + Error_ScreenshotLength = 1 + Error_OnePushConfig = 'provider: null' + + # Group `Optimization` + Optimization_ScreenshotInterval = 0.3 + Optimization_CombatScreenshotInterval = 1.0 + Optimization_WhenTaskQueueEmpty = 'goto_main' # stay_there, goto_main, close_game + + # Group `Cafe` + Cafe_Reward = True + Cafe_Touch = True + Cafe_AutoAdjust = True + + # Group `TacticalChallenge` + TacticalChallenge_PlayerSelect = 0 # 0, 1, 2, 3 + + # Group `ItemStorage` + ItemStorage_AP = {} + ItemStorage_Credit = {} + ItemStorage_Pyroxene = {} diff --git a/module/config/config_manual.py b/module/config/config_manual.py new file mode 100644 index 0000000..d5dbfd5 --- /dev/null +++ b/module/config/config_manual.py @@ -0,0 +1,54 @@ +import module.config.server as server + + +class ManualConfig: + @property + def LANG(self): + return server.lang + + SCHEDULER_PRIORITY = """ + Restart + > Cafe > Circle > Mail > DataUpdate > TacticalChallenge + """ + + """ + module.assets + """ + ASSETS_FOLDER = './assets' + ASSETS_MODULE = './tasks' + ASSETS_RESOLUTION = (1280, 720) + + """ + module.base + """ + COLOR_SIMILAR_THRESHOLD = 10 + BUTTON_OFFSET = (20, 20) + BUTTON_MATCH_SIMILARITY = 0.85 + WAIT_BEFORE_SAVING_SCREEN_SHOT = 1 + + """ + module.device + """ + DEVICE_OVER_HTTP = False + FORWARD_PORT_RANGE = (20000, 21000) + REVERSE_SERVER_PORT = 7903 + + ASCREENCAP_FILEPATH_LOCAL = './bin/ascreencap' + ASCREENCAP_FILEPATH_REMOTE = '/data/local/tmp/ascreencap' + + # 'DroidCast', 'DroidCast_raw' + DROIDCAST_VERSION = 'DroidCast' + DROIDCAST_FILEPATH_LOCAL = './bin/DroidCast/DroidCast-debug-1.1.0.apk' + DROIDCAST_FILEPATH_REMOTE = '/data/local/tmp/DroidCast.apk' + DROIDCAST_RAW_FILEPATH_LOCAL = './bin/DroidCast/DroidCastS-release-1.1.5.apk' + DROIDCAST_RAW_FILEPATH_REMOTE = '/data/local/tmp/DroidCastS.apk' + + MINITOUCH_FILEPATH_REMOTE = '/data/local/tmp/minitouch' + + HERMIT_FILEPATH_LOCAL = './bin/hermit/hermit.apk' + + SCRCPY_FILEPATH_LOCAL = './bin/scrcpy/scrcpy-server-v1.20.jar' + SCRCPY_FILEPATH_REMOTE = '/data/local/tmp/scrcpy-server-v1.20.jar' + + MAATOUCH_FILEPATH_LOCAL = './bin/MaaTouch/maatouch' + MAATOUCH_FILEPATH_REMOTE = '/data/local/tmp/maatouch' diff --git a/module/config/config_updater.py b/module/config/config_updater.py new file mode 100644 index 0000000..3d0beda --- /dev/null +++ b/module/config/config_updater.py @@ -0,0 +1,636 @@ +from copy import deepcopy + +from cached_property import cached_property + +from deploy.Windows.utils import DEPLOY_TEMPLATE, poor_yaml_read, poor_yaml_write +from module.base.timer import timer +from module.config.server import VALID_SERVER +from module.config.utils import * + +CONFIG_IMPORT = ''' +import datetime + +# This file was automatically generated by module/config/config_updater.py. +# Don't modify it manually. + + +class GeneratedConfig: + """ + Auto generated configuration + """ +'''.strip().split('\n') + +DICT_GUI_TO_INGAME = { + 'zh-CN': 'cn', + 'en-US': 'en', +} + + +def get_generator(): + from module.base.code_generator import CodeGenerator + return CodeGenerator() + + +class ConfigGenerator: + @cached_property + def argument(self): + """ + Load argument.yaml, and standardise its structure. + + : + : + type: checkbox|select|textarea|input + value: + option (Optional): Options, if argument has any options. + validate (Optional): datetime + """ + data = {} + raw = read_file(filepath_argument('argument')) + + def option_add(keys, options): + options = deep_get(raw, keys=keys, default=[]) + options + deep_set(raw, keys=keys, value=options) + + # Insert packages + option_add(keys='Emulator.PackageName.option', options=list(VALID_SERVER.keys())) + + # Load + for path, value in deep_iter(raw, depth=2): + arg = { + 'type': 'input', + 'value': '', + # option + } + if not isinstance(value, dict): + value = {'value': value} + arg['type'] = data_to_type(value, arg=path[1]) + if arg['type'] == 'stored': + value['value'] = {} + arg['display'] = 'hide' # Hide `stored` by default + if isinstance(value['value'], datetime): + arg['type'] = 'datetime' + arg['validate'] = 'datetime' + # Manual definition has the highest priority + arg.update(value) + deep_set(data, keys=path, value=arg) + + return data + + @cached_property + def task(self): + """ + : + : + : + """ + return read_file(filepath_argument('task')) + + @cached_property + def default(self): + """ + : + : + : value + """ + return read_file(filepath_argument('default')) + + @cached_property + def override(self): + """ + : + : + : value + """ + return read_file(filepath_argument('override')) + + @cached_property + def gui(self): + """ + : + : value, value is None + """ + return read_file(filepath_argument('gui')) + + @cached_property + @timer + def args(self): + """ + Merge definitions into standardised json. + + task.yaml ---+ + argument.yaml ---+-----> args.json + override.yaml ---+ + default.yaml ---+ + + """ + # Construct args + data = {} + for path, groups in deep_iter(self.task, depth=3): + if 'tasks' not in path: + continue + task = path[2] + # Add storage to all task + # groups.append('Storage') + for group in groups: + if group not in self.argument: + print(f'`{task}.{group}` is not related to any argument group') + continue + deep_set(data, keys=[task, group], value=deepcopy(self.argument[group])) + + def check_override(path, value): + # Check existence + old = deep_get(data, keys=path, default=None) + if old is None: + print(f'`{".".join(path)}` is not a existing argument') + return False + # Check type + # But allow `Interval` to be different + old_value = old.get('value', None) if isinstance(old, dict) else old + value = old.get('value', None) if isinstance(value, dict) else value + if type(value) != type(old_value) \ + and old_value is not None \ + and path[2] not in ['SuccessInterval', 'FailureInterval']: + print( + f'`{value}` ({type(value)}) and `{".".join(path)}` ({type(old_value)}) are in different types') + return False + # Check option + if isinstance(old, dict) and 'option' in old: + if value not in old['option']: + print(f'`{value}` is not an option of argument `{".".join(path)}`') + return False + return True + + # Set defaults + for p, v in deep_iter(self.default, depth=3): + if not check_override(p, v): + continue + deep_set(data, keys=p + ['value'], value=v) + # Override non-modifiable arguments + for p, v in deep_iter(self.override, depth=3): + if not check_override(p, v): + continue + if isinstance(v, dict): + typ = v.get('type') + if typ == 'state': + pass + elif typ == 'lock': + deep_default(v, keys='display', value="disabled") + elif deep_get(v, keys='value') is not None: + deep_default(v, keys='display', value='hide') + for arg_k, arg_v in v.items(): + deep_set(data, keys=p + [arg_k], value=arg_v) + else: + deep_set(data, keys=p + ['value'], value=v) + deep_set(data, keys=p + ['display'], value='hide') + # Set command + for path, groups in deep_iter(self.task, depth=3): + if 'tasks' not in path: + continue + task = path[2] + if deep_get(data, keys=f'{task}.Scheduler.Command'): + deep_set(data, keys=f'{task}.Scheduler.Command.value', value=task) + deep_set(data, keys=f'{task}.Scheduler.Command.display', value='hide') + + return data + + @timer + def generate_code(self): + """ + Generate python code. + + args.json ---> config_generated.py + + """ + visited_group = set() + visited_path = set() + lines = CONFIG_IMPORT + for path, data in deep_iter(self.argument, depth=2): + group, arg = path + if group not in visited_group: + lines.append('') + lines.append(f' # Group `{group}`') + visited_group.add(group) + + option = '' + if 'option' in data and data['option']: + option = ' # ' + ', '.join([str(opt) for opt in data['option']]) + path = '.'.join(path) + lines.append(f' {path_to_arg(path)} = {repr(parse_value(data["value"], data=data))}{option}') + visited_path.add(path) + + with open(filepath_code(), 'w', encoding='utf-8', newline='') as f: + for text in lines: + f.write(text + '\n') + + @timer + def generate_stored(self): + import module.config.stored.classes as classes + gen = get_generator() + gen.add('from module.config.stored.classes import (') + with gen.tab(): + for cls in sorted([name for name in dir(classes) if name.startswith('Stored')]): + gen.add(cls + ',') + gen.add(')') + gen.Empty() + gen.Empty() + gen.Empty() + gen.CommentAutoGenerage('module/config/config_updater.py') + + with gen.Class('StoredGenerated'): + for path, data in deep_iter(self.args, depth=3): + cls = data.get('stored') + if cls: + gen.add(f'{path[-1]} = {cls}("{".".join(path)}")') + + gen.write('module/config/stored/stored_generated.py') + + @timer + def generate_i18n(self, lang): + """ + Load old translations and generate new translation file. + + args.json ---+-----> i18n/.json + (old) i18n/.json ---+ + + """ + new = {} + old = read_file(filepath_i18n(lang)) + + def deep_load(keys, default=True, words=('name', 'help')): + for word in words: + k = keys + [str(word)] + d = ".".join(k) if default else str(word) + v = deep_get(old, keys=k, default=d) + deep_set(new, keys=k, value=v) + + # Menu + for path, data in deep_iter(self.task, depth=3): + if 'tasks' not in path: + continue + task_group, _, task = path + deep_load(['Menu', task_group]) + deep_load(['Task', task]) + # Arguments + visited_group = set() + for path, data in deep_iter(self.argument, depth=2): + if path[0] not in visited_group: + deep_load([path[0], '_info']) + visited_group.add(path[0]) + deep_load(path) + if 'option' in data: + deep_load(path, words=data['option'], default=False) + + # Package names + # for package, server in VALID_PACKAGE.items(): + # path = ['Emulator', 'PackageName', package] + # if deep_get(new, keys=path) == package: + # deep_set(new, keys=path, value=server.upper()) + # for package, server_and_channel in VALID_CHANNEL_PACKAGE.items(): + # server, channel = server_and_channel + # name = deep_get(new, keys=['Emulator', 'PackageName', to_package(server)]) + # if lang == SERVER_TO_LANG[server]: + # value = f'{name} {channel}渠道服 {package}' + # else: + # value = f'{name} {package}' + # deep_set(new, keys=['Emulator', 'PackageName', package], value=value) + # Game server names + # for server, _list in VALID_SERVER_LIST.items(): + # for index in range(len(_list)): + # path = ['Emulator', 'ServerName', f'{server}-{index}'] + # prefix = server.split('_')[0].upper() + # prefix = '国服' if prefix == 'CN' else prefix + # deep_set(new, keys=path, value=f'[{prefix}] {_list[index]}') + + # GUI i18n + for path, _ in deep_iter(self.gui, depth=2): + group, key = path + deep_load(keys=['Gui', group], words=(key,)) + + write_file(filepath_i18n(lang), new) + + @cached_property + def menu(self): + """ + Generate menu definitions + + task.yaml --> menu.json + + """ + data = {} + for task_group in self.task.keys(): + value = deep_get(self.task, keys=[task_group, 'menu']) + if value not in ['collapse', 'list']: + value = 'collapse' + deep_set(data, keys=[task_group, 'menu'], value=value) + value = deep_get(self.task, keys=[task_group, 'page']) + if value not in ['setting', 'tool']: + value = 'setting' + deep_set(data, keys=[task_group, 'page'], value=value) + tasks = deep_get(self.task, keys=[task_group, 'tasks'], default={}) + tasks = list(tasks.keys()) + deep_set(data, keys=[task_group, 'tasks'], value=tasks) + + return data + + @cached_property + def stored(self): + import module.config.stored.classes as classes + data = {} + for path, value in deep_iter(self.args, depth=3): + if value.get('type') != 'stored': + continue + name = path[-1] + stored = value.get('stored') + stored_class = getattr(classes, stored) + row = { + 'name': name, + 'path': '.'.join(path), + 'i18n': f'{path[1]}.{path[2]}.name', + 'stored': stored, + 'attrs': stored_class('')._attrs, + 'order': value.get('order', 0), + 'color': value.get('color', '#777777') + } + data[name] = row + + # sort by `order` ascending, but `order`==0 at last + data = sorted(data.items(), key=lambda kv: (kv[1]['order'] == 0, kv[1]['order'])) + data = {k: v for k, v in data} + return data + + @staticmethod + def generate_deploy_template(): + template = poor_yaml_read(DEPLOY_TEMPLATE) + cn = { + 'Repository': 'cn', + 'PypiMirror': 'https://pypi.tuna.tsinghua.edu.cn/simple', + 'Language': 'zh-CN', + } + aidlux = { + 'GitExecutable': '/usr/bin/git', + 'PythonExecutable': '/usr/bin/python', + 'RequirementsFile': './deploy/AidLux/0.92/requirements.txt', + 'AdbExecutable': '/usr/bin/adb', + } + + docker = { + 'GitExecutable': '/usr/bin/git', + 'PythonExecutable': '/usr/local/bin/python', + 'RequirementsFile': './deploy/docker/requirements.txt', + 'AdbExecutable': '/usr/bin/adb', + } + + def update(suffix, *args): + file = f'./config/deploy.{suffix}.yaml' + new = deepcopy(template) + for dic in args: + new.update(dic) + poor_yaml_write(data=new, file=file) + + update('template') + update('template-cn', cn) + # update('template-AidLux', aidlux) + # update('template-AidLux-cn', aidlux, cn) + # update('template-docker', docker) + # update('template-docker-cn', docker, cn) + + tpl = { + 'Repository': '{{repository}}', + 'GitExecutable': '{{gitExecutable}}', + 'PythonExecutable': '{{pythonExecutable}}', + 'AdbExecutable': '{{adbExecutable}}', + 'Language': '{{language}}', + 'Theme': '{{theme}}', + } + + def update(file, *args): + new = deepcopy(template) + for dic in args: + new.update(dic) + poor_yaml_write(data=new, file=file) + + update('./webapp/packages/main/public/deploy.yaml.tpl', tpl) + + @timer + def generate(self): + _ = self.args + _ = self.menu + _ = self.stored + # _ = self.event + # self.insert_server() + write_file(filepath_args(), self.args) + write_file(filepath_args('menu'), self.menu) + write_file(filepath_args('stored'), self.stored) + self.generate_code() + self.generate_stored() + for lang in LANGUAGES: + self.generate_i18n(lang) + self.generate_deploy_template() + + +class ConfigUpdater: + # source, target, (optional)convert_func + # redirection = [ + # ('Dungeon.Dungeon.Support', 'Dungeon.DungeonSupport.Use'), + # ('Dungeon.Dungeon.SupportCharacter', 'Dungeon.DungeonSupport.Character'), + # ('Dungeon.Dungeon.Name', 'Dungeon.Dungeon.Name', convert_daily), + # ('Dungeon.Dungeon.NameAtDoubleCalyx', 'Dungeon.Dungeon.NameAtDoubleCalyx', convert_daily), + # ('Dungeon.DungeonDaily.CalyxCrimson', 'Dungeon.DungeonDaily.CalyxCrimson', convert_daily), + # ] + + @cached_property + def args(self): + return read_file(filepath_args()) + + def config_update(self, old, is_template=False): + """ + Args: + old (dict): + is_template (bool): + + Returns: + dict: + """ + new = {} + + def deep_load(keys): + data = deep_get(self.args, keys=keys, default={}) + value = deep_get(old, keys=keys, default=data['value']) + typ = data['type'] + display = data.get('display') + if is_template or value is None or value == '' or typ == 'lock' or (display == 'hide' and typ != 'stored'): + value = data['value'] + value = parse_value(value, data=data) + deep_set(new, keys=keys, value=value) + + for path, _ in deep_iter(self.args, depth=3): + deep_load(path) + + # if not is_template: + # new = self.config_redirect(old, new) + # new = self.update_state(new) + + return new + + # def config_redirect(self, old, new): + # """ + # Convert old settings to the new. + # + # Args: + # old (dict): + # new (dict): + # + # Returns: + # dict: + # """ + # for row in self.redirection: + # if len(row) == 2: + # source, target = row + # update_func = None + # elif len(row) == 3: + # source, target, update_func = row + # else: + # continue + # + # if isinstance(source, tuple): + # value = [] + # error = False + # for attribute in source: + # tmp = deep_get(old, keys=attribute) + # if tmp is None: + # error = True + # continue + # value.append(tmp) + # if error: + # continue + # else: + # value = deep_get(old, keys=source) + # if value is None: + # continue + # + # if update_func is not None: + # value = update_func(value) + # + # if isinstance(target, tuple): + # for k, v in zip(target, value): + # # Allow update same key + # if (deep_get(old, keys=k) is None) or (source == target): + # deep_set(new, keys=k, value=v) + # elif (deep_get(old, keys=target) is None) or (source == target): + # deep_set(new, keys=target, value=value) + # + # return new + + # @staticmethod + # def update_state(data): + # def set_daily(quest, value): + # if value is True: + # value = 'achievable' + # if value is False: + # value = 'not_set' + # deep_set(data, keys=['DailyQuest', 'AchievableQuest', quest], value=value) + # + # set_daily('Complete_1_Daily_Mission', 'not_supported') + # # Dungeon + # dungeon = deep_get(data, keys='Dungeon.Scheduler.Enable') + # set_daily('Clear_Calyx_Golden_1_times', + # dungeon and deep_get(data, 'Dungeon.DungeonDaily.CalyxGolden') != 'do_not_achieve') + # set_daily('Complete_Calyx_Crimson_1_time', + # dungeon and deep_get(data, 'Dungeon.DungeonDaily.CalyxCrimson') != 'do_not_achieve') + # set_daily('Clear_Stagnant_Shadow_1_times', + # dungeon and deep_get(data, 'Dungeon.DungeonDaily.StagnantShadow') != 'do_not_achieve') + # set_daily('Clear_Cavern_of_Corrosion_1_times', + # dungeon and deep_get(data, 'Dungeon.DungeonDaily.CavernOfCorrosion') != 'do_not_achieve') + # # Combat requirements + # set_daily('In_a_single_battle_inflict_3_Weakness_Break_of_different_Types', 'achievable') + # set_daily('Inflict_Weakness_Break_5_times', 'achievable') + # set_daily('Defeat_a_total_of_20_enemies', 'achievable') + # set_daily('Enter_combat_by_attacking_enemy_Weakness_and_win_3_times', 'achievable') + # set_daily('Use_Technique_2_times', 'achievable') + # # Other game systems + # set_daily('Go_on_assignment_1_time', deep_get(data, 'Assignment.Scheduler.Enable')) + # set_daily('Take_1_photo', 'achievable') + # set_daily('Destroy_3_destructible_objects', 'achievable') + # set_daily('Complete_Forgotten_Hall_1_time', 'achievable') + # set_daily('Complete_Echo_of_War_1_times', deep_get(data, 'Weekly.Scheduler.Enable')) + # set_daily('Complete_1_stage_in_Simulated_Universe_Any_world', 'not_supported') + # set_daily('Obtain_victory_in_combat_with_support_characters_1_time', + # dungeon and deep_get(data, 'Dungeon.DungeonSupport.Use') in ['when_daily', 'always_use']) + # set_daily('Use_an_Ultimate_to_deal_the_final_blow_1_time', 'achievable') + # # Build + # set_daily('Level_up_any_character_1_time', 'not_supported') + # set_daily('Level_up_any_Light_Cone_1_time', 'not_supported') + # set_daily('Level_up_any_Relic_1_time', 'not_supported') + # # Items + # set_daily('Salvage_any_Relic', 'achievable') + # set_daily('Synthesize_Consumable_1_time', 'achievable') + # set_daily('Synthesize_material_1_time', 'achievable') + # set_daily('Use_Consumables_1_time', 'achievable') + # return data + + def read_file(self, config_name, is_template=False): + """ + Read and update config file. + + Args: + config_name (str): ./config/{file}.json + is_template (bool): + + Returns: + dict: + """ + old = read_file(filepath_config(config_name)) + new = self.config_update(old, is_template=is_template) + # The updated config did not write into file, although it doesn't matters. + # Commented for performance issue + # self.write_file(config_name, new) + return new + + @staticmethod + def write_file(config_name, data, mod_name='alas'): + """ + Write config file. + + Args: + config_name (str): ./config/{file}.json + data (dict): + mod_name (str): + """ + write_file(filepath_config(config_name, mod_name), data) + + @timer + def update_file(self, config_name, is_template=False): + """ + Read, update and write config file. + + Args: + config_name (str): ./config/{file}.json + is_template (bool): + + Returns: + dict: + """ + data = self.read_file(config_name, is_template=is_template) + self.write_file(config_name, data) + return data + + +if __name__ == '__main__': + """ + Process the whole config generation. + + task.yaml -+----------------> menu.json + argument.yaml -+-> args.json ---> config_generated.py + override.yaml -+ | + gui.yaml --------\| + || + (old) i18n/.json --------\\========> i18n/.json + (old) template.json ---------\========> template.json + """ + # Ensure running in Alas root folder + import os + + os.chdir(os.path.join(os.path.dirname(__file__), '../../')) + + ConfigGenerator().generate() + ConfigUpdater().update_file('template', is_template=True) diff --git a/module/config/i18n/en-US.json b/module/config/i18n/en-US.json new file mode 100644 index 0000000..ed879ed --- /dev/null +++ b/module/config/i18n/en-US.json @@ -0,0 +1,340 @@ +{ + "Menu": { + "Alas": { + "name": "Menu.Alas.name", + "help": "Menu.Alas.help" + }, + "Daily": { + "name": "Menu.Daily.name", + "help": "Menu.Daily.help" + } + }, + "Task": { + "Alas": { + "name": "Task.Alas.name", + "help": "Task.Alas.help" + }, + "Restart": { + "name": "Task.Restart.name", + "help": "Task.Restart.help" + }, + "Cafe": { + "name": "Task.Cafe.name", + "help": "Task.Cafe.help" + }, + "Mail": { + "name": "Task.Mail.name", + "help": "Task.Mail.help" + }, + "Circle": { + "name": "Task.Circle.name", + "help": "Task.Circle.help" + }, + "TacticalChallenge": { + "name": "Task.TacticalChallenge.name", + "help": "Task.TacticalChallenge.help" + }, + "DataUpdate": { + "name": "Task.DataUpdate.name", + "help": "Task.DataUpdate.help" + } + }, + "Scheduler": { + "_info": { + "name": "Scheduler._info.name", + "help": "Scheduler._info.help" + }, + "Enable": { + "name": "Scheduler.Enable.name", + "help": "Scheduler.Enable.help", + "True": "True", + "False": "False" + }, + "NextRun": { + "name": "Scheduler.NextRun.name", + "help": "Scheduler.NextRun.help" + }, + "Command": { + "name": "Scheduler.Command.name", + "help": "Scheduler.Command.help" + }, + "ServerUpdate": { + "name": "Scheduler.ServerUpdate.name", + "help": "Scheduler.ServerUpdate.help" + } + }, + "Emulator": { + "_info": { + "name": "Emulator._info.name", + "help": "Emulator._info.help" + }, + "Serial": { + "name": "Emulator.Serial.name", + "help": "Emulator.Serial.help" + }, + "PackageName": { + "name": "Emulator.PackageName.name", + "help": "Emulator.PackageName.help", + "auto": "auto", + "JP-Official": "JP-Official" + }, + "GameLanguage": { + "name": "Emulator.GameLanguage.name", + "help": "Emulator.GameLanguage.help", + "auto": "auto", + "jp": "jp" + }, + "ScreenshotMethod": { + "name": "Emulator.ScreenshotMethod.name", + "help": "Emulator.ScreenshotMethod.help", + "auto": "auto", + "ADB": "ADB", + "ADB_nc": "ADB_nc", + "uiautomator2": "uiautomator2", + "aScreenCap": "aScreenCap", + "aScreenCap_nc": "aScreenCap_nc", + "DroidCast": "DroidCast", + "DroidCast_raw": "DroidCast_raw", + "scrcpy": "scrcpy" + }, + "ControlMethod": { + "name": "Emulator.ControlMethod.name", + "help": "Emulator.ControlMethod.help", + "minitouch": "minitouch", + "MaaTouch": "MaaTouch" + }, + "AdbRestart": { + "name": "Emulator.AdbRestart.name", + "help": "Emulator.AdbRestart.help" + } + }, + "EmulatorInfo": { + "_info": { + "name": "EmulatorInfo._info.name", + "help": "EmulatorInfo._info.help" + }, + "Emulator": { + "name": "EmulatorInfo.Emulator.name", + "help": "EmulatorInfo.Emulator.help", + "auto": "auto", + "NoxPlayer": "NoxPlayer", + "NoxPlayer64": "NoxPlayer64", + "BlueStacks4": "BlueStacks4", + "BlueStacks5": "BlueStacks5", + "BlueStacks4HyperV": "BlueStacks4HyperV", + "BlueStacks5HyperV": "BlueStacks5HyperV", + "LDPlayer3": "LDPlayer3", + "LDPlayer4": "LDPlayer4", + "LDPlayer9": "LDPlayer9", + "MuMuPlayer": "MuMuPlayer", + "MuMuPlayerX": "MuMuPlayerX", + "MuMuPlayer12": "MuMuPlayer12", + "MEmuPlayer": "MEmuPlayer" + }, + "name": { + "name": "EmulatorInfo.name.name", + "help": "EmulatorInfo.name.help" + }, + "path": { + "name": "EmulatorInfo.path.name", + "help": "EmulatorInfo.path.help" + } + }, + "Error": { + "_info": { + "name": "Error._info.name", + "help": "Error._info.help" + }, + "Restart": { + "name": "Error.Restart.name", + "help": "Error.Restart.help", + "game": "game", + "game_emulator": "game_emulator" + }, + "SaveError": { + "name": "Error.SaveError.name", + "help": "Error.SaveError.help" + }, + "ScreenshotLength": { + "name": "Error.ScreenshotLength.name", + "help": "Error.ScreenshotLength.help" + }, + "OnePushConfig": { + "name": "Error.OnePushConfig.name", + "help": "Error.OnePushConfig.help" + } + }, + "Optimization": { + "_info": { + "name": "Optimization._info.name", + "help": "Optimization._info.help" + }, + "ScreenshotInterval": { + "name": "Optimization.ScreenshotInterval.name", + "help": "Optimization.ScreenshotInterval.help" + }, + "CombatScreenshotInterval": { + "name": "Optimization.CombatScreenshotInterval.name", + "help": "Optimization.CombatScreenshotInterval.help" + }, + "WhenTaskQueueEmpty": { + "name": "Optimization.WhenTaskQueueEmpty.name", + "help": "Optimization.WhenTaskQueueEmpty.help", + "stay_there": "stay_there", + "goto_main": "goto_main", + "close_game": "close_game" + } + }, + "Cafe": { + "_info": { + "name": "Cafe._info.name", + "help": "Cafe._info.help" + }, + "Reward": { + "name": "Cafe.Reward.name", + "help": "Cafe.Reward.help" + }, + "Touch": { + "name": "Cafe.Touch.name", + "help": "Cafe.Touch.help" + }, + "AutoAdjust": { + "name": "Cafe.AutoAdjust.name", + "help": "Cafe.AutoAdjust.help" + } + }, + "TacticalChallenge": { + "_info": { + "name": "TacticalChallenge._info.name", + "help": "TacticalChallenge._info.help" + }, + "PlayerSelect": { + "name": "TacticalChallenge.PlayerSelect.name", + "help": "TacticalChallenge.PlayerSelect.help", + "0": "0", + "1": "1", + "2": "2", + "3": "3" + } + }, + "ItemStorage": { + "_info": { + "name": "ItemStorage._info.name", + "help": "ItemStorage._info.help" + }, + "AP": { + "name": "ItemStorage.AP.name", + "help": "ItemStorage.AP.help" + }, + "Credit": { + "name": "ItemStorage.Credit.name", + "help": "ItemStorage.Credit.help" + }, + "Pyroxene": { + "name": "ItemStorage.Pyroxene.name", + "help": "ItemStorage.Pyroxene.help" + } + }, + "Gui": { + "Aside": { + "Install": "Gui.Aside.Install", + "Home": "Gui.Aside.Home", + "Develop": "Gui.Aside.Develop", + "Performance": "Gui.Aside.Performance", + "Setting": "Gui.Aside.Setting", + "AddAlas": "Gui.Aside.AddAlas" + }, + "Button": { + "Start": "Gui.Button.Start", + "Stop": "Gui.Button.Stop", + "ScrollON": "Gui.Button.ScrollON", + "ScrollOFF": "Gui.Button.ScrollOFF", + "ClearLog": "Gui.Button.ClearLog", + "Setting": "Gui.Button.Setting", + "CheckUpdate": "Gui.Button.CheckUpdate", + "ClickToUpdate": "Gui.Button.ClickToUpdate", + "RetryUpdate": "Gui.Button.RetryUpdate", + "CancelUpdate": "Gui.Button.CancelUpdate" + }, + "Toast": { + "DisableTranslateMode": "Gui.Toast.DisableTranslateMode", + "ConfigSaved": "Gui.Toast.ConfigSaved", + "AlasIsRunning": "Gui.Toast.AlasIsRunning", + "ClickToUpdate": "Gui.Toast.ClickToUpdate" + }, + "Status": { + "Running": "Gui.Status.Running", + "Inactive": "Gui.Status.Inactive", + "Warning": "Gui.Status.Warning", + "Updating": "Gui.Status.Updating" + }, + "MenuAlas": { + "Overview": "Gui.MenuAlas.Overview", + "Log": "Gui.MenuAlas.Log" + }, + "MenuDevelop": { + "HomePage": "Gui.MenuDevelop.HomePage", + "Translate": "Gui.MenuDevelop.Translate", + "Update": "Gui.MenuDevelop.Update", + "Remote": "Gui.MenuDevelop.Remote", + "Utils": "Gui.MenuDevelop.Utils" + }, + "Overview": { + "Scheduler": "Gui.Overview.Scheduler", + "Log": "Gui.Overview.Log", + "Running": "Gui.Overview.Running", + "Pending": "Gui.Overview.Pending", + "Waiting": "Gui.Overview.Waiting", + "NoTask": "Gui.Overview.NoTask" + }, + "Dashboard": { + "NoData": "Gui.Dashboard.NoData", + "TimeError": "Gui.Dashboard.TimeError", + "JustNow": "Gui.Dashboard.JustNow", + "MinutesAgo": "Gui.Dashboard.MinutesAgo", + "HoursAgo": "Gui.Dashboard.HoursAgo", + "DaysAgo": "Gui.Dashboard.DaysAgo", + "LongTimeAgo": "Gui.Dashboard.LongTimeAgo" + }, + "AddAlas": { + "PopupTitle": "Gui.AddAlas.PopupTitle", + "NewName": "Gui.AddAlas.NewName", + "CopyFrom": "Gui.AddAlas.CopyFrom", + "Confirm": "Gui.AddAlas.Confirm", + "FileExist": "Gui.AddAlas.FileExist", + "InvalidChar": "Gui.AddAlas.InvalidChar", + "InvalidPrefixTemplate": "Gui.AddAlas.InvalidPrefixTemplate" + }, + "Update": { + "UpToDate": "Gui.Update.UpToDate", + "HaveUpdate": "Gui.Update.HaveUpdate", + "UpdateStart": "Gui.Update.UpdateStart", + "UpdateWait": "Gui.Update.UpdateWait", + "UpdateRun": "Gui.Update.UpdateRun", + "UpdateSuccess": "Gui.Update.UpdateSuccess", + "UpdateFailed": "Gui.Update.UpdateFailed", + "UpdateChecking": "Gui.Update.UpdateChecking", + "UpdateCancel": "Gui.Update.UpdateCancel", + "UpdateFinish": "Gui.Update.UpdateFinish", + "Local": "Gui.Update.Local", + "Upstream": "Gui.Update.Upstream", + "Author": "Gui.Update.Author", + "Time": "Gui.Update.Time", + "Message": "Gui.Update.Message", + "DisabledWarn": "Gui.Update.DisabledWarn", + "DetailedHistory": "Gui.Update.DetailedHistory" + }, + "Remote": { + "Running": "Gui.Remote.Running", + "NotRunning": "Gui.Remote.NotRunning", + "NotEnable": "Gui.Remote.NotEnable", + "EntryPoint": "Gui.Remote.EntryPoint", + "ConfigureHint": "Gui.Remote.ConfigureHint", + "SSHNotInstall": "Gui.Remote.SSHNotInstall" + }, + "Text": { + "InvalidFeedBack": "Gui.Text.InvalidFeedBack", + "Clear": "Gui.Text.Clear" + } + } +} \ No newline at end of file diff --git a/module/config/i18n/zh-CN.json b/module/config/i18n/zh-CN.json new file mode 100644 index 0000000..665898e --- /dev/null +++ b/module/config/i18n/zh-CN.json @@ -0,0 +1,340 @@ +{ + "Menu": { + "Alas": { + "name": "AAS", + "help": "" + }, + "Daily": { + "name": "每日", + "help": "" + } + }, + "Task": { + "Alas": { + "name": "AAS设置", + "help": "" + }, + "Restart": { + "name": "异常处理", + "help": "" + }, + "Cafe": { + "name": "咖啡厅", + "help": "" + }, + "Mail": { + "name": "邮箱", + "help": "" + }, + "Circle": { + "name": "公会", + "help": "社团 / 小组" + }, + "TacticalChallenge": { + "name": "战术对抗赛", + "help": "战术大赛 / 竞技场" + }, + "DataUpdate": { + "name": "仪表盘更新", + "help": "" + } + }, + "Scheduler": { + "_info": { + "name": "任务设置", + "help": "" + }, + "Enable": { + "name": "启用该功能", + "help": "将这个任务加入调度器", + "True": "已启用", + "False": "已停用" + }, + "NextRun": { + "name": "下一次运行时间", + "help": "自动计算的数值,不需要手动修改。清空后将立即运行" + }, + "Command": { + "name": "内部任务名称", + "help": "" + }, + "ServerUpdate": { + "name": "服务器刷新时间", + "help": "一些任务运行成功后,将推迟下一次运行至服务器刷新时间\n自动换算时区,一般不需要修改" + } + }, + "Emulator": { + "_info": { + "name": "模拟器设置", + "help": "" + }, + "Serial": { + "name": "模拟器 Serial", + "help": "常见的模拟器 Serial 可以查询下方列表\n填 \"auto\" 自动检测模拟器,多个模拟器正在运行或使用不支持自动检测的模拟器时无法使用 \"auto\",必须手动填写\n\n模拟器默认 Serial:\n- 蓝叠模拟器 127.0.0.1:5555\n- 蓝叠模拟器4 Hyper-v版,填\"bluestacks4-hyperv\"自动连接,多开填\"bluestacks4-hyperv-2\"以此类推\n- 蓝叠模拟器5 Hyper-v版,填\"bluestacks5-hyperv\"自动连接,多开填\"bluestacks5-hyperv-1\"以此类推\n- 夜神模拟器 127.0.0.1:62001\n- 夜神模拟器64位 127.0.0.1:59865\n- MuMu模拟器/MuMu模拟器X 127.0.0.1:7555\n- MuMu模拟器12 127.0.0.1:16384\n- 逍遥模拟器 127.0.0.1:21503\n- 雷电模拟器 emulator-5554 或 127.0.0.1:5555\n- WSA,填\"wsa-0\"使游戏在后台运行,需要使用第三方软件操控或关闭(建议使用scrcpy操控)\n如果你使用了模拟器的多开功能,它们的 Serial 将不是默认的,可以在 console.bat 中执行 `adb devices` 查询,或根据模拟器官方的教程填写" + }, + "PackageName": { + "name": "游戏服务器", + "help": "无法区分国际服的不同地区,请手动选择服务器", + "auto": "自动检测", + "JP-Official": "[日服]-官服" + }, + "GameLanguage": { + "name": "游戏内文本语言", + "help": "", + "auto": "自动检测", + "jp": "日语" + }, + "ScreenshotMethod": { + "name": "模拟器截图方案", + "help": "使用自动选择时,将执行一次性能测试并自动更改为最快的截图方案\n一般情况下的速度: DroidCast_raw >> aScreenCap_nc > ADB_nc >>> aScreenCap > uiautomator2 ~= ADB\n运行 工具 - 性能测试 以寻找最快的方案", + "auto": "自动选择最快的", + "ADB": "ADB", + "ADB_nc": "ADB_nc", + "uiautomator2": "uiautomator2", + "aScreenCap": "aScreenCap", + "aScreenCap_nc": "aScreenCap_nc", + "DroidCast": "DroidCast", + "DroidCast_raw": "DroidCast_raw", + "scrcpy": "scrcpy" + }, + "ControlMethod": { + "name": "模拟器控制方案", + "help": "速度: MaaTouch = minitouch >>> uiautomator2 ~= ADB\n建议选MaaTouch", + "minitouch": "minitouch", + "MaaTouch": "MaaTouch" + }, + "AdbRestart": { + "name": "在检测不到设备的时候尝试重启adb", + "help": "" + } + }, + "EmulatorInfo": { + "_info": { + "name": "模拟器设置", + "help": "下列数值是根据Serial自动填充的,如果不懂请不要随意修改" + }, + "Emulator": { + "name": "模拟器类型", + "help": "", + "auto": "自动检测", + "NoxPlayer": "夜神模拟器", + "NoxPlayer64": "夜神模拟器64位", + "BlueStacks4": "蓝叠模拟器4", + "BlueStacks5": "蓝叠模拟器5", + "BlueStacks4HyperV": "蓝叠模拟器4 Hyper-V", + "BlueStacks5HyperV": "蓝叠模拟器5 Hyper-V", + "LDPlayer3": "雷电模拟器3", + "LDPlayer4": "雷电模拟器4", + "LDPlayer9": "雷电模拟器9", + "MuMuPlayer": "MuMu模拟器", + "MuMuPlayerX": "MuMu模拟器X", + "MuMuPlayer12": "MuMu模拟器12", + "MEmuPlayer": "逍遥模拟器" + }, + "name": { + "name": "模拟器实例名称", + "help": "" + }, + "path": { + "name": "模拟器安装路径", + "help": "" + } + }, + "Error": { + "_info": { + "name": "调试设置", + "help": "" + }, + "Restart": { + "name": "出错时,重启游戏", + "help": "", + "game": "重启游戏", + "game_emulator": "重启模拟器和游戏" + }, + "SaveError": { + "name": "出错时,保存 Log 和截图", + "help": "" + }, + "ScreenshotLength": { + "name": "出错时,保留最后 X 张截图", + "help": "" + }, + "OnePushConfig": { + "name": "错误推送设置", + "help": "发生无法处理的异常后,使用 Onepush 推送一条错误信息。配置方法见文档:https://github.com/LmeSzinc/AzurLaneAutoScript/wiki/Onepush-configuration-%5BCN%5D" + } + }, + "Optimization": { + "_info": { + "name": "优化设置", + "help": "" + }, + "ScreenshotInterval": { + "name": "放慢截图速度至 X 秒一张", + "help": "执行两次截图之间的最小间隔,限制在 0.1 ~ 0.3,对于高配置电脑能降低 CPU 占用" + }, + "CombatScreenshotInterval": { + "name": "战斗中放慢截图速度至 X 秒一张", + "help": "执行两次截图之间的最小间隔,限制在 0.1 ~ 1.0,能降低战斗时的 CPU 占用" + }, + "WhenTaskQueueEmpty": { + "name": "当任务队列清空后", + "help": "无任务时关闭游戏,能在收菜期间降低 CPU 占用", + "stay_there": "停在原处", + "goto_main": "前往主界面", + "close_game": "关闭游戏" + } + }, + "Cafe": { + "_info": { + "name": "咖啡厅设置", + "help": "" + }, + "Reward": { + "name": "领取体力", + "help": "" + }, + "Touch": { + "name": "学生互动点击", + "help": "自动检测可互动的学生并点击" + }, + "AutoAdjust": { + "name": "自动调整界面", + "help": "在进行学生互动点击前对咖啡馆界面进行缩放和位置调整,以增加互动成功率" + } + }, + "TacticalChallenge": { + "_info": { + "name": "战术对抗赛设置", + "help": "" + }, + "PlayerSelect": { + "name": "挑战对手选取", + "help": "", + "0": "随机", + "1": "第一位", + "2": "第二位", + "3": "第三位" + } + }, + "ItemStorage": { + "_info": { + "name": "ItemStorage._info.name", + "help": "ItemStorage._info.help" + }, + "AP": { + "name": "体力", + "help": "" + }, + "Credit": { + "name": "信用点", + "help": "" + }, + "Pyroxene": { + "name": "青辉石", + "help": "" + } + }, + "Gui": { + "Aside": { + "Install": "安装", + "Home": "主页", + "Develop": "开发", + "Performance": "性能", + "Setting": "设置", + "AddAlas": "新增" + }, + "Button": { + "Start": "启动", + "Stop": "停止", + "ScrollON": "自动滚动 开", + "ScrollOFF": "自动滚动 关", + "ClearLog": "清空日志", + "Setting": "设置", + "CheckUpdate": "检查更新", + "ClickToUpdate": "进行更新", + "RetryUpdate": "重试更新", + "CancelUpdate": "取消更新" + }, + "Toast": { + "DisableTranslateMode": "点击这里关闭翻译模式", + "ConfigSaved": "设置已保存", + "AlasIsRunning": "调度器已在运行中", + "ClickToUpdate": "有更新可用,点击这里进行更新" + }, + "Status": { + "Running": "运行中", + "Inactive": "闲置", + "Warning": "发生错误", + "Updating": "等待更新" + }, + "MenuAlas": { + "Overview": "总览", + "Log": "运行日志" + }, + "MenuDevelop": { + "HomePage": "主页", + "Translate": "翻译", + "Update": "更新器", + "Remote": "远程控制", + "Utils": "工具" + }, + "Overview": { + "Scheduler": "调度器", + "Log": "日志", + "Running": "运行中", + "Pending": "队列中", + "Waiting": "等待中", + "NoTask": "无任务" + }, + "Dashboard": { + "NoData": "无数据", + "TimeError": "时间错误", + "JustNow": "刚刚", + "MinutesAgo": "{time}分钟前", + "HoursAgo": "{time}小时前", + "DaysAgo": "{time}天前", + "LongTimeAgo": "很久以前" + }, + "AddAlas": { + "PopupTitle": "添加新配置", + "NewName": "新的配置文件名", + "CopyFrom": "从现有的配置中复制", + "Confirm": "添加", + "FileExist": "存在同名的配置文件,请重新输入一个", + "InvalidChar": "配置文件名不能包含下列任何字符:.\\/:*?\"<>|", + "InvalidPrefixTemplate": "配置文件名不能以 template 开头" + }, + "Update": { + "UpToDate": "已是最新版本", + "HaveUpdate": "有新版本可用", + "UpdateStart": "开始更新", + "UpdateWait": "等待所有 Alas 完成当前任务", + "UpdateRun": "更新中", + "UpdateSuccess": "更新成功,正在重启", + "UpdateFailed": "更新失败,可在./log/*_gui.txt中找到错误日志", + "UpdateChecking": "检查更新中", + "UpdateCancel": "取消更新,重启 Alas 中", + "UpdateFinish": "更新成功,请手动重启", + "Local": "本地", + "Upstream": "上游仓库", + "Author": "作者", + "Time": "提交时间", + "Message": "提交信息", + "DisabledWarn": "更新模块未启用,你需要手动重启 Alas 进行更新", + "DetailedHistory": "详细提交历史" + }, + "Remote": { + "Running": "运行中", + "NotRunning": "未运行,与服务器的连接断开或服务器离线", + "NotEnable": "未启用,在 deploy.yaml 中设置 webui 密码并启用远程控制", + "EntryPoint": "远程访问 url 地址:", + "ConfigureHint": "配置教程:", + "SSHNotInstall": "系统中没有 ssh 工具,请参考教程下载或安装 ssh" + }, + "Text": { + "InvalidFeedBack": "格式错误。 示例:{0}", + "Clear": "清除" + } + } +} \ No newline at end of file diff --git a/module/config/server.py b/module/config/server.py new file mode 100644 index 0000000..6e789d7 --- /dev/null +++ b/module/config/server.py @@ -0,0 +1,59 @@ +""" +This file stores server, such as 'cn', 'en'. +Use 'import module.config.server as server' to import, don't use 'from xxx import xxx'. +""" +lang = 'jp' # Setting default to cn, will avoid errors when using dev_tools +server = 'JP-Official' + +VALID_LANG = ['jp'] +VALID_SERVER = { + 'JP-Official': 'com.YostarJP.BlueArchive' +} +VALID_PACKAGE = set(list(VALID_SERVER.values())) + + +def set_lang(lang_: str): + """ + Change language and this will affect globally, + including assets and language specific methods. + + Args: + lang_: package name or server. + """ + global lang + lang = lang_ + + from module.base.resource import release_resources + release_resources() + + +def to_server(package_or_server: str) -> str: + """ + Convert package/server to server. + To unknown packages, consider they are a CN channel servers. + """ + # Can't distinguish different regions of oversea servers, + # assume it's 'OVERSEA-Asia' + # if package_or_server == 'com.HoYoverse.hkrpgoversea': + # return 'OVERSEA-Asia' + + for key, value in VALID_SERVER.items(): + if value == package_or_server: + return key + if key == package_or_server: + return key + + raise ValueError(f'Package invalid: {package_or_server}') + + +def to_package(package_or_server: str) -> str: + """ + Convert package/server to package. + """ + for key, value in VALID_SERVER.items(): + if value == package_or_server: + return value + if key == package_or_server: + return value + + raise ValueError(f'Server invalid: {package_or_server}') diff --git a/module/config/stored/classes.py b/module/config/stored/classes.py new file mode 100644 index 0000000..ef515b2 --- /dev/null +++ b/module/config/stored/classes.py @@ -0,0 +1,318 @@ +from datetime import datetime +from functools import cached_property as functools_cached_property + +from module.base.decorator import cached_property +from module.config.utils import DEFAULT_TIME, deep_get, get_server_last_monday_update, get_server_last_update +# from module.exception import ScriptError + + +def now(): + return datetime.now().replace(microsecond=0) + + +def iter_attribute(cls): + """ + Args: + cls: Class or object + + Yields: + str, obj: Attribute name, attribute value + """ + for attr in dir(cls): + if attr.startswith('_'): + continue + value = getattr(cls, attr) + if type(value).__name__ in ['function', 'property']: + continue + yield attr, value + + +class StoredBase: + time = DEFAULT_TIME + + def __init__(self, key): + self._key = key + self._config = None + + @cached_property + def _name(self): + return self._key.split('.')[-1] + + def _bind(self, config): + """ + Args: + config (AzurLaneConfig): + """ + self._config = config + + @functools_cached_property + def _stored(self): + assert self._config is not None, 'StoredBase._bind() must be called before getting stored data' + from module.logger import logger + + out = {} + stored = deep_get(self._config.data, keys=self._key, default={}) + for attr, default in self._attrs.items(): + value = stored.get(attr, default) + if attr == 'time': + if not isinstance(value, datetime): + try: + value = datetime.fromisoformat(value) + except ValueError: + logger.warning(f'{self._name} has invalid attr: {attr}={value}, use default={default}') + value = default + else: + if not isinstance(value, type(default)): + logger.warning(f'{self._name} has invalid attr: {attr}={value}, use default={default}') + value = default + + out[attr] = value + return out + + @cached_property + def _attrs(self) -> dict: + """ + All attributes defined + """ + attrs = { + # time is the first one + 'time': DEFAULT_TIME + } + for attr, value in iter_attribute(self.__class__): + if attr.islower(): + attrs[attr] = value + return attrs + + def __setattr__(self, key, value): + if key in self._attrs: + stored = self._stored + stored['time'] = now() + stored[key] = value + self._config.modified[self._key] = stored + if self._config.auto_update: + self._config.update() + else: + super().__setattr__(key, value) + + def __getattribute__(self, item): + if not item.startswith('_') and item in self._attrs: + return self._stored[item] + else: + return super().__getattribute__(item) + + def is_expired(self) -> bool: + return False + + def show(self): + """ + Log self + """ + from module.logger import logger + logger.attr(self._name, self._stored) + + +class StoredExpiredAt0400(StoredBase): + def is_expired(self): + from module.logger import logger + self.show() + expired = self.time < get_server_last_update('04:00') + logger.attr(f'{self._name} expired', expired) + return expired + + +class StoredExpiredAtMonday0400(StoredBase): + def is_expired(self): + from module.logger import logger + self.show() + expired = self.time < get_server_last_monday_update('04:00') + logger.attr(f'{self._name} expired', expired) + return expired + + +class StoredInt(StoredBase): + value = 0 + + +class StoredCounter(StoredBase): + value = 0 + total = 0 + + FIXED_TOTAL = 0 + + def set(self, value, total=0): + if self.FIXED_TOTAL: + total = self.FIXED_TOTAL + with self._config.multi_set(): + self.value = value + self.total = total + + def to_counter(self) -> str: + return f'{self.value}/{self.total}' + + def is_full(self) -> bool: + return self.value >= self.total + + def get_remain(self) -> int: + return self.total - self.value + + @cached_property + def _attrs(self) -> dict: + attrs = super()._attrs + if self.FIXED_TOTAL: + attrs['total'] = self.FIXED_TOTAL + return attrs + + @functools_cached_property + def _stored(self): + stored = super()._stored + if self.FIXED_TOTAL: + stored['total'] = self.FIXED_TOTAL + return stored + + +class StoredAP(StoredCounter): + pass + +# class StoredDailyActivity(StoredCounter, StoredExpiredAt0400): +# FIXED_TOTAL = 500 +# +# +# class StoredTrailblazePower(StoredCounter): +# FIXED_TOTAL = 240 +# +# +# class StoredSimulatedUniverse(StoredCounter, StoredExpiredAt0400): +# pass +# +# +# class StoredAssignment(StoredCounter): +# pass +# +# +# class StoredDaily(StoredCounter, StoredExpiredAt0400): +# quest1 = '' +# quest2 = '' +# quest3 = '' +# quest4 = '' +# quest5 = '' +# quest6 = '' +# +# FIXED_TOTAL = 6 +# +# def load_quests(self): +# """ +# Returns: +# list[DailyQuest]: Note that must check if quests are expired +# """ +# # DailyQuest should be lazy loaded +# from tasks.daily.keywords import DailyQuest +# quests = [] +# for name in [self.quest1, self.quest2, self.quest3, self.quest4, self.quest5, self.quest6]: +# if not name: +# continue +# try: +# quest = DailyQuest.find(name) +# quests.append(quest) +# except ScriptError: +# pass +# return quests +# +# def write_quests(self, quests): +# """ +# Args: +# quests (list[DailyQuest, str]): +# """ +# from tasks.daily.keywords import DailyQuest +# quests = [q.name if isinstance(q, DailyQuest) else q for q in quests] +# with self._config.multi_set(): +# self.set(value=max(self.FIXED_TOTAL - len(quests), 0)) +# try: +# self.quest1 = quests[0] +# except IndexError: +# self.quest1 = '' +# try: +# self.quest2 = quests[1] +# except IndexError: +# self.quest2 = '' +# try: +# self.quest3 = quests[2] +# except IndexError: +# self.quest3 = '' +# try: +# self.quest4 = quests[3] +# except IndexError: +# self.quest4 = '' +# try: +# self.quest5 = quests[4] +# except IndexError: +# self.quest5 = '' +# try: +# self.quest6 = quests[5] +# except IndexError: +# self.quest6 = '' +# +# +# class StoredDungeonDouble(StoredExpiredAt0400): +# calyx = 0 +# relic = 0 +# +# +# class StoredEchoOfWar(StoredCounter, StoredExpiredAtMonday0400): +# FIXED_TOTAL = 3 +# +# +# class StoredBattlePassLevel(StoredCounter): +# FIXED_TOTAL = 50 +# +# +# class StoredBattlePassTodayQuest(StoredCounter, StoredExpiredAt0400): +# quest1 = '' +# quest2 = '' +# quest3 = '' +# quest4 = '' +# +# FIXED_TOTAL = 4 +# +# def load_quests(self): +# """ +# Returns: +# list[DailyQuest]: Note that must check if quests are expired +# """ +# # BattlePassQuest should be lazy loaded +# from tasks.battle_pass.keywords import BattlePassQuest +# quests = [] +# for name in [self.quest1, self.quest2, self.quest3, self.quest4]: +# if not name: +# continue +# try: +# quest = BattlePassQuest.find(name) +# quests.append(quest) +# except ScriptError: +# pass +# return quests +# +# def write_quests(self, quests): +# """ +# Args: +# quests (list[DailyQuest, str]): +# """ +# from tasks.battle_pass.keywords import BattlePassQuest +# quests = [q.name if isinstance(q, BattlePassQuest) else q for q in quests] +# with self._config.multi_set(): +# self.set(value=max(self.FIXED_TOTAL - len(quests), 0)) +# try: +# self.quest1 = quests[0] +# except IndexError: +# self.quest1 = '' +# try: +# self.quest2 = quests[1] +# except IndexError: +# self.quest2 = '' +# try: +# self.quest3 = quests[2] +# except IndexError: +# self.quest3 = '' +# try: +# self.quest4 = quests[3] +# except IndexError: +# self.quest4 = '' diff --git a/module/config/stored/stored_generated.py b/module/config/stored/stored_generated.py new file mode 100644 index 0000000..6ff610f --- /dev/null +++ b/module/config/stored/stored_generated.py @@ -0,0 +1,17 @@ +from module.config.stored.classes import ( + StoredAP, + StoredBase, + StoredCounter, + StoredExpiredAt0400, + StoredExpiredAtMonday0400, + StoredInt, +) + + +# This file was auto-generated, do not modify it manually. To generate: +# ``` python -m module/config/config_updater.py ``` + +class StoredGenerated: + AP = StoredAP("DataUpdate.ItemStorage.AP") + Credit = StoredInt("DataUpdate.ItemStorage.Credit") + Pyroxene = StoredInt("DataUpdate.ItemStorage.Pyroxene") diff --git a/module/config/utils.py b/module/config/utils.py new file mode 100644 index 0000000..84cecd4 --- /dev/null +++ b/module/config/utils.py @@ -0,0 +1,664 @@ +import json +import os +import random +import string +from datetime import datetime, timedelta, timezone + +import yaml +from filelock import FileLock + +import module.config.server as server_ +from module.config.atomicwrites import atomic_write + +LANGUAGES = ['zh-CN', 'en-US'] +SERVER_TO_TIMEZONE = { + 'JP-Official': timedelta(hours=9), +} +DEFAULT_TIME = datetime(2020, 1, 1, 0, 0) + + +# https://stackoverflow.com/questions/8640959/how-can-i-control-what-scalar-form-pyyaml-uses-for-my-data/15423007 +def str_presenter(dumper, data): + if len(data.splitlines()) > 1: # check for multiline string + return dumper.represent_scalar('tag:yaml.org,2002:str', data, style='|') + return dumper.represent_scalar('tag:yaml.org,2002:str', data) + + +yaml.add_representer(str, str_presenter) +yaml.representer.SafeRepresenter.add_representer(str, str_presenter) + + +def filepath_args(filename='args', mod_name='alas'): + return f'./module/config/argument/{filename}.json' + + +def filepath_argument(filename): + return f'./module/config/argument/{filename}.yaml' + + +def filepath_i18n(lang, mod_name='alas'): + return os.path.join('./module/config/i18n', f'{lang}.json') + + +def filepath_config(filename, mod_name='alas'): + if mod_name == 'alas': + return os.path.join('./config', f'{filename}.json') + else: + return os.path.join('./config', f'{filename}.{mod_name}.json') + + +def filepath_code(): + return './module/config/config_generated.py' + + +def read_file(file): + """ + Read a file, support both .yaml and .json format. + Return empty dict if file not exists. + + Args: + file (str): + + Returns: + dict, list: + """ + folder = os.path.dirname(file) + if not os.path.exists(folder): + os.mkdir(folder) + + if not os.path.exists(file): + return {} + + _, ext = os.path.splitext(file) + lock = FileLock(f"{file}.lock") + with lock: + print(f'read: {file}') + if ext == '.yaml': + with open(file, mode='r', encoding='utf-8') as f: + s = f.read() + data = list(yaml.safe_load_all(s)) + if len(data) == 1: + data = data[0] + if not data: + data = {} + return data + elif ext == '.json': + with open(file, mode='r', encoding='utf-8') as f: + s = f.read() + return json.loads(s) + else: + print(f'Unsupported config file extension: {ext}') + return {} + + +def write_file(file, data): + """ + Write data into a file, supports both .yaml and .json format. + + Args: + file (str): + data (dict, list): + """ + folder = os.path.dirname(file) + if not os.path.exists(folder): + os.mkdir(folder) + + _, ext = os.path.splitext(file) + lock = FileLock(f"{file}.lock") + with lock: + print(f'write: {file}') + if ext == '.yaml': + with atomic_write(file, overwrite=True, encoding='utf-8', newline='') as f: + if isinstance(data, list): + yaml.safe_dump_all(data, f, default_flow_style=False, encoding='utf-8', allow_unicode=True, + sort_keys=False) + else: + yaml.safe_dump(data, f, default_flow_style=False, encoding='utf-8', allow_unicode=True, + sort_keys=False) + elif ext == '.json': + with atomic_write(file, overwrite=True, encoding='utf-8', newline='') as f: + s = json.dumps(data, indent=2, ensure_ascii=False, sort_keys=False, default=str) + f.write(s) + else: + print(f'Unsupported config file extension: {ext}') + + +def iter_folder(folder, is_dir=False, ext=None): + """ + Args: + folder (str): + is_dir (bool): True to iter directories only + ext (str): File extension, such as `.yaml` + + Yields: + str: Absolute path of files + """ + for file in os.listdir(folder): + sub = os.path.join(folder, file) + if is_dir: + if os.path.isdir(sub): + yield sub.replace('\\\\', '/').replace('\\', '/') + elif ext is not None: + if not os.path.isdir(sub): + _, extension = os.path.splitext(file) + if extension == ext: + yield os.path.join(folder, file).replace('\\\\', '/').replace('\\', '/') + else: + yield os.path.join(folder, file).replace('\\\\', '/').replace('\\', '/') + + +def alas_template(): + """ + Returns: + list[str]: Name of all Alas instances, except `template`. + """ + out = [] + for file in os.listdir('./config'): + name, extension = os.path.splitext(file) + if name == 'template' and extension == '.json': + out.append(f'{name}-aas') + + # out.extend(mod_template()) + + return out + + +def alas_instance(): + """ + Returns: + list[str]: Name of all Alas instances, except `template`. + """ + out = [] + for file in os.listdir('./config'): + name, extension = os.path.splitext(file) + config_name, mod_name = os.path.splitext(name) + mod_name = mod_name[1:] + if name != 'template' and extension == '.json' and mod_name == '': + out.append(name) + + # out.extend(mod_instance()) + + if not len(out): + out = ['aas'] + + return out + + +def deep_get(d, keys, default=None): + """ + Get values in dictionary safely. + https://stackoverflow.com/questions/25833613/safe-method-to-get-value-of-nested-dictionary + + Args: + d (dict): + keys (str, list): Such as `Scheduler.NextRun.value` + default: Default return if key not found. + + Returns: + + """ + if isinstance(keys, str): + keys = keys.split('.') + assert type(keys) is list + if d is None: + return default + if not keys: + return d + return deep_get(d.get(keys[0]), keys[1:], default) + + +def deep_set(d, keys, value): + """ + Set value into dictionary safely, imitating deep_get(). + """ + if isinstance(keys, str): + keys = keys.split('.') + assert type(keys) is list + if not keys: + return value + if not isinstance(d, dict): + d = {} + d[keys[0]] = deep_set(d.get(keys[0], {}), keys[1:], value) + return d + + +def deep_pop(d, keys, default=None): + """ + Pop value from dictionary safely, imitating deep_get(). + """ + if isinstance(keys, str): + keys = keys.split('.') + assert type(keys) is list + if not isinstance(d, dict): + return default + if not keys: + return default + elif len(keys) == 1: + return d.pop(keys[0], default) + return deep_pop(d.get(keys[0]), keys[1:], default) + + +def deep_default(d, keys, value): + """ + Set default value into dictionary safely, imitating deep_get(). + Value is set only when the dict doesn't contain such keys. + """ + if isinstance(keys, str): + keys = keys.split('.') + assert type(keys) is list + if not keys: + if d: + return d + else: + return value + if not isinstance(d, dict): + d = {} + d[keys[0]] = deep_default(d.get(keys[0], {}), keys[1:], value) + return d + + +def deep_iter(data, depth=0, current_depth=1): + """ + Iter a dictionary safely. + + Args: + data (dict): + depth (int): Maximum depth to iter + current_depth (int): + + Returns: + list: Key path + Any: + """ + if isinstance(data, dict) \ + and (depth and current_depth <= depth): + for key, value in data.items(): + for child_path, child_value in deep_iter(value, depth=depth, current_depth=current_depth + 1): + yield [key] + child_path, child_value + else: + yield [], data + + +def parse_value(value, data): + """ + Convert a string to float, int, datetime, if possible. + + Args: + value (str): + data (dict): + + Returns: + + """ + if 'option' in data: + if value not in data['option']: + return data['value'] + if isinstance(value, str): + if value == '': + return None + if value == 'true' or value == 'True': + return True + if value == 'false' or value == 'False': + return False + if '.' in value: + try: + return float(value) + except ValueError: + pass + else: + try: + return int(value) + except ValueError: + pass + try: + return datetime.fromisoformat(value) + except ValueError: + pass + + return value + + +def data_to_type(data, **kwargs): + """ + | Condition | Type | + | ------------------------------------ | -------- | + | `value` is bool | checkbox | + | Arg has `options` | select | + | Arg has `stored` | select | + | `Filter` is in name (in data['arg']) | textarea | + | Rest of the args | input | + + Args: + data (dict): + kwargs: Any additional properties + + Returns: + str: + """ + kwargs.update(data) + if isinstance(kwargs.get('value'), bool): + return 'checkbox' + elif 'option' in kwargs and kwargs['option']: + return 'select' + elif 'stored' in kwargs and kwargs['stored']: + return 'stored' + elif 'Filter' in kwargs['arg']: + return 'textarea' + else: + return 'input' + + +def data_to_path(data): + """ + Args: + data (dict): + + Returns: + str: .. + """ + return '.'.join([data.get(attr, '') for attr in ['func', 'group', 'arg']]) + + +def path_to_arg(path): + """ + Convert dictionary keys in .yaml files to argument names in config. + + Args: + path (str): Such as `Scheduler.ServerUpdate` + + Returns: + str: Such as `Scheduler_ServerUpdate` + """ + return path.replace('.', '_') + + +def dict_to_kv(dictionary, allow_none=True): + """ + Args: + dictionary: Such as `{'path': 'Scheduler.ServerUpdate', 'value': True}` + allow_none (bool): + + Returns: + str: Such as `path='Scheduler.ServerUpdate', value=True` + """ + return ', '.join([f'{k}={repr(v)}' for k, v in dictionary.items() if allow_none or v is not None]) + + +def server_timezone() -> timedelta: + return SERVER_TO_TIMEZONE.get(server_.server, SERVER_TO_TIMEZONE['JP-Official']) + + +def server_time_offset() -> timedelta: + """ + To convert local time to server time: + server_time = local_time + server_time_offset() + To convert server time to local time: + local_time = server_time - server_time_offset() + """ + return datetime.now(timezone.utc).astimezone().utcoffset() - server_timezone() + + +def random_normal_distribution_int(a, b, n=3): + """ + A non-numpy implementation of the `random_normal_distribution_int` in module.base.utils + + + 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. + b (int): The maximum of the interval. + n (int): The amount of numbers in simulation. Default to 3. + + Returns: + int + """ + if a < b: + output = sum([random.randint(a, b) for _ in range(n)]) / n + return int(round(output)) + else: + return b + + +def ensure_time(second, n=3, precision=3): + """Ensure to be time. + + Args: + second (int, float, tuple): time, such as 10, (10, 30), '10, 30' + n (int): The amount of numbers in simulation. Default to 5. + precision (int): Decimals. + + Returns: + float: + """ + if isinstance(second, tuple): + multiply = 10 ** precision + return random_normal_distribution_int(second[0] * multiply, second[1] * multiply, n) / multiply + elif isinstance(second, str): + if ',' in second: + lower, upper = second.replace(' ', '').split(',') + lower, upper = int(lower), int(upper) + return ensure_time((lower, upper), n=n, precision=precision) + if '-' in second: + lower, upper = second.replace(' ', '').split('-') + lower, upper = int(lower), int(upper) + return ensure_time((lower, upper), n=n, precision=precision) + else: + return int(second) + else: + return second + + +def get_os_next_reset(): + """ + Get the first day of next month. + + Returns: + datetime.datetime + """ + diff = server_time_offset() + server_now = datetime.now() - diff + server_reset = (server_now.replace(day=1) + timedelta(days=32)) \ + .replace(day=1, hour=0, minute=0, second=0, microsecond=0) + local_reset = server_reset + diff + return local_reset + + +def get_os_reset_remain(): + """ + Returns: + int: number of days before next opsi reset + """ + from module.logger import logger + + next_reset = get_os_next_reset() + now = datetime.now() + logger.attr('OpsiNextReset', next_reset) + + remain = int((next_reset - now).total_seconds() // 86400) + logger.attr('ResetRemain', remain) + return remain + + +def get_server_next_update(daily_trigger): + """ + Args: + daily_trigger (list[str], str): [ "00:00", "12:00", "18:00",] + + Returns: + datetime.datetime + """ + if isinstance(daily_trigger, str): + daily_trigger = daily_trigger.replace(' ', '').split(',') + + diff = server_time_offset() + local_now = datetime.now() + trigger = [] + for t in daily_trigger: + h, m = [int(x) for x in t.split(':')] + future = local_now.replace(hour=h, minute=m, second=0, microsecond=0) + diff + s = (future - local_now).total_seconds() % 86400 + future = local_now + timedelta(seconds=s) + trigger.append(future) + update = sorted(trigger)[0] + return update + + +def get_server_last_update(daily_trigger): + """ + Args: + daily_trigger (list[str], str): [ "00:00", "12:00", "18:00",] + + Returns: + datetime.datetime + """ + if isinstance(daily_trigger, str): + daily_trigger = daily_trigger.replace(' ', '').split(',') + + diff = server_time_offset() + local_now = datetime.now() + trigger = [] + for t in daily_trigger: + h, m = [int(x) for x in t.split(':')] + future = local_now.replace(hour=h, minute=m, second=0, microsecond=0) + diff + s = (future - local_now).total_seconds() % 86400 - 86400 + future = local_now + timedelta(seconds=s) + trigger.append(future) + update = sorted(trigger)[-1] + return update + + +def get_server_last_monday_update(daily_trigger): + """ + Args: + daily_trigger (list[str], str): [ "00:00", "12:00", "18:00",] + + Returns: + datetime.datetime + """ + update = get_server_next_update(daily_trigger) + diff = update.weekday() + update = update - timedelta(days=diff) + return update + + +def get_server_next_monday_update(daily_trigger): + """ + Args: + daily_trigger (list[str], str): [ "00:00", "12:00", "18:00",] + + Returns: + datetime.datetime + """ + update = get_server_next_update(daily_trigger) + diff = (7 - update.weekday()) % 7 + update = update + timedelta(days=diff) + return update + + +def nearest_future(future, interval=120): + """ + Get the neatest future time. + Return the last one if two things will finish within `interval`. + + Args: + future (list[datetime.datetime]): + interval (int): Seconds + + Returns: + datetime.datetime: + """ + future = [datetime.fromisoformat(f) if isinstance(f, str) else f for f in future] + future = sorted(future) + next_run = future[0] + for finish in future: + if finish - next_run < timedelta(seconds=interval): + next_run = finish + + return next_run + + +def get_nearest_weekday_date(target): + """ + Get nearest weekday date starting + from current date + + Args: + target (int): target weekday to + calculate + + Returns: + datetime.datetime + """ + diff = server_time_offset() + server_now = datetime.now() - diff + + days_ahead = target - server_now.weekday() + if days_ahead <= 0: + # Target day has already happened + days_ahead += 7 + server_reset = (server_now + timedelta(days=days_ahead)) \ + .replace(hour=0, minute=0, second=0, microsecond=0) + + local_reset = server_reset + diff + return local_reset + + +def get_server_weekday(): + """ + Returns: + int: The server's current day of the week + """ + diff = server_time_offset() + server_now = datetime.now() - diff + result = server_now.weekday() + return result + + +def random_id(length=32): + """ + Args: + length (int): + + Returns: + str: Random azurstat id. + """ + return ''.join(random.sample(string.ascii_lowercase + string.digits, length)) + + +def to_list(text, length=1): + """ + Args: + text (str): Such as `1, 2, 3` + length (int): If there's only one digit, return a list expanded to given length, + i.e. text='3', length=5, returns `[3, 3, 3, 3, 3]` + + Returns: + list[int]: + """ + if text.isdigit(): + return [int(text)] * length + out = [int(letter.strip()) for letter in text.split(',')] + return out + + +def type_to_str(typ): + """ + Convert any types or any objects to a string。 + Remove <> to prevent them from being parsed as HTML tags. + + Args: + typ: + + Returns: + str: Such as `int`, 'datetime.datetime'. + """ + if not isinstance(typ, type): + typ = type(typ).__name__ + return str(typ) + + +if __name__ == '__main__': + get_os_reset_remain() diff --git a/module/config/watcher.py b/module/config/watcher.py new file mode 100644 index 0000000..0dc38f7 --- /dev/null +++ b/module/config/watcher.py @@ -0,0 +1,33 @@ +import os +from datetime import datetime + +from module.config.utils import filepath_config, DEFAULT_TIME +from module.logger import logger + + +class ConfigWatcher: + config_name = 'alas' + start_mtime = DEFAULT_TIME + + def start_watching(self) -> None: + self.start_mtime = self.get_mtime() + + def get_mtime(self) -> datetime: + """ + Last modify time of the file + """ + timestamp = os.stat(filepath_config(self.config_name)).st_mtime + mtime = datetime.fromtimestamp(timestamp).replace(microsecond=0) + return mtime + + def should_reload(self) -> bool: + """ + Returns: + bool: Whether the file has been modified and configs should reload + """ + mtime = self.get_mtime() + if mtime > self.start_mtime: + logger.info(f'Config "{self.config_name}" changed at {mtime}') + return True + else: + return False diff --git a/module/daemon/benchmark.py b/module/daemon/benchmark.py new file mode 100644 index 0000000..68476b4 --- /dev/null +++ b/module/daemon/benchmark.py @@ -0,0 +1,238 @@ +import time +import typing as t + +import numpy as np +from rich.table import Table +from rich.text import Text + +from module.base.utils import float2str as float2str_ +from module.base.utils import random_rectangle_point +from module.daemon.daemon_base import DaemonBase +from module.exception import RequestHumanTakeover +from module.logger import logger + + +def float2str(n, decimal=3): + if not isinstance(n, (float, int)): + return str(n) + else: + return float2str_(n, decimal=decimal) + 's' + + +class Benchmark(DaemonBase): + TEST_TOTAL = 15 + TEST_BEST = int(TEST_TOTAL * 0.8) + + def benchmark_test(self, func, *args, **kwargs): + """ + Args: + func: Function to test. + *args: Passes to func. + **kwargs: Passes to func. + + Returns: + float: Time cost on average. + """ + logger.hr(f'Benchmark test', level=2) + logger.info(f'Testing function: {func.__name__}') + record = [] + + for n in range(1, self.TEST_TOTAL + 1): + start = time.time() + + try: + func(*args, **kwargs) + except RequestHumanTakeover: + logger.critical('RequestHumanTakeover') + logger.warning(f'Benchmark tests failed on func: {func.__name__}') + return 'Failed' + except Exception as e: + logger.exception(e) + logger.warning(f'Benchmark tests failed on func: {func.__name__}') + return 'Failed' + + cost = time.time() - start + logger.attr( + f'{str(n).rjust(2, "0")}/{self.TEST_TOTAL}', + f'{float2str(cost)}' + ) + record.append(cost) + + logger.info('Benchmark tests done') + average = float(np.mean(np.sort(record)[:self.TEST_BEST])) + logger.info(f'Time cost {float2str(average)} ({self.TEST_BEST} best results out of {self.TEST_TOTAL} tests)') + return average + + @staticmethod + def evaluate_screenshot(cost): + if not isinstance(cost, (float, int)): + return Text(cost, style="bold bright_red") + + if cost < 0.10: + return Text('Ultra Fast', style="bold bright_green") + if cost < 0.20: + return Text('Very Fast', style="bright_green") + if cost < 0.30: + return Text('Fast', style="green") + if cost < 0.50: + return Text('Medium', style="yellow") + if cost < 0.75: + return Text('Slow', style="red") + if cost < 1.00: + return Text('Very Slow', style="bright_red") + return Text('Ultra Slow', style="bold bright_red") + + @staticmethod + def evaluate_click(cost): + if not isinstance(cost, (float, int)): + return Text(cost, style="bold bright_red") + + if cost < 0.1: + return Text('Fast', style="bright_green") + if cost < 0.2: + return Text('Medium', style="yellow") + if cost < 0.4: + return Text('Slow', style="red") + return Text('Very Slow', style="bright_red") + + @staticmethod + def show(test, data, evaluate_func): + """ + +--------------+--------+--------+ + | Screenshot | time | Speed | + +--------------+--------+--------+ + | ADB | 0.319s | Fast | + | uiautomator2 | 0.476s | Medium | + | aScreenCap | Failed | Failed | + +--------------+--------+--------+ + """ + # table = PrettyTable() + # table.field_names = [test, 'Time', 'Speed'] + # for row in data: + # table.add_row([row[0], f'{float2str(row[1])}', evaluate_func(row[1])]) + + # for row in table.get_string().split('\n'): + # logger.info(row) + table = Table(show_lines=True) + table.add_column( + test, header_style="bright_cyan", style="cyan", no_wrap=True + ) + table.add_column("Time", style="magenta") + table.add_column("Speed", style="green") + for row in data: + table.add_row( + row[0], + float2str(row[1]), + evaluate_func(row[1]), + ) + logger.print(table, justify='center') + + def benchmark(self, screenshot: t.Tuple[str] = (), click: t.Tuple[str] = ()): + logger.hr('Benchmark', level=1) + logger.info(f'Testing screenshot methods: {screenshot}') + logger.info(f'Testing click methods: {click}') + + screenshot_result = [] + for method in screenshot: + result = self.benchmark_test(self.device.screenshot_methods[method]) + screenshot_result.append([method, result]) + + area = (124, 4, 649, 106) # Somewhere safe to click. + click_result = [] + for method in click: + x, y = random_rectangle_point(area) + result = self.benchmark_test(self.device.click_methods[method], x, y) + click_result.append([method, result]) + + def compare(res): + res = res[1] + if not isinstance(res, (int, float)): + return 100 + else: + return res + + logger.hr('Benchmark Results', level=1) + fastest_screenshot = 'ADB_nc' + fastest_click = 'minitouch' + if screenshot_result: + self.show(test='Screenshot', data=screenshot_result, evaluate_func=self.evaluate_screenshot) + fastest = sorted(screenshot_result, key=lambda item: compare(item))[0] + logger.info(f'Recommend screenshot method: {fastest[0]} ({float2str(fastest[1])})') + fastest_screenshot = fastest[0] + if click_result: + self.show(test='Control', data=click_result, evaluate_func=self.evaluate_click) + fastest = sorted(click_result, key=lambda item: compare(item))[0] + logger.info(f'Recommend control method: {fastest[0]} ({float2str(fastest[1])})') + fastest_click = fastest[0] + + return fastest_screenshot, fastest_click + + def get_test_methods(self) -> t.Tuple[t.Tuple[str], t.Tuple[str]]: + device = self.config.Benchmark_DeviceType + # device == 'emulator' + screenshot = ['ADB', 'ADB_nc', 'uiautomator2', 'aScreenCap', 'aScreenCap_nc', 'DroidCast', 'DroidCast_raw'] + click = ['ADB', 'uiautomator2', 'minitouch'] + + def remove(*args): + return [l for l in screenshot if l not in args] + + # No ascreencap on Android > 9 + if device in ['emulator_android_12', 'android_phone_12']: + screenshot = remove('aScreenCap', 'aScreenCap_nc') + # No nc loopback + if device in ['plone_cloud_with_adb']: + screenshot = remove('ADB_nc', 'aScreenCap_nc') + # VMOS + if device == 'android_phone_vmos': + screenshot = ['ADB', 'aScreenCap', 'DroidCast', 'DroidCast_raw'] + click = ['ADB', 'Hermit', 'MaaTouch'] + + scene = self.config.Benchmark_TestScene + if 'screenshot' not in scene: + screenshot = [] + if 'click' not in scene: + click = [] + + return tuple(screenshot), tuple(click) + + def run(self): + try: + self.config.override(Emulator_ScreenshotMethod='ADB') + self.device.uninstall_minicap() + except RequestHumanTakeover: + logger.critical('Request human takeover') + return + + logger.attr('DeviceType', self.config.Benchmark_DeviceType) + logger.attr('TestScene', self.config.Benchmark_TestScene) + screenshot, click = self.get_test_methods() + self.benchmark(screenshot, click) + + def run_simple_screenshot_benchmark(self): + """ + Returns: + str: The fastest screenshot method on current device. + """ + screenshot = ['ADB', 'ADB_nc', 'uiautomator2', 'aScreenCap', 'aScreenCap_nc', 'DroidCast', 'DroidCast_raw'] + + def remove(*args): + return [l for l in screenshot if l not in args] + + sdk = self.device.sdk_ver + logger.info(f'sdk_ver: {sdk}') + if not (21 <= sdk <= 28): + screenshot = remove('aScreenCap', 'aScreenCap_nc') + if self.device.is_chinac_phone_cloud: + screenshot = remove('ADB_nc', 'aScreenCap_nc') + screenshot = tuple(screenshot) + + self.TEST_TOTAL = 3 + self.TEST_BEST = 1 + method, _ = self.benchmark(screenshot, tuple()) + + return method + + +if __name__ == '__main__': + b = Benchmark('alas', task='Benchmark') + b.run() diff --git a/module/daemon/daemon_base.py b/module/daemon/daemon_base.py new file mode 100644 index 0000000..2dca3db --- /dev/null +++ b/module/daemon/daemon_base.py @@ -0,0 +1,7 @@ +from module.base.base import ModuleBase + + +class DaemonBase(ModuleBase): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.device.disable_stuck_detection() diff --git a/module/device/app_control.py b/module/device/app_control.py new file mode 100644 index 0000000..390c505 --- /dev/null +++ b/module/device/app_control.py @@ -0,0 +1,69 @@ +from lxml import etree + +from module.device.method.adb import Adb +from module.device.method.uiautomator_2 import Uiautomator2 +from module.device.method.utils import HierarchyButton +from module.device.method.wsa import WSA +from module.logger import logger + + +class AppControl(Adb, WSA, Uiautomator2): + hierarchy: etree._Element + # Use ADB for all + # See https://github.com/openatx/uiautomator2/issues/565 + _app_u2_family = [] + + def app_is_running(self) -> bool: + method = self.config.Emulator_ControlMethod + if self.is_wsa: + package = self.app_current_wsa() + elif method in AppControl._app_u2_family: + package = self.app_current_uiautomator2() + else: + package = self.app_current_adb() + + package = package.strip(' \t\r\n') + logger.attr('Package_name', package) + return package == self.package + + def app_start(self): + method = self.config.Emulator_ControlMethod + logger.info(f'App start: {self.package}') + if self.config.Emulator_Serial == 'wsa-0': + self.app_start_wsa(display=0) + elif method in AppControl._app_u2_family: + self.app_start_uiautomator2() + else: + self.app_start_adb() + + def app_stop(self): + method = self.config.Emulator_ControlMethod + logger.info(f'App stop: {self.package}') + if method in AppControl._app_u2_family: + self.app_stop_uiautomator2() + else: + self.app_stop_adb() + + def dump_hierarchy(self) -> etree._Element: + """ + Returns: + etree._Element: Select elements with `self.hierarchy.xpath('//*[@text="Hermit"]')` for example. + """ + method = self.config.Emulator_ControlMethod + if method in AppControl._app_u2_family: + self.hierarchy = self.dump_hierarchy_uiautomator2() + else: + self.hierarchy = self.dump_hierarchy_adb() + return self.hierarchy + + def xpath_to_button(self, xpath: str) -> HierarchyButton: + """ + Args: + xpath (str): + + Returns: + HierarchyButton: + An object with methods and properties similar to Button. + If element not found or multiple elements were found, return None. + """ + return HierarchyButton(self.hierarchy, xpath) diff --git a/module/device/connection.py b/module/device/connection.py new file mode 100644 index 0000000..e2e5dfd --- /dev/null +++ b/module/device/connection.py @@ -0,0 +1,882 @@ +import ipaddress +import logging +import platform +import re +import socket +import subprocess +import time +from functools import wraps + +import uiautomator2 as u2 +from adbutils import AdbClient, AdbDevice, AdbTimeout, ForwardItem, ReverseItem +from adbutils.errors import AdbError + +import module.config.server as server_ +from module.base.decorator import Config, cached_property, del_cached_property +from module.base.utils import SelectedGrids, ensure_time +from module.device.connection_attr import ConnectionAttr +from module.device.method.utils import ( + PackageNotInstalled, RETRY_TRIES, get_serial_pair, handle_adb_error, + possible_reasons, random_port, recv_all, remove_shell_warning, retry_sleep) +from module.exception import EmulatorNotRunningError, RequestHumanTakeover +from module.logger import logger + + +def retry(func): + @wraps(func) + def retry_wrapper(self, *args, **kwargs): + """ + Args: + self (Adb): + """ + init = None + for _ in range(RETRY_TRIES): + try: + if callable(init): + retry_sleep(_) + init() + return func(self, *args, **kwargs) + # Can't handle + except RequestHumanTakeover: + break + # When adb server was killed + except ConnectionResetError as e: + logger.error(e) + + def init(): + self.adb_reconnect() + # AdbError + except AdbError as e: + if handle_adb_error(e): + def init(): + self.adb_reconnect() + else: + break + # Package not installed + except PackageNotInstalled as e: + logger.error(e) + + def init(): + self.detect_package() + # Unknown, probably a trucked image + except Exception as e: + logger.exception(e) + + def init(): + pass + + logger.critical(f'Retry {func.__name__}() failed') + raise RequestHumanTakeover + + return retry_wrapper + + +class AdbDeviceWithStatus(AdbDevice): + def __init__(self, client: AdbClient, serial: str, status: str): + self.status = status + super().__init__(client, serial) + + def __str__(self): + return f'AdbDevice({self.serial}, {self.status})' + + __repr__ = __str__ + + def __bool__(self): + return True + + +class Connection(ConnectionAttr): + def __init__(self, config): + """ + Args: + config (AzurLaneConfig, str): Name of the user config under ./config + """ + super().__init__(config) + if not self.is_over_http: + self.detect_device() + + # Connect + self.adb_connect(self.serial) + logger.attr('AdbDevice', self.adb) + + # Package + if self.config.Emulator_PackageName == 'auto': + self.detect_package() + else: + self.package = server_.to_package(self.config.Emulator_PackageName) + # No set_server cause game client and UI language can be different + # else: + # set_server(self.package) + logger.attr('Server', self.config.Emulator_PackageName) + server_.server = self.config.Emulator_PackageName + logger.attr('PackageName', self.package) + server_.lang = self.config.Emulator_GameLanguage + logger.attr('Lang', self.config.LANG) + + self.check_mumu_app_keep_alive() + + @Config.when(DEVICE_OVER_HTTP=False) + def adb_command(self, cmd, timeout=10): + """ + Execute ADB commands in a subprocess, + usually to be used when pulling or pushing large files. + + Args: + cmd (list): + timeout (int): + + Returns: + str: + """ + cmd = list(map(str, cmd)) + cmd = [self.adb_binary, '-s', self.serial] + cmd + logger.info(f'Execute: {cmd}') + + # Use shell=True to disable console window when using GUI. + # Although, there's still a window when you stop running in GUI, which cause by gooey. + # To disable it, edit gooey/gui/util/taskkill.py + + # No gooey anymore, just shell=False + process = subprocess.Popen(cmd, stdout=subprocess.PIPE, shell=False) + try: + stdout, stderr = process.communicate(timeout=timeout) + except subprocess.TimeoutExpired: + process.kill() + stdout, stderr = process.communicate() + logger.warning(f'TimeoutExpired when calling {cmd}, stdout={stdout}, stderr={stderr}') + return stdout + + @Config.when(DEVICE_OVER_HTTP=True) + def adb_command(self, cmd, timeout=10): + logger.warning( + f'adb_command() is not available when connecting over http: {self.serial}, ' + ) + raise RequestHumanTakeover + + @Config.when(DEVICE_OVER_HTTP=False) + def adb_shell(self, cmd, stream=False, recvall=True, timeout=10, rstrip=True): + """ + Equivalent to `adb -s shell <*cmd>` + + Args: + cmd (list, str): + stream (bool): Return stream instead of string output (Default: False) + recvall (bool): Receive all data when stream=True (Default: True) + timeout (int): (Default: 10) + rstrip (bool): Strip the last empty line (Default: True) + + Returns: + str if stream=False + bytes if stream=True and recvall=True + socket if stream=True and recvall=False + """ + if not isinstance(cmd, str): + cmd = list(map(str, cmd)) + + if stream: + result = self.adb.shell(cmd, stream=stream, timeout=timeout, rstrip=rstrip) + if recvall: + # bytes + return recv_all(result) + else: + # socket + return result + else: + result = self.adb.shell(cmd, stream=stream, timeout=timeout, rstrip=rstrip) + result = remove_shell_warning(result) + # str + return result + + @Config.when(DEVICE_OVER_HTTP=True) + def adb_shell(self, cmd, stream=False, recvall=True, timeout=10, rstrip=True): + """ + Equivalent to http://127.0.0.1:7912/shell?command={command} + + Args: + cmd (list, str): + stream (bool): Return stream instead of string output (Default: False) + recvall (bool): Receive all data when stream=True (Default: True) + timeout (int): (Default: 10) + rstrip (bool): Strip the last empty line (Default: True) + + Returns: + str if stream=False + bytes if stream=True + """ + if not isinstance(cmd, str): + cmd = list(map(str, cmd)) + + if stream: + result = self.u2.shell(cmd, stream=stream, timeout=timeout) + # Already received all, so `recvall` is ignored + result = remove_shell_warning(result.content) + # bytes + return result + else: + result = self.u2.shell(cmd, stream=stream, timeout=timeout).output + if rstrip: + result = result.rstrip() + result = remove_shell_warning(result) + # str + return result + + def adb_getprop(self, name): + """ + Get system property in Android, same as `getprop ` + + Args: + name (str): Property name + + Returns: + str: + """ + return self.adb_shell(['getprop', name]).strip() + + @cached_property + def cpu_abi(self) -> str: + """ + Returns: + str: arm64-v8a, armeabi-v7a, x86, x86_64 + """ + abi = self.adb_getprop('ro.product.cpu.abi') + if not len(abi): + logger.error(f'CPU ABI invalid: "{abi}"') + return abi + + @cached_property + def sdk_ver(self) -> int: + """ + Android SDK/API levels, see https://apilevels.com/ + """ + sdk = self.adb_getprop('ro.build.version.sdk') + try: + return int(sdk) + except ValueError: + logger.error(f'SDK version invalid: {sdk}') + + return 0 + + @cached_property + def is_avd(self): + if get_serial_pair(self.serial)[0] is None: + return False + if 'ranchu' in self.adb_getprop('ro.hardware'): + return True + if 'goldfish' in self.adb_getprop('ro.hardware.audio.primary'): + return True + return False + + def check_mumu_app_keep_alive(self): + if not self.is_mumu_family: + return False + + res = self.adb_getprop('nemud.app_keep_alive') + logger.attr('nemud.app_keep_alive', res) + if res == '': + # Empry property, might not be a mumu emulator or might be an old mumu + return True + elif res == 'false': + # Disabled + return True + elif res == 'true': + # https://mumu.163.com/help/20230802/35047_1102450.html + logger.critical('请在MuMu模拟器设置内关闭 "后台挂机时保活运行"') + raise RequestHumanTakeover + else: + logger.warning(f'Invalid nemud.app_keep_alive value: {res}') + return False + + @cached_property + def _nc_server_host_port(self): + """ + Returns: + str, int, str, int: + server_listen_host, server_listen_port, client_connect_host, client_connect_port + """ + # For BlueStacks hyper-v, use ADB reverse + if self.is_bluestacks_hyperv: + host = '127.0.0.1' + logger.info(f'Connecting to BlueStacks hyper-v, using host {host}') + port = self.adb_reverse(f'tcp:{self.config.REVERSE_SERVER_PORT}') + return host, port, host, self.config.REVERSE_SERVER_PORT + # For emulators, listen on current host + if self.is_emulator or self.is_over_http: + try: + host = socket.gethostbyname(socket.gethostname()) + except socket.gaierror as e: + logger.error(e) + logger.error(f'Unknown host name: {socket.gethostname()}') + host = '127.0.0.1' + if platform.system() == 'Linux' and host == '127.0.1.1': + host = '127.0.0.1' + logger.info(f'Connecting to local emulator, using host {host}') + port = random_port(self.config.FORWARD_PORT_RANGE) + + # For AVD instance + if self.is_avd: + return host, port, "10.0.2.2", port + + return host, port, host, port + # For local network devices, listen on the host under the same network as target device + if self.is_network_device: + hosts = socket.gethostbyname_ex(socket.gethostname())[2] + logger.info(f'Current hosts: {hosts}') + ip = ipaddress.ip_address(self.serial.split(':')[0]) + for host in hosts: + if ip in ipaddress.ip_interface(f'{host}/24').network: + logger.info(f'Connecting to local network device, using host {host}') + port = random_port(self.config.FORWARD_PORT_RANGE) + return host, port, host, port + # For other devices, create an ADB reverse and listen on 127.0.0.1 + host = '127.0.0.1' + logger.info(f'Connecting to unknown device, using host {host}') + port = self.adb_reverse(f'tcp:{self.config.REVERSE_SERVER_PORT}') + return host, port, host, self.config.REVERSE_SERVER_PORT + + @cached_property + def reverse_server(self): + """ + Setup a server on Alas, access it from emulator. + This will bypass adb shell and be faster. + """ + del_cached_property(self, '_nc_server_host_port') + host_port = self._nc_server_host_port + logger.info(f'Reverse server listening on {host_port[0]}:{host_port[1]}, ' + f'client can send data to {host_port[2]}:{host_port[3]}') + server = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + server.bind(host_port[:2]) + server.settimeout(5) + server.listen(5) + return server + + @cached_property + def nc_command(self): + """ + Returns: + list[str]: ['nc'] or ['busybox', 'nc'] + """ + sdk = self.sdk_ver + logger.info(f'sdk_ver: {sdk}') + if sdk >= 28: + # Android 9 emulators does not have `nc`, try `busybox nc` + # BlueStacks Pie (Android 9) has `nc` but cannot send data, try `busybox nc` first + trial = [ + ['busybox', 'nc'], + ['nc'], + ] + else: + trial = [ + ['nc'], + ['busybox', 'nc'], + ] + for command in trial: + # About 3ms + result = self.adb_shell(command) + # Result should be command help if success + # `/system/bin/sh: nc: not found` + if 'not found' in result: + continue + # `/system/bin/sh: busybox: inaccessible or not found\n` + if 'inaccessible' in result: + continue + logger.attr('nc command', command) + return command + + logger.error('No `netcat` command available, please use screenshot methods without `_nc` suffix') + raise RequestHumanTakeover + + def adb_shell_nc(self, cmd, timeout=5, chunk_size=262144): + """ + Args: + cmd (list): + timeout (int): + chunk_size (int): Default to 262144 + + Returns: + bytes: + """ + # Server start listening + server = self.reverse_server + server.settimeout(timeout) + # Client send data, waiting for server accept + # | nc 127.0.0.1 {port} + cmd += ["|", *self.nc_command, *self._nc_server_host_port[2:]] + stream = self.adb_shell(cmd, stream=True, recvall=False) + try: + # Server accept connection + conn, conn_port = server.accept() + except socket.timeout: + output = recv_all(stream, chunk_size=chunk_size) + logger.warning(str(output)) + raise AdbTimeout('reverse server accept timeout') + + # Server receive data + data = recv_all(conn, chunk_size=chunk_size, recv_interval=0.001) + + # Server close connection + conn.close() + return data + + def adb_exec_out(self, cmd, serial=None): + cmd.insert(0, 'exec-out') + return self.adb_command(cmd, serial) + + def adb_forward(self, remote): + """ + Do `adb forward `. + choose a random port in FORWARD_PORT_RANGE or reuse an existing forward, + and also remove redundant forwards. + + Args: + remote (str): + tcp: + localabstract: + localreserved: + localfilesystem: + dev: + jdwp: (remote only) + + Returns: + int: Port + """ + port = 0 + for forward in self.adb.forward_list(): + if forward.serial == self.serial and forward.remote == remote and forward.local.startswith('tcp:'): + if not port: + logger.info(f'Reuse forward: {forward}') + port = int(forward.local[4:]) + else: + logger.info(f'Remove redundant forward: {forward}') + self.adb_forward_remove(forward.local) + + if port: + return port + else: + # Create new forward + port = random_port(self.config.FORWARD_PORT_RANGE) + forward = ForwardItem(self.serial, f'tcp:{port}', remote) + logger.info(f'Create forward: {forward}') + self.adb.forward(forward.local, forward.remote) + return port + + def adb_reverse(self, remote): + port = 0 + for reverse in self.adb.reverse_list(): + if reverse.remote == remote and reverse.local.startswith('tcp:'): + if not port: + logger.info(f'Reuse reverse: {reverse}') + port = int(reverse.local[4:]) + else: + logger.info(f'Remove redundant forward: {reverse}') + self.adb_forward_remove(reverse.local) + + if port: + return port + else: + # Create new reverse + port = random_port(self.config.FORWARD_PORT_RANGE) + reverse = ReverseItem(f'tcp:{port}', remote) + logger.info(f'Create reverse: {reverse}') + self.adb.reverse(reverse.local, reverse.remote) + return port + + def adb_forward_remove(self, local): + """ + Equivalent to `adb -s forward --remove ` + More about the commands send to ADB server, see: + https://cs.android.com/android/platform/superproject/+/master:packages/modules/adb/SERVICES.TXT + + Args: + local (str): Such as 'tcp:2437' + """ + with self.adb_client._connect() as c: + list_cmd = f"host-serial:{self.serial}:killforward:{local}" + c.send_command(list_cmd) + c.check_okay() + + def adb_reverse_remove(self, local): + """ + Equivalent to `adb -s reverse --remove ` + + Args: + local (str): Such as 'tcp:2437' + """ + with self.adb_client._connect() as c: + c.send_command(f"host:transport:{self.serial}") + c.check_okay() + list_cmd = f"reverse:killforward:{local}" + c.send_command(list_cmd) + c.check_okay() + + def adb_push(self, local, remote): + """ + Args: + local (str): + remote (str): + + Returns: + str: + """ + cmd = ['push', local, remote] + return self.adb_command(cmd) + + @Config.when(DEVICE_OVER_HTTP=False) + def adb_connect(self, serial): + """ + Connect to a serial, try 3 times at max. + If there's an old ADB server running while Alas is using a newer one, which happens on Chinese emulators, + the first connection is used to kill the other one, and the second is the real connect. + + Args: + serial (str): + + Returns: + bool: If success + """ + # Disconnect offline device before connecting + for device in self.list_device(): + if device.status == 'offline': + logger.warning(f'Device {serial} is offline, disconnect it before connecting') + self.adb_disconnect(serial) + elif device.status == 'unauthorized': + logger.error(f'Device {serial} is unauthorized, please accept ADB debugging on your device') + elif device.status == 'device': + pass + else: + logger.warning(f'Device {serial} is is having a unknown status: {device.status}') + + # Skip for emulator-5554 + if 'emulator-' in serial: + logger.info(f'"{serial}" is a `emulator-*` serial, skip adb connect') + return True + if re.match(r'^[a-zA-Z0-9]+$', serial): + logger.info(f'"{serial}" seems to be a Android serial, skip adb connect') + return True + + # Try to connect + for _ in range(3): + msg = self.adb_client.connect(serial) + logger.info(msg) + if 'connected' in msg: + # Connected to 127.0.0.1:59865 + # Already connected to 127.0.0.1:59865 + return True + elif 'bad port' in msg: + # bad port number '598265' in '127.0.0.1:598265' + logger.error(msg) + possible_reasons('Serial incorrect, might be a typo') + raise RequestHumanTakeover + elif '(10061)' in msg: + # cannot connect to 127.0.0.1:55555: + # No connection could be made because the target machine actively refused it. (10061) + logger.info(msg) + logger.warning('No such device exists, please restart the emulator or set a correct serial') + raise EmulatorNotRunningError + + # Failed to connect + logger.warning(f'Failed to connect {serial} after 3 trial, assume connected') + self.detect_device() + return False + + @Config.when(DEVICE_OVER_HTTP=True) + def adb_connect(self, serial): + # No adb connect if over http + return True + + def adb_disconnect(self, serial): + msg = self.adb_client.disconnect(serial) + if msg: + logger.info(msg) + + del_cached_property(self, 'hermit_session') + del_cached_property(self, 'droidcast_session') + del_cached_property(self, 'minitouch_builder') + del_cached_property(self, 'reverse_server') + + def adb_restart(self): + """ + Reboot adb client + """ + logger.info('Restart adb') + # Kill current client + self.adb_client.server_kill() + # Init adb client + del_cached_property(self, 'adb_client') + _ = self.adb_client + + @Config.when(DEVICE_OVER_HTTP=False) + def adb_reconnect(self): + """ + Reboot adb client if no device found, otherwise try reconnecting device. + """ + if self.config.Emulator_AdbRestart and len(self.list_device()) == 0: + # Restart Adb + self.adb_restart() + # Connect to device + self.adb_connect(self.serial) + self.detect_device() + else: + self.adb_disconnect(self.serial) + self.adb_connect(self.serial) + self.detect_device() + + @Config.when(DEVICE_OVER_HTTP=True) + def adb_reconnect(self): + logger.warning( + f'When connecting a device over http: {self.serial} ' + f'adb_reconnect() is skipped, you may need to restart ATX manually' + ) + + def install_uiautomator2(self): + """ + Init uiautomator2 and remove minicap. + """ + logger.info('Install uiautomator2') + init = u2.init.Initer(self.adb, loglevel=logging.DEBUG) + # MuMu X has no ro.product.cpu.abi, pick abi from ro.product.cpu.abilist + if init.abi not in ['x86_64', 'x86', 'arm64-v8a', 'armeabi-v7a', 'armeabi']: + init.abi = init.abis[0] + init.set_atx_agent_addr('127.0.0.1:7912') + try: + init.install() + except ConnectionError: + u2.init.GITHUB_BASEURL = 'http://tool.appetizer.io/openatx' + init.install() + self.uninstall_minicap() + + def uninstall_minicap(self): + """ minicap can't work or will send compressed images on some emulators. """ + logger.info('Removing minicap') + self.adb_shell(["rm", "/data/local/tmp/minicap"]) + self.adb_shell(["rm", "/data/local/tmp/minicap.so"]) + + @Config.when(DEVICE_OVER_HTTP=False) + def restart_atx(self): + """ + Minitouch supports only one connection at a time. + Restart ATX to kick the existing one. + """ + logger.info('Restart ATX') + atx_agent_path = '/data/local/tmp/atx-agent' + self.adb_shell([atx_agent_path, 'server', '--stop']) + self.adb_shell([atx_agent_path, 'server', '--nouia', '-d', '--addr', '127.0.0.1:7912']) + + @Config.when(DEVICE_OVER_HTTP=True) + def restart_atx(self): + logger.warning( + f'When connecting a device over http: {self.serial} ' + f'restart_atx() is skipped, you may need to restart ATX manually' + ) + + @staticmethod + def sleep(second): + """ + Args: + second(int, float, tuple): + """ + time.sleep(ensure_time(second)) + + _orientation_description = { + 0: 'Normal', + 1: 'HOME key on the right', + 2: 'HOME key on the top', + 3: 'HOME key on the left', + } + orientation = 0 + + @retry + def get_orientation(self): + """ + Rotation of the phone + + Returns: + int: + 0: 'Normal' + 1: 'HOME key on the right' + 2: 'HOME key on the top' + 3: 'HOME key on the left' + """ + _DISPLAY_RE = re.compile( + r'.*DisplayViewport{.*valid=true, .*orientation=(?P\d+), .*deviceWidth=(?P\d+), deviceHeight=(?P\d+).*' + ) + output = self.adb_shell(['dumpsys', 'display']) + + res = _DISPLAY_RE.search(output, 0) + + if res: + o = int(res.group('orientation')) + if o in Connection._orientation_description: + pass + else: + o = 0 + logger.warning(f'Invalid device orientation: {o}, assume it is normal') + else: + o = 0 + logger.warning('Unable to get device orientation, assume it is normal') + + self.orientation = o + logger.attr('Device Orientation', f'{o} ({Connection._orientation_description.get(o, "Unknown")})') + return o + + @retry + def list_device(self): + """ + Returns: + SelectedGrids[AdbDeviceWithStatus]: + """ + devices = [] + try: + with self.adb_client._connect() as c: + c.send_command("host:devices") + c.check_okay() + output = c.read_string_block() + for line in output.splitlines(): + parts = line.strip().split("\t") + if len(parts) != 2: + continue + device = AdbDeviceWithStatus(self.adb_client, parts[0], parts[1]) + devices.append(device) + except ConnectionResetError as e: + # Happens only on CN users. + # ConnectionResetError: [WinError 10054] 远程主机强迫关闭了一个现有的连接。 + logger.error(e) + if '强迫关闭' in str(e): + logger.critical('无法连接至ADB服务,请关闭UU加速器、原神私服、以及一些劣质代理软件。' + '它们会劫持电脑上所有的网络连接,包括Alas与模拟器之间的本地连接。') + return SelectedGrids(devices) + + def detect_device(self): + """ + Find available devices + If serial=='auto' and only 1 device detected, use it + """ + logger.hr('Detect device') + logger.info('Here are the available devices, ' + 'copy to Alas.Emulator.Serial to use it or set Alas.Emulator.Serial="auto"') + devices = self.list_device() + + # Show available devices + available = devices.select(status='device') + for device in available: + logger.info(device.serial) + if not len(available): + logger.info('No available devices') + + # Show unavailable devices if having any + unavailable = devices.delete(available) + if len(unavailable): + logger.info('Here are the devices detected but unavailable') + for device in unavailable: + logger.info(f'{device.serial} ({device.status})') + + # Auto device detection + if self.config.Emulator_Serial == 'auto': + if available.count == 0: + logger.critical('No available device found, auto device detection cannot work, ' + 'please set an exact serial in Alas.Emulator.Serial instead of using "auto"') + raise RequestHumanTakeover + elif available.count == 1: + logger.info(f'Auto device detection found only one device, using it') + self.serial = devices[0].serial + del_cached_property(self, 'adb') + else: + logger.critical('Multiple devices found, auto device detection cannot decide which to choose, ' + 'please copy one of the available devices listed above to Alas.Emulator.Serial') + raise RequestHumanTakeover + + # Handle LDPlayer + # LDPlayer serial jumps between `127.0.0.1:5555+{X}` and `emulator-5554+{X}` + port_serial, emu_serial = get_serial_pair(self.serial) + if port_serial and emu_serial: + # Might be LDPlayer, check connected devices + port_device = devices.select(serial=port_serial).first_or_none() + emu_device = devices.select(serial=emu_serial).first_or_none() + if port_device and emu_device: + # Paired devices found, check status to get the correct one + if port_device.status == 'device' and emu_device.status == 'offline': + self.serial = port_serial + logger.info(f'LDPlayer device pair found: {port_device}, {emu_device}. ' + f'Using serial: {self.serial}') + elif port_device.status == 'offline' and emu_device.status == 'device': + self.serial = emu_serial + logger.info(f'LDPlayer device pair found: {port_device}, {emu_device}. ' + f'Using serial: {self.serial}') + elif not devices.select(serial=self.serial): + # Current serial not found + if port_device and not emu_device: + logger.info(f'Current serial {self.serial} not found but paired device {port_serial} found. ' + f'Using serial: {port_serial}') + self.serial = port_serial + if not port_device and emu_device: + logger.info(f'Current serial {self.serial} not found but paired device {emu_serial} found. ' + f'Using serial: {emu_serial}') + self.serial = emu_serial + + @retry + def list_package(self, show_log=True): + """ + Find all packages on device. + Use dumpsys first for faster. + """ + # 80ms + if show_log: + logger.info('Get package list') + output = self.adb_shell(r'dumpsys package | grep "Package \["') + packages = re.findall(r'Package \[([^\s]+)\]', output) + if len(packages): + return packages + + # 200ms + if show_log: + logger.info('Get package list') + output = self.adb_shell(['pm', 'list', 'packages']) + packages = re.findall(r'package:([^\s]+)', output) + return packages + + def list_azurlane_packages(self, show_log=True): + """ + Args: + show_log: + + Returns: + list[str]: List of package names + """ + packages = self.list_package(show_log=show_log) + packages = [p for p in packages if p in server_.VALID_PACKAGE] + return packages + + def detect_package(self, set_config=True): + """ + Show all possible packages with the given keyword on this device. + """ + logger.hr('Detect package') + packages = self.list_azurlane_packages() + + # Show packages + logger.info(f'Here are the available packages in device "{self.serial}", ' + f'copy to Alas.Emulator.PackageName to use it') + if len(packages): + for package in packages: + logger.info(package) + else: + logger.info(f'No available packages on device "{self.serial}"') + + # Auto package detection + if len(packages) == 0: + logger.critical(f'No Star Rail package found, ' + f'please confirm Star Rail has been installed on device "{self.serial}"') + raise RequestHumanTakeover + if len(packages) == 1: + logger.info('Auto package detection found only one package, using it') + self.package = packages[0] + # Set config + if set_config: + self.config.Emulator_PackageName = server_.to_server(self.package) + # Set server + # logger.info('Server changed, release resources') + # set_server(self.package) + else: + logger.critical( + f'Multiple Star Rail packages found, auto package detection cannot decide which to choose, ' + 'please copy one of the available devices listed above to Alas.Emulator.PackageName') + raise RequestHumanTakeover diff --git a/module/device/connection_attr.py b/module/device/connection_attr.py new file mode 100644 index 0000000..cfa78a9 --- /dev/null +++ b/module/device/connection_attr.py @@ -0,0 +1,290 @@ +import os +import re + +import adbutils +import uiautomator2 as u2 +from adbutils import AdbClient, AdbDevice + +from module.base.decorator import cached_property +from module.config.config import AzurLaneConfig +from module.config.utils import deep_iter +from module.exception import RequestHumanTakeover +from module.logger import logger + + +class ConnectionAttr: + config: AzurLaneConfig + serial: str + + adb_binary_list = [ + './bin/adb/adb.exe', + './toolkit/Lib/site-packages/adbutils/binaries/adb.exe', + '/usr/bin/adb' + ] + + def __init__(self, config): + """ + Args: + config (AzurLaneConfig, str): Name of the user config under ./config + """ + logger.hr('Device', level=1) + if isinstance(config, str): + self.config = AzurLaneConfig(config, task=None) + else: + self.config = config + + # Init adb client + logger.attr('AdbBinary', self.adb_binary) + # Monkey patch to custom adb + adbutils.adb_path = lambda: self.adb_binary + # Remove global proxies, or uiautomator2 will go through it + for k in list(os.environ.keys()): + if k.lower().endswith('_proxy'): + del os.environ[k] + # Cache adb_client + _ = self.adb_client + + # Parse custom serial + self.serial = str(self.config.Emulator_Serial) + self.serial_check() + self.config.DEVICE_OVER_HTTP = self.is_over_http + + + @staticmethod + def revise_serial(serial): + serial = serial.replace(' ', '') + # 127。0。0。1:5555 + serial = serial.replace('。', '.').replace(',', '.').replace(',', '.').replace(':', ':') + # 127.0.0.1.5555 + serial = serial.replace('127.0.0.1.', '127.0.0.1:') + # 16384 + try: + port = int(serial) + if 1000 < port < 65536: + serial = f'127.0.0.1:{port}' + except ValueError: + pass + # 夜神模拟器 127.0.0.1:62001 + # MuMu模拟器12127.0.0.1:16384 + if '模拟' in serial: + res = re.search(r'(127\.\d+\.\d+\.\d+:\d+)', serial) + if res: + serial = res.group(1) + return str(serial) + + def serial_check(self): + """ + serial check + """ + # fool-proof + new = self.revise_serial(self.serial) + if new != self.serial: + logger.warning(f'Serial "{self.config.Emulator_Serial}" is revised to "{new}"') + self.config.Emulator_Serial = new + self.serial = new + if self.is_bluestacks4_hyperv: + self.serial = self.find_bluestacks4_hyperv(self.serial) + if self.is_bluestacks5_hyperv: + self.serial = self.find_bluestacks5_hyperv(self.serial) + if "127.0.0.1:58526" in self.serial: + logger.warning('Serial 127.0.0.1:58526 seems to be WSA, ' + 'please use "wsa-0" or others instead') + raise RequestHumanTakeover + if self.is_wsa: + self.serial = '127.0.0.1:58526' + if self.config.Emulator_ScreenshotMethod != 'uiautomator2' \ + or self.config.Emulator_ControlMethod != 'uiautomator2': + with self.config.multi_set(): + self.config.Emulator_ScreenshotMethod = 'uiautomator2' + self.config.Emulator_ControlMethod = 'uiautomator2' + if self.is_over_http: + if self.config.Emulator_ScreenshotMethod not in ["ADB", "uiautomator2", "aScreenCap"] \ + or self.config.Emulator_ControlMethod not in ["ADB", "uiautomator2", "minitouch"]: + logger.warning( + f'When connecting to a device over http: {self.serial} ' + f'ScreenshotMethod can only use ["ADB", "uiautomator2", "aScreenCap"], ' + f'ControlMethod can only use ["ADB", "uiautomator2", "minitouch"]' + ) + raise RequestHumanTakeover + + @cached_property + def is_bluestacks4_hyperv(self): + return "bluestacks4-hyperv" in self.serial + + @cached_property + def is_bluestacks5_hyperv(self): + return "bluestacks5-hyperv" in self.serial + + @cached_property + def is_bluestacks_hyperv(self): + return self.is_bluestacks4_hyperv or self.is_bluestacks5_hyperv + + @cached_property + def is_wsa(self): + return bool(re.match(r'^wsa', self.serial)) + + @cached_property + def is_mumu_family(self): + # 127.0.0.1:7555 + # 127.0.0.1:16384 + 32*n + return self.serial == '127.0.0.1:7555' or self.serial.startswith('127.0.0.1:16') + + @cached_property + def is_emulator(self): + return self.serial.startswith('emulator-') or self.serial.startswith('127.0.0.1:') + + @cached_property + def is_network_device(self): + return bool(re.match(r'\d+\.\d+\.\d+\.\d+:\d+', self.serial)) + + @cached_property + def is_over_http(self): + return bool(re.match(r"^https?://", self.serial)) + + @cached_property + def is_chinac_phone_cloud(self): + # Phone cloud with public ADB connection + # Serial like xxx.xxx.xxx.xxx:301 + return bool(re.search(r":30[0-9]$", self.serial)) + + @staticmethod + def find_bluestacks4_hyperv(serial): + """ + Find dynamic serial of BlueStacks4 Hyper-V Beta. + + Args: + serial (str): 'bluestacks4-hyperv', 'bluestacks4-hyperv-2' for multi instance, and so on. + + Returns: + str: 127.0.0.1:{port} + """ + from winreg import HKEY_LOCAL_MACHINE, OpenKey, QueryValueEx + + logger.info("Use BlueStacks4 Hyper-V Beta") + logger.info("Reading Realtime adb port") + + if serial == "bluestacks4-hyperv": + folder_name = "Android" + else: + folder_name = f"Android_{serial[19:]}" + + try: + with OpenKey(HKEY_LOCAL_MACHINE, + rf"SOFTWARE\BlueStacks_bgp64_hyperv\Guests\{folder_name}\Config") as key: + port = QueryValueEx(key, "BstAdbPort")[0] + except FileNotFoundError: + logger.error(rf'Unable to find registry HKEY_LOCAL_MACHINE\SOFTWARE\BlueStacks_bgp64_hyperv\Guests\{folder_name}\Config') + logger.error('Please confirm that your are using BlueStack 4 hyper-v and not regular BlueStacks 4') + logger.error(r'Please check if there is any other emulator instances under ' + r'registry HKEY_LOCAL_MACHINE\SOFTWARE\BlueStacks_bgp64_hyperv\Guests') + raise RequestHumanTakeover + logger.info(f"New adb port: {port}") + return f"127.0.0.1:{port}" + + @staticmethod + def find_bluestacks5_hyperv(serial): + """ + Find dynamic serial of BlueStacks5 Hyper-V. + + Args: + serial (str): 'bluestacks5-hyperv', 'bluestacks5-hyperv-1' for multi instance, and so on. + + Returns: + str: 127.0.0.1:{port} + """ + from winreg import HKEY_LOCAL_MACHINE, OpenKey, QueryValueEx + + logger.info("Use BlueStacks5 Hyper-V") + logger.info("Reading Realtime adb port") + + if serial == "bluestacks5-hyperv": + parameter_name = r"bst\.instance\.(Nougat64|Pie64|Rvc64)\.status\.adb_port" + else: + parameter_name = rf"bst\.instance\.(Nougat64|Pie64|Rvc64)_{serial[19:]}\.status.adb_port" + + try: + with OpenKey(HKEY_LOCAL_MACHINE, r"SOFTWARE\BlueStacks_nxt") as key: + directory = QueryValueEx(key, 'UserDefinedDir')[0] + except FileNotFoundError: + try: + with OpenKey(HKEY_LOCAL_MACHINE, r"SOFTWARE\BlueStacks_nxt_cn") as key: + directory = QueryValueEx(key, 'UserDefinedDir')[0] + except FileNotFoundError: + logger.error('Unable to find registry HKEY_LOCAL_MACHINE\SOFTWARE\BlueStacks_nxt ' + 'or HKEY_LOCAL_MACHINE\SOFTWARE\BlueStacks_nxt_cn') + logger.error('Please confirm that you are using BlueStacks 5 hyper-v and not regular BlueStacks 5') + raise RequestHumanTakeover + logger.info(f"Configuration file directory: {directory}") + + with open(os.path.join(directory, 'bluestacks.conf'), encoding='utf-8') as f: + content = f.read() + port = re.search(rf'{parameter_name}="(\d+)"', content) + if port is None: + logger.warning(f"Did not match the result: {serial}.") + raise RequestHumanTakeover + port = port.group(2) + logger.info(f"Match to dynamic port: {port}") + return f"127.0.0.1:{port}" + + @cached_property + def adb_binary(self): + # Try adb in deploy.yaml + from module.webui.setting import State + file = State.deploy_config.AdbExecutable + file = file.replace('\\', '/') + if os.path.exists(file): + return os.path.abspath(file) + + # Try existing adb.exe + for file in self.adb_binary_list: + if os.path.exists(file): + return os.path.abspath(file) + + # Try adb in python environment + import sys + file = os.path.join(sys.executable, '../Lib/site-packages/adbutils/binaries/adb.exe') + file = os.path.abspath(file).replace('\\', '/') + if os.path.exists(file): + return file + + # Use adb in system PATH + file = 'adb' + return file + + @cached_property + def adb_client(self) -> AdbClient: + host = '127.0.0.1' + port = 5037 + + # Trying to get adb port from env + env = os.environ.get('ANDROID_ADB_SERVER_PORT', None) + if env is not None: + try: + port = int(env) + except ValueError: + logger.warning(f'Invalid environ variable ANDROID_ADB_SERVER_PORT={port}, using default port') + + logger.attr('AdbClient', f'AdbClient({host}, {port})') + return AdbClient(host, port) + + @cached_property + def adb(self) -> AdbDevice: + return AdbDevice(self.adb_client, self.serial) + + @cached_property + def u2(self) -> u2.Device: + if self.is_over_http: + # Using uiautomator2_http + device = u2.connect(self.serial) + else: + # Normal uiautomator2 + if self.serial.startswith('emulator-') or self.serial.startswith('127.0.0.1:'): + device = u2.connect_usb(self.serial) + else: + device = u2.connect(self.serial) + + # Stay alive + device.set_new_command_timeout(604800) + + logger.attr('u2.Device', f'Device(atx_agent_url={device._get_atx_agent_url()})') + return device diff --git a/module/device/control.py b/module/device/control.py new file mode 100644 index 0000000..3205cd3 --- /dev/null +++ b/module/device/control.py @@ -0,0 +1,207 @@ +from module.base.button import ClickButton +from module.base.decorator import cached_property +from module.base.timer import Timer +from module.base.utils import * +from module.device.method.hermit import Hermit +from module.device.method.maatouch import MaaTouch +from module.device.method.minitouch import Minitouch +from module.device.method.scrcpy import Scrcpy +from module.logger import logger + +import numpy as np + + +class Control(Hermit, Minitouch, Scrcpy, MaaTouch): + def handle_control_check(self, button): + # Will be overridden in Device + pass + + @cached_property + def click_methods(self): + return { + 'ADB': self.click_adb, + 'uiautomator2': self.click_uiautomator2, + 'minitouch': self.click_minitouch, + 'Hermit': self.click_hermit, + 'MaaTouch': self.click_maatouch, + } + + def click(self, button, control_check=True): + """Method to click a button. + + Args: + button (button.Button): AzurLane Button instance. + control_check (bool): + """ + if control_check: + self.handle_control_check(button) + x, y = random_rectangle_point(button.button) + x, y = ensure_int(x, y) + logger.info( + 'Click %s @ %s' % (point2str(x, y), button) + ) + method = self.click_methods.get( + self.config.Emulator_ControlMethod, + self.click_adb + ) + method(x, y) + + def multi_click(self, button, n, interval=(0.1, 0.2)): + self.handle_control_check(button) + click_timer = Timer(0.1) + for _ in range(n): + remain = ensure_time(interval) - click_timer.current() + if remain > 0: + self.sleep(remain) + click_timer.reset() + + self.click(button, control_check=False) + + def long_click(self, button, duration=(1, 1.2)): + """Method to long click a button. + + Args: + button (button.Button): AzurLane Button instance. + duration(int, float, tuple): + """ + self.handle_control_check(button) + x, y = random_rectangle_point(button.button) + x, y = ensure_int(x, y) + duration = ensure_time(duration) + logger.info( + 'Click %s @ %s, %s' % (point2str(x, y), button, duration) + ) + method = self.config.Emulator_ControlMethod + if method == 'minitouch': + self.long_click_minitouch(x, y, duration) + elif method == 'uiautomator2': + self.long_click_uiautomator2(x, y, duration) + elif method == 'scrcpy': + self.long_click_scrcpy(x, y, duration) + elif method == 'MaaTouch': + self.long_click_maatouch(x, y, duration) + else: + self.swipe_adb((x, y), (x, y), duration) + + def swipe(self, p1, p2, duration=(0.1, 0.2), name='SWIPE', distance_check=True): + self.handle_control_check(name) + p1, p2 = ensure_int(p1, p2) + duration = ensure_time(duration) + method = self.config.Emulator_ControlMethod + if method == 'minitouch': + logger.info('Swipe %s -> %s' % (point2str(*p1), point2str(*p2))) + elif method == 'uiautomator2': + logger.info('Swipe %s -> %s, %s' % (point2str(*p1), point2str(*p2), duration)) + elif method == 'scrcpy': + logger.info('Swipe %s -> %s' % (point2str(*p1), point2str(*p2))) + elif method == 'MaaTouch': + logger.info('Swipe %s -> %s' % (point2str(*p1), point2str(*p2))) + else: + # ADB needs to be slow, or swipe doesn't work + duration *= 2.5 + logger.info('Swipe %s -> %s, %s' % (point2str(*p1), point2str(*p2), duration)) + + if distance_check: + if np.linalg.norm(np.subtract(p1, p2)) < 10: + # Should swipe a certain distance, otherwise AL will treat it as click. + # uiautomator2 should >= 6px, minitouch should >= 5px + logger.info('Swipe distance < 10px, dropped') + return + + if method == 'minitouch': + self.swipe_minitouch(p1, p2) + elif method == 'uiautomator2': + self.swipe_uiautomator2(p1, p2, duration=duration) + elif method == 'scrcpy': + self.swipe_scrcpy(p1, p2) + elif method == 'MaaTouch': + self.swipe_maatouch(p1, p2) + else: + self.swipe_adb(p1, p2, duration=duration) + + def swipe_vector(self, vector, box=(123, 159, 1175, 628), random_range=(0, 0, 0, 0), padding=15, + duration=(0.1, 0.2), whitelist_area=None, blacklist_area=None, name='SWIPE', distance_check=True): + """Method to swipe. + + Args: + box (tuple): Swipe in box (upper_left_x, upper_left_y, bottom_right_x, bottom_right_y). + vector (tuple): (x, y). + random_range (tuple): (x_min, y_min, x_max, y_max). + padding (int): + duration (int, float, tuple): + whitelist_area: (list[tuple[int]]): + A list of area that safe to click. Swipe path will end there. + blacklist_area: (list[tuple[int]]): + If none of the whitelist_area satisfies current vector, blacklist_area will be used. + Delete random path that ends in any blacklist_area. + name (str): Swipe name + distance_check: (bool): + """ + p1, p2 = random_rectangle_vector_opted( + vector, + box=box, + random_range=random_range, + padding=padding, + whitelist_area=whitelist_area, + blacklist_area=blacklist_area + ) + self.swipe(p1, p2, duration=duration, name=name, distance_check=distance_check) + + def drag(self, p1, p2, segments=1, shake=(0, 15), point_random=(-10, -10, 10, 10), shake_random=(-5, -5, 5, 5), + swipe_duration=0.25, shake_duration=0.1, name='DRAG'): + self.handle_control_check(name) + p1, p2 = ensure_int(p1, p2) + logger.info( + 'Drag %s -> %s' % (point2str(*p1), point2str(*p2)) + ) + method = self.config.Emulator_ControlMethod + if method == 'minitouch': + self.drag_minitouch(p1, p2, point_random=point_random) + elif method == 'uiautomator2': + self.drag_uiautomator2( + p1, p2, segments=segments, shake=shake, point_random=point_random, shake_random=shake_random, + swipe_duration=swipe_duration, shake_duration=shake_duration) + elif method == 'scrcpy': + self.drag_scrcpy(p1, p2, point_random=point_random) + elif method == 'MaaTouch': + self.drag_maatouch(p1, p2, point_random=point_random) + else: + logger.warning(f'Control method {method} does not support drag well, ' + f'falling back to ADB swipe may cause unexpected behaviour') + self.swipe_adb(p1, p2, duration=ensure_time(swipe_duration * 2)) + self.click(ClickButton(button=area_offset(point_random, p2), name=name)) + + # just used in cafe + def pinch(self, box=(33, 130, 1247, 569), name='PINCH'): + self.handle_control_check(name) + middle_point = (box[0] + box[2]) // 2, (box[1] + box[3]) // 2 + width = box[2] - middle_point[0] + height = box[3] - middle_point[1] + box_0 = (middle_point[0], box[1], box[2], middle_point[1]) + box_1 = (box[0], middle_point[1], middle_point[0], box[3]) + r = np.random.uniform(0.8, 0.9) + vector_0 = (-width * r, height * r) + vector_1 = (width * r, -height * r) + p1_1, p1_2 = random_rectangle_vector_opted( + vector_0, + box=box_0, + random_range=(-5, -5, 5, 5), + padding=5, + whitelist_area=None, + blacklist_area=None + ) + p2_1, p2_2 = random_rectangle_vector_opted( + vector_1, + box=box_1, + random_range=(-5, -5, 5, 5), + padding=5, + whitelist_area=None, + blacklist_area=None + ) + logger.info('Pinch') + method = self.config.Emulator_ControlMethod + if method == 'MaaTouch': + self.pinch_maatouch(p1_1, p1_2, p2_1, p2_2) + else: + logger.warning(f'Control method {method} does not support pinch well') + return diff --git a/module/device/device.py b/module/device/device.py new file mode 100644 index 0000000..7f7bf27 --- /dev/null +++ b/module/device/device.py @@ -0,0 +1,188 @@ +import sys +from collections import deque + +from module.base.timer import Timer +from module.device.app_control import AppControl +from module.device.control import Control +from module.device.screenshot import Screenshot +from module.exception import ( + EmulatorNotRunningError, + GameNotRunningError, + GameStuckError, + GameTooManyClickError, + RequestHumanTakeover +) +from module.logger import logger + +if sys.platform == 'win32': + from module.device.platform.platform_windows import PlatformWindows as Platform +else: + from module.device.platform.platform_base import PlatformBase as Platform + + +class Device(Screenshot, Control, AppControl, Platform): + _screen_size_checked = False + detect_record = set() + click_record = deque(maxlen=15) + stuck_timer = Timer(60, count=60).start() + + def __init__(self, *args, **kwargs): + for _ in range(2): + try: + super().__init__(*args, **kwargs) + break + except EmulatorNotRunningError: + # Try to start emulator + if self.emulator_instance is not None: + self.emulator_start() + else: + logger.critical( + f'No emulator with serial "{self.config.Emulator_Serial}" found, ' + f'please set a correct serial' + ) + raise + + self.screenshot_interval_set() + + # Auto-select the fastest screenshot method + if not self.config.is_template_config and self.config.Emulator_ScreenshotMethod == 'auto': + self.run_simple_screenshot_benchmark() + + def run_simple_screenshot_benchmark(self): + """ + Perform a screenshot method benchmark, test 3 times on each method. + The fastest one will be set into config. + """ + logger.info('run_simple_screenshot_benchmark') + # Check resolution first + self.resolution_check_uiautomator2() + # Perform benchmark + from module.daemon.benchmark import Benchmark + bench = Benchmark(config=self.config, device=self) + method = bench.run_simple_screenshot_benchmark() + # Set + self.config.Emulator_ScreenshotMethod = method + + def screenshot(self): + """ + Returns: + np.ndarray: + """ + self.stuck_record_check() + + try: + super().screenshot() + except RequestHumanTakeover: + if not self.ascreencap_available: + logger.error('aScreenCap unavailable on current device, fallback to auto') + self.run_simple_screenshot_benchmark() + super().screenshot() + else: + raise + + return self.image + + def release_during_wait(self): + # Scrcpy server is still sending video stream, + # stop it during wait + if self.config.Emulator_ScreenshotMethod == 'scrcpy': + self._scrcpy_server_stop() + + def stuck_record_add(self, button): + self.detect_record.add(str(button)) + + def stuck_record_clear(self): + self.detect_record = set() + self.stuck_timer.reset() + + def stuck_record_check(self): + """ + Raises: + GameStuckError: + """ + reached = self.stuck_timer.reached() + if not reached: + return False + + logger.warning('Wait too long') + logger.warning(f'Waiting for {self.detect_record}') + self.stuck_record_clear() + + if self.app_is_running(): + raise GameStuckError(f'Wait too long') + else: + raise GameNotRunningError('Game died') + + def handle_control_check(self, button): + self.stuck_record_clear() + self.click_record_add(button) + self.click_record_check() + + def click_record_add(self, button): + self.click_record.append(str(button)) + + def click_record_clear(self): + self.click_record.clear() + + def click_record_remove(self, button): + """ + Remove a button from `click_record` + + Args: + button (Button): + + Returns: + int: Number of button removed + """ + removed = 0 + for _ in range(self.click_record.maxlen): + try: + self.click_record.remove(str(button)) + removed += 1 + except ValueError: + # Value not in queue + break + + return removed + + def click_record_check(self): + """ + Raises: + GameTooManyClickError: + """ + count = {} + for key in self.click_record: + count[key] = count.get(key, 0) + 1 + count = sorted(count.items(), key=lambda item: item[1]) + if count[0][1] >= 12: + logger.warning(f'Too many click for a button: {count[0][0]}') + logger.warning(f'History click: {[str(prev) for prev in self.click_record]}') + self.click_record_clear() + raise GameTooManyClickError(f'Too many click for a button: {count[0][0]}') + if len(count) >= 2 and count[0][1] >= 6 and count[1][1] >= 6: + logger.warning(f'Too many click between 2 buttons: {count[0][0]}, {count[1][0]}') + logger.warning(f'History click: {[str(prev) for prev in self.click_record]}') + self.click_record_clear() + raise GameTooManyClickError(f'Too many click between 2 buttons: {count[0][0]}, {count[1][0]}') + + def disable_stuck_detection(self): + """ + Disable stuck detection and its handler. Usually uses in semi auto and debugging. + """ + logger.info('Disable stuck detection') + + def empty_function(*arg, **kwargs): + return False + + self.click_record_check = empty_function + self.stuck_record_check = empty_function + + def app_start(self): + super().app_start() + self.stuck_record_clear() + self.click_record_clear() + + def app_stop(self): + super().app_stop() + self.stuck_record_clear() + self.click_record_clear() diff --git a/module/device/method/adb.py b/module/device/method/adb.py new file mode 100644 index 0000000..282447c --- /dev/null +++ b/module/device/method/adb.py @@ -0,0 +1,319 @@ +import re +from functools import wraps + +import cv2 +import numpy as np +import time +from adbutils.errors import AdbError +from lxml import etree + +from module.base.decorator import Config +from module.device.connection import Connection +from module.device.method.utils import (RETRY_TRIES, retry_sleep, remove_prefix, handle_adb_error, + ImageTruncated, PackageNotInstalled) +from module.exception import RequestHumanTakeover, ScriptError +from module.logger import logger + + +def retry(func): + @wraps(func) + def retry_wrapper(self, *args, **kwargs): + """ + Args: + self (Adb): + """ + init = None + for _ in range(RETRY_TRIES): + try: + if callable(init): + retry_sleep(_) + init() + return func(self, *args, **kwargs) + # Can't handle + except RequestHumanTakeover: + break + # When adb server was killed + except ConnectionResetError as e: + logger.error(e) + + def init(): + self.adb_reconnect() + # AdbError + except AdbError as e: + if handle_adb_error(e): + def init(): + self.adb_reconnect() + else: + break + # Package not installed + except PackageNotInstalled as e: + logger.error(e) + + def init(): + self.detect_package() + # ImageTruncated + except ImageTruncated as e: + logger.error(e) + + def init(): + pass + # Unknown + except Exception as e: + logger.exception(e) + + def init(): + pass + + logger.critical(f'Retry {func.__name__}() failed') + raise RequestHumanTakeover + + return retry_wrapper + + +def load_screencap(data): + """ + Args: + data: Raw data from `screencap` + + Returns: + np.ndarray: + """ + # Load data + header = np.frombuffer(data[0:12], dtype=np.uint32) + channel = 4 # screencap sends an RGBA image + width, height, _ = header # Usually to be 1280, 720, 1 + + image = np.frombuffer(data, dtype=np.uint8) + if image is None: + raise ImageTruncated('Empty image after reading from buffer') + + try: + image = image[-int(width * height * channel):].reshape(height, width, channel) + except ValueError as e: + # ValueError: cannot reshape array of size 0 into shape (720,1280,4) + raise ImageTruncated(str(e)) + + image = cv2.cvtColor(image, cv2.COLOR_BGRA2BGR) + if image is None: + raise ImageTruncated('Empty image after cv2.cvtColor') + + return image + + +class Adb(Connection): + __screenshot_method = [0, 1, 2] + __screenshot_method_fixed = [0, 1, 2] + + @staticmethod + def __load_screenshot(screenshot, method): + if method == 0: + pass + elif method == 1: + screenshot = screenshot.replace(b'\r\n', b'\n') + elif method == 2: + screenshot = screenshot.replace(b'\r\r\n', b'\n') + else: + raise ScriptError(f'Unknown method to load screenshots: {method}') + + # fix compatibility issues for adb screencap decode problem when the data is from vmos pro + # When use adb screencap for a screenshot from vmos pro, there would be a header more than that from emulator + # which would cause image decode problem. So i check and remove the header there. + screenshot = remove_prefix(screenshot, b'long long=8 fun*=10\n') + + image = np.frombuffer(screenshot, np.uint8) + if image is None: + raise ImageTruncated('Empty image after reading from buffer') + + image = cv2.imdecode(image, cv2.IMREAD_COLOR) + if image is None: + raise ImageTruncated('Empty image after cv2.imdecode') + + image = cv2.cvtColor(image, cv2.COLOR_BGR2RGB) + if image is None: + raise ImageTruncated('Empty image after cv2.cvtColor') + + return image + + def __process_screenshot(self, screenshot): + for method in self.__screenshot_method_fixed: + try: + result = self.__load_screenshot(screenshot, method=method) + self.__screenshot_method_fixed = [method] + self.__screenshot_method + return result + except (OSError, ImageTruncated): + continue + + self.__screenshot_method_fixed = self.__screenshot_method + if len(screenshot) < 500: + logger.warning(f'Unexpected screenshot: {screenshot}') + raise OSError(f'cannot load screenshot') + + @retry + @Config.when(DEVICE_OVER_HTTP=False) + def screenshot_adb(self): + data = self.adb_shell(['screencap', '-p'], stream=True) + if len(data) < 500: + logger.warning(f'Unexpected screenshot: {data}') + + return self.__process_screenshot(data) + + @retry + @Config.when(DEVICE_OVER_HTTP=True) + def screenshot_adb(self): + data = self.adb_shell(['screencap'], stream=True) + if len(data) < 500: + logger.warning(f'Unexpected screenshot: {data}') + + return load_screencap(data) + + @retry + def screenshot_adb_nc(self): + data = self.adb_shell_nc(['screencap']) + if len(data) < 500: + logger.warning(f'Unexpected screenshot: {data}') + + return load_screencap(data) + + @retry + def click_adb(self, x, y): + start = time.time() + self.adb_shell(['input', 'tap', x, y]) + if time.time() - start <= 0.05: + self.sleep(0.05) + + @retry + def swipe_adb(self, p1, p2, duration=0.1): + duration = int(duration * 1000) + self.adb_shell(['input', 'swipe', *p1, *p2, duration]) + + @retry + def app_current_adb(self): + """ + Copied from uiautomator2 + + Returns: + str: Package name. + + Raises: + OSError + + For developer: + Function reset_uiautomator need this function, so can't use jsonrpc here. + """ + # Related issue: https://github.com/openatx/uiautomator2/issues/200 + # $ adb shell dumpsys window windows + # Example output: + # mCurrentFocus=Window{41b37570 u0 com.incall.apps.launcher/com.incall.apps.launcher.Launcher} + # mFocusedApp=AppWindowToken{422df168 token=Token{422def98 ActivityRecord{422dee38 u0 com.example/.UI.play.PlayActivity t14}}} + # Regexp + # r'mFocusedApp=.*ActivityRecord{\w+ \w+ (?P.*)/(?P.*) .*' + # r'mCurrentFocus=Window{\w+ \w+ (?P.*)/(?P.*)\}') + _focusedRE = re.compile( + r'mCurrentFocus=Window{.*\s+(?P[^\s]+)/(?P[^\s]+)\}' + ) + m = _focusedRE.search(self.adb_shell(['dumpsys', 'window', 'windows'])) + if m: + return m.group('package') + + # try: adb shell dumpsys activity top + _activityRE = re.compile( + r'ACTIVITY (?P[^\s]+)/(?P[^/\s]+) \w+ pid=(?P\d+)' + ) + output = self.adb_shell(['dumpsys', 'activity', 'top']) + ms = _activityRE.finditer(output) + ret = None + for m in ms: + ret = m.group('package') + if ret: # get last result + return ret + raise OSError("Couldn't get focused app") + + @retry + def app_start_adb(self, package_name=None, allow_failure=False): + """ + Args: + package_name (str): + allow_failure (bool): + + Returns: + bool: If success to start + """ + if not package_name: + package_name = self.package + result = self.adb_shell([ + 'monkey', '-p', package_name, '-c', + 'android.intent.category.LAUNCHER', '--pct-syskeys', '0', '1' + ]) + if 'No activities found' in result: + # ** No activities found to run, monkey aborted. + if allow_failure: + return False + else: + logger.error(result) + raise PackageNotInstalled(package_name) + elif 'inaccessible' in result: + # /system/bin/sh: monkey: inaccessible or not found + pass + else: + # Events injected: 1 + # ## Network stats: elapsed time=4ms (0ms mobile, 0ms wifi, 4ms not connected) + return True + + result = self.adb_shell(['dumpsys', 'package', package_name]) + res = re.search(r'android.intent.action.MAIN:\s+\w+ ([\w.\/]+) filter \w+\s+' + r'.*\s+Category: "android.intent.category.LAUNCHER"', + result) + if res: + activity_name = res.group(1) + else: + if allow_failure: + return False + else: + logger.error(result) + raise PackageNotInstalled(package_name) + self.adb_shell(['am', 'start', '-a', 'android.intent.action.MAIN', '-c', + 'android.intent.category.LAUNCHER', '-n', activity_name]) + + @retry + def app_stop_adb(self, package_name=None): + """ Stop one application: am force-stop""" + if not package_name: + package_name = self.package + self.adb_shell(['am', 'force-stop', package_name]) + + @retry + def dump_hierarchy_adb(self, temp: str = '/data/local/tmp/hierarchy.xml') -> etree._Element: + """ + Args: + temp (str): Temp file store on emulator. + + Returns: + etree._Element: + """ + # Remove existing file + # self.adb_shell(['rm', '/data/local/tmp/hierarchy.xml']) + + # Dump hierarchy + for _ in range(2): + response = self.adb_shell(['uiautomator', 'dump', '--compressed', temp]) + if 'hierchary' in response: + # UI hierchary dumped to: /data/local/tmp/hierarchy.xml + break + else: + # + # Must kill uiautomator2 + self.app_stop_adb('com.github.uiautomator') + self.app_stop_adb('com.github.uiautomator.test') + continue + + # Read from device + content = b'' + for chunk in self.adb.sync.iter_content(temp): + if chunk: + content += chunk + else: + break + + # Parse with lxml + hierarchy = etree.fromstring(content) + return hierarchy diff --git a/module/device/method/ascreencap.py b/module/device/method/ascreencap.py new file mode 100644 index 0000000..10ac111 --- /dev/null +++ b/module/device/method/ascreencap.py @@ -0,0 +1,206 @@ +import os +from functools import wraps + +import lz4.block +from adbutils.errors import AdbError + +from module.base.utils import * +from module.device.connection import Connection +from module.device.method.utils import (RETRY_TRIES, retry_sleep, + handle_adb_error, ImageTruncated) +from module.exception import RequestHumanTakeover, ScriptError +from module.logger import logger + + +class AscreencapError(Exception): + pass + + +def retry(func): + @wraps(func) + def retry_wrapper(self, *args, **kwargs): + """ + Args: + self (AScreenCap): + """ + init = None + for _ in range(RETRY_TRIES): + try: + if callable(init): + retry_sleep(_) + init() + return func(self, *args, **kwargs) + # Can't handle + except RequestHumanTakeover: + break + # When adb server was killed + except ConnectionResetError as e: + logger.error(e) + + def init(): + self.adb_reconnect() + # When ascreencap is not installed + except AscreencapError as e: + logger.error(e) + + def init(): + self.ascreencap_init() + # AdbError + except AdbError as e: + if handle_adb_error(e): + def init(): + self.adb_reconnect() + else: + break + # ImageTruncated + except ImageTruncated as e: + logger.error(e) + + def init(): + pass + # Unknown + except Exception as e: + logger.exception(e) + + def init(): + pass + + logger.critical(f'Retry {func.__name__}() failed') + raise RequestHumanTakeover + + return retry_wrapper + + +class AScreenCap(Connection): + __screenshot_method = [0, 1, 2] + __screenshot_method_fixed = [0, 1, 2] + __bytepointer = 0 + ascreencap_available = True + + def ascreencap_init(self): + logger.hr('aScreenCap init') + self.__bytepointer = 0 + self.ascreencap_available = True + + arc = self.cpu_abi + sdk = self.sdk_ver + logger.info(f'cpu_arc: {arc}, sdk_ver: {sdk}') + + if sdk in range(21, 26): + ver = "Android_5.x-7.x" + elif sdk in range(26, 28): + ver = "Android_8.x" + elif sdk == 28: + ver = "Android_9.x" + else: + ver = "0" + filepath = os.path.join(self.config.ASCREENCAP_FILEPATH_LOCAL, ver, arc, 'ascreencap') + if not os.path.exists(filepath): + self.ascreencap_available = False + logger.error('No suitable version of aScreenCap lib available for this device, ' + 'please use other screenshot methods instead') + raise RequestHumanTakeover + + logger.info(f'pushing {filepath}') + self.adb_push(filepath, self.config.ASCREENCAP_FILEPATH_REMOTE) + + logger.info(f'chmod 0777 {self.config.ASCREENCAP_FILEPATH_REMOTE}') + self.adb_shell(['chmod', '0777', self.config.ASCREENCAP_FILEPATH_REMOTE]) + + def uninstall_ascreencap(self): + logger.info('Removing ascreencap') + self.adb_shell(['rm', self.config.ASCREENCAP_FILEPATH_REMOTE]) + + def _ascreencap_reposition_byte_pointer(self, byte_array): + """Method to return the sanitized version of ascreencap stdout for devices + that suffers from linker warnings. The correct pointer location will be saved + for subsequent screen refreshes + """ + while byte_array[self.__bytepointer:self.__bytepointer + 4] != b'BMZ1': + self.__bytepointer += 1 + if self.__bytepointer >= len(byte_array): + text = 'Repositioning byte pointer failed, corrupted aScreenCap data received' + logger.warning(text) + if len(byte_array) < 500: + logger.warning(f'Unexpected screenshot: {byte_array}') + raise AscreencapError(text) + return byte_array[self.__bytepointer:] + + def __load_screenshot(self, screenshot, method): + if method == 0: + return screenshot + elif method == 1: + return screenshot.replace(b'\r\n', b'\n') + elif method == 2: + return screenshot.replace(b'\r\r\n', b'\n') + else: + raise ScriptError(f'Unknown method to load screenshots: {method}') + + def __uncompress(self, screenshot): + raw_compressed_data = self._ascreencap_reposition_byte_pointer(screenshot) + + # See headers in: + # https://github.com/ClnViewer/Android-fast-screen-capture#streamimage-compressed---header-format-using + compressed_data_header = np.frombuffer(raw_compressed_data[0:20], dtype=np.uint32) + if compressed_data_header[0] != 828001602: + compressed_data_header = compressed_data_header.byteswap() + if compressed_data_header[0] != 828001602: + text = f'aScreenCap header verification failure, corrupted image received. ' \ + f'HEADER IN HEX = {compressed_data_header.tobytes().hex()}' + logger.warning(text) + raise AscreencapError(text) + + _, uncompressed_size, _, width, height = compressed_data_header + channel = 3 + data = lz4.block.decompress(raw_compressed_data[20:], uncompressed_size=uncompressed_size) + + image = np.frombuffer(data, dtype=np.uint8) + if image is None: + raise ImageTruncated('Empty image after reading from buffer') + + # Equivalent to cv2.imdecode() + try: + image = image[-int(width * height * channel):].reshape(height, width, channel) + except ValueError as e: + # ValueError: cannot reshape array of size 0 into shape (720,1280,4) + raise ImageTruncated(str(e)) + + image = cv2.flip(image, 0) + if image is None: + raise ImageTruncated('Empty image after cv2.flip') + + image = cv2.cvtColor(image, cv2.COLOR_BGR2RGB) + if image is None: + raise ImageTruncated('Empty image after cv2.cvtColor') + + return image + + def __process_screenshot(self, screenshot): + for method in self.__screenshot_method_fixed: + try: + result = self.__load_screenshot(screenshot, method=method) + result = self.__uncompress(result) + self.__screenshot_method_fixed = [method] + self.__screenshot_method + return result + except lz4.block.LZ4BlockError: + self.__bytepointer = 0 + continue + + self.__screenshot_method_fixed = self.__screenshot_method + if len(screenshot) < 500: + logger.warning(f'Unexpected screenshot: {screenshot}') + raise OSError(f'cannot load screenshot') + + @retry + def screenshot_ascreencap(self): + content = self.adb_shell([self.config.ASCREENCAP_FILEPATH_REMOTE, '--pack', '2', '--stdout'], stream=True) + + return self.__process_screenshot(content) + + @retry + def screenshot_ascreencap_nc(self): + data = self.adb_shell_nc([self.config.ASCREENCAP_FILEPATH_REMOTE, '--pack', '2', '--stdout']) + if len(data) < 500: + logger.warning(f'Unexpected screenshot: {data}') + + return self.__uncompress(data) diff --git a/module/device/method/droidcast.py b/module/device/method/droidcast.py new file mode 100644 index 0000000..39baeb4 --- /dev/null +++ b/module/device/method/droidcast.py @@ -0,0 +1,291 @@ +import typing as t +from functools import wraps + +import cv2 +import numpy as np +import requests +from adbutils.errors import AdbError + +from module.base.decorator import Config, cached_property, del_cached_property +from module.base.timer import Timer +from module.device.method.uiautomator_2 import Uiautomator2, ProcessInfo +from module.device.method.utils import (retry_sleep, RETRY_TRIES, handle_adb_error, + ImageTruncated, PackageNotInstalled) +from module.exception import RequestHumanTakeover +from module.logger import logger + + +class DroidCastVersionIncompatible(Exception): + pass + + +def retry(func): + @wraps(func) + def retry_wrapper(self, *args, **kwargs): + """ + Args: + self (Adb): + """ + init = None + for _ in range(RETRY_TRIES): + try: + if callable(init): + retry_sleep(_) + init() + return func(self, *args, **kwargs) + # Can't handle + except RequestHumanTakeover: + break + # When adb server was killed + except ConnectionResetError as e: + logger.error(e) + + def init(): + self.adb_reconnect() + # AdbError + except AdbError as e: + if handle_adb_error(e): + def init(): + self.adb_reconnect() + else: + break + # Package not installed + except PackageNotInstalled as e: + logger.error(e) + + def init(): + self.detect_package() + # DroidCast not running + # requests.exceptions.ConnectionError: ('Connection aborted.', RemoteDisconnected('Remote end closed connection without response')) + # ReadTimeout: HTTPConnectionPool(host='127.0.0.1', port=20482): Read timed out. (read timeout=3) + except (requests.exceptions.ConnectionError, requests.exceptions.ReadTimeout) as e: + logger.error(e) + + def init(): + self.droidcast_init() + # DroidCastVersionIncompatible + except DroidCastVersionIncompatible as e: + logger.error(e) + + def init(): + self.droidcast_init() + # ImageTruncated + except ImageTruncated as e: + logger.error(e) + + def init(): + pass + # Unknown + except Exception as e: + logger.exception(e) + + def init(): + pass + + logger.critical(f'Retry {func.__name__}() failed') + raise RequestHumanTakeover + + return retry_wrapper + + +class DroidCast(Uiautomator2): + """ + DroidCast, another screenshot method, https://github.com/rayworks/DroidCast + DroidCast_raw, a modified version of DroidCast sending raw bitmap https://github.com/Torther/DroidCastS + """ + + _droidcast_port: int = 0 + + @cached_property + def droidcast_session(self): + session = requests.Session() + session.trust_env = False # Ignore proxy + self._droidcast_port = self.adb_forward('tcp:53516') + return session + + def droidcast_url(self, url='/screenshot?format=png'): + """ + Check APIs from source code: + https://github.com/rayworks/DroidCast/blob/master/app/src/main/java/com/rayworks/droidcast/Main.java + + Available APIs: + - /screenshot + To get JPG screenshots. + - /screenshot?format=png + To get PNG screenshots. + - /screenshot?format=webp + To get WEBP screenshots. + - /src + Websocket to get JPG screenshots. + + Note that /screenshot?format=jpg is unavailable. + """ + return f'http://127.0.0.1:{self._droidcast_port}{url}' + + @Config.when(DROIDCAST_VERSION='DroidCast') + def droidcast_init(self): + logger.hr('Droidcast init') + self.droidcast_stop() + + logger.info('Pushing DroidCast apk') + self.adb_push(self.config.DROIDCAST_FILEPATH_LOCAL, self.config.DROIDCAST_FILEPATH_REMOTE) + + logger.info('Starting DroidCast apk') + # CLASSPATH=/data/local/tmp/DroidCast.apk app_process / com.rayworks.droidcast.Main > /dev/null + resp = self.u2_shell_background([ + 'CLASSPATH=/data/local/tmp/DroidCast.apk', + 'app_process', + '/', + 'com.rayworks.droidcast.Main', + '>', + '/dev/null' + ]) + logger.info(resp) + + del_cached_property(self, 'droidcast_session') + _ = self.droidcast_session + logger.attr('DroidCast', self.droidcast_url()) + self.droidcast_wait_startup() + + @Config.when(DROIDCAST_VERSION='DroidCast_raw') + def droidcast_init(self): + logger.hr('Droidcast init') + self.resolution_check_uiautomator2() + self.droidcast_stop() + + logger.info('Pushing DroidCast apk') + self.adb_push(self.config.DROIDCAST_RAW_FILEPATH_LOCAL, self.config.DROIDCAST_RAW_FILEPATH_REMOTE) + + logger.info('Starting DroidCast apk') + # DroidCastS-release-1.1.5.apk + # CLASSPATH=/data/local/tmp/DroidCastS-release-1.1.5.apk app_process / com.torther.droidcasts.Main > /dev/null + resp = self.u2_shell_background([ + 'CLASSPATH=/data/local/tmp/DroidCastS.apk', + 'app_process', + '/', + 'com.torther.droidcasts.Main', + '>', + '/dev/null' + ]) + logger.info(resp) + + del_cached_property(self, 'droidcast_session') + _ = self.droidcast_session + logger.attr('DroidCast', self.droidcast_url()) + self.droidcast_wait_startup() + + @retry + def screenshot_droidcast(self): + self.config.DROIDCAST_VERSION = 'DroidCast' + image = self.droidcast_session.get(self.droidcast_url(), timeout=3).content + image = np.frombuffer(image, np.uint8) + if image is None: + raise ImageTruncated('Empty image after reading from buffer') + if image.shape == (1843200,): + raise DroidCastVersionIncompatible('Requesting screenshots from `DroidCast` but server is `DroidCast_raw`') + + image = cv2.imdecode(image, cv2.IMREAD_COLOR) + if image is None: + raise ImageTruncated('Empty image after cv2.imdecode') + + image = cv2.cvtColor(image, cv2.COLOR_BGR2RGB) + if image is None: + raise ImageTruncated('Empty image after cv2.cvtColor') + + return image + + @retry + def screenshot_droidcast_raw(self): + self.config.DROIDCAST_VERSION = 'DroidCast_raw' + image = self.droidcast_session.get(self.droidcast_url(), timeout=3).content + # DroidCast_raw returns a RGB565 bitmap + + try: + arr = np.frombuffer(image, dtype=np.uint16).reshape((720, 1280)) + except ValueError as e: + # Try to load as `DroidCast` + image = np.frombuffer(image, np.uint8) + if image is not None: + image = cv2.imdecode(image, cv2.IMREAD_COLOR) + if image is not None: + raise DroidCastVersionIncompatible( + 'Requesting screenshots from `DroidCast_raw` but server is `DroidCast`') + # ValueError: cannot reshape array of size 0 into shape (720,1280) + raise ImageTruncated(str(e)) + + # Convert RGB565 to RGB888 + # https://blog.csdn.net/happy08god/article/details/10516871 + + # r = (arr & 0b1111100000000000) >> (11 - 3) + # g = (arr & 0b0000011111100000) >> (5 - 2) + # b = (arr & 0b0000000000011111) << 3 + # r |= (r & 0b11100000) >> 5 + # g |= (g & 0b11000000) >> 6 + # b |= (b & 0b11100000) >> 5 + # r = r.astype(np.uint8) + # g = g.astype(np.uint8) + # b = b.astype(np.uint8) + # image = cv2.merge([r, g, b]) + + # The same as the code above but costs about 5ms instead of 10ms. + r = cv2.multiply(arr & 0b1111100000000000, 0.00390625).astype(np.uint8) + g = cv2.multiply(arr & 0b0000011111100000, 0.125).astype(np.uint8) + b = cv2.multiply(arr & 0b0000000000011111, 8).astype(np.uint8) + r = cv2.add(r, cv2.multiply(r, 0.03125)) + g = cv2.add(g, cv2.multiply(g, 0.015625)) + b = cv2.add(b, cv2.multiply(b, 0.03125)) + image = cv2.merge([r, g, b]) + + return image + + def droidcast_wait_startup(self): + """ + Wait until DroidCast startup completed. + """ + timeout = Timer(10).start() + while 1: + self.sleep(0.25) + if timeout.reached(): + break + + try: + resp = self.droidcast_session.get(self.droidcast_url('/'), timeout=3) + # Route `/` is unavailable, but 404 means startup completed + if resp.status_code == 404: + logger.attr('DroidCast', 'online') + return True + except requests.exceptions.ConnectionError: + logger.attr('DroidCast', 'offline') + + logger.warning('Wait DroidCast startup timeout, assume started') + return False + + def droidcast_uninstall(self): + """ + Stop all DroidCast processes and remove DroidCast APK. + DroidCast has't been installed but a JAVA class call, uninstall is a file delete. + """ + self.droidcast_stop() + logger.info('Removing DroidCast') + self.adb_shell(["rm", self.config.DROIDCAST_FILEPATH_REMOTE]) + self.adb_shell(["rm", self.config.DROIDCAST_RAW_FILEPATH_REMOTE]) + + def _iter_droidcast_proc(self) -> t.Iterable[ProcessInfo]: + """ + List all DroidCast processes. + """ + processes = self.proc_list_uiautomator2() + for proc in processes: + if 'com.rayworks.droidcast.Main' in proc.cmdline: + yield proc + if 'com.torther.droidcasts.Main' in proc.cmdline: + yield proc + + def droidcast_stop(self): + """ + Stop all DroidCast processes. + """ + logger.info('Stopping DroidCast') + for proc in self._iter_droidcast_proc(): + logger.info(f'Kill pid={proc.pid}') + self.adb_shell(['kill', '-s', 9, proc.pid]) diff --git a/module/device/method/hermit.py b/module/device/method/hermit.py new file mode 100644 index 0000000..ad0c397 --- /dev/null +++ b/module/device/method/hermit.py @@ -0,0 +1,239 @@ +import json +from functools import wraps + +import requests +from adbutils.errors import AdbError + +from module.base.decorator import cached_property +from module.base.timer import Timer +from module.base.utils import point2str, random_rectangle_point +from module.device.method.adb import Adb +from module.device.method.utils import (RETRY_TRIES, retry_sleep, + HierarchyButton, handle_adb_error) +from module.exception import RequestHumanTakeover +from module.logger import logger + + +class HermitError(Exception): + pass + + +def retry(func): + @wraps(func) + def retry_wrapper(self, *args, **kwargs): + """ + Args: + self (Hermit): + """ + init = None + for _ in range(RETRY_TRIES): + try: + if callable(init): + retry_sleep(_) + init() + return func(self, *args, **kwargs) + # Can't handle + except RequestHumanTakeover: + break + # When adb server was killed + except ConnectionResetError as e: + logger.error(e) + + def init(): + self.adb_reconnect() + # When unable to send requests + except requests.exceptions.ConnectionError as e: + logger.error(e) + text = str(e) + if 'Connection aborted' in text: + # Hermit not installed or not running + # ('Connection aborted.', RemoteDisconnected('Remote end closed connection without response')) + def init(): + self.adb_reconnect() + self.hermit_init() + else: + # Lost connection, adb server was killed + # HTTPConnectionPool(host='127.0.0.1', port=20269): + # Max retries exceeded with url: /click?x=500&y=500 + def init(): + self.adb_reconnect() + # AdbError + except AdbError as e: + if handle_adb_error(e): + def init(): + self.adb_reconnect() + else: + break + # HermitError: {"code":-1,"msg":"error"} + except HermitError as e: + logger.error(e) + + def init(): + self.adb_reconnect() + self.hermit_init() + # Unknown, probably a trucked image + except Exception as e: + logger.exception(e) + + def init(): + pass + + logger.critical(f'Retry {func.__name__}() failed') + raise RequestHumanTakeover + + return retry_wrapper + + +class Hermit(Adb): + """ + Hermit, https://github.com/LookCos/hermit. + API docs: https://www.lookcos.cn/docs/hermit#/zh-cn/API + + True, Hermit has other control APIs and screenshot APIs but they ALL WORK LIKE SHIT. + Hermit screenshot is slower than ADB and you are likely to get request timeout or trucked images. + Thus, it requests root permission every time, + so you will get a toast showing forever: Superuser granted to Hermit. + + Hermit is added to Alas in order to have a better performance in vmos which can't run uiautomator2 and minitouch. + Note that Hermit requires Android>=7.0 + """ + _hermit_port = 9999 + _hermit_package_name = 'com.lookcos.hermit' + + @property + def _hermit_url(self): + return f'http://127.0.0.1:{self._hermit_port}' + + def hermit_init(self): + logger.hr('Hermit init') + + self.app_stop_adb(self._hermit_package_name) + # self.uninstall_hermit() + + logger.info('Try to start hermit') + if self.app_start_adb(self._hermit_package_name, allow_failure=True): + # Success to start hermit + logger.info('Success to start hermit') + else: + # Hermit not installed + logger.warning(f'{self._hermit_package_name} not found, installing hermit') + self.adb_command(['install', '-t', self.config.HERMIT_FILEPATH_LOCAL]) + self.app_start_adb(self._hermit_package_name) + + # Enable accessibility service + self.hermit_enable_accessibility() + + # Hide Hermit + # 0 --> "KEYCODE_UNKNOWN" + # 1 --> "KEYCODE_MENU" + # 2 --> "KEYCODE_SOFT_RIGHT" + # 3 --> "KEYCODE_HOME" + # 4 --> "KEYCODE_BACK" + # 5 --> "KEYCODE_CALL" + # 6 --> "KEYCODE_ENDCALL" + self.adb_shell(['input', 'keyevent', '3']) + + # Switch back to AzurLane + self.app_start_adb() + + def uninstall_hermit(self): + self.adb_command(['uninstall', self._hermit_package_name]) + + def hermit_enable_accessibility(self): + """ + Turn on accessibility service for Hermit. + + Raises: + RequestHumanTakeover: If failed and user should do it manually. + """ + logger.hr('Enable accessibility service') + interval = Timer(0.3) + timeout = Timer(10, count=10).start() + while 1: + h = self.dump_hierarchy_adb() + interval.wait() + interval.reset() + + def appear(xpath): + return bool(HierarchyButton(h, xpath)) + + def appear_then_click(xpath): + b = HierarchyButton(h, xpath) + if b: + point = random_rectangle_point(b.button) + logger.info(f'Click {point2str(*point)} @ {b}') + self.click_adb(*point) + return True + else: + return False + + if appear_then_click('//*[@text="Hermit" and @resource-id="android:id/title"]'): + continue + if appear_then_click('//*[@class="android.widget.Switch" and @checked="false"]'): + continue + if appear_then_click('//*[@resource-id="android:id/button1"]'): + # Just plain click here + # Can't use uiautomator once hermit has access to accessibility service, + # or uiautomator will get the access. + break + if appear('//*[@class="android.widget.Switch" and @checked="true"]'): + raise HermitError('Accessibility service already enable but get error') + + # End + if timeout.reached(): + logger.critical('Unable to turn on accessibility service for Hermit') + logger.critical( + '\n\n' + 'Please do this manually:\n' + '1. Find "Hermit" in accessibility setting and click it\n' + '2. Turn it ON and click OK\n' + '3. Switch back to AzurLane\n' + ) + raise RequestHumanTakeover + + @cached_property + def hermit_session(self): + session = requests.Session() + session.trust_env = False # Ignore proxy + self._hermit_port = self.adb_forward('tcp:9999') + return session + + def hermit_send(self, url, **kwargs): + """ + Args: + url (str): + **kwargs: + + Returns: + dict: Usually to be {"code":0,"msg":"ok"} + """ + result = self.hermit_session.get(f'{self._hermit_url}{url}', params=kwargs, timeout=3).text + try: + result = json.loads(result, encoding='utf-8') + if result['code'] != 0: + # {"code":-1,"msg":"error"} + raise HermitError(result) + except (json.decoder.JSONDecodeError, KeyError): + e = HermitError(result) + if 'GestureDescription$Builder' in result: + logger.error(e) + logger.critical('Hermit cannot run on current device, hermit requires Android>=7.0') + raise RequestHumanTakeover + if 'accessibilityservice' in result: + # Attempt to invoke virtual method + # 'boolean android.accessibilityservice.AccessibilityService.dispatchGesture( + # android.accessibilityservice.GestureDescription, + # android.accessibilityservice.AccessibilityService$GestureResultCallback, + # android.os.Handler + # )' on a null object reference + logger.error('Unable to access accessibility service') + raise e + + # Hermit only takes 2-4ms + # Add a 50ms delay because game can't response quickly. + self.sleep(0.05) + return result + + @retry + def click_hermit(self, x, y): + self.hermit_send('/click', x=x, y=y) diff --git a/module/device/method/maatouch.py b/module/device/method/maatouch.py new file mode 100644 index 0000000..18b00c5 --- /dev/null +++ b/module/device/method/maatouch.py @@ -0,0 +1,273 @@ +import socket +from functools import wraps + +from adbutils.errors import AdbError + +from module.base.decorator import cached_property, del_cached_property +from module.base.timer import Timer +from module.base.utils import * +from module.device.connection import Connection +from module.device.method.minitouch import CommandBuilder, insert_swipe +from module.device.method.utils import RETRY_TRIES, retry_sleep, handle_adb_error +from module.exception import RequestHumanTakeover +from module.logger import logger + + +def retry(func): + @wraps(func) + def retry_wrapper(self, *args, **kwargs): + """ + Args: + self (MaaTouch): + """ + init = None + for _ in range(RETRY_TRIES): + try: + if callable(init): + retry_sleep(_) + init() + return func(self, *args, **kwargs) + # Can't handle + except RequestHumanTakeover: + break + # When adb server was killed + except ConnectionResetError as e: + logger.error(e) + + def init(): + self.adb_reconnect() + del_cached_property(self, 'maatouch_builder') + # Emulator closed + except ConnectionAbortedError as e: + logger.error(e) + + def init(): + self.adb_reconnect() + del_cached_property(self, 'maatouch_builder') + # AdbError + except AdbError as e: + if handle_adb_error(e): + def init(): + self.adb_reconnect() + del_cached_property(self, 'maatouch_builder') + else: + break + # MaaTouchNotInstalledError: Received "Aborted" from MaaTouch + except MaaTouchNotInstalledError as e: + logger.error(e) + + def init(): + self.maatouch_install() + del_cached_property(self, 'maatouch_builder') + except BrokenPipeError as e: + logger.error(e) + + def init(): + del_cached_property(self, 'maatouch_builder') + # Unknown, probably a trucked image + except Exception as e: + logger.exception(e) + + def init(): + pass + + logger.critical(f'Retry {func.__name__}() failed') + raise RequestHumanTakeover + + return retry_wrapper + + +class MaatouchBuilder(CommandBuilder): + def __init__(self, device, contact=0, handle_orientation=False): + """ + Args: + device (MaaTouch): + """ + + super().__init__(device, contact, handle_orientation) + + def send(self): + return self.device.maatouch_send(builder=self) + + +class MaaTouchNotInstalledError(Exception): + pass + + +class MaaTouch(Connection): + """ + Control method that implements the same as scrcpy and has an interface similar to minitouch. + https://github.com/MaaAssistantArknights/MaaTouch + """ + max_x: int + max_y: int + _maatouch_stream = socket.socket + _maatouch_stream_storage = None + + @cached_property + def maatouch_builder(self): + self.maatouch_init() + return MaatouchBuilder(self) + + def maatouch_init(self): + logger.hr('MaaTouch init') + max_x, max_y = 1280, 720 + max_contacts = 2 + max_pressure = 50 + + # CLASSPATH=/data/local/tmp/maatouch app_process / com.shxyke.MaaTouch.App + stream = self.adb_shell( + ['CLASSPATH=/data/local/tmp/maatouch', 'app_process', '/', 'com.shxyke.MaaTouch.App'], + stream=True, + recvall=False + ) + # Prevent shell stream from being deleted causing socket close + self._maatouch_stream_storage = stream + stream = stream.conn + stream.settimeout(10) + self._maatouch_stream = stream + + retry_timeout = Timer(5).start() + while 1: + # v + # protocol version, usually it is 1. needn't use this + # get maatouch server info + socket_out = stream.makefile() + + # ^ + out = socket_out.readline().replace("\n", "").replace("\r", "") + logger.info(out) + if out.strip() == 'Aborted': + stream.close() + raise MaaTouchNotInstalledError( + 'Received "Aborted" MaaTouch, ' + 'probably because MaaTouch is not installed' + ) + try: + _, max_contacts, max_x, max_y, max_pressure = out.split(" ") + break + except ValueError: + stream.close() + if retry_timeout.reached(): + raise MaaTouchNotInstalledError( + 'Received empty data from MaaTouch, ' + 'probably because MaaTouch is not installed' + ) + else: + # maatouch may not start that fast + self.sleep(1) + continue + + # self.max_contacts = max_contacts + self.max_x = int(max_x) + self.max_y = int(max_y) + # self.max_pressure = max_pressure + + # $ + out = socket_out.readline().replace("\n", "").replace("\r", "") + logger.info(out) + # _, pid = out.split(" ") + # self._maatouch_pid = pid + + logger.info( + "MaaTouch stream connected" + ) + logger.info( + "max_contact: {}; max_x: {}; max_y: {}; max_pressure: {}".format( + max_contacts, max_x, max_y, max_pressure + ) + ) + + def maatouch_send(self, builder: MaatouchBuilder): + content = builder.to_minitouch() + # logger.info("send operation: {}".format(content.replace("\n", "\\n"))) + byte_content = content.encode('utf-8') + self._maatouch_stream.sendall(byte_content) + self._maatouch_stream.recv(0) + self.sleep(self.maatouch_builder.delay / 1000 + builder.DEFAULT_DELAY) + builder.clear() + + def maatouch_install(self): + logger.hr('MaaTouch install') + self.adb_push(self.config.MAATOUCH_FILEPATH_LOCAL, self.config.MAATOUCH_FILEPATH_REMOTE) + + def maatouch_uninstall(self): + logger.hr('MaaTouch uninstall') + self.adb_shell(["rm", self.config.MAATOUCH_FILEPATH_REMOTE]) + + @retry + def click_maatouch(self, x, y): + builder = self.maatouch_builder + builder.down(x, y).commit() + builder.up().commit() + builder.send() + + @retry + def long_click_maatouch(self, x, y, duration=1.0): + duration = int(duration * 1000) + builder = self.maatouch_builder + builder.down(x, y).commit().wait(duration) + builder.up().commit() + builder.send() + + @retry + def swipe_maatouch(self, p1, p2): + points = insert_swipe(p0=p1, p3=p2) + builder = self.maatouch_builder + + builder.down(*points[0]).commit() + builder.send() + + for point in points[1:]: + builder.move(*point).commit().wait(10) + builder.send() + + builder.up().commit() + builder.send() + + @retry + def drag_maatouch(self, p1, p2, point_random=(-10, -10, 10, 10)): + p1 = np.array(p1) - random_rectangle_point(point_random) + p2 = np.array(p2) - random_rectangle_point(point_random) + points = insert_swipe(p0=p1, p3=p2, speed=20) + builder = self.maatouch_builder + + builder.down(*points[0]).commit() + builder.send() + + for point in points[1:]: + builder.move(*point).commit().wait(10) + builder.send() + + builder.move(*p2).commit().wait(140) + builder.move(*p2).commit().wait(140) + builder.send() + + builder.up().commit() + builder.send() + + @retry + def pinch_maatouch(self, p1_1, p1_2, p2_1, p2_2): + p1 = insert_swipe(p0=p1_1, p3=p1_2) + p2 = insert_swipe(p0=p2_1, p3=p2_2) + builder = self.maatouch_builder + + builder.contact = 0 + builder.down(*p1[0]).commit() + builder.contact = 1 + builder.down(*p2[0]).commit() + builder.send() + + builder.contact = 0 + for point in p1[1:]: + builder.move(*point).commit().wait(10) + builder.contact = 1 + for point in p2[1:]: + builder.move(*point).commit().wait(10) + builder.send() + + builder.contact = 0 + builder.up().commit() + builder.contact = 1 + builder.up().commit() + builder.send() diff --git a/module/device/method/minitouch.py b/module/device/method/minitouch.py new file mode 100644 index 0000000..405bc02 --- /dev/null +++ b/module/device/method/minitouch.py @@ -0,0 +1,582 @@ +import asyncio +import json +import re +import socket +import time +from functools import wraps +from typing import List + +import websockets +from adbutils.errors import AdbError +from uiautomator2 import _Service + +from module.base.decorator import Config, cached_property, del_cached_property +from module.base.timer import Timer +from module.base.utils import * +from module.device.connection import Connection +from module.device.method.utils import RETRY_TRIES, retry_sleep, handle_adb_error +from module.exception import RequestHumanTakeover, ScriptError +from module.logger import logger + + +def random_normal_distribution(a, b, n=5): + output = np.mean(np.random.uniform(a, b, size=n)) + return output + + +def random_theta(): + theta = np.random.uniform(0, 2 * np.pi) + return np.array([np.sin(theta), np.cos(theta)]) + + +def random_rho(dis): + return random_normal_distribution(-dis, dis) + + +def insert_swipe(p0, p3, speed=15, min_distance=10): + """ + Insert way point from start to end. + First generate a cubic bézier curve + + Args: + p0: Start point. + p3: End point. + speed: Average move speed, pixels per 10ms. + min_distance: + + Returns: + list[list[int]]: List of points. + + Examples: + > insert_swipe((400, 400), (600, 600), speed=20) + [[400, 400], [406, 406], [416, 415], [429, 428], [444, 442], [462, 459], [481, 478], [504, 500], [527, 522], + [545, 540], [560, 557], [573, 570], [584, 582], [592, 590], [597, 596], [600, 600]] + """ + p0 = np.array(p0) + p3 = np.array(p3) + + # Random control points in Bézier curve + distance = np.linalg.norm(p3 - p0) + p1 = 2 / 3 * p0 + 1 / 3 * p3 + random_theta() * random_rho(distance * 0.1) + p2 = 1 / 3 * p0 + 2 / 3 * p3 + random_theta() * random_rho(distance * 0.1) + + # Random `t` on Bézier curve, sparse in the middle, dense at start and end + segments = max(int(distance / speed) + 1, 5) + lower = random_normal_distribution(-85, -60) + upper = random_normal_distribution(80, 90) + theta = np.arange(lower + 0., upper + 0.0001, (upper - lower) / segments) + ts = np.sin(theta / 180 * np.pi) + ts = np.sign(ts) * abs(ts) ** 0.9 + ts = (ts - min(ts)) / (max(ts) - min(ts)) + + # Generate cubic Bézier curve + points = [] + prev = (-100, -100) + for t in ts: + point = p0 * (1 - t) ** 3 + 3 * p1 * t * (1 - t) ** 2 + 3 * p2 * t ** 2 * (1 - t) + p3 * t ** 3 + point = point.astype(int).tolist() + if np.linalg.norm(np.subtract(point, prev)) < min_distance: + continue + + points.append(point) + prev = point + + # Delete nearing points + if len(points[1:]): + distance = np.linalg.norm(np.subtract(points[1:], points[0]), axis=1) + mask = np.append(True, distance > min_distance) + points = np.array(points)[mask].tolist() + else: + points = [p0, p3] + + return points + + +class Command: + def __init__( + self, + operation: str, + contact: int = 0, + x: int = 0, + y: int = 0, + ms: int = 10, + pressure: int = 100 + ): + """ + See https://github.com/openstf/minitouch#writable-to-the-socket + + Args: + operation: c, r, d, m, u, w + contact: + x: + y: + ms: + pressure: + """ + self.operation = operation + self.contact = contact + self.x = x + self.y = y + self.ms = ms + self.pressure = pressure + + def to_minitouch(self) -> str: + """ + String that write into minitouch socket + """ + if self.operation == 'c': + return f'{self.operation}\n' + elif self.operation == 'r': + return f'{self.operation}\n' + elif self.operation == 'd': + return f'{self.operation} {self.contact} {self.x} {self.y} {self.pressure}\n' + elif self.operation == 'm': + return f'{self.operation} {self.contact} {self.x} {self.y} {self.pressure}\n' + elif self.operation == 'u': + return f'{self.operation} {self.contact}\n' + elif self.operation == 'w': + return f'{self.operation} {self.ms}\n' + else: + return '' + + def to_atx_agent(self, max_x=1280, max_y=720) -> str: + """ + Dict that send to atx-agent, $DEVICE_URL/minitouch + See https://github.com/openatx/atx-agent#minitouch%E6%93%8D%E4%BD%9C%E6%96%B9%E6%B3%95 + """ + x, y = self.x / max_x, self.y / max_y + if self.operation == 'c': + out = dict(operation=self.operation) + elif self.operation == 'r': + out = dict(operation=self.operation) + elif self.operation == 'd': + out = dict(operation=self.operation, index=self.contact, pressure=self.pressure, xP=x, yP=y) + elif self.operation == 'm': + out = dict(operation=self.operation, index=self.contact, pressure=self.pressure, xP=x, yP=y) + elif self.operation == 'u': + out = dict(operation=self.operation, index=self.contact) + elif self.operation == 'w': + out = dict(operation=self.operation, milliseconds=self.ms) + else: + out = dict() + return json.dumps(out) + + +class CommandBuilder: + """Build command str for minitouch. + + You can use this, to custom actions as you wish:: + + with safe_connection(_DEVICE_ID) as connection: + builder = CommandBuilder() + builder.down(0, 400, 400, 50) + builder.commit() + builder.move(0, 500, 500, 50) + builder.commit() + builder.move(0, 800, 400, 50) + builder.commit() + builder.up(0) + builder.commit() + builder.publish(connection) + + """ + DEFAULT_DELAY = 0.05 + max_x = 1280 + max_y = 720 + + def __init__(self, device, contact=0, handle_orientation=True): + """ + Args: + device: + """ + self.device = device + self.commands = [] + self.delay = 0 + self.contact = contact + self.handle_orientation = handle_orientation + + @property + def orientation(self): + if self.handle_orientation: + return self.device.orientation + else: + return 0 + + def convert(self, x, y): + max_x, max_y = self.device.max_x, self.device.max_y + orientation = self.orientation + + if orientation == 0: + pass + elif orientation == 1: + x, y = 720 - y, x + max_x, max_y = max_y, max_x + elif orientation == 2: + x, y = 1280 - x, 720 - y + elif orientation == 3: + x, y = y, 1280 - x + max_x, max_y = max_y, max_x + else: + raise ScriptError(f'Invalid device orientation: {orientation}') + + self.max_x, self.max_y = max_x, max_y + if not self.device.config.DEVICE_OVER_HTTP: + # Maximum X and Y coordinates may, but usually do not, match the display size. + x, y = int(x / 1280 * max_x), int(y / 720 * max_y) + else: + # When over http, max_x and max_y are default to 1280 and 720, skip matching display size + x, y = int(x), int(y) + return x, y + + def commit(self): + """ add minitouch command: 'c\n' """ + self.commands.append(Command('c')) + return self + + def reset(self): + """ add minitouch command: 'r\n' """ + self.commands.append(Command('r')) + return self + + def wait(self, ms=10): + """ add minitouch command: 'w \n' """ + self.commands.append(Command('w', ms=ms)) + self.delay += ms + return self + + def up(self): + """ add minitouch command: 'u \n' """ + self.commands.append(Command('u', contact=self.contact)) + return self + + def down(self, x, y, pressure=100): + """ add minitouch command: 'd \n' """ + x, y = self.convert(x, y) + self.commands.append(Command('d', x=x, y=y, contact=self.contact, pressure=pressure)) + return self + + def move(self, x, y, pressure=100): + """ add minitouch command: 'm \n' """ + x, y = self.convert(x, y) + self.commands.append(Command('m', x=x, y=y, contact=self.contact, pressure=pressure)) + return self + + def clear(self): + """ clear current commands """ + self.commands = [] + self.delay = 0 + + def to_minitouch(self) -> str: + return ''.join([command.to_minitouch() for command in self.commands]) + + def to_atx_agent(self) -> List[str]: + return [command.to_atx_agent(self.max_x, self.max_y) for command in self.commands] + + def send(self): + return self.device.minitouch_send(builder=self) + + +class MinitouchNotInstalledError(Exception): + pass + + +class MinitouchOccupiedError(Exception): + pass + + +class U2Service(_Service): + def __init__(self, name, u2obj): + self.name = name + self.u2obj = u2obj + self.service_url = self.u2obj.path2url("/services/" + name) + + +def retry(func): + @wraps(func) + def retry_wrapper(self, *args, **kwargs): + """ + Args: + self (Minitouch): + """ + init = None + for _ in range(RETRY_TRIES): + try: + if callable(init): + retry_sleep(_) + init() + return func(self, *args, **kwargs) + # Can't handle + except RequestHumanTakeover: + break + # When adb server was killed + except ConnectionResetError as e: + logger.error(e) + + def init(): + self.adb_reconnect() + # Emulator closed + except ConnectionAbortedError as e: + logger.error(e) + + def init(): + self.adb_reconnect() + # MinitouchNotInstalledError: Received empty data from minitouch + except MinitouchNotInstalledError as e: + logger.error(e) + + def init(): + self.install_uiautomator2() + if self._minitouch_port: + self.adb_forward_remove(f'tcp:{self._minitouch_port}') + del_cached_property(self, 'minitouch_builder') + # MinitouchOccupiedError: Timeout when connecting to minitouch + except MinitouchOccupiedError as e: + logger.error(e) + + def init(): + self.restart_atx() + if self._minitouch_port: + self.adb_forward_remove(f'tcp:{self._minitouch_port}') + del_cached_property(self, 'minitouch_builder') + # AdbError + except AdbError as e: + if handle_adb_error(e): + def init(): + self.adb_reconnect() + else: + break + except BrokenPipeError as e: + logger.error(e) + + def init(): + del_cached_property(self, 'minitouch_builder') + # Unknown, probably a trucked image + except Exception as e: + logger.exception(e) + + def init(): + pass + + logger.critical(f'Retry {func.__name__}() failed') + raise RequestHumanTakeover + + return retry_wrapper + + +class Minitouch(Connection): + _minitouch_port: int = 0 + _minitouch_client: socket.socket + _minitouch_pid: int + _minitouch_ws: websockets.WebSocketClientProtocol + max_x: int + max_y: int + + @cached_property + def minitouch_builder(self): + self.minitouch_init() + return CommandBuilder(self) + + @Config.when(DEVICE_OVER_HTTP=False) + def minitouch_init(self): + logger.hr('MiniTouch init') + max_x, max_y = 1280, 720 + max_contacts = 2 + max_pressure = 50 + self.get_orientation() + + self._minitouch_port = self.adb_forward("localabstract:minitouch") + + # No need, minitouch already started by uiautomator2 + # self.adb_shell([self.config.MINITOUCH_FILEPATH_REMOTE]) + + retry_timeout = Timer(2).start() + while 1: + client = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + client.settimeout(1) + client.connect(('127.0.0.1', self._minitouch_port)) + self._minitouch_client = client + + # get minitouch server info + socket_out = client.makefile() + + # v + # protocol version, usually it is 1. needn't use this + try: + out = socket_out.readline().replace("\n", "").replace("\r", "") + except socket.timeout: + client.close() + raise MinitouchOccupiedError( + 'Timeout when connecting to minitouch, ' + 'probably because another connection has been established' + ) + logger.info(out) + + # ^ + out = socket_out.readline().replace("\n", "").replace("\r", "") + logger.info(out) + try: + _, max_contacts, max_x, max_y, max_pressure, *_ = out.split(" ") + break + except ValueError: + client.close() + if retry_timeout.reached(): + raise MinitouchNotInstalledError( + 'Received empty data from minitouch, ' + 'probably because minitouch is not installed' + ) + else: + # Minitouch may not start that fast + self.sleep(1) + continue + + # self.max_contacts = max_contacts + self.max_x = int(max_x) + self.max_y = int(max_y) + # self.max_pressure = max_pressure + + # $ + out = socket_out.readline().replace("\n", "").replace("\r", "") + logger.info(out) + _, pid = out.split(" ") + self._minitouch_pid = pid + + logger.info( + "minitouch running on port: {}, pid: {}".format(self._minitouch_port, self._minitouch_pid) + ) + logger.info( + "max_contact: {}; max_x: {}; max_y: {}; max_pressure: {}".format( + max_contacts, max_x, max_y, max_pressure + ) + ) + + @Config.when(DEVICE_OVER_HTTP=False) + def minitouch_send(self, builder: CommandBuilder): + content = builder.to_minitouch() + # logger.info("send operation: {}".format(content.replace("\n", "\\n"))) + byte_content = content.encode('utf-8') + self._minitouch_client.sendall(byte_content) + self._minitouch_client.recv(0) + time.sleep(self.minitouch_builder.delay / 1000 + builder.DEFAULT_DELAY) + builder.clear() + + @cached_property + def _minitouch_loop(self): + return asyncio.new_event_loop() + + def _minitouch_loop_run(self, event): + """ + Args: + event: Async function + + Raises: + MinitouchOccupiedError + """ + try: + return self._minitouch_loop.run_until_complete(event) + except websockets.ConnectionClosedError as e: + # ConnectionClosedError: no close frame received or sent + # ConnectionClosedError: sent 1011 (unexpected error) keepalive ping timeout; no close frame received + logger.error(e) + raise MinitouchOccupiedError( + 'ConnectionClosedError, ' + 'probably because another connection has been established' + ) + + @Config.when(DEVICE_OVER_HTTP=True) + def minitouch_init(self): + logger.hr('MiniTouch init') + self.max_x, self.max_y = 1280, 720 + self.get_orientation() + + logger.info('Stop minitouch service') + s = U2Service('minitouch', self.u2) + s.stop() + while 1: + if not s.running(): + break + self.sleep(0.05) + + logger.info('Start minitouch service') + s.start() + while 1: + if s.running(): + break + self.sleep(0.05) + + # 'ws://127.0.0.1:7912/minitouch' + url = re.sub(r"^https?://", 'ws://', self.serial) + '/minitouch' + logger.attr('Minitouch', url) + + async def connect(): + ws = await websockets.connect(url) + # start @minitouch service + logger.info(await ws.recv()) + # dial unix:@minitouch + logger.info(await ws.recv()) + return ws + + self._minitouch_ws = self._minitouch_loop_run(connect()) + + @Config.when(DEVICE_OVER_HTTP=True) + def minitouch_send(self, builder: CommandBuilder): + content = builder.to_atx_agent() + + async def send(): + for row in content: + # logger.info("send operation: {}".format(row.replace("\n", "\\n"))) + await self._minitouch_ws.send(row) + + self._minitouch_loop_run(send()) + time.sleep(builder.delay / 1000 + builder.DEFAULT_DELAY) + builder.clear() + + @retry + def click_minitouch(self, x, y): + builder = self.minitouch_builder + builder.down(x, y).commit() + builder.up().commit() + builder.send() + + @retry + def long_click_minitouch(self, x, y, duration=1.0): + duration = int(duration * 1000) + builder = self.minitouch_builder + builder.down(x, y).commit().wait(duration) + builder.up().commit() + builder.send() + + @retry + def swipe_minitouch(self, p1, p2): + points = insert_swipe(p0=p1, p3=p2) + builder = self.minitouch_builder + + builder.down(*points[0]).commit() + builder.send() + + for point in points[1:]: + builder.move(*point).commit().wait(10) + builder.send() + + builder.up().commit() + builder.send() + + @retry + def drag_minitouch(self, p1, p2, point_random=(-10, -10, 10, 10)): + p1 = np.array(p1) - random_rectangle_point(point_random) + p2 = np.array(p2) - random_rectangle_point(point_random) + points = insert_swipe(p0=p1, p3=p2, speed=20) + builder = self.minitouch_builder + + builder.down(*points[0]).commit() + builder.send() + + for point in points[1:]: + builder.move(*point).commit().wait(10) + builder.send() + + builder.move(*p2).commit().wait(140) + builder.move(*p2).commit().wait(140) + builder.send() + + builder.up().commit() + builder.send() diff --git a/module/device/method/scrcpy/__init__.py b/module/device/method/scrcpy/__init__.py new file mode 100644 index 0000000..f4b3035 --- /dev/null +++ b/module/device/method/scrcpy/__init__.py @@ -0,0 +1 @@ +from .scrcpy import Scrcpy, ScrcpyError diff --git a/module/device/method/scrcpy/const.py b/module/device/method/scrcpy/const.py new file mode 100644 index 0000000..69dcd25 --- /dev/null +++ b/module/device/method/scrcpy/const.py @@ -0,0 +1,326 @@ +""" +This module includes all consts used in this project +""" + +# Action +ACTION_DOWN = 0 +ACTION_UP = 1 +ACTION_MOVE = 2 + +# KeyCode +KEYCODE_UNKNOWN = 0 +KEYCODE_SOFT_LEFT = 1 +KEYCODE_SOFT_RIGHT = 2 +KEYCODE_HOME = 3 +KEYCODE_BACK = 4 +KEYCODE_CALL = 5 +KEYCODE_ENDCALL = 6 +KEYCODE_0 = 7 +KEYCODE_1 = 8 +KEYCODE_2 = 9 +KEYCODE_3 = 10 +KEYCODE_4 = 11 +KEYCODE_5 = 12 +KEYCODE_6 = 13 +KEYCODE_7 = 14 +KEYCODE_8 = 15 +KEYCODE_9 = 16 +KEYCODE_STAR = 17 +KEYCODE_POUND = 18 +KEYCODE_DPAD_UP = 19 +KEYCODE_DPAD_DOWN = 20 +KEYCODE_DPAD_LEFT = 21 +KEYCODE_DPAD_RIGHT = 22 +KEYCODE_DPAD_CENTER = 23 +KEYCODE_VOLUME_UP = 24 +KEYCODE_VOLUME_DOWN = 25 +KEYCODE_POWER = 26 +KEYCODE_CAMERA = 27 +KEYCODE_CLEAR = 28 +KEYCODE_A = 29 +KEYCODE_B = 30 +KEYCODE_C = 31 +KEYCODE_D = 32 +KEYCODE_E = 33 +KEYCODE_F = 34 +KEYCODE_G = 35 +KEYCODE_H = 36 +KEYCODE_I = 37 +KEYCODE_J = 38 +KEYCODE_K = 39 +KEYCODE_L = 40 +KEYCODE_M = 41 +KEYCODE_N = 42 +KEYCODE_O = 43 +KEYCODE_P = 44 +KEYCODE_Q = 45 +KEYCODE_R = 46 +KEYCODE_S = 47 +KEYCODE_T = 48 +KEYCODE_U = 49 +KEYCODE_V = 50 +KEYCODE_W = 51 +KEYCODE_X = 52 +KEYCODE_Y = 53 +KEYCODE_Z = 54 +KEYCODE_COMMA = 55 +KEYCODE_PERIOD = 56 +KEYCODE_ALT_LEFT = 57 +KEYCODE_ALT_RIGHT = 58 +KEYCODE_SHIFT_LEFT = 59 +KEYCODE_SHIFT_RIGHT = 60 +KEYCODE_TAB = 61 +KEYCODE_SPACE = 62 +KEYCODE_SYM = 63 +KEYCODE_EXPLORER = 64 +KEYCODE_ENVELOPE = 65 +KEYCODE_ENTER = 66 +KEYCODE_DEL = 67 +KEYCODE_GRAVE = 68 +KEYCODE_MINUS = 69 +KEYCODE_EQUALS = 70 +KEYCODE_LEFT_BRACKET = 71 +KEYCODE_RIGHT_BRACKET = 72 +KEYCODE_BACKSLASH = 73 +KEYCODE_SEMICOLON = 74 +KEYCODE_APOSTROPHE = 75 +KEYCODE_SLASH = 76 +KEYCODE_AT = 77 +KEYCODE_NUM = 78 +KEYCODE_HEADSETHOOK = 79 +KEYCODE_PLUS = 81 +KEYCODE_MENU = 82 +KEYCODE_NOTIFICATION = 83 +KEYCODE_SEARCH = 84 +KEYCODE_MEDIA_PLAY_PAUSE = 85 +KEYCODE_MEDIA_STOP = 86 +KEYCODE_MEDIA_NEXT = 87 +KEYCODE_MEDIA_PREVIOUS = 88 +KEYCODE_MEDIA_REWIND = 89 +KEYCODE_MEDIA_FAST_FORWARD = 90 +KEYCODE_MUTE = 91 +KEYCODE_PAGE_UP = 92 +KEYCODE_PAGE_DOWN = 93 +KEYCODE_BUTTON_A = 96 +KEYCODE_BUTTON_B = 97 +KEYCODE_BUTTON_C = 98 +KEYCODE_BUTTON_X = 99 +KEYCODE_BUTTON_Y = 100 +KEYCODE_BUTTON_Z = 101 +KEYCODE_BUTTON_L1 = 102 +KEYCODE_BUTTON_R1 = 103 +KEYCODE_BUTTON_L2 = 104 +KEYCODE_BUTTON_R2 = 105 +KEYCODE_BUTTON_THUMBL = 106 +KEYCODE_BUTTON_THUMBR = 107 +KEYCODE_BUTTON_START = 108 +KEYCODE_BUTTON_SELECT = 109 +KEYCODE_BUTTON_MODE = 110 +KEYCODE_ESCAPE = 111 +KEYCODE_FORWARD_DEL = 112 +KEYCODE_CTRL_LEFT = 113 +KEYCODE_CTRL_RIGHT = 114 +KEYCODE_CAPS_LOCK = 115 +KEYCODE_SCROLL_LOCK = 116 +KEYCODE_META_LEFT = 117 +KEYCODE_META_RIGHT = 118 +KEYCODE_FUNCTION = 119 +KEYCODE_SYSRQ = 120 +KEYCODE_BREAK = 121 +KEYCODE_MOVE_HOME = 122 +KEYCODE_MOVE_END = 123 +KEYCODE_INSERT = 124 +KEYCODE_FORWARD = 125 +KEYCODE_MEDIA_PLAY = 126 +KEYCODE_MEDIA_PAUSE = 127 +KEYCODE_MEDIA_CLOSE = 128 +KEYCODE_MEDIA_EJECT = 129 +KEYCODE_MEDIA_RECORD = 130 +KEYCODE_F1 = 131 +KEYCODE_F2 = 132 +KEYCODE_F3 = 133 +KEYCODE_F4 = 134 +KEYCODE_F5 = 135 +KEYCODE_F6 = 136 +KEYCODE_F7 = 137 +KEYCODE_F8 = 138 +KEYCODE_F9 = 139 +KEYCODE_F10 = 140 +KEYCODE_F11 = 141 +KEYCODE_F12 = 142 +KEYCODE_NUM_LOCK = 143 +KEYCODE_NUMPAD_0 = 144 +KEYCODE_NUMPAD_1 = 145 +KEYCODE_NUMPAD_2 = 146 +KEYCODE_NUMPAD_3 = 147 +KEYCODE_NUMPAD_4 = 148 +KEYCODE_NUMPAD_5 = 149 +KEYCODE_NUMPAD_6 = 150 +KEYCODE_NUMPAD_7 = 151 +KEYCODE_NUMPAD_8 = 152 +KEYCODE_NUMPAD_9 = 153 +KEYCODE_NUMPAD_DIVIDE = 154 +KEYCODE_NUMPAD_MULTIPLY = 155 +KEYCODE_NUMPAD_SUBTRACT = 156 +KEYCODE_NUMPAD_ADD = 157 +KEYCODE_NUMPAD_DOT = 158 +KEYCODE_NUMPAD_COMMA = 159 +KEYCODE_NUMPAD_ENTER = 160 +KEYCODE_NUMPAD_EQUALS = 161 +KEYCODE_NUMPAD_LEFT_PAREN = 162 +KEYCODE_NUMPAD_RIGHT_PAREN = 163 +KEYCODE_VOLUME_MUTE = 164 +KEYCODE_INFO = 165 +KEYCODE_CHANNEL_UP = 166 +KEYCODE_CHANNEL_DOWN = 167 +KEYCODE_ZOOM_IN = 168 +KEYCODE_ZOOM_OUT = 169 +KEYCODE_TV = 170 +KEYCODE_WINDOW = 171 +KEYCODE_GUIDE = 172 +KEYCODE_DVR = 173 +KEYCODE_BOOKMARK = 174 +KEYCODE_CAPTIONS = 175 +KEYCODE_SETTINGS = 176 +KEYCODE_TV_POWER = 177 +KEYCODE_TV_INPUT = 178 +KEYCODE_STB_POWER = 179 +KEYCODE_STB_INPUT = 180 +KEYCODE_AVR_POWER = 181 +KEYCODE_AVR_INPUT = 182 +KEYCODE_PROG_RED = 183 +KEYCODE_PROG_GREEN = 184 +KEYCODE_PROG_YELLOW = 185 +KEYCODE_PROG_BLUE = 186 +KEYCODE_APP_SWITCH = 187 +KEYCODE_BUTTON_1 = 188 +KEYCODE_BUTTON_2 = 189 +KEYCODE_BUTTON_3 = 190 +KEYCODE_BUTTON_4 = 191 +KEYCODE_BUTTON_5 = 192 +KEYCODE_BUTTON_6 = 193 +KEYCODE_BUTTON_7 = 194 +KEYCODE_BUTTON_8 = 195 +KEYCODE_BUTTON_9 = 196 +KEYCODE_BUTTON_10 = 197 +KEYCODE_BUTTON_11 = 198 +KEYCODE_BUTTON_12 = 199 +KEYCODE_BUTTON_13 = 200 +KEYCODE_BUTTON_14 = 201 +KEYCODE_BUTTON_15 = 202 +KEYCODE_BUTTON_16 = 203 +KEYCODE_LANGUAGE_SWITCH = 204 +KEYCODE_MANNER_MODE = 205 +KEYCODE_3D_MODE = 206 +KEYCODE_CONTACTS = 207 +KEYCODE_CALENDAR = 208 +KEYCODE_MUSIC = 209 +KEYCODE_CALCULATOR = 210 +KEYCODE_ZENKAKU_HANKAKU = 211 +KEYCODE_EISU = 212 +KEYCODE_MUHENKAN = 213 +KEYCODE_HENKAN = 214 +KEYCODE_KATAKANA_HIRAGANA = 215 +KEYCODE_YEN = 216 +KEYCODE_RO = 217 +KEYCODE_KANA = 218 +KEYCODE_ASSIST = 219 +KEYCODE_BRIGHTNESS_DOWN = 220 +KEYCODE_BRIGHTNESS_UP = 221 +KEYCODE_MEDIA_AUDIO_TRACK = 222 +KEYCODE_SLEEP = 223 +KEYCODE_WAKEUP = 224 +KEYCODE_PAIRING = 225 +KEYCODE_MEDIA_TOP_MENU = 226 +KEYCODE_11 = 227 +KEYCODE_12 = 228 +KEYCODE_LAST_CHANNEL = 229 +KEYCODE_TV_DATA_SERVICE = 230 +KEYCODE_VOICE_ASSIST = 231 +KEYCODE_TV_RADIO_SERVICE = 232 +KEYCODE_TV_TELETEXT = 233 +KEYCODE_TV_NUMBER_ENTRY = 234 +KEYCODE_TV_TERRESTRIAL_ANALOG = 235 +KEYCODE_TV_TERRESTRIAL_DIGITAL = 236 +KEYCODE_TV_SATELLITE = 237 +KEYCODE_TV_SATELLITE_BS = 238 +KEYCODE_TV_SATELLITE_CS = 239 +KEYCODE_TV_SATELLITE_SERVICE = 240 +KEYCODE_TV_NETWORK = 241 +KEYCODE_TV_ANTENNA_CABLE = 242 +KEYCODE_TV_INPUT_HDMI_1 = 243 +KEYCODE_TV_INPUT_HDMI_2 = 244 +KEYCODE_TV_INPUT_HDMI_3 = 245 +KEYCODE_TV_INPUT_HDMI_4 = 246 +KEYCODE_TV_INPUT_COMPOSITE_1 = 247 +KEYCODE_TV_INPUT_COMPOSITE_2 = 248 +KEYCODE_TV_INPUT_COMPONENT_1 = 249 +KEYCODE_TV_INPUT_COMPONENT_2 = 250 +KEYCODE_TV_INPUT_VGA_1 = 251 +KEYCODE_TV_AUDIO_DESCRIPTION = 252 +KEYCODE_TV_AUDIO_DESCRIPTION_MIX_UP = 253 +KEYCODE_TV_AUDIO_DESCRIPTION_MIX_DOWN = 254 +KEYCODE_TV_ZOOM_MODE = 255 +KEYCODE_TV_CONTENTS_MENU = 256 +KEYCODE_TV_MEDIA_CONTEXT_MENU = 257 +KEYCODE_TV_TIMER_PROGRAMMING = 258 +KEYCODE_HELP = 259 +KEYCODE_NAVIGATE_PREVIOUS = 260 +KEYCODE_NAVIGATE_NEXT = 261 +KEYCODE_NAVIGATE_IN = 262 +KEYCODE_NAVIGATE_OUT = 263 +KEYCODE_STEM_PRIMARY = 264 +KEYCODE_STEM_1 = 265 +KEYCODE_STEM_2 = 266 +KEYCODE_STEM_3 = 267 +KEYCODE_DPAD_UP_LEFT = 268 +KEYCODE_DPAD_DOWN_LEFT = 269 +KEYCODE_DPAD_UP_RIGHT = 270 +KEYCODE_DPAD_DOWN_RIGHT = 271 +KEYCODE_MEDIA_SKIP_FORWARD = 272 +KEYCODE_MEDIA_SKIP_BACKWARD = 273 +KEYCODE_MEDIA_STEP_FORWARD = 274 +KEYCODE_MEDIA_STEP_BACKWARD = 275 +KEYCODE_SOFT_SLEEP = 276 +KEYCODE_CUT = 277 +KEYCODE_COPY = 278 +KEYCODE_PASTE = 279 +KEYCODE_SYSTEM_NAVIGATION_UP = 280 +KEYCODE_SYSTEM_NAVIGATION_DOWN = 281 +KEYCODE_SYSTEM_NAVIGATION_LEFT = 282 +KEYCODE_SYSTEM_NAVIGATION_RIGHT = 283 +KEYCODE_KEYCODE_ALL_APPS = 284 +KEYCODE_KEYCODE_REFRESH = 285 +KEYCODE_KEYCODE_THUMBS_UP = 286 +KEYCODE_KEYCODE_THUMBS_DOWN = 287 + +# Event +EVENT_INIT = "init" +EVENT_FRAME = "frame" +EVENT_DISCONNECT = "disconnect" + +# Type +TYPE_INJECT_KEYCODE = 0 +TYPE_INJECT_TEXT = 1 +TYPE_INJECT_TOUCH_EVENT = 2 +TYPE_INJECT_SCROLL_EVENT = 3 +TYPE_BACK_OR_SCREEN_ON = 4 +TYPE_EXPAND_NOTIFICATION_PANEL = 5 +TYPE_EXPAND_SETTINGS_PANEL = 6 +TYPE_COLLAPSE_PANELS = 7 +TYPE_GET_CLIPBOARD = 8 +TYPE_SET_CLIPBOARD = 9 +TYPE_SET_SCREEN_POWER_MODE = 10 +TYPE_ROTATE_DEVICE = 11 + +# Lock screen orientation +LOCK_SCREEN_ORIENTATION_UNLOCKED = -1 +LOCK_SCREEN_ORIENTATION_INITIAL = -2 +LOCK_SCREEN_ORIENTATION_0 = 0 +LOCK_SCREEN_ORIENTATION_1 = 1 +LOCK_SCREEN_ORIENTATION_2 = 2 +LOCK_SCREEN_ORIENTATION_3 = 3 + +# Screen power mode +POWER_MODE_OFF = 0 +POWER_MODE_NORMAL = 2 diff --git a/module/device/method/scrcpy/control.py b/module/device/method/scrcpy/control.py new file mode 100644 index 0000000..3e64314 --- /dev/null +++ b/module/device/method/scrcpy/control.py @@ -0,0 +1,266 @@ +import functools +import socket +import struct +import time + +import module.device.method.scrcpy.const as const + + +def inject(control_type: int): + """ + Inject control code, with this inject, we will be able to do unit test + + Args: + control_type: event to send, TYPE_* + """ + + def wrapper(f): + @functools.wraps(f) + def inner(self, *args, **kwargs): + package = struct.pack(">B", control_type) + f(self, *args, **kwargs) + if self.control_socket is not None: + with self.control_socket_lock: + self.control_socket.send(package) + return package + + return inner + + return wrapper + + +class ControlSender: + def __init__(self, parent): + self.parent = parent + + @property + def control_socket(self): + return self.parent._scrcpy_control_socket + + @property + def control_socket_lock(self): + return self.parent._scrcpy_control_socket_lock + + @property + def resolution(self): + return self.parent._scrcpy_resolution + + @inject(const.TYPE_INJECT_KEYCODE) + def keycode( + self, keycode: int, action: int = const.ACTION_DOWN, repeat: int = 0 + ) -> bytes: + """ + Send keycode to device + + Args: + keycode: const.KEYCODE_* + action: ACTION_DOWN | ACTION_UP + repeat: repeat count + """ + return struct.pack(">Biii", action, keycode, repeat, 0) + + @inject(const.TYPE_INJECT_TEXT) + def text(self, text: str) -> bytes: + """ + Send text to device + + Args: + text: text to send + """ + + buffer = text.encode("utf-8") + return struct.pack(">i", len(buffer)) + buffer + + @inject(const.TYPE_INJECT_TOUCH_EVENT) + def touch( + self, x: int, y: int, action: int = const.ACTION_DOWN, touch_id: int = -1 + ) -> bytes: + """ + Touch screen + + Args: + x: horizontal position + y: vertical position + action: ACTION_DOWN | ACTION_UP | ACTION_MOVE + touch_id: Default using virtual id -1, you can specify it to emulate multi finger touch + """ + x, y = max(x, 0), max(y, 0) + return struct.pack( + ">BqiiHHHi", + action, + touch_id, + int(x), + int(y), + int(self.resolution[0]), + int(self.resolution[1]), + 0xFFFF, + 1, + ) + + @inject(const.TYPE_INJECT_SCROLL_EVENT) + def scroll(self, x: int, y: int, h: int, v: int) -> bytes: + """ + Scroll screen + + Args: + x: horizontal position + y: vertical position + h: horizontal movement + v: vertical movement + """ + + x, y = max(x, 0), max(y, 0) + return struct.pack( + ">iiHHii", + int(x), + int(y), + int(self.resolution[0]), + int(self.resolution[1]), + int(h), + int(v), + ) + + @inject(const.TYPE_BACK_OR_SCREEN_ON) + def back_or_turn_screen_on(self, action: int = const.ACTION_DOWN) -> bytes: + """ + If the screen is off, it is turned on only on ACTION_DOWN + + Args: + action: ACTION_DOWN | ACTION_UP + """ + return struct.pack(">B", action) + + @inject(const.TYPE_EXPAND_NOTIFICATION_PANEL) + def expand_notification_panel(self) -> bytes: + """ + Expand notification panel + """ + return b"" + + @inject(const.TYPE_EXPAND_SETTINGS_PANEL) + def expand_settings_panel(self) -> bytes: + """ + Expand settings panel + """ + return b"" + + @inject(const.TYPE_COLLAPSE_PANELS) + def collapse_panels(self) -> bytes: + """ + Collapse all panels + """ + return b"" + + def get_clipboard(self) -> str: + """ + Get clipboard + """ + # Since this function need socket response, we can't auto inject it any more + s: socket.socket = self.control_socket + + with self.control_socket_lock: + # Flush socket + s.setblocking(False) + while True: + try: + s.recv(1024) + except BlockingIOError: + break + s.setblocking(True) + + # Read package + package = struct.pack(">B", const.TYPE_GET_CLIPBOARD) + s.send(package) + (code,) = struct.unpack(">B", s.recv(1)) + assert code == 0 + (length,) = struct.unpack(">i", s.recv(4)) + + return s.recv(length).decode("utf-8") + + @inject(const.TYPE_SET_CLIPBOARD) + def set_clipboard(self, text: str, paste: bool = False) -> bytes: + """ + Set clipboard + + Args: + text: the string you want to set + paste: paste now + """ + buffer = text.encode("utf-8") + return struct.pack(">?i", paste, len(buffer)) + buffer + + @inject(const.TYPE_SET_SCREEN_POWER_MODE) + def set_screen_power_mode(self, mode: int = const.POWER_MODE_NORMAL) -> bytes: + """ + Set screen power mode + + Args: + mode: POWER_MODE_OFF | POWER_MODE_NORMAL + """ + return struct.pack(">b", mode) + + @inject(const.TYPE_ROTATE_DEVICE) + def rotate_device(self) -> bytes: + """ + Rotate device + """ + return b"" + + def swipe( + self, + start_x: int, + start_y: int, + end_x: int, + end_y: int, + move_step_length: int = 5, + move_steps_delay: float = 0.005, + ) -> None: + """ + Swipe on screen + + Args: + start_x: start horizontal position + start_y: start vertical position + end_x: start horizontal position + end_y: end vertical position + move_step_length: length per step + move_steps_delay: sleep seconds after each step + :return: + """ + + self.touch(start_x, start_y, const.ACTION_DOWN) + next_x = start_x + next_y = start_y + + if end_x > self.resolution[0]: + end_x = self.resolution[0] + + if end_y > self.resolution[1]: + end_y = self.resolution[1] + + decrease_x = True if start_x > end_x else False + decrease_y = True if start_y > end_y else False + while True: + if decrease_x: + next_x -= move_step_length + if next_x < end_x: + next_x = end_x + else: + next_x += move_step_length + if next_x > end_x: + next_x = end_x + + if decrease_y: + next_y -= move_step_length + if next_y < end_y: + next_y = end_y + else: + next_y += move_step_length + if next_y > end_y: + next_y = end_y + + self.touch(next_x, next_y, const.ACTION_MOVE) + + if next_x == end_x and next_y == end_y: + self.touch(next_x, next_y, const.ACTION_UP) + break + time.sleep(move_steps_delay) diff --git a/module/device/method/scrcpy/core.py b/module/device/method/scrcpy/core.py new file mode 100644 index 0000000..aadb49b --- /dev/null +++ b/module/device/method/scrcpy/core.py @@ -0,0 +1,216 @@ +import socket +import struct +import threading +import time +import typing as t +from time import sleep + +import numpy as np +from adbutils import AdbError, Network + +from module.base.decorator import cached_property +from module.base.timer import Timer +from module.device.connection import Connection +from module.device.method.scrcpy.control import ControlSender +from module.device.method.scrcpy.options import ScrcpyOptions +from module.device.method.utils import AdbConnection, recv_all +from module.exception import RequestHumanTakeover +from module.logger import logger + + +class ScrcpyError(Exception): + pass + + +class ScrcpyCore(Connection): + """ + Scrcpy: https://github.com/Genymobile/scrcpy + Module from https://github.com/leng-yue/py-scrcpy-client + """ + + _scrcpy_last_frame: t.Optional[np.ndarray] = None + _scrcpy_last_frame_time: float = 0. + + _scrcpy_alive = False + _scrcpy_server_stream: t.Optional[AdbConnection] = None + _scrcpy_video_socket: t.Optional[socket.socket] = None + _scrcpy_control_socket: t.Optional[socket.socket] = None + _scrcpy_control_socket_lock = threading.Lock() + + _scrcpy_stream_loop_thread = None + _scrcpy_resolution: t.Tuple[int, int] = (1280, 720) + + @cached_property + def _scrcpy_control(self) -> ControlSender: + return ControlSender(self) + + def scrcpy_init(self): + self._scrcpy_server_stop() + + logger.hr('Scrcpy init') + logger.info(f'pushing {self.config.SCRCPY_FILEPATH_LOCAL}') + self.adb_push(self.config.SCRCPY_FILEPATH_LOCAL, self.config.SCRCPY_FILEPATH_REMOTE) + + self._scrcpy_alive = False + self.scrcpy_ensure_running() + + def scrcpy_ensure_running(self): + if not self._scrcpy_alive: + with self._scrcpy_control_socket_lock: + self._scrcpy_server_start() + + def _scrcpy_server_start(self): + """ + Connect to scrcpy server, there will be two sockets, video and control socket. + + Raises: + ScrcpyError: + """ + logger.hr('Scrcpy server start') + commands = ScrcpyOptions.command_v120(jar_path=self.config.SCRCPY_FILEPATH_REMOTE) + self._scrcpy_server_stream: AdbConnection = self.adb.shell( + commands, + stream=True, + ) + + logger.info('Create server stream') + ret = self._scrcpy_server_stream.read(10) + # b'Aborted \r\n' + # Probably because file not exists + if b'Aborted' in ret: + raise ScrcpyError('Aborted') + if ret == b'[server] E': + # [server] ERROR: ... + ret += recv_all(self._scrcpy_server_stream) + logger.error(ret) + # java.lang.IllegalArgumentException: The server version (1.25) does not match the client (...) + if b'does not match the client' in ret: + raise ScrcpyError('Server version does not match the client') + else: + raise ScrcpyError('Unknown scrcpy error') + else: + # [server] INFO: Device: ... + ret += self._scrcpy_receive_from_server_stream() + logger.info(ret) + pass + + logger.info('Create video socket') + timeout = Timer(3).start() + while 1: + if timeout.reached(): + raise ScrcpyError('Connect scrcpy-server timeout') + + try: + self._scrcpy_video_socket = self.adb.create_connection( + Network.LOCAL_ABSTRACT, "scrcpy" + ) + break + except AdbError: + sleep(0.1) + dummy_byte = self._scrcpy_video_socket.recv(1) + if not len(dummy_byte) or dummy_byte != b"\x00": + raise ScrcpyError('Did not receive Dummy Byte from video stream') + + logger.info('Create control socket') + self._scrcpy_control_socket = self.adb.create_connection( + Network.LOCAL_ABSTRACT, "scrcpy" + ) + + logger.info('Fetch device info') + device_name = self._scrcpy_video_socket.recv(64).decode("utf-8").rstrip("\x00") + if len(device_name): + logger.attr('Scrcpy Device', device_name) + else: + raise ScrcpyError('Did not receive Device Name') + ret = self._scrcpy_video_socket.recv(4) + self._scrcpy_resolution = struct.unpack(">HH", ret) + logger.attr('Scrcpy Resolution', self._scrcpy_resolution) + + self._scrcpy_video_socket.setblocking(False) + self._scrcpy_alive = True + + logger.info('Start video stream loop thread') + self._scrcpy_stream_loop_thread = threading.Thread( + target=self._scrcpy_stream_loop, daemon=True + ) + self._scrcpy_stream_loop_thread.start() + while 1: + if self._scrcpy_stream_loop_thread is not None and self._scrcpy_stream_loop_thread.is_alive(): + break + self.sleep(0.001) + + logger.info('Scrcpy server is up') + + def _scrcpy_server_stop(self): + """ + Stop listening (both threaded and blocked) + """ + logger.hr('Scrcpy server stop') + # err = self._scrcpy_receive_from_server_stream() + # if err: + # logger.error(err) + + self._scrcpy_alive = False + if self._scrcpy_server_stream is not None: + try: + self._scrcpy_server_stream.close() + except Exception: + pass + + if self._scrcpy_control_socket is not None: + try: + self._scrcpy_control_socket.close() + except Exception: + pass + + if self._scrcpy_video_socket is not None: + try: + self._scrcpy_video_socket.close() + except Exception: + pass + + logger.info('Scrcpy server stopped') + + def _scrcpy_receive_from_server_stream(self): + if self._scrcpy_server_stream is not None: + try: + return self._scrcpy_server_stream.conn.recv(4096) + except Exception: + pass + + def _scrcpy_stream_loop(self) -> None: + """ + Core loop for video parsing + """ + try: + from av.codec import CodecContext + from av.error import InvalidDataError + except ImportError as e: + logger.error(e) + logger.error('You must have `av` installed to use scrcpy screenshot, please update dependencies') + raise RequestHumanTakeover + + codec = CodecContext.create("h264", "r") + while self._scrcpy_alive: + try: + raw_h264 = self._scrcpy_video_socket.recv(0x10000) + if raw_h264 == b"": + raise ScrcpyError("Video stream is disconnected") + packets = codec.parse(raw_h264) + for packet in packets: + frames = codec.decode(packet) + for frame in frames: + # logger.info('frame received') + frame = frame.to_ndarray(format="rgb24") + self._scrcpy_last_frame = frame + self._scrcpy_last_frame_time = time.time() + self._scrcpy_resolution = (frame.shape[1], frame.shape[0]) + except (BlockingIOError, InvalidDataError): + # only return nonempty frames, may block cv2 render thread + time.sleep(0.001) + except (ConnectionError, OSError) as e: # Socket Closed + if self._scrcpy_alive: + logger.error(f'_scrcpy_stream_loop_thread: {repr(e)}') + raise + + raise ScrcpyError('_scrcpy_stream_loop stopped') diff --git a/module/device/method/scrcpy/options.py b/module/device/method/scrcpy/options.py new file mode 100644 index 0000000..dff59f4 --- /dev/null +++ b/module/device/method/scrcpy/options.py @@ -0,0 +1,132 @@ +import typing as t + +import module.device.method.scrcpy.const as const + + +class ScrcpyOptions: + frame_rate = 10 + + @classmethod + def codec_options(cls) -> str: + """ + Custom codec options passing through scrcpy. + https://developer.android.com/reference/android/media/MediaFormat + + Returns: + key_profile=1,key_level=4096,... + """ + options = dict( + # H.264 profile and level + # https://developer.android.com/reference/android/media/MediaCodecInfo.CodecProfileLevel + # Baseline, which only has I/P frames + key_profile=1, + # Level 4.1, for 1280x720@30fps + key_level=4096, + # Max quality + key_quality=100, + # https://developer.android.com/reference/android/media/MediaCodecInfo.EncoderCapabilities + # Constant quality + key_bitrate_mode=0, + # A zero value means a stream containing all key frames is requested. + key_i_frame_interval=0, + # https://developer.android.com/reference/android/media/MediaCodecInfo.CodecCapabilities + # COLOR_Format24bitBGR888 + key_color_format=12, + # The same as output frame rate to lower CPU consumption + key_capture_rate=cls.frame_rate, + # 20Mbps, the maximum output bitrate of scrcpy + key_bit_rate=20000000, + ) + return ','.join([f'{k}={v}' for k, v in options.items()]) + + @classmethod + def arguments(cls) -> t.List[str]: + """ + https://github.com/Genymobile/scrcpy/blob/master/server/src/main/java/com/genymobile/scrcpy/Server.java + https://github.com/Genymobile/scrcpy/blob/master/server/src/main/java/com/genymobile/scrcpy/Options.java + + Returns: + ['log_level=info', 'max_size=1280', ...] + """ + options = [ + 'log_level=info', + 'max_size=1280', + # 20Mbps, the maximum output bitrate of scrcpy + # If a higher value is set, scrcpy fallback to 8Mbps default. + 'bit_rate=20000000', + # Screenshot time cost <= 300ms is enough for human speed. + f'max_fps={cls.frame_rate}', + # No orientation lock + f'lock_video_orientation={const.LOCK_SCREEN_ORIENTATION_UNLOCKED}', + # Always true + 'tunnel_forward=true', + # Always true for controlling via scrcpy + 'control=true', + # Default to 0 + 'display_id=0', + # Useless, always false + 'show_touches=false', + # Not determined, leave it as default + 'stay_awake=false', + # Encoder name + # Should in [ + # "OMX.google.h264.encoder", + # "OMX.qcom.video.encoder.avc", + # "c2.qti.avc.encoder", + # "c2.android.avc.encoder", + # ] + # Empty value, let scrcpy to decide + # 'encoder_name=', + # Codec options + f'codec_options={cls.codec_options()}', + # Useless, always false + 'power_off_on_close=false', + 'clipboard_autosync=false', + 'downsize_on_error=false', + ] + return options + + @classmethod + def command_v125(cls, jar_path='/data/local/tmp/scrcpy-server.jar') -> t.List[str]: + """ + Generate the commands to run scrcpy. + """ + commands = [ + f'CLASSPATH={jar_path}', + 'app_process', + '/', + 'com.genymobile.scrcpy.Server', + '1.25', + ] + commands += cls.arguments() + return commands + + @classmethod + def command_v120(cls, jar_path='/data/local/tmp/scrcpy-server.jar') -> t.List[str]: + commands = [ + f"CLASSPATH={jar_path}", + "app_process", + "/", + "com.genymobile.scrcpy.Server", + "1.20", # Scrcpy server version + "info", # Log level: info, verbose... + f"1280", # Max screen width (long side) + f"20000000", # Bitrate of video + f"{cls.frame_rate}", # Max frame per second + f"{const.LOCK_SCREEN_ORIENTATION_UNLOCKED}", # Lock screen orientation: LOCK_SCREEN_ORIENTATION + "true", # Tunnel forward + "-", # Crop screen + "false", # Send frame rate to client + "true", # Control enabled + "0", # Display id + "false", # Show touches + "false", # Stay awake + cls.codec_options(), # Codec (video encoding) options + "-", # Encoder name + "false", # Power off screen after server closed + ] + return commands + + +if __name__ == '__main__': + print(' '.join(ScrcpyOptions.command_v120())) diff --git a/module/device/method/scrcpy/scrcpy.py b/module/device/method/scrcpy/scrcpy.py new file mode 100644 index 0000000..50eff74 --- /dev/null +++ b/module/device/method/scrcpy/scrcpy.py @@ -0,0 +1,152 @@ +import time +from functools import wraps + +import numpy as np +from adbutils.errors import AdbError + +import module.device.method.scrcpy.const as const +from module.base.utils import random_rectangle_point +from module.device.method.minitouch import insert_swipe +from module.device.method.scrcpy.core import ScrcpyCore, ScrcpyError +from module.device.method.uiautomator_2 import Uiautomator2 +from module.device.method.utils import RETRY_TRIES, retry_sleep, handle_adb_error +from module.exception import RequestHumanTakeover +from module.logger import logger + + +def retry(func): + @wraps(func) + def retry_wrapper(self, *args, **kwargs): + """ + Args: + self (Minitouch): + """ + init = None + for _ in range(RETRY_TRIES): + try: + if callable(init): + retry_sleep(_) + init() + return func(self, *args, **kwargs) + # Can't handle + except RequestHumanTakeover: + break + # When adb server was killed + except ConnectionResetError as e: + logger.error(e) + + def init(): + self.adb_reconnect() + # Emulator closed + except ConnectionAbortedError as e: + logger.error(e) + + def init(): + self.adb_reconnect() + # ScrcpyError + except ScrcpyError as e: + logger.error(e) + + def init(): + self.scrcpy_init() + # AdbError + except AdbError as e: + if handle_adb_error(e): + def init(): + self.adb_reconnect() + else: + break + # Unknown, probably a trucked image + except Exception as e: + logger.exception(e) + + def init(): + pass + + logger.critical(f'Retry {func.__name__}() failed') + raise RequestHumanTakeover + + return retry_wrapper + + +class Scrcpy(ScrcpyCore, Uiautomator2): + def _scrcpy_resolution_check(self): + if not self._scrcpy_alive: + with self._scrcpy_control_socket_lock: + self.resolution_check_uiautomator2() + + @retry + def screenshot_scrcpy(self): + self._scrcpy_resolution_check() + self.scrcpy_ensure_running() + + with self._scrcpy_control_socket_lock: + # Wait new frame + now = time.time() + while 1: + time.sleep(0.001) + if self._scrcpy_stream_loop_thread is None or not self._scrcpy_stream_loop_thread.is_alive(): + raise ScrcpyError('_scrcpy_stream_loop_thread died') + if self._scrcpy_last_frame_time > now: + screenshot = self._scrcpy_last_frame.copy() + return screenshot + + @retry + def click_scrcpy(self, x, y): + self.scrcpy_ensure_running() + + with self._scrcpy_control_socket_lock: + self._scrcpy_control.touch(x, y, const.ACTION_DOWN) + self._scrcpy_control.touch(x, y, const.ACTION_UP) + self.sleep(0.05) + + @retry + def long_click_scrcpy(self, x, y, duration=1.0): + self.scrcpy_ensure_running() + + with self._scrcpy_control_socket_lock: + self._scrcpy_control.touch(x, y, const.ACTION_DOWN) + self.sleep(duration) + self._scrcpy_control.touch(x, y, const.ACTION_UP) + self.sleep(0.05) + + @retry + def swipe_scrcpy(self, p1, p2): + self.scrcpy_ensure_running() + + with self._scrcpy_control_socket_lock: + # Unlike minitouch, scrcpy swipes needs to be continuous + # So 5 times smother + points = insert_swipe(p0=p1, p3=p2, speed=4, min_distance=2) + self._scrcpy_control.touch(*p1, const.ACTION_DOWN) + + for point in points[1:-1]: + self._scrcpy_control.touch(*point, const.ACTION_MOVE) + self.sleep(0.002) + + self._scrcpy_control.touch(*p2, const.ACTION_MOVE) + self._scrcpy_control.touch(*p2, const.ACTION_UP) + self.sleep(0.05) + + @retry + def drag_scrcpy(self, p1, p2, point_random=(-10, -10, 10, 10)): + self.scrcpy_ensure_running() + + with self._scrcpy_control_socket_lock: + p1 = np.array(p1) - random_rectangle_point(point_random) + p2 = np.array(p2) - random_rectangle_point(point_random) + points = insert_swipe(p0=p1, p3=p2, speed=4, min_distance=2) + + self._scrcpy_control.touch(*p1, const.ACTION_DOWN) + + for point in points[1:-1]: + self._scrcpy_control.touch(*point, const.ACTION_MOVE) + self.sleep(0.002) + + # Hold 280ms + for _ in range(int(0.14 // 0.002) * 2): + self._scrcpy_control.touch(*p2, const.ACTION_MOVE) + self.sleep(0.002) + + self._scrcpy_control.touch(*p2, const.ACTION_UP) + self.sleep(0.05) diff --git a/module/device/method/uiautomator_2.py b/module/device/method/uiautomator_2.py new file mode 100644 index 0000000..dea534a --- /dev/null +++ b/module/device/method/uiautomator_2.py @@ -0,0 +1,325 @@ +import typing as t +from dataclasses import dataclass +from functools import wraps +from json.decoder import JSONDecodeError +from subprocess import list2cmdline + +import uiautomator2 as u2 +from adbutils.errors import AdbError +from lxml import etree + +from module.base.utils import * +from module.device.connection import Connection +from module.device.method.utils import (RETRY_TRIES, retry_sleep, handle_adb_error, + ImageTruncated, PackageNotInstalled, possible_reasons) +from module.exception import RequestHumanTakeover +from module.logger import logger + + +def retry(func): + @wraps(func) + def retry_wrapper(self, *args, **kwargs): + """ + Args: + self (Uiautomator2): + """ + init = None + for _ in range(RETRY_TRIES): + try: + if callable(init): + retry_sleep(_) + init() + return func(self, *args, **kwargs) + # Can't handle + except RequestHumanTakeover: + break + # When adb server was killed + except ConnectionResetError as e: + logger.error(e) + + def init(): + self.adb_reconnect() + # In `device.set_new_command_timeout(604800)` + # json.decoder.JSONDecodeError: Expecting value: line 1 column 2 (char 1) + except JSONDecodeError as e: + logger.error(e) + + def init(): + self.install_uiautomator2() + # AdbError + except AdbError as e: + if handle_adb_error(e): + def init(): + self.adb_reconnect() + else: + break + # RuntimeError: USB device 127.0.0.1:5555 is offline + except RuntimeError as e: + if handle_adb_error(e): + def init(): + self.adb_reconnect() + else: + break + # In `assert c.read string(4) == _OKAY` + # ADB on emulator not enabled + except AssertionError as e: + logger.exception(e) + possible_reasons( + 'If you are using BlueStacks or LD player or WSA, ' + 'please enable ADB in the settings of your emulator' + ) + break + # Package not installed + except PackageNotInstalled as e: + logger.error(e) + + def init(): + self.detect_package() + # ImageTruncated + except ImageTruncated as e: + logger.error(e) + + def init(): + pass + # Unknown + except Exception as e: + logger.exception(e) + + def init(): + pass + + logger.critical(f'Retry {func.__name__}() failed') + raise RequestHumanTakeover + + return retry_wrapper + + +@dataclass +class ProcessInfo: + pid: int + ppid: int + thread_count: int + cmdline: str + name: str + + +@dataclass +class ShellBackgroundResponse: + success: bool + pid: int + description: str + + +class Uiautomator2(Connection): + @retry + def screenshot_uiautomator2(self): + image = self.u2.screenshot(format='raw') + image = np.frombuffer(image, np.uint8) + if image is None: + raise ImageTruncated('Empty image after reading from buffer') + + image = cv2.imdecode(image, cv2.IMREAD_COLOR) + if image is None: + raise ImageTruncated('Empty image after cv2.imdecode') + + image = cv2.cvtColor(image, cv2.COLOR_BGR2RGB) + if image is None: + raise ImageTruncated('Empty image after cv2.cvtColor') + + return image + + @retry + def click_uiautomator2(self, x, y): + self.u2.click(x, y) + + @retry + def long_click_uiautomator2(self, x, y, duration=(1, 1.2)): + self.u2.long_click(x, y, duration=duration) + + @retry + def swipe_uiautomator2(self, p1, p2, duration=0.1): + self.u2.swipe(*p1, *p2, duration=duration) + + @retry + def _drag_along(self, path): + """Swipe following path. + + Args: + path (list): (x, y, sleep) + + Examples: + al.drag_along([ + (403, 421, 0.2), + (821, 326, 0.1), + (821, 326-10, 0.1), + (821, 326+10, 0.1), + (821, 326, 0), + ]) + Equals to: + al.device.touch.down(403, 421) + time.sleep(0.2) + al.device.touch.move(821, 326) + time.sleep(0.1) + al.device.touch.move(821, 326-10) + time.sleep(0.1) + al.device.touch.move(821, 326+10) + time.sleep(0.1) + al.device.touch.up(821, 326) + """ + length = len(path) + for index, data in enumerate(path): + x, y, second = data + if index == 0: + self.u2.touch.down(x, y) + logger.info(point2str(x, y) + ' down') + elif index - length == -1: + self.u2.touch.up(x, y) + logger.info(point2str(x, y) + ' up') + else: + self.u2.touch.move(x, y) + logger.info(point2str(x, y) + ' move') + self.sleep(second) + + def drag_uiautomator2(self, p1, p2, segments=1, shake=(0, 15), point_random=(-10, -10, 10, 10), + shake_random=(-5, -5, 5, 5), swipe_duration=0.25, shake_duration=0.1): + """Drag and shake, like: + /\ + +-----------+ + + + \/ + A simple swipe or drag don't work well, because it only has two points. + Add some way point to make it more like swipe. + + Args: + p1 (tuple): Start point, (x, y). + p2 (tuple): End point, (x, y). + segments (int): + shake (tuple): Shake after arrive end point. + point_random: Add random to start point and end point. + shake_random: Add random to shake array. + swipe_duration: Duration between way points. + shake_duration: Duration between shake points. + """ + p1 = np.array(p1) - random_rectangle_point(point_random) + p2 = np.array(p2) - random_rectangle_point(point_random) + path = [(x, y, swipe_duration) for x, y in random_line_segments(p1, p2, n=segments, random_range=point_random)] + path += [ + (*p2 + shake + random_rectangle_point(shake_random), shake_duration), + (*p2 - shake - random_rectangle_point(shake_random), shake_duration), + (*p2, shake_duration) + ] + path = [(int(x), int(y), d) for x, y, d in path] + self._drag_along(path) + + @retry + def app_current_uiautomator2(self): + """ + Returns: + str: Package name. + """ + result = self.u2.app_current() + return result['package'] + + @retry + def app_start_uiautomator2(self, package_name=None): + if not package_name: + package_name = self.package + try: + self.u2.app_start(package_name) + except u2.exceptions.BaseError as e: + # BaseError: package "com.bilibili.azurlane" not found + logger.error(e) + raise PackageNotInstalled(package_name) + + @retry + def app_stop_uiautomator2(self, package_name=None): + if not package_name: + package_name = self.package + self.u2.app_stop(package_name) + + @retry + def dump_hierarchy_uiautomator2(self) -> etree._Element: + content = self.u2.dump_hierarchy(compressed=True) + hierarchy = etree.fromstring(content.encode('utf-8')) + return hierarchy + + @retry + def resolution_uiautomator2(self) -> t.Tuple[int, int]: + """ + Faster u2.window_size(), cause that calls `dumpsys display` twice. + + Returns: + (width, height) + """ + info = self.u2.http.get('/info').json() + w, h = info['display']['width'], info['display']['height'] + rotation = self.get_orientation() + if (w > h) != (rotation % 2 == 1): + w, h = h, w + return w, h + + def resolution_check_uiautomator2(self): + """ + Alas does not actively check resolution but the width and height of screenshots. + However, some screenshot methods do not provide device resolution, so check it here. + + Returns: + (width, height) + + Raises: + RequestHumanTakeover: If resolution is not 1280x720 + """ + width, height = self.resolution_uiautomator2() + logger.attr('Screen_size', f'{width}x{height}') + if width == 1280 and height == 720: + return (width, height) + if width == 720 and height == 1280: + return (width, height) + + logger.critical(f'Resolution not supported: {width}x{height}') + logger.critical('Please set emulator resolution to 1280x720') + raise RequestHumanTakeover + + @retry + def proc_list_uiautomator2(self) -> t.List[ProcessInfo]: + """ + Get info about current processes. + """ + resp = self.u2.http.get("/proc/list", timeout=10) + resp.raise_for_status() + result = [ + ProcessInfo( + pid=proc['pid'], + ppid=proc['ppid'], + thread_count=proc['threadCount'], + cmdline=' '.join(proc['cmdline']) if proc['cmdline'] is not None else '', + name=proc['name'], + ) for proc in resp.json() + ] + return result + + @retry + def u2_shell_background(self, cmdline, timeout=10) -> ShellBackgroundResponse: + """ + Run at background. + + Note that this function will always return a success response, + as this is a untested and hidden method in ATX. + """ + if isinstance(cmdline, (list, tuple)): + cmdline = list2cmdline(cmdline) + elif isinstance(cmdline, str): + cmdline = cmdline + else: + raise TypeError("cmdargs type invalid", type(cmdline)) + + data = dict(command=cmdline, timeout=str(timeout)) + ret = self.u2.http.post("/shell/background", data=data, timeout=timeout + 10) + ret.raise_for_status() + + resp = ret.json() + resp = ShellBackgroundResponse( + success=bool(resp.get('success', False)), + pid=resp.get('pid', 0), + description=resp.get('description', '') + ) + return resp diff --git a/module/device/method/utils.py b/module/device/method/utils.py new file mode 100644 index 0000000..67bd654 --- /dev/null +++ b/module/device/method/utils.py @@ -0,0 +1,360 @@ +import random +import re +import socket +import time +import typing as t + +import uiautomator2 as u2 +from adbutils import AdbTimeout +from lxml import etree + +try: + # adbutils 0.x + from adbutils import _AdbStreamConnection as AdbConnection +except ImportError: + # adbutils >= 1.0 + from adbutils import AdbConnection + # Patch list2cmdline back to subprocess.list2cmdline + # We expect `screencap | nc 192.168.0.1 20298` instead of `screencap '|' nc 192.168.80.1 20298` + import adbutils + import subprocess + adbutils._utils.list2cmdline = subprocess.list2cmdline + adbutils._device.list2cmdline = subprocess.list2cmdline + + # BaseDevice.shell() is missing a check_okay() call before reading output, + # resulting in an `OKAY` prefix in output. + def shell(self, + cmdargs: t.Union[str, list, tuple], + stream: bool = False, + timeout: t.Optional[float] = None, + rstrip=True) -> t.Union[AdbConnection, str]: + if isinstance(cmdargs, (list, tuple)): + cmdargs = subprocess.list2cmdline(cmdargs) + if stream: + timeout = None + c = self.open_transport(timeout=timeout) + c.send_command("shell:" + cmdargs) + c.check_okay() # check_okay() is missing here + if stream: + return c + output = c.read_until_close() + return output.rstrip() if rstrip else output + + adbutils._device.BaseDevice.shell = shell + +from module.base.decorator import cached_property +from module.logger import logger + +RETRY_TRIES = 5 +RETRY_DELAY = 3 + + +def is_port_using(port_num): + """ if port is using by others, return True. else return False """ + s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + s.settimeout(2) + + try: + s.bind(('127.0.0.1', port_num)) + return False + except OSError: + # Address already bind + return True + finally: + s.close() + + +def random_port(port_range): + """ get a random port from port set """ + new_port = random.choice(list(range(*port_range))) + if is_port_using(new_port): + return random_port(port_range) + else: + return new_port + + +def recv_all(stream, chunk_size=4096, recv_interval=0.000) -> bytes: + """ + Args: + stream: + chunk_size: + recv_interval (float): Default to 0.000, use 0.001 if receiving as server + + Returns: + bytes: + + Raises: + AdbTimeout + """ + if isinstance(stream, AdbConnection): + stream = stream.conn + stream.settimeout(10) + else: + stream.settimeout(10) + + try: + fragments = [] + while 1: + chunk = stream.recv(chunk_size) + if chunk: + fragments.append(chunk) + # See https://stackoverflow.com/questions/23837827/python-server-program-has-high-cpu-usage/41749820#41749820 + time.sleep(recv_interval) + else: + break + return remove_shell_warning(b''.join(fragments)) + except socket.timeout: + raise AdbTimeout('adb read timeout') + + +def possible_reasons(*args): + """ + Show possible reasons + + Possible reason #1: + Possible reason #2: + """ + for index, reason in enumerate(args): + index += 1 + logger.critical(f'Possible reason #{index}: {reason}') + + +class PackageNotInstalled(Exception): + pass + + +class ImageTruncated(Exception): + pass + + +def retry_sleep(trial): + # First trial + if trial == 0: + pass + # Failed once, fast retry + elif trial == 1: + pass + # Failed twice + elif trial == 2: + time.sleep(1) + # Failed more + else: + time.sleep(RETRY_DELAY) + + +def handle_adb_error(e): + """ + Args: + e (Exception): + + Returns: + bool: If should retry + """ + text = str(e) + if 'not found' in text: + # When you call `adb disconnect ` + # Or when adb server was killed (low possibility) + # AdbError(device '127.0.0.1:59865' not found) + logger.error(e) + return True + elif 'timeout' in text: + # AdbTimeout(adb read timeout) + logger.error(e) + return True + elif 'closed' in text: + # AdbError(closed) + # Usually after AdbTimeout(adb read timeout) + # Disconnect and re-connect should fix this. + logger.error(e) + return True + elif 'device offline' in text: + # AdbError(device offline) + # When a device that has been connected wirelessly is disconnected passively, + # it does not disappear from the adb device list, + # but will be displayed as offline. + # In many cases, such as disconnection and recovery caused by network fluctuations, + # or after VMOS reboot when running Alas on a phone, + # the device is still available, but it needs to be disconnected and re-connected. + logger.error(e) + return True + elif 'is offline' in text: + # RuntimeError: USB device 127.0.0.1:7555 is offline + # Raised by uiautomator2 when current adb service is killed by another version of adb service. + logger.error(e) + return True + elif 'unknown host service' in text: + # AdbError(unknown host service) + # Another version of ADB service started, current ADB service has been killed. + # Usually because user opened a Chinese emulator, which uses ADB from the Stone Age. + logger.error(e) + return True + else: + # AdbError() + logger.exception(e) + possible_reasons( + 'If you are using BlueStacks or LD player or WSA, please enable ADB in the settings of your emulator', + 'Emulator died, please restart emulator', + 'Serial incorrect, no such device exists or emulator is not running' + ) + return False + + +def get_serial_pair(serial): + """ + Args: + serial (str): + + Returns: + str, str: `127.0.0.1:5555+{X}` and `emulator-5554+{X}`, 0 <= X <= 32 + """ + if serial.startswith('127.0.0.1:'): + try: + port = int(serial[10:]) + if 5555 <= port <= 5555 + 32: + return f'127.0.0.1:{port}', f'emulator-{port - 1}' + except (ValueError, IndexError): + pass + if serial.startswith('emulator-'): + try: + port = int(serial[9:]) + if 5554 <= port <= 5554 + 32: + return f'127.0.0.1:{port + 1}', f'emulator-{port}' + except (ValueError, IndexError): + pass + + return None, None + + +def remove_prefix(s, prefix): + """ + Remove prefix of a string or bytes like `string.removeprefix(prefix)`, which is on Python3.9+ + + Args: + s (str, bytes): + prefix (str, bytes): + + Returns: + str, bytes: + """ + return s[len(prefix):] if s.startswith(prefix) else s + + +def remove_suffix(s, suffix): + """ + Remove suffix of a string or bytes like `string.removesuffix(suffix)`, which is on Python3.9+ + + Args: + s (str, bytes): + suffix (str, bytes): + + Returns: + str, bytes: + """ + return s[:len(suffix)] if s.endswith(suffix) else s + + +def remove_shell_warning(s): + """ + Remove warnings from shell + + Args: + s (str, bytes): + + Returns: + str, bytes: + """ + # WARNING: linker: [vdso]: unused DT entry: type 0x70000001 arg 0x0\n\x89PNG\r\n\x1a\n\x00\x00\x00\rIH + if isinstance(s, bytes): + if s.startswith(b'WARNING'): + try: + s = s.split(b'\n', maxsplit=1)[1] + except IndexError: + pass + return s + # return re.sub(b'^WARNING.+\n', b'', s) + elif isinstance(s, str): + if s.startswith('WARNING'): + try: + s = s.split('\n', maxsplit=1)[1] + except IndexError: + pass + return s + + +class IniterNoMinicap(u2.init.Initer): + @property + def minicap_urls(self): + """ + Don't install minicap on emulators, return empty urls. + + binary from https://github.com/openatx/stf-binaries + only got abi: armeabi-v7a and arm64-v8a + """ + return [] + + +class Device(u2.Device): + def show_float_window(self, show=True): + """ + Don't show float windows. + """ + pass + + +# Monkey patch +u2.init.Initer = IniterNoMinicap +u2.Device = Device + + +class HierarchyButton: + """ + Convert UI hierarchy to an object like the Button in Alas. + """ + _name_regex = re.compile('@.*?=[\'\"](.*?)[\'\"]') + + def __init__(self, hierarchy: etree._Element, xpath: str): + self.hierarchy = hierarchy + self.xpath = xpath + self.nodes = hierarchy.xpath(xpath) + + @cached_property + def name(self): + res = HierarchyButton._name_regex.findall(self.xpath) + if res: + return res[0] + else: + return 'HierarchyButton' + + @cached_property + def count(self): + return len(self.nodes) + + @cached_property + def exist(self): + return self.count == 1 + + @cached_property + def area(self): + if self.exist: + bounds = self.nodes[0].attrib.get("bounds") + lx, ly, rx, ry = map(int, re.findall(r"\d+", bounds)) + return lx, ly, rx, ry + else: + return None + + @cached_property + def button(self): + return self.area + + def __bool__(self): + return self.exist + + def __str__(self): + return self.name + + @cached_property + def focused(self): + if self.exist: + return self.nodes[0].attrib.get("focused").lower() == 'true' + else: + return False diff --git a/module/device/method/wsa.py b/module/device/method/wsa.py new file mode 100644 index 0000000..56f0ea2 --- /dev/null +++ b/module/device/method/wsa.py @@ -0,0 +1,149 @@ +import re +from functools import wraps + +from adbutils.errors import AdbError + +from module.device.connection import Connection +from module.device.method.utils import (RETRY_TRIES, retry_sleep, + handle_adb_error, PackageNotInstalled) +from module.exception import RequestHumanTakeover +from module.logger import logger + + +def retry(func): + @wraps(func) + def retry_wrapper(self, *args, **kwargs): + """ + Args: + self (Adb): + """ + init = None + for _ in range(RETRY_TRIES): + try: + if callable(init): + retry_sleep(_) + init() + return func(self, *args, **kwargs) + # Can't handle + except RequestHumanTakeover: + break + # When adb server was killed + except ConnectionResetError as e: + logger.error(e) + + def init(): + self.adb_reconnect() + # AdbError + except AdbError as e: + if handle_adb_error(e): + def init(): + self.adb_reconnect() + else: + break + # Package not installed + except PackageNotInstalled as e: + logger.error(e) + + def init(): + self.detect_package() + # Unknown, probably a trucked image + except Exception as e: + logger.exception(e) + + def init(): + pass + + logger.critical(f'Retry {func.__name__}() failed') + raise RequestHumanTakeover + + return retry_wrapper + + +class WSA(Connection): + + @retry + def app_current_wsa(self): + """ + Returns: + str: Package name. + + Raises: + OSError + """ + # try: adb shell dumpsys activity top + _activityRE = re.compile( + r'ACTIVITY (?P[^\s]+)/(?P[^/\s]+) \w+ pid=(?P\d+)' + ) + output = self.adb_shell(['dumpsys', 'activity', 'top']) + ms = _activityRE.finditer(output) + ret = None + for m in ms: + ret = m.group('package') + if ret == self.package: + return ret + if ret: # get last result + return ret + raise OSError("Couldn't get focused app") + + @retry + def app_start_wsa(self, package_name=None, display=0): + """ + Args: + package_name (str): + display (int): + + Returns: + bool: If success to start + """ + if not package_name: + package_name = self.package + self.adb_shell(['svc', 'power', 'stayon', 'true']) + activity_name = self.get_main_activity_name(package_name=package_name) + result = self.adb_shell(['am', 'start', '--display', display, f'{package_name}/{activity_name}']) + if 'Activity not started' in result or 'does not exist' in result: + # Starting: Intent { act=android.intent.action.MAIN cat=[android.intent.category.LAUNCHER] pkg=xxx } + # Error: Activity not started, unable to resolve Intent { act=android.intent.action.MAIN cat=[android.intent.category.LAUNCHER] flg=0x10000000 pkg=xxx } + + # Starting: Intent { act=android.intent.action.MAIN cat=[android.intent.category.LAUNCHER] cmp=com.bilibili.azurlane/xxx } + # Error type 3 + # Error: Activity class {com.bilibili.azurlane/com.manjuu.azurlane.MainAct} does not exist. + logger.error(result) + raise PackageNotInstalled(package_name) + else: + # Starting: Intent { act=android.intent.action.MAIN cat=[android.intent.category.LAUNCHER] cmp=com.bilibili.azurlane/com.manjuu.azurlane.MainActivity } + return True + + @retry + def get_main_activity_name(self, package_name=None): + if not package_name: + package_name = self.package + try: + output = self.adb_shell(['dumpsys', 'package', package_name]) + _activityRE = re.compile( + r'\w+ ' + package_name + r'/(?P[^/\s]+) filter' + ) + ms = _activityRE.finditer(output) + ret = next(ms).group('activity') + return ret + except StopIteration: + raise PackageNotInstalled(package_name) + + @retry + def get_display_id(self): + """ + Returns: + 0: Could not find + int: Display id of the game + """ + try: + get_dump_sys_display = str(self.adb_shell(['dumpsys', 'display'])) + display_id_list = re.findall(r'systemapp:' + self.package + ':' + '(.+?)', get_dump_sys_display, re.S) + display_id = int(display_id_list[0]) + return display_id + except IndexError: + return 0 # When game running on display 0, its display id could not be found + + @retry + def display_resize_wsa(self, display): + logger.warning('display ' + str(display) + ' should be resized') + self.adb_shell(['wm', 'size', '1280x720', '-d', str(display)]) diff --git a/module/device/platform/emulator_base.py b/module/device/platform/emulator_base.py new file mode 100644 index 0000000..cdb4759 --- /dev/null +++ b/module/device/platform/emulator_base.py @@ -0,0 +1,243 @@ +import os +import re +import typing as t +from dataclasses import dataclass + +from module.device.platform.utils import cached_property, iter_folder + + +def abspath(path): + return os.path.abspath(path).replace('\\', '/') + + +def get_serial_pair(serial): + """ + Args: + serial (str): + + Returns: + str, str: `127.0.0.1:5555+{X}` and `emulator-5554+{X}`, 0 <= X <= 32 + """ + if serial.startswith('127.0.0.1:'): + try: + port = int(serial[10:]) + if 5555 <= port <= 5555 + 32: + return f'127.0.0.1:{port}', f'emulator-{port - 1}' + except (ValueError, IndexError): + pass + if serial.startswith('emulator-'): + try: + port = int(serial[9:]) + if 5554 <= port <= 5554 + 32: + return f'127.0.0.1:{port + 1}', f'emulator-{port}' + except (ValueError, IndexError): + pass + + return None, None + + +@dataclass +class EmulatorInstanceBase: + # Serial for adb connection + serial: str + # Emulator instance name, used for start/stop emulator + name: str + # Path to emulator .exe + path: str + + def __str__(self): + return f'{self.type}(serial="{self.serial}", name="{self.name}", path="{self.path}")' + + @cached_property + def type(self) -> str: + """ + Returns: + str: Emulator type, such as Emulator.NoxPlayer + """ + return EmulatorBase.path_to_type(self.path) + + @cached_property + def emulator(self): + """ + Returns: + Emulator: + """ + return EmulatorBase(self.path) + + def __eq__(self, other): + if isinstance(other, str) and self.type == other: + return True + if isinstance(other, list) and self.type in other: + return True + if isinstance(other, EmulatorInstanceBase): + return super().__eq__(other) and self.type == other.type + return super().__eq__(other) + + def __hash__(self): + return hash(str(self)) + + def __bool__(self): + return True + + @cached_property + def MuMuPlayer12_id(self): + """ + Convert MuMu 12 instance name to instance id. + Example name: MuMuPlayer-12.0-3 + Example ID : 3 + + Returns: + int: Instance ID, or None if this is not a MuMu 12 instance + """ + res = re.search(r'MuMuPlayer-12.0-(\d+)', self.name) + if res: + return int(res.group(1)) + else: + return None + + +class EmulatorBase: + # Values here must match those in argument.yaml EmulatorInfo.Emulator.option + NoxPlayer = 'NoxPlayer' + NoxPlayer64 = 'NoxPlayer64' + NoxPlayerFamily = [NoxPlayer, NoxPlayer64] + BlueStacks4 = 'BlueStacks4' + BlueStacks5 = 'BlueStacks5' + BlueStacks4HyperV = 'BlueStacks4HyperV' + BlueStacks5HyperV = 'BlueStacks5HyperV' + BlueStacksFamily = [BlueStacks4, BlueStacks5] + LDPlayer3 = 'LDPlayer3' + LDPlayer4 = 'LDPlayer4' + LDPlayer9 = 'LDPlayer9' + LDPlayerFamily = [LDPlayer3, LDPlayer4, LDPlayer9] + MuMuPlayer = 'MuMuPlayer' + MuMuPlayerX = 'MuMuPlayerX' + MuMuPlayer12 = 'MuMuPlayer12' + MuMuPlayerFamily = [MuMuPlayer, MuMuPlayerX, MuMuPlayer12] + MEmuPlayer = 'MEmuPlayer' + + @classmethod + def path_to_type(cls, path: str) -> str: + """ + Args: + path: Path to .exe file + + Returns: + str: Emulator type, such as Emulator.NoxPlayer, + or '' if this is not a emulator. + """ + return '' + + def iter_instances(self) -> t.Iterable[EmulatorInstanceBase]: + """ + Yields: + EmulatorInstance: Emulator instances found in this emulator + """ + pass + + def iter_adb_binaries(self) -> t.Iterable[str]: + """ + Yields: + str: Filepath to adb binaries found in this emulator + """ + pass + + def __init__(self, path): + # Path to .exe file + self.path = path.replace('\\', '/') + # Path to emulator folder + self.dir = os.path.dirname(path) + # str: Emulator type, or '' if this is not a emulator. + self.type = self.__class__.path_to_type(path) + + def __eq__(self, other): + if isinstance(other, str) and self.type == other: + return True + if isinstance(other, list) and self.type in other: + return True + return super().__eq__(other) + + def __str__(self): + return f'{self.type}(path="{self.path}")' + + __repr__ = __str__ + + def __hash__(self): + return hash(self.path) + + def __bool__(self): + return True + + def abspath(self, path, folder=None): + if folder is None: + folder = self.dir + return abspath(os.path.join(folder, path)) + + @classmethod + def is_emulator(cls, path: str) -> bool: + """ + Args: + path: Path to .exe file. + + Returns: + bool: If this is a emulator. + """ + return bool(cls.path_to_type(path)) + + def list_folder(self, folder, is_dir=False, ext=None): + """ + Safely list files in a folder + + Args: + folder: + is_dir: + ext: + + Returns: + list[str]: + """ + folder = self.abspath(folder) + return list(iter_folder(folder, is_dir=is_dir, ext=ext)) + + +class EmulatorManagerBase: + @cached_property + def all_emulators(self) -> t.List[EmulatorBase]: + """ + Get all emulators installed on current computer. + """ + return [] + + @cached_property + def all_emulator_instances(self) -> t.List[EmulatorInstanceBase]: + """ + Get all emulator instances installed on current computer. + """ + return [] + + @cached_property + def all_emulator_serials(self) -> t.List[str]: + """ + Returns: + list[str]: All possible serials on current computer. + """ + out = [] + for emulator in self.all_emulator_instances: + out.append(emulator.serial) + # Also add serial like `emulator-5554` + port_serial, emu_serial = get_serial_pair(emulator.serial) + if emu_serial: + out.append(emu_serial) + return out + + @cached_property + def all_adb_binaries(self) -> t.List[str]: + """ + Returns: + list[str]: All adb binaries of emulators on current computer. + """ + out = [] + for emulator in self.all_emulators: + for exe in emulator.iter_adb_binaries(): + out.append(exe) + return out diff --git a/module/device/platform/emulator_windows.py b/module/device/platform/emulator_windows.py new file mode 100644 index 0000000..a317ad4 --- /dev/null +++ b/module/device/platform/emulator_windows.py @@ -0,0 +1,509 @@ +import codecs +import os +import re +import typing as t +import winreg +from dataclasses import dataclass + +# module/device/platform/emulator_base.py +# module/device/platform/emulator_windows.py +# Will be used in Alas Easy Install, they shouldn't import any Alas modules. +from module.device.platform.utils import cached_property, iter_folder +from module.device.platform.emulator_base import EmulatorBase, EmulatorInstanceBase, EmulatorManagerBase + + +@dataclass +class RegValue: + name: str + value: str + typ: int + + +def list_reg(reg) -> t.List[RegValue]: + """ + List all values in a reg key + """ + rows = [] + index = 0 + try: + while 1: + value = RegValue(*winreg.EnumValue(reg, index)) + index += 1 + rows.append(value) + except OSError: + pass + return rows + + +def list_key(reg) -> t.List[RegValue]: + """ + List all values in a reg key + """ + rows = [] + index = 0 + try: + while 1: + value = winreg.EnumKey(reg, index) + index += 1 + rows.append(value) + except OSError: + pass + return rows + + +def abspath(path): + return os.path.abspath(path).replace('\\', '/') + + +class EmulatorInstance(EmulatorInstanceBase): + @cached_property + def type(self) -> str: + """ + Returns: + str: Emulator type, such as Emulator.NoxPlayer + """ + return Emulator.path_to_type(self.path) + + @cached_property + def emulator(self): + """ + Returns: + Emulator: + """ + return Emulator(self.path) + + +class Emulator(EmulatorBase): + @classmethod + def path_to_type(cls, path: str) -> str: + """ + Args: + path: Path to .exe file + + Returns: + str: Emulator type, such as Emulator.NoxPlayer + """ + folder, exe = os.path.split(path) + folder, dir1 = os.path.split(folder) + folder, dir2 = os.path.split(folder) + if exe == 'Nox.exe': + if dir2 == 'Nox': + return cls.NoxPlayer + elif dir2 == 'Nox64': + return cls.NoxPlayer64 + else: + return cls.NoxPlayer + if exe == 'Bluestacks.exe': + if dir1 in ['BlueStacks', 'BlueStacks_cn']: + return cls.BlueStacks4 + elif dir1 in ['BlueStacks_nxt', 'BlueStacks_nxt_cn']: + return cls.BlueStacks5 + else: + return cls.BlueStacks4 + if exe == 'HD-Player.exe': + if dir1 in ['BlueStacks', 'BlueStacks_cn']: + return cls.BlueStacks4 + elif dir1 in ['BlueStacks_nxt', 'BlueStacks_nxt_cn']: + return cls.BlueStacks5 + else: + return cls.BlueStacks5 + if exe == 'dnplayer.exe': + if dir1 == 'LDPlayer': + return cls.LDPlayer3 + elif dir1 == 'LDPlayer4': + return cls.LDPlayer4 + elif dir1 == 'LDPlayer9': + return cls.LDPlayer9 + else: + return cls.LDPlayer3 + if exe == 'NemuPlayer.exe': + if dir2 == 'nemu': + return cls.MuMuPlayer + elif dir2 == 'nemu9': + return cls.MuMuPlayerX + else: + return cls.MuMuPlayer + if exe == 'MuMuPlayer.exe': + return cls.MuMuPlayer12 + if exe == 'MEmu.exe': + return cls.MEmuPlayer + + return '' + + @staticmethod + def multi_to_single(exe): + """ + Convert a string that might be a multi-instance manager to its single instance executable. + + Args: + exe (str): Path to emulator executable + + Yields: + str: Path to emulator executable + """ + if 'HD-MultiInstanceManager.exe' in exe: + yield exe.replace('HD-MultiInstanceManager.exe', 'HD-Player.exe') + yield exe.replace('HD-MultiInstanceManager.exe', 'Bluestacks.exe') + elif 'MultiPlayerManager.exe' in exe: + yield exe.replace('MultiPlayerManager.exe', 'Nox.exe') + elif 'dnmultiplayer.exe' in exe: + yield exe.replace('dnmultiplayer.exe', 'dnplayer.exe') + elif 'NemuMultiPlayer.exe' in exe: + yield exe.replace('NemuMultiPlayer.exe', 'NemuPlayer.exe') + elif 'MuMuMultiPlayer.exe' in exe: + yield exe.replace('MuMuMultiPlayer.exe', 'MuMuManager.exe') + elif 'MEmuConsole.exe' in exe: + yield exe.replace('MEmuConsole.exe', 'MEmu.exe') + else: + yield exe + + @staticmethod + def vbox_file_to_serial(file: str) -> str: + """ + Args: + file: Path to vbox file + + Returns: + str: serial such as `127.0.0.1:5555` + """ + regex = re.compile('<*?hostport="(.*?)".*?guestport="5555"/>') + try: + with open(file, 'r', encoding='utf-8', errors='ignore') as f: + for line in f.readlines(): + # + res = regex.search(line) + if res: + return f'127.0.0.1:{res.group(1)}' + return '' + except FileNotFoundError: + return '' + + def iter_instances(self): + """ + Yields: + EmulatorInstance: Emulator instances found in this emulator + """ + if self == Emulator.NoxPlayerFamily: + # ./BignoxVMS/{name}/{name}.vbox + for folder in self.list_folder('./BignoxVMS', is_dir=True): + for file in iter_folder(folder, ext='.vbox'): + serial = Emulator.vbox_file_to_serial(file) + if serial: + yield EmulatorInstance( + serial=serial, + name=os.path.basename(folder), + path=self.path, + ) + elif self == Emulator.BlueStacks5: + # Get UserDefinedDir, where BlueStacks stores data + folder = None + try: + with winreg.OpenKey(winreg.HKEY_LOCAL_MACHINE, r"SOFTWARE\BlueStacks_nxt") as reg: + folder = winreg.QueryValueEx(reg, 'UserDefinedDir')[0] + except FileNotFoundError: + pass + try: + with winreg.OpenKey(winreg.HKEY_LOCAL_MACHINE, r"SOFTWARE\BlueStacks_nxt_cn") as reg: + folder = winreg.QueryValueEx(reg, 'UserDefinedDir')[0] + except FileNotFoundError: + pass + if not folder: + return + # Read {UserDefinedDir}/bluestacks.conf + try: + with open(self.abspath('./bluestacks.conf', folder), encoding='utf-8') as f: + content = f.read() + except FileNotFoundError: + return + # bst.instance.Nougat64.adb_port="5555" + emulators = re.findall(r'bst.instance.(\w+).status.adb_port="(\d+)"', content) + for emulator in emulators: + yield EmulatorInstance( + serial=f'127.0.0.1:{emulator[1]}', + name=emulator[0], + path=self.path, + ) + elif self == Emulator.BlueStacks4: + # ../Engine/Android + regex = re.compile(r'^Android') + for folder in self.list_folder('../Engine', is_dir=True): + folder = os.path.basename(folder) + res = regex.match(folder) + if not res: + continue + # Serial from BlueStacks4 are not static, they get increased on every emulator launch + # Assume all use 127.0.0.1:5555 + yield EmulatorInstance( + serial=f'127.0.0.1:5555', + name=folder, + path=self.path + ) + elif self == Emulator.LDPlayerFamily: + # ./vms/leidian0 + regex = re.compile(r'^leidian(\d+)$') + for folder in self.list_folder('./vms', is_dir=True): + folder = os.path.basename(folder) + res = regex.match(folder) + if not res: + continue + # LDPlayer has no forward port config in .vbox file + # Ports are auto increase, 5555, 5557, 5559, etc + port = int(res.group(1)) * 2 + 5555 + yield EmulatorInstance( + serial=f'127.0.0.1:{port}', + name=folder, + path=self.path + ) + elif self == Emulator.MuMuPlayer: + # MuMu has no multi instances, on 7555 only + yield EmulatorInstance( + serial='127.0.0.1:7555', + name='', + path=self.path, + ) + elif self == Emulator.MuMuPlayerX: + # vms/nemu-12.0-x64-default + for folder in self.list_folder('../vms', is_dir=True): + for file in iter_folder(folder, ext='.nemu'): + serial = Emulator.vbox_file_to_serial(file) + if serial: + yield EmulatorInstance( + serial=serial, + name=os.path.basename(folder), + path=self.path, + ) + elif self == Emulator.MuMuPlayer12: + # vms/MuMuPlayer-12.0-0 + for folder in self.list_folder('../vms', is_dir=True): + for file in iter_folder(folder, ext='.nemu'): + serial = Emulator.vbox_file_to_serial(file) + if serial: + yield EmulatorInstance( + serial=serial, + name=os.path.basename(folder), + path=self.path, + ) + elif self == Emulator.MEmuPlayer: + # ./MemuHyperv VMs/{name}/{name}.memu + for folder in self.list_folder('./MemuHyperv VMs', is_dir=True): + for file in iter_folder(folder, ext='.memu'): + serial = Emulator.vbox_file_to_serial(file) + if serial: + yield EmulatorInstance( + serial=serial, + name=os.path.basename(folder), + path=self.path, + ) + + def iter_adb_binaries(self) -> t.Iterable[str]: + """ + Yields: + str: Filepath to adb binaries found in this emulator + """ + if self == Emulator.NoxPlayerFamily: + exe = self.abspath('./nox_adb.exe') + if os.path.exists(exe): + yield exe + if self == Emulator.MuMuPlayerFamily: + # From MuMu9\emulator\nemu9\EmulatorShell + # to MuMu9\emulator\nemu9\vmonitor\bin\adb_server.exe + exe = self.abspath('../vmonitor/bin/adb_server.exe') + if os.path.exists(exe): + yield exe + + # All emulators have adb.exe + exe = self.abspath('./adb.exe') + if os.path.exists(exe): + yield exe + + +class EmulatorManager(EmulatorManagerBase): + @staticmethod + def iter_user_assist(): + """ + Get recently executed programs in UserAssist + https://github.com/forensicmatt/MonitorUserAssist + + Returns: + str: Path to emulator executables, may contains duplicate values + """ + path = r'Software\Microsoft\Windows\CurrentVersion\Explorer\UserAssist' + # {XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX}\xxx.exe + regex_hash = re.compile(r'{.*}') + with winreg.OpenKey(winreg.HKEY_CURRENT_USER, path) as reg: + folders = list_key(reg) + for folder in folders: + try: + with winreg.OpenKey(winreg.HKEY_CURRENT_USER, f'{path}\\{folder}\\Count') as reg: + for key in list_reg(reg): + key = codecs.decode(key.name, 'rot-13') + # Skip those with hash + if regex_hash.search(key): + continue + for file in Emulator.multi_to_single(key): + yield file + except FileNotFoundError: + # FileNotFoundError: [WinError 2] 系统找不到指定的文件。 + # Might be a random directory without "Count" subdirectory + continue + + @staticmethod + def iter_mui_cache(): + """ + Iter emulator executables that has ever run. + http://what-when-how.com/windows-forensic-analysis/registry-analysis-windows-forensic-analysis-part-8/ + https://3gstudent.github.io/%E6%B8%97%E9%80%8F%E6%8A%80%E5%B7%A7-Windows%E7%B3%BB%E7%BB%9F%E6%96%87%E4%BB%B6%E6%89%A7%E8%A1%8C%E8%AE%B0%E5%BD%95%E7%9A%84%E8%8E%B7%E5%8F%96%E4%B8%8E%E6%B8%85%E9%99%A4 + + Yields: + str: Path to emulator executable, may contains duplicate values + """ + path = r'Software\Classes\Local Settings\Software\Microsoft\Windows\Shell\MuiCache' + with winreg.OpenKey(winreg.HKEY_CURRENT_USER, path) as reg: + rows = list_reg(reg) + + regex = re.compile(r'(^.*\.exe)\.') + for row in rows: + res = regex.search(row.name) + if not res: + continue + for file in Emulator.multi_to_single(res.group(1)): + yield file + + @staticmethod + def get_install_dir_from_reg(path, key): + """ + Args: + path (str): f'SOFTWARE\\leidian\\ldplayer' + key (str): 'InstallDir' + + Returns: + str: Installation dir or None + """ + try: + with winreg.OpenKey(winreg.HKEY_CURRENT_USER, path) as reg: + root = winreg.QueryValueEx(reg, key)[0] + return root + except FileNotFoundError: + pass + try: + with winreg.OpenKey(winreg.HKEY_LOCAL_MACHINE, path) as reg: + root = winreg.QueryValueEx(reg, key)[0] + return root + except FileNotFoundError: + pass + + return None + + @staticmethod + def iter_uninstall_registry(): + """ + Iter emulator uninstaller from registry. + + Yields: + str: Path to uninstall exe file + """ + known_uninstall_registry_path = [ + r'SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall', + r'Software\Microsoft\Windows\CurrentVersion\Uninstall' + ] + known_emulator_registry_name = [ + 'Nox', + 'Nox64', + 'BlueStacks', + 'BlueStacks_nxt', + 'BlueStacks_cn', + 'BlueStacks_nxt_cn', + 'LDPlayer', + 'LDPlayer4', + 'LDPlayer9', + 'leidian', + 'leidian4', + 'leidian9', + 'Nemu', + 'Nemu9', + 'MuMuPlayer-12.0' + 'MEmu', + ] + for path in known_uninstall_registry_path: + with winreg.OpenKey(winreg.HKEY_LOCAL_MACHINE, path) as reg: + for software in list_key(reg): + if software not in known_emulator_registry_name: + continue + with winreg.OpenKey(winreg.HKEY_LOCAL_MACHINE, f'{path}\\{software}') as software_reg: + try: + uninstall = winreg.QueryValueEx(software_reg, 'UninstallString')[0] + except FileNotFoundError: + continue + if not uninstall: + continue + # UninstallString is like: + # C:\Program Files\BlueStacks_nxt\BlueStacksUninstaller.exe -tmp + # "E:\ProgramFiles\Microvirt\MEmu\uninstall\uninstall.exe" -u + # Extract path in "" + res = re.search('"(.*?)"', uninstall) + uninstall = res.group(1) if res else uninstall + yield uninstall + + @cached_property + def all_emulators(self) -> t.List[Emulator]: + """ + Get all emulators installed on current computer. + """ + exe = set([]) + + # MuiCache + for file in EmulatorManager.iter_mui_cache(): + if Emulator.is_emulator(file) and os.path.exists(file): + exe.add(file) + + # UserAssist + for file in EmulatorManager.iter_user_assist(): + if Emulator.is_emulator(file) and os.path.exists(file): + exe.add(file) + + # LDPlayer install path + for path in [r'SOFTWARE\leidian\ldplayer', + r'SOFTWARE\leidian\ldplayer9']: + ld = self.get_install_dir_from_reg(path, 'InstallDir') + if ld: + ld = abspath(os.path.join(ld, './dnplayer.exe')) + if Emulator.is_emulator(ld) and os.path.exists(ld): + exe.add(ld) + + # Uninstall registry + for uninstall in self.iter_uninstall_registry(): + # Find emulator executable from uninstaller + for file in iter_folder(abspath(os.path.dirname(uninstall)), ext='.exe'): + if Emulator.is_emulator(file) and os.path.exists(file): + exe.add(file) + # Find from parent directory + for file in iter_folder(abspath(os.path.join(os.path.dirname(uninstall), '../')), ext='.exe'): + if Emulator.is_emulator(file) and os.path.exists(file): + exe.add(file) + # MuMu specific directory + for file in iter_folder(abspath(os.path.join(os.path.dirname(uninstall), 'EmulatorShell')), ext='.exe'): + if Emulator.is_emulator(file) and os.path.exists(file): + exe.add(file) + + exe = [Emulator(path).path for path in exe if Emulator.is_emulator(path)] + exe = sorted(set(exe)) + exe = [Emulator(path) for path in exe] + return exe + + @cached_property + def all_emulator_instances(self) -> t.List[EmulatorInstance]: + """ + Get all emulator instances installed on current computer. + """ + instances = [] + for emulator in self.all_emulators: + instances += list(emulator.iter_instances()) + + instances: t.List[EmulatorInstance] = sorted(instances, key=lambda x: str(x)) + return instances + + +if __name__ == '__main__': + self = EmulatorManager() + for emu in self.all_emulator_instances: + print(emu) diff --git a/module/device/platform/platform_base.py b/module/device/platform/platform_base.py new file mode 100644 index 0000000..9f47cea --- /dev/null +++ b/module/device/platform/platform_base.py @@ -0,0 +1,176 @@ +import sys +import typing as t + +import yaml +from pydantic import BaseModel, SecretStr + +from module.base.decorator import cached_property, del_cached_property +from module.device.connection import Connection +from module.device.platform.emulator_base import EmulatorInstanceBase, EmulatorManagerBase +from module.logger import logger +from module.base.utils import SelectedGrids + + +class EmulatorInfo(BaseModel): + emulator: str = '' + name: str = '' + path: str = '' + + # For APIs of chinac.com, a phone cloud platform. + # access_key: SecretStr = '' + # secret: SecretStr = '' + + +class PlatformBase(Connection, EmulatorManagerBase): + """ + Base interface of a platform, platform can be various operating system or phone clouds. + For each `Platform` class, the following APIs must be implemented. + - all_emulators() + - all_emulator_instances() + - emulator_start() + - emulator_stop() + """ + + def emulator_start(self): + """ + Start a emulator, until startup completed. + - Retry is required. + - Using bored sleep to wait startup is forbidden. + """ + logger.info(f'Current platform {sys.platform} does not support emulator_start, skip') + + def emulator_stop(self): + """ + Stop a emulator. + """ + logger.info(f'Current platform {sys.platform} does not support emulator_stop, skip') + + @cached_property + def emulator_info(self) -> EmulatorInfo: + emulator = self.config.EmulatorInfo_Emulator + name = str(self.config.EmulatorInfo_name).strip().replace('\n', '') + path = str(self.config.EmulatorInfo_path).strip().replace('\n', '') + + return EmulatorInfo( + emulator=emulator, + name=name, + path=path, + ) + + @cached_property + def emulator_instance(self) -> t.Optional[EmulatorInstanceBase]: + """ + Returns: + EmulatorInstanceBase: Emulator instance or None + """ + data = self.emulator_info + old_info = dict( + emulator=data.emulator, + path=data.path, + name=data.name, + ) + instance = self.find_emulator_instance( + serial=str(self.config.Emulator_Serial).strip(), + name=data.name, + path=data.path, + emulator=data.emulator, + ) + + # Write complete emulator data + if instance is not None: + new_info = dict( + emulator=instance.type, + path=instance.path, + name=instance.name, + ) + if new_info != old_info: + with self.config.multi_set(): + self.config.EmulatorInfo_Emulator = instance.type + self.config.EmulatorInfo_name = instance.name + self.config.EmulatorInfo_path = instance.path + del_cached_property(self, 'emulator_info') + + return instance + + def find_emulator_instance( + self, + serial: str, + name: str = None, + path: str = None, + emulator: str = None + ) -> t.Optional[EmulatorInstanceBase]: + """ + Args: + serial: Serial like "127.0.0.1:5555" + name: Instance name like "Nougat64" + path: Emulator install path like "C:/Program Files/BlueStacks_nxt/HD-Player.exe" + emulator: Emulator type defined in Emulator class, like "BlueStacks5" + + Returns: + EmulatorInstance: Emulator instance or None if no instances not found. + """ + logger.hr('Find emulator instance', level=2) + instances = SelectedGrids(self.all_emulator_instances) + for instance in instances: + logger.info(instance) + search_args = dict(serial=serial) + + # Search by serial + select = instances.select(**search_args) + if select.count == 0: + logger.warning(f'No emulator instance with {search_args}') + return None + if select.count == 1: + instance = select[0] + logger.hr('Emulator instance', level=2) + logger.info(f'Found emulator instance: {instance}') + return instance + + # Multiple instances in given serial, search by name + if name: + search_args['name'] = name + select = instances.select(**search_args) + if select.count == 0: + logger.warning(f'No emulator instances with {search_args}') + return None + if select.count == 1: + instance = select[0] + logger.hr('Emulator instance', level=2) + logger.info(f'Found emulator instance: {instance}') + return instance + + # Multiple instances in given serial and name, search by path + if path: + search_args['path'] = path + select = instances.select(**search_args) + if select.count == 0: + logger.warning(f'No emulator instances with {search_args}') + return None + if select.count == 1: + instance = select[0] + logger.hr('Emulator instance', level=2) + logger.info(f'Found emulator instance: {instance}') + return instance + + # Multiple instances in given serial, name and path, search by emulator + if emulator: + search_args['type'] = emulator + select = instances.select(**search_args) + if select.count == 0: + logger.warning(f'No emulator instances with {search_args}') + return None + if select.count == 1: + instance = select[0] + logger.hr('Emulator instance', level=2) + logger.info(f'Found emulator instance: {instance}') + return instance + + # Still too many instances + logger.warning(f'Found multiple emulator instances with {search_args}') + return None + + +if __name__ == '__main__': + self = PlatformBase('alas') + d = self.emulator_instance + print(d) diff --git a/module/device/platform/platform_windows.py b/module/device/platform/platform_windows.py new file mode 100644 index 0000000..0490156 --- /dev/null +++ b/module/device/platform/platform_windows.py @@ -0,0 +1,316 @@ +import ctypes +import re +import subprocess + +import psutil + +from deploy.Windows.utils import DataProcessInfo +from module.base.decorator import run_once +from module.base.timer import Timer +from module.device.connection import AdbDeviceWithStatus +from module.device.platform.platform_base import PlatformBase +from module.device.platform.emulator_windows import Emulator, EmulatorInstance, EmulatorManager +from module.logger import logger + + +class EmulatorUnknown(Exception): + pass + + +def get_focused_window(): + return ctypes.windll.user32.GetForegroundWindow() + + +def set_focus_window(hwnd): + ctypes.windll.user32.SetForegroundWindow(hwnd) + + +def minimize_window(hwnd): + ctypes.windll.user32.ShowWindow(hwnd, 6) + + +def get_window_title(hwnd): + """Returns the window title as a string.""" + text_len_in_characters = ctypes.windll.user32.GetWindowTextLengthW(hwnd) + string_buffer = ctypes.create_unicode_buffer( + text_len_in_characters + 1) # +1 for the \0 at the end of the null-terminated string. + ctypes.windll.user32.GetWindowTextW(hwnd, string_buffer, text_len_in_characters + 1) + return string_buffer.value + + +def flash_window(hwnd, flash=True): + ctypes.windll.user32.FlashWindow(hwnd, flash) + + +class PlatformWindows(PlatformBase, EmulatorManager): + @classmethod + def execute(cls, command): + """ + Args: + command (str): + + Returns: + subprocess.Popen: + """ + command = command.replace(r"\\", "/").replace("\\", "/").replace('"', '"') + logger.info(f'Execute: {command}') + return subprocess.Popen(command, close_fds=True) # only work on Windows + + @classmethod + def kill_process_by_regex(cls, regex: str) -> int: + """ + Kill processes with cmdline match the given regex. + + Args: + regex: + + Returns: + int: Number of processes killed + """ + count = 0 + + for proc in psutil.process_iter(): + cmdline = DataProcessInfo(proc=proc, pid=proc.pid).cmdline + if re.search(regex, cmdline): + logger.info(f'Kill emulator: {cmdline}') + proc.kill() + count += 1 + + return count + + def _emulator_start(self, instance: EmulatorInstance): + """ + Start a emulator without error handling + """ + exe = instance.emulator.path + if instance == Emulator.MuMuPlayer: + # NemuPlayer.exe + self.execute(exe) + elif instance == Emulator.MuMuPlayerX: + # NemuPlayer.exe -m nemu-12.0-x64-default + self.execute(f'"{exe}" -m {instance.name}') + elif instance == Emulator.MuMuPlayer12: + # MuMuPlayer.exe -v 0 + self.execute(f'"{exe}" -v {instance.MuMuPlayer12_id}') + elif instance == Emulator.NoxPlayerFamily: + # Nox.exe -clone:Nox_1 + self.execute(f'"{exe}" -clone:{instance.name}') + elif instance == Emulator.BlueStacks5: + # HD-Player.exe -instance Pie64 + self.execute(f'"{exe}" -instance {instance.name}') + elif instance == Emulator.BlueStacks4: + # BlueStacks\Client\Bluestacks.exe -vmname Android_1 + self.execute(f'"{exe}" -vmname {instance.name}') + else: + raise EmulatorUnknown(f'Cannot start an unknown emulator instance: {instance}') + + def _emulator_stop(self, instance: EmulatorInstance): + """ + Stop a emulator without error handling + """ + logger.hr('Emulator stop', level=2) + exe = instance.emulator.path + if instance == Emulator.MuMuPlayer: + # MuMu6 does not have multi instance, kill one means kill all + # Has 4 processes + # "C:\Program Files\NemuVbox\Hypervisor\NemuHeadless.exe" --comment nemu-6.0-x64-default --startvm + # "E:\ProgramFiles\MuMu\emulator\nemu\EmulatorShell\NemuPlayer.exe" + # E:\ProgramFiles\MuMu\emulator\nemu\EmulatorShell\NemuService.exe + # "C:\Program Files\NemuVbox\Hypervisor\NemuSVC.exe" -Embedding + self.kill_process_by_regex( + rf'(' + rf'NemuHeadless.exe' + rf'|NemuPlayer.exe\"' + rf'|NemuPlayer.exe$' + rf'|NemuService.exe' + rf'|NemuSVC.exe' + rf')' + ) + elif instance == Emulator.MuMuPlayerX: + # MuMu X has 3 processes + # "E:\ProgramFiles\MuMu9\emulator\nemu9\EmulatorShell\NemuPlayer.exe" -m nemu-12.0-x64-default -s 0 -l + # "C:\Program Files\Muvm6Vbox\Hypervisor\Muvm6Headless.exe" --comment nemu-12.0-x64-default --startvm xxx + # "C:\Program Files\Muvm6Vbox\Hypervisor\Muvm6SVC.exe" --Embedding + self.kill_process_by_regex( + rf'(' + rf'NemuPlayer.exe.*-m {instance.name}' + rf'|Muvm6Headless.exe' + rf'|Muvm6SVC.exe' + rf')' + ) + elif instance == Emulator.MuMuPlayer12: + # MuMu 12 has 2 processes: + # E:\ProgramFiles\Netease\MuMuPlayer-12.0\shell\MuMuPlayer.exe -v 0 + # "C:\Program Files\MuMuVMMVbox\Hypervisor\MuMuVMMHeadless.exe" --comment MuMuPlayer-12.0-0 --startvm xxx + self.kill_process_by_regex( + rf'(' + rf'MuMuVMMHeadless.exe.*--comment {instance.name}' + rf'|MuMuPlayer.exe.*-v {instance.MuMuPlayer12_id}' + rf')' + ) + # There is also a shared service, no need to kill it + # "C:\Program Files\MuMuVMMVbox\Hypervisor\MuMuVMMSVC.exe" --Embedding + elif instance == Emulator.NoxPlayerFamily: + # Nox.exe -clone:Nox_1 -quit + self.execute(f'"{exe}" -clone:{instance.name} -quit') + else: + raise EmulatorUnknown(f'Cannot stop an unknown emulator instance: {instance}') + + def _emulator_function_wrapper(self, func): + """ + Args: + func (callable): _emulator_start or _emulator_stop + + Returns: + bool: If success + """ + try: + func(self.emulator_instance) + return True + except OSError as e: + msg = str(e) + # OSError: [WinError 740] 请求的操作需要提升。 + if 'WinError 740' in msg: + logger.error('To start/stop MumuAppPlayer, ALAS needs to be run as administrator') + except EmulatorUnknown as e: + logger.error(e) + except Exception as e: + logger.exception(e) + + logger.error(f'Emulator function {func.__name__}() failed') + return False + + def emulator_start_watch(self): + """ + Returns: + bool: True if startup completed + False if timeout + """ + logger.hr('Emulator start', level=2) + current_window = get_focused_window() + serial = self.emulator_instance.serial + logger.info(f'Current window: {current_window}') + + def adb_connect(): + m = self.adb_client.connect(self.serial) + if 'connected' in m: + # Connected to 127.0.0.1:59865 + # Already connected to 127.0.0.1:59865 + return False + elif '(10061)' in m: + # cannot connect to 127.0.0.1:55555: + # No connection could be made because the target machine actively refused it. (10061) + return False + else: + return True + + @run_once + def show_online(m): + logger.info(f'Emulator online: {m}') + + @run_once + def show_ping(m): + logger.info(f'Command ping: {m}') + + @run_once + def show_package(m): + logger.info(f'Found azurlane packages: {m}') + + interval = Timer(0.5).start() + timeout = Timer(300).start() + new_window = 0 + while 1: + interval.wait() + interval.reset() + if timeout.reached(): + logger.warning(f'Emulator start timeout') + return False + + # Check emulator window showing up + # logger.info([get_focused_window(), get_window_title(get_focused_window())]) + if current_window != 0 and new_window == 0: + new_window = get_focused_window() + if current_window != new_window: + logger.info(f'New window showing up: {new_window}, focus back') + set_focus_window(current_window) + else: + new_window = 0 + + # Check device connection + devices = self.list_device().select(serial=serial) + # logger.info(devices) + if devices: + device: AdbDeviceWithStatus = devices.first_or_none() + if device.status == 'device': + # Emulator online + pass + if device.status == 'offline': + self.adb_client.disconnect(serial) + adb_connect() + continue + else: + # Try to connect + adb_connect() + continue + show_online(devices.first_or_none()) + + # Check command availability + try: + pong = self.adb_shell(['echo', 'pong']) + except Exception as e: + logger.info(e) + continue + show_ping(pong) + + # Check azuelane package + packages = self.list_azurlane_packages(show_log=False) + if len(packages): + pass + else: + continue + show_package(packages) + + # All check passed + break + + if new_window != 0 and new_window != current_window: + logger.info(f'Minimize new window: {new_window}') + minimize_window(new_window) + if current_window: + logger.info(f'De-flash current window: {current_window}') + flash_window(current_window, flash=False) + if new_window: + logger.info(f'Flash new window: {new_window}') + flash_window(new_window, flash=True) + logger.info('Emulator start completed') + return True + + def emulator_start(self): + logger.hr('Emulator start', level=1) + for _ in range(3): + # Stop + if not self._emulator_function_wrapper(self._emulator_stop): + return False + # Start + if self._emulator_function_wrapper(self._emulator_start): + # Success + self.emulator_start_watch() + return True + else: + # Failed to start, stop and start again + if self._emulator_function_wrapper(self._emulator_stop): + continue + else: + return False + + logger.error('Failed to start emulator 3 times, stopped') + return False + + def emulator_stop(self): + logger.hr('Emulator stop', level=1) + return self._emulator_function_wrapper(self._emulator_stop) + + +if __name__ == '__main__': + self = PlatformWindows('alas') + self.emulator_start() diff --git a/module/device/platform/utils.py b/module/device/platform/utils.py new file mode 100644 index 0000000..4ebf94a --- /dev/null +++ b/module/device/platform/utils.py @@ -0,0 +1,54 @@ +import os +from typing import Callable, Generic, TypeVar + +T = TypeVar("T") + + +class cached_property(Generic[T]): + """ + cached-property from https://github.com/pydanny/cached-property + Add typing support + + A property that is only computed once per instance and then replaces itself + with an ordinary attribute. Deleting the attribute resets the property. + Source: https://github.com/bottlepy/bottle/commit/fa7733e075da0d790d809aa3d2f53071897e6f76 + """ + + def __init__(self, func: Callable[..., T]): + self.func = func + + def __get__(self, obj, cls) -> T: + if obj is None: + return self + + value = obj.__dict__[self.func.__name__] = self.func(obj) + return value + + +def iter_folder(folder, is_dir=False, ext=None): + """ + Args: + folder (str): + is_dir (bool): True to iter directories only + ext (str): File extension, such as `.yaml` + + Yields: + str: Absolute path of files + """ + try: + files = os.listdir(folder) + except FileNotFoundError: + return + + for file in files: + sub = os.path.join(folder, file) + if is_dir: + if os.path.isdir(sub): + yield sub.replace('\\\\', '/').replace('\\', '/') + elif ext is not None: + if not os.path.isdir(sub): + _, extension = os.path.splitext(file) + if extension == ext: + yield os.path.join(folder, file).replace('\\\\', '/').replace('\\', '/') + else: + yield os.path.join(folder, file).replace('\\\\', '/').replace('\\', '/') diff --git a/module/device/screenshot.py b/module/device/screenshot.py new file mode 100644 index 0000000..c054bcc --- /dev/null +++ b/module/device/screenshot.py @@ -0,0 +1,255 @@ +import os +import time +from collections import deque +from datetime import datetime + +import cv2 +import numpy as np +from PIL import Image + +from module.base.decorator import cached_property +from module.base.timer import Timer +from module.base.utils import get_color, image_size, limit_in, save_image +from module.device.method.adb import Adb +from module.device.method.ascreencap import AScreenCap +from module.device.method.droidcast import DroidCast +from module.device.method.scrcpy import Scrcpy +from module.device.method.wsa import WSA +from module.exception import RequestHumanTakeover, ScriptError +from module.logger import logger + + +class Screenshot(Adb, WSA, DroidCast, AScreenCap, Scrcpy): + _screen_size_checked = False + _screen_black_checked = False + _minicap_uninstalled = False + _screenshot_interval = Timer(0.1) + _last_save_time = {} + image: np.ndarray + + @cached_property + def screenshot_methods(self): + return { + 'ADB': self.screenshot_adb, + 'ADB_nc': self.screenshot_adb_nc, + 'uiautomator2': self.screenshot_uiautomator2, + 'aScreenCap': self.screenshot_ascreencap, + 'aScreenCap_nc': self.screenshot_ascreencap_nc, + 'DroidCast': self.screenshot_droidcast, + 'DroidCast_raw': self.screenshot_droidcast_raw, + 'scrcpy': self.screenshot_scrcpy, + } + + def screenshot(self): + """ + Returns: + np.ndarray: + """ + self._screenshot_interval.wait() + self._screenshot_interval.reset() + + for _ in range(2): + method = self.screenshot_methods.get( + self.config.Emulator_ScreenshotMethod, + self.screenshot_adb + ) + self.image = method() + + # if self.config.Emulator_ScreenshotDedithering: + # # This will take 40-60ms + # cv2.fastNlMeansDenoising(self.image, self.image, h=17, templateWindowSize=1, searchWindowSize=2) + self.image = self._handle_orientated_image(self.image) + + if self.config.Error_SaveError: + self.screenshot_deque.append({'time': datetime.now(), 'image': self.image}) + + if self.check_screen_size() and self.check_screen_black(): + break + else: + continue + + return self.image + + def _handle_orientated_image(self, image): + """ + Args: + image (np.ndarray): + + Returns: + np.ndarray: + """ + width, height = image_size(self.image) + if width == 1280 and height == 720: + return image + + # Rotate screenshots only when they're not 1280x720 + if self.orientation == 0: + pass + elif self.orientation == 1: + image = cv2.rotate(image, cv2.ROTATE_90_COUNTERCLOCKWISE) + elif self.orientation == 2: + image = cv2.rotate(image, cv2.ROTATE_180) + elif self.orientation == 3: + image = cv2.rotate(image, cv2.ROTATE_90_CLOCKWISE) + else: + raise ScriptError(f'Invalid device orientation: {self.orientation}') + + return image + + @cached_property + def screenshot_deque(self): + return deque(maxlen=int(self.config.Error_ScreenshotLength)) + + def save_screenshot(self, genre='items', interval=None, to_base_folder=False): + """Save a screenshot. Use millisecond timestamp as file name. + + Args: + genre (str, optional): Screenshot type. + interval (int, float): Seconds between two save. Saves in the interval will be dropped. + to_base_folder (bool): If save to base folder. + + Returns: + bool: True if save succeed. + """ + now = time.time() + if interval is None: + interval = self.config.SCREEN_SHOT_SAVE_INTERVAL + + if now - self._last_save_time.get(genre, 0) > interval: + fmt = 'png' + file = '%s.%s' % (int(now * 1000), fmt) + + folder = self.config.SCREEN_SHOT_SAVE_FOLDER_BASE if to_base_folder else self.config.SCREEN_SHOT_SAVE_FOLDER + folder = os.path.join(folder, genre) + if not os.path.exists(folder): + os.mkdir(folder) + + file = os.path.join(folder, file) + self.image_save(file) + self._last_save_time[genre] = now + return True + else: + self._last_save_time[genre] = now + return False + + def screenshot_last_save_time_reset(self, genre): + self._last_save_time[genre] = 0 + + def screenshot_interval_set(self, interval=None): + """ + Args: + interval (int, float, str): + Minimum interval between 2 screenshots in seconds. + Or None for Optimization_ScreenshotInterval, 'combat' for Optimization_CombatScreenshotInterval + """ + if interval is None: + origin = self.config.Optimization_ScreenshotInterval + interval = limit_in(origin, 0.1, 0.3) + if interval != origin: + logger.warning(f'Optimization.ScreenshotInterval {origin} is revised to {interval}') + self.config.Optimization_ScreenshotInterval = interval + elif interval == 'combat': + origin = self.config.Optimization_CombatScreenshotInterval + interval = limit_in(origin, 0.3, 1.0) + if interval != origin: + logger.warning(f'Optimization.CombatScreenshotInterval {origin} is revised to {interval}') + self.config.Optimization_CombatScreenshotInterval = interval + elif isinstance(interval, (int, float)): + # No limitation for manual set in code + pass + else: + logger.warning(f'Unknown screenshot interval: {interval}') + raise ScriptError(f'Unknown screenshot interval: {interval}') + # Screenshot interval in scrcpy is meaningless, + # video stream is received continuously no matter you use it or not. + if self.config.Emulator_ScreenshotMethod == 'scrcpy': + interval = 0.1 + + if interval != self._screenshot_interval.limit: + logger.info(f'Screenshot interval set to {interval}s') + self._screenshot_interval.limit = interval + + def image_show(self, image=None): + if image is None: + image = self.image + Image.fromarray(image).show() + + def image_save(self, file=None): + if file is None: + file = f'{int(time.time() * 1000)}.png' + save_image(self.image, file) + + def check_screen_size(self): + """ + Screen size must be 1280x720. + Take a screenshot before call. + """ + if self._screen_size_checked: + return True + + orientated = False + for _ in range(2): + # Check screen size + width, height = image_size(self.image) + logger.attr('Screen_size', f'{width}x{height}') + if width == 1280 and height == 720: + self._screen_size_checked = True + return True + elif not orientated and (width == 720 and height == 1280): + logger.info('Received orientated screenshot, handling') + self.get_orientation() + self.image = self._handle_orientated_image(self.image) + orientated = True + width, height = image_size(self.image) + if width == 720 and height == 1280: + logger.info('Unable to handle orientated screenshot, continue for now') + return True + else: + continue + elif self.config.Emulator_Serial == 'wsa-0': + self.display_resize_wsa(0) + return False + elif hasattr(self, 'app_is_running') and not self.app_is_running(): + logger.warning('Received orientated screenshot, game not running') + return True + else: + logger.critical(f'Resolution not supported: {width}x{height}') + logger.critical('Please set emulator resolution to 1280x720') + raise RequestHumanTakeover + + def check_screen_black(self): + if self._screen_black_checked: + return True + # Check screen color + # May get a pure black screenshot on some emulators. + color = get_color(self.image, area=(0, 0, 1280, 720)) + if sum(color) < 1: + if self.config.Emulator_Serial == 'wsa-0': + for _ in range(2): + display = self.get_display_id() + if display == 0: + return True + logger.info(f'Game running on display {display}') + logger.warning('Game not running on display 0, will be restarted') + self.app_stop_uiautomator2() + return False + elif self.config.Emulator_ScreenshotMethod == 'uiautomator2': + logger.warning(f'Received pure black screenshots from emulator, color: {color}') + logger.warning('Uninstall minicap and retry') + self.uninstall_minicap() + self._screen_black_checked = False + return False + else: + # logger.warning(f'Received pure black screenshots from emulator, color: {color}') + # logger.warning(f'Screenshot method `{self.config.Emulator_ScreenshotMethod}` ' + # f'may not work on emulator `{self.serial}`, or the emulator is not fully started') + if self.is_mumu_family: + if self.config.Emulator_ScreenshotMethod == 'DroidCast': + self.droidcast_stop() + else: + logger.warning('If you are using MuMu X, please upgrade to version >= 12.1.5.0') + self._screen_black_checked = False + return False + else: + self._screen_black_checked = True + return True diff --git a/module/exception.py b/module/exception.py new file mode 100644 index 0000000..e9bf713 --- /dev/null +++ b/module/exception.py @@ -0,0 +1,35 @@ +class ScriptError(Exception): + # This is likely to be a mistake of developers, but sometimes a random issue + pass + + +class GameStuckError(Exception): + pass + + +class GameBugError(Exception): + # An error has occurred in Azur Lane game client. Alas is unable to handle. + # A restart should fix it. + pass + + +class GameTooManyClickError(Exception): + pass + + +class EmulatorNotRunningError(Exception): + pass + + +class GameNotRunningError(Exception): + pass + + +class GamePageUnknownError(Exception): + pass + + +class RequestHumanTakeover(Exception): + # Request human takeover + # Alas is unable to handle such error, probably because of wrong settings. + pass diff --git a/module/handler/sensitive_info.py b/module/handler/sensitive_info.py new file mode 100644 index 0000000..6f3cadc --- /dev/null +++ b/module/handler/sensitive_info.py @@ -0,0 +1,15 @@ +def handle_sensitive_image(image): + """ + Args: + image: + + Returns: + np.ndarray: + """ + # Paint UID to black + image[680:720, 0:180, :] = 0 + return image + + +def handle_sensitive_logs(logs): + return logs diff --git a/module/logger/__init__.py b/module/logger/__init__.py new file mode 100644 index 0000000..b584b3c --- /dev/null +++ b/module/logger/__init__.py @@ -0,0 +1,3 @@ +from .logger import logger +from .logger import set_file_logger, set_func_logger +from .logger import WEB_THEME, Highlighter, HTMLConsole diff --git a/module/logger/logger.py b/module/logger/logger.py new file mode 100644 index 0000000..bbcf9a5 --- /dev/null +++ b/module/logger/logger.py @@ -0,0 +1,360 @@ +import datetime +import logging +import os +import sys +from typing import Callable, List + +from rich.console import Console, ConsoleOptions, ConsoleRenderable, NewLine +from rich.highlighter import NullHighlighter, RegexHighlighter +from rich.logging import RichHandler +from rich.rule import Rule +from rich.style import Style +from rich.theme import Theme +from rich.traceback import Traceback + +sys.stdout.reconfigure(encoding='utf-8') +sys.stderr.reconfigure(encoding='utf-8') + + +def empty_function(*args, **kwargs): + pass + + +# cnocr will set root logger in cnocr.utils +# Delete logging.basicConfig to avoid logging the same message twice. +logging.basicConfig = empty_function +logging.raiseExceptions = True # Set True if wanna see encode errors on console + +# Remove HTTP keywords (GET, POST etc.) +RichHandler.KEYWORDS = [] + + +class RichFileHandler(RichHandler): + # Rename + pass + + +class RichRenderableHandler(RichHandler): + """ + Pass renderable into a function + """ + + def __init__(self, *args, func: Callable[[ConsoleRenderable], None] = None, **kwargs): + super().__init__(*args, **kwargs) + self._func = func + + def emit(self, record: logging.LogRecord) -> None: + message = self.format(record) + traceback = None + if ( + self.rich_tracebacks + and record.exc_info + and record.exc_info != (None, None, None) + ): + exc_type, exc_value, exc_traceback = record.exc_info + assert exc_type is not None + assert exc_value is not None + traceback = Traceback.from_exception( + exc_type, + exc_value, + exc_traceback, + width=self.tracebacks_width, + extra_lines=self.tracebacks_extra_lines, + theme=self.tracebacks_theme, + word_wrap=self.tracebacks_word_wrap, + show_locals=self.tracebacks_show_locals, + locals_max_length=self.locals_max_length, + locals_max_string=self.locals_max_string, + ) + message = record.getMessage() + if self.formatter: + record.message = record.getMessage() + formatter = self.formatter + if hasattr(formatter, "usesTime") and formatter.usesTime(): + record.asctime = formatter.formatTime( + record, formatter.datefmt) + message = formatter.formatMessage(record) + + message_renderable = self.render_message(record, message) + log_renderable = self.render( + record=record, traceback=traceback, message_renderable=message_renderable + ) + + # Directly put renderable into function + self._func(log_renderable) + + def handle(self, record: logging.LogRecord) -> bool: + if not self._func: + return True + super().handle(record) + + +class HTMLConsole(Console): + """ + Force full feature console + but not working lol :( + """ + + @property + def options(self) -> ConsoleOptions: + return ConsoleOptions( + max_height=self.size.height, + size=self.size, + legacy_windows=False, + min_width=1, + max_width=self.width, + encoding='utf-8', + is_terminal=False, + ) + + +class Highlighter(RegexHighlighter): + base_style = 'web.' + highlights = [ + # (r'(?P(\d{2}|\d{4})(?:\-)?([0]{1}\d{1}|[1]{1}[0-2]{1})' + # r'(?:\-)?([0-2]{1}\d{1}|[3]{1}[0-1]{1})(?:\s)?([0-1]{1}\d{1}|' + # r'[2]{1}[0-3]{1})(?::)?([0-5]{1}\d{1})(?::)?([0-5]{1}\d{1}).\d+\b)'), + (r'(?P