diff --git a/assets/gui/css/alas-mobile.css b/assets/gui/css/alas-mobile.css
new file mode 100644
index 000000000..9acad6eb7
--- /dev/null
+++ b/assets/gui/css/alas-mobile.css
@@ -0,0 +1,62 @@
+.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;
+}
\ No newline at end of file
diff --git a/assets/gui/css/alas-pc.css b/assets/gui/css/alas-pc.css
new file mode 100644
index 000000000..57d95088e
--- /dev/null
+++ b/assets/gui/css/alas-pc.css
@@ -0,0 +1,48 @@
+[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-scheduler-bar,
+#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;
+}
\ No newline at end of file
diff --git a/assets/gui/css/alas.css b/assets/gui/css/alas.css
new file mode 100644
index 000000000..82dbace97
--- /dev/null
+++ b/assets/gui/css/alas.css
@@ -0,0 +1,495 @@
+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;
+}
+
+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-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;
+}
+
+*[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-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-bar {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+}
+
+#pywebio-scope-log-bar-btns {
+ display: grid;
+ grid-auto-flow: column;
+}
+
+#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 000000000..94481d617
--- /dev/null
+++ b/assets/gui/css/dark-alas.css
@@ -0,0 +1,123 @@
+.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;
+}
+
+.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;
+}
+
+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==");
+}
+
+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;
+}
\ 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 000000000..8c42cc4cd
--- /dev/null
+++ b/assets/gui/css/light-alas.css
@@ -0,0 +1,122 @@
+.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;
+}
+
+.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;
+}
+
+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=");
+}
+
+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;
+}
\ 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 000000000..344503ea2
--- /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 000000000..308a869e0
--- /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 000000000..c734fa60c
--- /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 000000000..d3b16bc7b
--- /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 000000000..ccfb0046b
--- /dev/null
+++ b/assets/gui/icon/setting.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
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 000000000..a53654fc8
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 000000000..50608a21f
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 000000000..3673f3ff0
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 000000000..3738bf226
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 000000000..acd5bec28
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 000000000..4eb60eb56
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 000000000..99e023f03
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 000000000..3bb69d692
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 000000000..f486e5009
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 000000000..ec66c9951
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 000000000..c11aa1c3c
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 000000000..4a5a511bc
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 000000000..fc6684650
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 000000000..18c6a2dc8
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 000000000..3d91a0d4b
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 000000000..d1d8016e6
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 000000000..5f55d0a3b
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 000000000..4f8319f7c
--- /dev/null
+++ b/config/deploy.template-cn.yaml
@@ -0,0 +1,159 @@
+Deploy:
+ Git:
+ # URL of AzurLaneAutoScript repository
+ # [CN user] Use 'https://e.coding.net/llop18870/alas/AzurLaneAutoScript.git' for faster and more stable download
+ # [Other] Use 'https://github.com/LmeSzinc/AzurLaneAutoScript'
+ Repository: https://e.coding.net/llop18870/alas/AzurLaneAutoScript.git
+ # 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
diff --git a/config/deploy.template.yaml b/config/deploy.template.yaml
new file mode 100644
index 000000000..55973d6a7
--- /dev/null
+++ b/config/deploy.template.yaml
@@ -0,0 +1,159 @@
+Deploy:
+ Git:
+ # URL of AzurLaneAutoScript repository
+ # [CN user] Use 'https://e.coding.net/llop18870/alas/AzurLaneAutoScript.git' for faster and more stable download
+ # [Other] Use 'https://github.com/LmeSzinc/AzurLaneAutoScript'
+ Repository: https://github.com/LmeSzinc/AzurLaneAutoScript
+ # 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
diff --git a/config/template.json b/config/template.json
new file mode 100644
index 000000000..f34146570
--- /dev/null
+++ b/config/template.json
@@ -0,0 +1,43 @@
+{
+ "Alas": {
+ "Emulator": {
+ "Serial": "auto",
+ "PackageName": "auto",
+ "ScreenshotMethod": "auto",
+ "ControlMethod": "MaaTouch",
+ "ScreenshotDedithering": false,
+ "AdbRestart": false
+ },
+ "EmulatorInfo": {
+ "Emulator": "auto",
+ "name": null,
+ "path": null
+ },
+ "Error": {
+ "Restart": "game",
+ "SaveError": true,
+ "ScreenshotLength": 1,
+ "OnePushConfig": "provider: null"
+ },
+ "Optimization": {
+ "ScreenshotInterval": 0.3,
+ "CombatScreenshotInterval": 1.0,
+ "TaskHoardingDuration": 0,
+ "WhenTaskQueueEmpty": "goto_main"
+ },
+ "Storage": {
+ "Storage": {}
+ }
+ },
+ "Restart": {
+ "Scheduler": {
+ "Enable": true,
+ "NextRun": "2020-01-01 00:00:00",
+ "Command": "Restart",
+ "ServerUpdate": "00:00"
+ },
+ "Storage": {
+ "Storage": {}
+ }
+ }
+}
\ No newline at end of file
diff --git a/deploy/Readme.md b/deploy/Readme.md
new file mode 100644
index 000000000..0a471e702
--- /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 000000000..840eeaf1f
--- /dev/null
+++ b/deploy/Windows/adb.py
@@ -0,0 +1,72 @@
+import logging
+
+from deploy.Windows.emulator import EmulatorManager
+from deploy.Windows.logger import logger
+from deploy.Windows.utils import *
+
+
+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()
+ if self.AutoConnect:
+ logger.hr('ADB Connect', 1)
+ self.brute_force_connect()
+
+ 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 000000000..11985bb31
--- /dev/null
+++ b/deploy/Windows/alas.py
@@ -0,0 +1,73 @@
+import time
+import typing as t
+
+from deploy.Windows.config import DeployConfig
+from deploy.Windows.logger import logger
+from deploy.Windows.utils import *
+
+
+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:
+ for folder in self.alas_folder:
+ if folder in proc.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):
+ while 1:
+ logger.hr(f'Kill existing Alas', 0)
+ proc_list = list(self.iter_process_by_names(['alas.exe', 'python.exe'], in_alas=True))
+ if not len(proc_list):
+ break
+ for proc in proc_list:
+ logger.info(proc)
+ self.kill_process(proc)
+
+
+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 000000000..9edc3a428
--- /dev/null
+++ b/deploy/Windows/app.py
@@ -0,0 +1,55 @@
+import filecmp
+import shutil
+
+from deploy.Windows.config import DeployConfig
+from deploy.Windows.logger import logger
+from deploy.Windows.utils import *
+
+
+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.AutoUpdate:
+ logger.info('AutoUpdate is disabled, skip')
+ return False
+
+ return self.app_asar_replace(os.getcwd())
diff --git a/deploy/Windows/config.py b/deploy/Windows/config.py
new file mode 100644
index 000000000..83e33884c
--- /dev/null
+++ b/deploy/Windows/config.py
@@ -0,0 +1,216 @@
+import copy
+import subprocess
+from typing import Optional, Union
+
+from deploy.Windows.logger import logger
+from deploy.Windows.utils import *
+
+
+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 = 22267
+ Language: str = "en-US"
+ Theme: str = "default"
+ DpiScaling: bool = True
+ Password: Optional[str] = None
+ CDN: Union[str, bool] = False
+ Run: Optional[str] = None
+
+
+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()
+ if self.Repository == 'https://gitee.com/LmeSzinc/AzurLaneAutoScript':
+ self.Repository = 'https://e.coding.net/llop18870/alas/AzurLaneAutoScript.git'
+ if self.Repository == 'https://gitee.com/lmeszinc/azur-lane-auto-script-mirror':
+ self.Repository = 'https://e.coding.net/llop18870/alas/AzurLaneAutoScript.git'
+ 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.
+ """
+ return (
+ os.path.abspath(os.path.join(self.root_filepath, path))
+ .replace(r"\\", "/")
+ .replace("\\", "/")
+ .replace('"', '"')
+ )
+
+ @cached_property
+ def root_filepath(self):
+ return (
+ os.path.abspath(os.path.join(os.path.dirname(__file__), "../../"))
+ .replace(r"\\", "/")
+ .replace("\\", "/")
+ .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 000000000..e01d59cbd
--- /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 000000000..d6e4eb929
--- /dev/null
+++ b/deploy/Windows/git.py
@@ -0,0 +1,117 @@
+import configparser
+
+from deploy.Windows.config import DeployConfig
+from deploy.Windows.logger import logger
+from deploy.Windows.utils import *
+
+
+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 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')
+
+ 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)
+
+ 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}')
+
+ logger.hr('Fetch Repository Branch', 1)
+ self.execute(f'"{self.git}" fetch {source} {branch}')
+
+ 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}')
+ # 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}')
+
+ logger.hr('Show Version', 1)
+ self.execute(f'"{self.git}" --no-pager log --no-merges -1')
+
+ def git_install(self):
+ logger.hr('Update Alas', 0)
+
+ if not self.AutoUpdate:
+ logger.info('AutoUpdate is disabled, skip')
+ 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 000000000..1c3a14fc2
--- /dev/null
+++ b/deploy/Windows/installer_test.py
@@ -0,0 +1,116 @@
+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% ]
+"""
+
+
+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 000000000..2d2f1c327
--- /dev/null
+++ b/deploy/Windows/logger.py
@@ -0,0 +1,36 @@
+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} >>>")
+
+
+logger.hr = hr
diff --git a/deploy/Windows/patch.py b/deploy/Windows/patch.py
new file mode 100644
index 000000000..a1d3be03f
--- /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 000000000..42bdc8869
--- /dev/null
+++ b/deploy/Windows/pip.py
@@ -0,0 +1,126 @@
+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
+from deploy.Windows.utils import *
+
+
+@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()
+
+ @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')
+ return
+
+ if not len(self.set_dependency_to_install):
+ logger.info('All dependencies installed')
+ 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}')
diff --git a/deploy/Windows/template.yaml b/deploy/Windows/template.yaml
new file mode 100644
index 000000000..95186ac69
--- /dev/null
+++ b/deploy/Windows/template.yaml
@@ -0,0 +1,159 @@
+Deploy:
+ Git:
+ # URL of AzurLaneAutoScript repository
+ # [CN user] Use 'https://e.coding.net/llop18870/alas/AzurLaneAutoScript.git' for faster and more stable download
+ # [Other] Use 'https://github.com/LmeSzinc/AzurLaneAutoScript'
+ Repository: 'https://github.com/LmeSzinc/AzurLaneAutoScript'
+ # 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
diff --git a/deploy/Windows/utils.py b/deploy/Windows/utils.py
new file mode 100644
index 000000000..49d2369ca
--- /dev/null
+++ b/deploy/Windows/utils.py
@@ -0,0 +1,166 @@
+import os
+import re
+from dataclasses import dataclass
+from typing import Callable, Iterable, Generic, 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/installer.py b/deploy/installer.py
new file mode 100644
index 000000000..ef2d38639
--- /dev/null
+++ b/deploy/installer.py
@@ -0,0 +1,26 @@
+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)
+
+
+if __name__ == '__main__':
+ Installer().install()
diff --git a/deploy/set.py b/deploy/set.py
new file mode 100644
index 000000000..c72a2c0e5
--- /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/gui.py b/gui.py
new file mode 100644
index 000000000..97b5f3cf4
--- /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 22267
+ 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 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/base/base.py b/module/base/base.py
new file mode 100644
index 000000000..7862866ba
--- /dev/null
+++ b/module/base/base.py
@@ -0,0 +1,265 @@
+from module.base.button import Button
+from module.base.timer import Timer
+from module.base.utils import *
+from module.config.config import AzurLaneConfig
+from module.device.device import Device
+from module.device.method.utils import HierarchyButton
+from module.logger import logger
+
+
+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):
+ 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 ensure_button(self, button):
+ if isinstance(button, str):
+ button = HierarchyButton(self.device.hierarchy, button)
+
+ return button
+
+ def appear(self, button, offset=0, interval=0, threshold=None):
+ """
+ Args:
+ button (Button, Template, HierarchyButton, str):
+ offset (bool, int):
+ interval (int, float): interval between two active events.
+ threshold (int, float): 0 to 1 if use offset, bigger means more similar,
+ 0 to 255 if not use offset, smaller means more similar
+
+ Returns:
+ bool:
+
+ Examples:
+ Image detection:
+ ```
+ self.device.screenshot()
+ self.appear(Button(area=(...), color=(...), button=(...))
+ self.appear(Template(file='...')
+ ```
+
+ Hierarchy detection (detect elements with xpath):
+ ```
+ self.device.dump_hierarchy()
+ self.appear('//*[@resource-id="..."]')
+ ```
+ """
+ button = self.ensure_button(button)
+ self.device.stuck_record_add(button)
+
+ if interval:
+ 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)
+ if not self.interval_timer[button.name].reached():
+ return False
+
+ if isinstance(button, HierarchyButton):
+ appear = bool(button)
+ elif offset:
+ if isinstance(offset, bool):
+ offset = self.config.BUTTON_OFFSET
+ appear = button.match(self.device.image, offset=offset,
+ threshold=self.config.BUTTON_MATCH_SIMILARITY if threshold is None else threshold)
+ else:
+ appear = button.appear_on(self.device.image,
+ threshold=self.config.COLOR_SIMILAR_THRESHOLD if threshold is None else threshold)
+
+ if appear and interval:
+ self.interval_timer[button.name].reset()
+
+ return appear
+
+ def appear_then_click(self, button, screenshot=False, genre='items', offset=0, interval=0, threshold=None):
+ button = self.ensure_button(button)
+ appear = self.appear(button, offset=offset, interval=interval, threshold=threshold)
+ if appear:
+ if screenshot:
+ self.device.sleep(self.config.WAIT_BEFORE_SAVING_SCREEN_SHOT)
+ self.device.screenshot()
+ self.device.save_screenshot(genre=genre)
+ self.device.click(button)
+ return appear
+
+ def wait_until_appear(self, button, offset=0, skip_first_screenshot=False):
+ while 1:
+ if skip_first_screenshot:
+ skip_first_screenshot = False
+ else:
+ self.device.screenshot()
+ if self.appear(button, offset=offset):
+ break
+
+ def wait_until_appear_then_click(self, button, offset=0):
+ self.wait_until_appear(button, offset=offset)
+ self.device.click(button)
+
+ def wait_until_disappear(self, button, offset=0):
+ while 1:
+ self.device.screenshot()
+ if not self.appear(button, offset=offset):
+ break
+
+ def wait_until_stable(self, button, timer=Timer(0.3, count=1), timeout=Timer(5, count=10), skip_first_screenshot=True):
+ button._match_init = False
+ timeout.reset()
+ while 1:
+ if skip_first_screenshot:
+ skip_first_screenshot = False
+ else:
+ self.device.screenshot()
+
+ if button._match_init:
+ if button.match(self.device.image, offset=(0, 0)):
+ if timer.reached():
+ break
+ else:
+ button.load_color(self.device.image)
+ timer.reset()
+ else:
+ button.load_color(self.device.image)
+ button._match_init = True
+
+ if timeout.reached():
+ logger.warning(f'wait_until_stable({button}) timeout')
+ break
+
+ def image_crop(self, button):
+ """Extract the area from image.
+
+ Args:
+ button(Button, tuple): Button instance or area tuple.
+ """
+ if isinstance(button, Button):
+ return crop(self.device.image, button.area)
+ else:
+ return crop(self.device.image, button)
+
+ 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:
+ """
+ image = self.image_crop(button)
+ mask = color_similarity_2d(image, color=color) > threshold
+ return np.sum(mask) > 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)
+ color = get_color(self.device.image, button_area)
+ return Button(area=button_area, color=color, button=button_area, name=name)
+
+ def interval_reset(self, button):
+ if isinstance(button, (list, tuple)):
+ for b in button:
+ self.interval_reset(b)
+ return
+
+ if button.name in self.interval_timer:
+ self.interval_timer[button.name].reset()
+ else:
+ self.interval_timer[button.name] = Timer(3).reset()
+
+ def interval_clear(self, button):
+ if isinstance(button, (list, tuple)):
+ for b in button:
+ self.interval_clear(b)
+ return
+
+ if button.name in self.interval_timer:
+ self.interval_timer[button.name].clear()
+ else:
+ self.interval_timer[button.name] = Timer(3).clear()
+
+ _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_server(self, server):
+ """
+ For development.
+ Change server and this will effect globally,
+ including assets and server specific methods.
+ """
+ package = to_package(server)
+ self.device.package = package
+ set_server(server)
+ logger.attr('Server', self.config.SERVER)
diff --git a/module/base/button.py b/module/base/button.py
new file mode 100644
index 000000000..2f9e86106
--- /dev/null
+++ b/module/base/button.py
@@ -0,0 +1,475 @@
+import os
+import traceback
+
+import imageio
+from PIL import ImageDraw
+
+from module.base.decorator import cached_property
+from module.base.resource import Resource
+from module.base.utils import *
+from module.config.server import VALID_SERVER
+
+
+class Button(Resource):
+ def __init__(self, area, color, button, file=None, name=None):
+ """Initialize a Button instance.
+
+ Args:
+ area (dict[tuple], tuple): Area that the button would appear on the image.
+ (upper_left_x, upper_left_y, bottom_right_x, bottom_right_y)
+ color (dict[tuple], tuple): Color we expect the area would be.
+ (r, g, b)
+ button (dict[tuple], tuple): Area to be click if button appears on the image.
+ (upper_left_x, upper_left_y, bottom_right_x, bottom_right_y)
+ If tuple is empty, this object can be use as a checker.
+ Examples:
+ BATTLE_PREPARATION = Button(
+ area=(1562, 908, 1864, 1003),
+ color=(231, 181, 90),
+ button=(1562, 908, 1864, 1003)
+ )
+ """
+ self.raw_area = area
+ self.raw_color = color
+ self.raw_button = button
+ self.raw_file = file
+ self.raw_name = name
+
+ self._button_offset = None
+ self._match_init = False
+ self._match_binary_init = False
+ self._match_luma_init = False
+ self.image = None
+ self.image_binary = None
+ self.image_luma = None
+
+ if self.file:
+ self.resource_add(key=self.file)
+
+ cached = ['area', 'color', '_button', 'file', 'name', 'is_gif']
+
+ @cached_property
+ def area(self):
+ return self.parse_property(self.raw_area)
+
+ @cached_property
+ def color(self):
+ return self.parse_property(self.raw_color)
+
+ @cached_property
+ def _button(self):
+ return self.parse_property(self.raw_button)
+
+ @cached_property
+ def file(self):
+ return self.parse_property(self.raw_file)
+
+ @cached_property
+ def name(self):
+ if self.raw_name:
+ return self.raw_name
+ elif self.file:
+ return os.path.splitext(os.path.split(self.file)[1])[0]
+ else:
+ return 'BUTTON'
+
+ @cached_property
+ def is_gif(self):
+ if self.file:
+ return os.path.splitext(self.file)[1] == '.gif'
+ else:
+ return False
+
+ 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
+
+ @property
+ def button(self):
+ if self._button_offset is None:
+ return self._button
+ else:
+ return self._button_offset
+
+ def appear_on(self, image, threshold=10):
+ """Check if the button appears on the image.
+
+ Args:
+ image (np.ndarray): Screenshot.
+ threshold (int): Default to 10.
+
+ Returns:
+ bool: True if button appears on screenshot.
+ """
+ return color_similar(
+ color1=get_color(image, self.area),
+ color2=self.color,
+ threshold=threshold
+ )
+
+ def load_color(self, image):
+ """Load color from the specific area of the given image.
+ This method is irreversible, this would be only use in some special occasion.
+
+ Args:
+ image: Another screenshot.
+
+ Returns:
+ tuple: Color (r, g, b).
+ """
+ self.__dict__['color'] = get_color(image, self.area)
+ self.image = crop(image, self.area)
+ self.__dict__['is_gif'] = False
+ return self.color
+
+ def load_offset(self, button):
+ """
+ Load offset from another button.
+
+ Args:
+ button (Button):
+ """
+ offset = np.subtract(button.button, button._button)[:2]
+ self._button_offset = area_offset(self._button, offset=offset)
+
+ def clear_offset(self):
+ self._button_offset = None
+
+ def ensure_template(self):
+ """
+ Load asset image.
+ If needs to call self.match, call this first.
+ """
+ if not self._match_init:
+ if self.is_gif:
+ self.image = []
+ for image in imageio.mimread(self.file):
+ image = image[:, :, :3].copy() if len(image.shape) == 3 else image
+ image = crop(image, self.area)
+ self.image.append(image)
+ else:
+ self.image = load_image(self.file, self.area)
+ self._match_init = True
+
+ def ensure_binary_template(self):
+ """
+ Load asset image.
+ If needs to call self.match, call this first.
+ """
+ if not self._match_binary_init:
+ if self.is_gif:
+ self.image_binary = []
+ for image in self.image:
+ image_gray = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY)
+ _, image_binary = cv2.threshold(image_gray, 0, 255, cv2.THRESH_BINARY | cv2.THRESH_OTSU)
+ self.image_binary.append(image_binary)
+ else:
+ image_gray = cv2.cvtColor(self.image, cv2.COLOR_BGR2GRAY)
+ _, self.image_binary = cv2.threshold(image_gray, 0, 255, cv2.THRESH_BINARY | cv2.THRESH_OTSU)
+ self._match_binary_init = True
+
+ def ensure_luma_template(self):
+ if not self._match_luma_init:
+ if self.is_gif:
+ self.image_luma = []
+ for image in self.image:
+ luma = rgb2luma(image)
+ self.image_luma.append(luma)
+ else:
+ self.image_luma = rgb2luma(self.image)
+ self._match_luma_init = True
+
+ def resource_release(self):
+ super().resource_release()
+ self.image = None
+ self.image_binary = None
+ self.image_luma = None
+ self._match_init = False
+ self._match_binary_init = False
+ self._match_luma_init = False
+
+ def match(self, image, offset=30, threshold=0.85):
+ """Detects button by template matching. To Some button, its location may not be static.
+
+ Args:
+ image: Screenshot.
+ offset (int, tuple): Detection area offset.
+ threshold (float): 0-1. Similarity.
+
+ Returns:
+ bool.
+ """
+ self.ensure_template()
+
+ if isinstance(offset, tuple):
+ if len(offset) == 2:
+ offset = np.array((-offset[0], -offset[1], offset[0], offset[1]))
+ else:
+ offset = np.array(offset)
+ else:
+ offset = np.array((-3, -offset, 3, offset))
+ image = crop(image, offset + self.area)
+
+ if self.is_gif:
+ for template in self.image:
+ res = cv2.matchTemplate(template, image, cv2.TM_CCOEFF_NORMED)
+ _, similarity, _, point = cv2.minMaxLoc(res)
+ self._button_offset = area_offset(self._button, offset[:2] + np.array(point))
+ if similarity > threshold:
+ return True
+ return False
+ else:
+ res = cv2.matchTemplate(self.image, image, cv2.TM_CCOEFF_NORMED)
+ _, similarity, _, point = cv2.minMaxLoc(res)
+ self._button_offset = area_offset(self._button, offset[:2] + np.array(point))
+ return similarity > threshold
+
+ def match_binary(self, image, offset=30, threshold=0.85):
+ """Detects button by template matching. To Some button, its location may not be static.
+ This method will apply template matching under binarization.
+
+ Args:
+ image: Screenshot.
+ offset (int, tuple): Detection area offset.
+ threshold (float): 0-1. Similarity.
+
+ Returns:
+ bool.
+ """
+ self.ensure_template()
+ self.ensure_binary_template()
+
+ if isinstance(offset, tuple):
+ if len(offset) == 2:
+ offset = np.array((-offset[0], -offset[1], offset[0], offset[1]))
+ else:
+ offset = np.array(offset)
+ else:
+ offset = np.array((-3, -offset, 3, offset))
+ image = crop(image, offset + self.area)
+
+ if self.is_gif:
+ for template in self.image_binary:
+ # graying
+ image_gray = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY)
+ # binarization
+ _, image_binary = cv2.threshold(image_gray, 0, 255, cv2.THRESH_BINARY | cv2.THRESH_OTSU)
+ # template matching
+ res = cv2.matchTemplate(template, image_binary, cv2.TM_CCOEFF_NORMED)
+ _, similarity, _, point = cv2.minMaxLoc(res)
+ self._button_offset = area_offset(self._button, offset[:2] + np.array(point))
+ if similarity > threshold:
+ return True
+ return False
+ else:
+ # graying
+ image_gray = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY)
+ # binarization
+ _, image_binary = cv2.threshold(image_gray, 0, 255, cv2.THRESH_BINARY | cv2.THRESH_OTSU)
+ # template matching
+ res = cv2.matchTemplate(self.image_binary, image_binary, cv2.TM_CCOEFF_NORMED)
+ _, similarity, _, point = cv2.minMaxLoc(res)
+ self._button_offset = area_offset(self._button, offset[:2] + np.array(point))
+ return similarity > threshold
+
+ def match_luma(self, image, offset=30, threshold=0.85):
+ """
+ Detects button by template matching under Y channel (Luminance)
+
+ Args:
+ image: Screenshot.
+ offset (int, tuple): Detection area offset.
+ threshold (float): 0-1. Similarity.
+
+ Returns:
+ bool.
+ """
+ self.ensure_template()
+ self.ensure_luma_template()
+
+ if isinstance(offset, tuple):
+ if len(offset) == 2:
+ offset = np.array((-offset[0], -offset[1], offset[0], offset[1]))
+ else:
+ offset = np.array(offset)
+ else:
+ offset = np.array((-3, -offset, 3, offset))
+ image = crop(image, offset + self.area)
+
+ if self.is_gif:
+ image_luma = rgb2luma(image)
+ for template in self.image_luma:
+ res = cv2.matchTemplate(template, image_luma, cv2.TM_CCOEFF_NORMED)
+ _, similarity, _, point = cv2.minMaxLoc(res)
+ self._button_offset = area_offset(self._button, offset[:2] + np.array(point))
+ if similarity > threshold:
+ return True
+ else:
+ image_luma = rgb2luma(image)
+ res = cv2.matchTemplate(self.image_luma, image_luma, cv2.TM_CCOEFF_NORMED)
+ _, similarity, _, point = cv2.minMaxLoc(res)
+ self._button_offset = area_offset(self._button, offset[:2] + np.array(point))
+ return similarity > threshold
+
+ def match_appear_on(self, image, threshold=30):
+ """
+ Args:
+ image: Screenshot.
+ threshold: Default to 10.
+
+ Returns:
+ bool:
+ """
+ diff = np.subtract(self.button, self._button)[:2]
+ area = area_offset(self.area, offset=diff)
+ return color_similar(color1=get_color(image, area), color2=self.color, threshold=threshold)
+
+ def crop(self, area, image=None, name=None):
+ """
+ Get a new button by relative coordinates.
+
+ Args:
+ area (tuple):
+ image (np.ndarray): Screenshot. If provided, load color and image from it.
+ name (str):
+
+ Returns:
+ Button:
+ """
+ if name is None:
+ name = self.name
+ new_area = area_offset(area, offset=self.area[:2])
+ new_button = area_offset(area, offset=self.button[:2])
+ button = Button(area=new_area, color=self.color, button=new_button, file=self.file, name=name)
+ if image is not None:
+ button.load_color(image)
+ return button
+
+ def move(self, vector, image=None, name=None):
+ """
+ Move button.
+
+ Args:
+ vector (tuple):
+ image (np.ndarray): Screenshot. If provided, load color and image from it.
+ name (str):
+
+ Returns:
+ Button:
+ """
+ if name is None:
+ name = self.name
+ new_area = area_offset(self.area, offset=vector)
+ new_button = area_offset(self.button, offset=vector)
+ button = Button(area=new_area, color=self.color, button=new_button, file=self.file, name=name)
+ if image is not None:
+ button.load_color(image)
+ return button
+
+ def split_server(self):
+ """
+ Split into 4 server specific buttons.
+
+ Returns:
+ dict[str, Button]:
+ """
+ out = {}
+ for s in VALID_SERVER:
+ out[s] = Button(
+ area=self.parse_property(self.raw_area, s),
+ color=self.parse_property(self.raw_color, s),
+ button=self.parse_property(self.raw_button, s),
+ file=self.parse_property(self.raw_file, s),
+ name=self.name
+ )
+ return out
+
+
+class ButtonGrid:
+ def __init__(self, origin, delta, button_shape, grid_shape, name=None):
+ self.origin = np.array(origin)
+ self.delta = np.array(delta)
+ self.button_shape = np.array(button_shape)
+ self.grid_shape = np.array(grid_shape)
+ if name:
+ self._name = name
+ else:
+ (filename, line_number, function_name, text) = traceback.extract_stack()[-2]
+ self._name = text[:text.find('=')].strip()
+
+ def __getitem__(self, item):
+ base = np.round(np.array(item) * self.delta + self.origin).astype(int)
+ area = tuple(np.append(base, base + self.button_shape))
+ return Button(area=area, color=(), button=area, name='%s_%s_%s' % (self._name, item[0], item[1]))
+
+ def generate(self):
+ for y in range(self.grid_shape[1]):
+ for x in range(self.grid_shape[0]):
+ yield x, y, self[x, y]
+
+ @cached_property
+ def buttons(self):
+ return list([button for _, _, button in self.generate()])
+
+ def crop(self, area, name=None):
+ """
+ Args:
+ area (tuple): Area related to self.origin
+ name (str): Name of the new ButtonGrid instance.
+
+ Returns:
+ ButtonGrid:
+ """
+ if name is None:
+ name = self._name
+ origin = self.origin + area[:2]
+ button_shape = np.subtract(area[2:], area[:2])
+ return ButtonGrid(
+ origin=origin, delta=self.delta, button_shape=button_shape, grid_shape=self.grid_shape, name=name)
+
+ def move(self, vector, name=None):
+ """
+ Args:
+ vector (tuple): Move vector.
+ name (str): Name of the new ButtonGrid instance.
+
+ Returns:
+ ButtonGrid:
+ """
+ if name is None:
+ name = self._name
+ origin = self.origin + vector
+ return ButtonGrid(
+ origin=origin, delta=self.delta, button_shape=self.button_shape, grid_shape=self.grid_shape, name=name)
+
+ def gen_mask(self):
+ """
+ Generate a mask image to display this ButtonGrid object for debugging.
+
+ Returns:
+ PIL.Image.Image: Area in white, background in black.
+ """
+ image = Image.new("RGB", (1280, 720), (0, 0, 0))
+ draw = ImageDraw.Draw(image)
+ for button in self.buttons:
+ draw.rectangle((button.area[:2], button.button[2:]), fill=(255, 255, 255), outline=None)
+ return image
+
+ def show_mask(self):
+ self.gen_mask().show()
+
+ def save_mask(self):
+ """
+ Save mask to {name}.png
+ """
+ self.gen_mask().save(f'{self._name}.png')
diff --git a/module/base/decorator.py b/module/base/decorator.py
new file mode 100644
index 000000000..f4d693735
--- /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 000000000..baac89085
--- /dev/null
+++ b/module/base/filter.py
@@ -0,0 +1,103 @@
+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):
+ string = str(string)
+ self.filter_raw = [f.strip(' \t\r\n') for f in 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)
diff --git a/module/base/mask.py b/module/base/mask.py
new file mode 100644
index 000000000..5795810b5
--- /dev/null
+++ b/module/base/mask.py
@@ -0,0 +1,56 @@
+import cv2
+import numpy as np
+
+from module.base.template import Template
+from module.base.utils import image_channel, load_image, rgb2gray
+
+
+class Mask(Template):
+ @property
+ def image(self):
+ if self._image is None:
+ image = load_image(self.file)
+ if image_channel(image) == 3:
+ image = rgb2gray(image)
+ self._image = image
+
+ return self._image
+
+ @image.setter
+ def image(self, value):
+ self._image = value
+
+ def set_channel(self, channel):
+ """
+ Args:
+ channel (int): 0 for monochrome, 3 for RGB.
+
+ Returns:
+ bool: If changed.
+ """
+ mask_channel = image_channel(self.image)
+ if channel == 0:
+ if mask_channel == 0:
+ return False
+ else:
+ self._image, _, _ = cv2.split(self._image)
+ return True
+ else:
+ if mask_channel == 0:
+ self._image = cv2.merge([self._image] * 3)
+ return True
+ else:
+ return False
+
+ def apply(self, image):
+ """
+ Apply mask on image.
+
+ Args:
+ image:
+
+ Returns:
+ np.ndarray:
+ """
+ self.set_channel(image_channel(image))
+ return cv2.bitwise_and(image, self.image)
diff --git a/module/base/resource.py b/module/base/resource.py
new file mode 100644
index 000000000..97b69dfd6
--- /dev/null
+++ b/module/base/resource.py
@@ -0,0 +1,136 @@
+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='./module/ui/assets.py',
+ regex=re.compile(r'^([A-Za-z][A-Za-z0-9_]+) = ')
+ )
+ assets |= get_assets_from_file(
+ file='./module/ui/ui.py',
+ regex=re.compile(r'\(([A-Z][A-Z0-9_]+),')
+ )
+ assets |= get_assets_from_file(
+ file='./module/handler/info_handler.py',
+ regex=re.compile(r'\(([A-Z][A-Z0-9_]+),')
+ )
+ # MAIN_CHECK == MAIN_GOTO_CAMPAIGN
+ assets.add('MAIN_GOTO_CAMPAIGN')
+ return assets
+
+
+_preserved_assets = PreservedAssets()
+
+
+class Resource:
+ # Class property, record all button and templates
+ instances = {}
+ # Instance property, record cached properties of instance
+ cached = []
+
+ def resource_add(self, key):
+ Resource.instances[key] = self
+
+ def resource_release(self):
+ for cache in self.cached:
+ del_cached_property(self, cache)
+
+ @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}')
+
+ @staticmethod
+ def parse_property(data, s=None):
+ """
+ Parse properties of Button or Template object input.
+ Such as `area`, `color` and `button`.
+
+ Args:
+ data: Dict or str
+ s (str): Load from given a server or load from global attribute `server.server`
+ """
+ if s is None:
+ s = server.server
+ if isinstance(data, dict):
+ return data[s]
+ else:
+ return data
+
+
+def release_resources(next_task=''):
+ # Release all OCR models
+ # Usually to have 2 models loaded and each model takes about 20MB
+ # This will release 20-40MB
+ from module.webui.setting import State
+ if not State.deploy_config.UseOcrServer:
+ # Release only when using per-instance OCR
+ from module.ocr.ocr import OCR_MODEL
+ if 'Opsi' in next_task or 'commission' in next_task:
+ # OCR models will be used soon, don't release
+ models = []
+ elif next_task:
+ # Release OCR models except 'azur_lane'
+ models = ['cnocr', 'jp', 'tw']
+ else:
+ models = ['azur_lane', 'cnocr', 'jp', 'tw']
+ for model in models:
+ del_cached_property(OCR_MODEL, model)
+
+ # 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()
+
+ # Release cached images for map detection
+ from module.map_detection.utils_assets import ASSETS
+ attr_list = [
+ 'ui_mask',
+ 'ui_mask_os',
+ 'ui_mask_stroke',
+ 'ui_mask_in_map',
+ 'ui_mask_os_in_map',
+ 'tile_center_image',
+ 'tile_corner_image',
+ 'tile_corner_image_list'
+ ]
+ for attr in attr_list:
+ del_cached_property(ASSETS, attr)
+
+ # 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 000000000..2106ae2cc
--- /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/template.py b/module/base/template.py
new file mode 100644
index 000000000..37d58638b
--- /dev/null
+++ b/module/base/template.py
@@ -0,0 +1,240 @@
+import os
+
+import imageio
+
+from module.base.button import Button
+from module.base.decorator import cached_property
+from module.base.resource import Resource
+from module.base.utils import *
+from module.config.server import VALID_SERVER
+
+
+class Template(Resource):
+ def __init__(self, file):
+ """
+ Args:
+ file (dict[str], str): Filepath of template file.
+ """
+ self.raw_file = file
+ self._image = None
+ self._image_binary = None
+
+ self.resource_add(self.file)
+
+ cached = ['file', 'name', 'is_gif']
+
+ @cached_property
+ def file(self):
+ return self.parse_property(self.raw_file)
+
+ @cached_property
+ def name(self):
+ return os.path.splitext(os.path.basename(self.file))[0].upper()
+
+ @cached_property
+ def is_gif(self):
+ return os.path.splitext(self.file)[1] == '.gif'
+
+ @property
+ def image(self):
+ if self._image is None:
+ if self.is_gif:
+ self._image = []
+ channel = 0
+ for image in imageio.mimread(self.file):
+ if not channel:
+ channel = len(image.shape)
+ if channel == 3:
+ image = image[:, :, :3].copy()
+ elif len(image.shape) == 3:
+ # Follow the first frame
+ image = image[:, :, 0].copy()
+
+ image = self.pre_process(image)
+ self._image += [image, cv2.flip(image, 1)]
+ else:
+ self._image = self.pre_process(load_image(self.file))
+
+ return self._image
+
+ @property
+ def image_binary(self):
+ if self._image_binary is None:
+ if self.is_gif:
+ self._image_binary = []
+ for image in self.image:
+ image_gray = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY)
+ _, image_binary = cv2.threshold(image_gray, 0, 255, cv2.THRESH_BINARY | cv2.THRESH_OTSU)
+ self._image_binary.append(image_binary)
+ else:
+ image_gray = cv2.cvtColor(self.image, cv2.COLOR_BGR2GRAY)
+ _, self._image_binary = cv2.threshold(image_gray, 0, 255, cv2.THRESH_BINARY | cv2.THRESH_OTSU)
+
+ return self._image_binary
+
+ @image.setter
+ def image(self, value):
+ self._image = value
+
+ def resource_release(self):
+ super().resource_release()
+ self._image = None
+
+ def pre_process(self, image):
+ """
+ Args:
+ image (np.ndarray):
+
+ Returns:
+ np.ndarray:
+ """
+ return image
+
+ @cached_property
+ def size(self):
+ if self.is_gif:
+ return self.image[0].shape[0:2][::-1]
+ else:
+ return self.image.shape[0:2][::-1]
+
+ def match(self, image, scaling=1.0, similarity=0.85):
+ """
+ Args:
+ image:
+ scaling (int, float): Scale the template to match image
+ similarity (float): 0 to 1.
+
+ Returns:
+ bool: If matches.
+ """
+ scaling = 1 / scaling
+ if scaling != 1.0:
+ image = cv2.resize(image, None, fx=scaling, fy=scaling)
+
+ if self.is_gif:
+ for template in self.image:
+ res = cv2.matchTemplate(image, template, cv2.TM_CCOEFF_NORMED)
+ _, sim, _, _ = cv2.minMaxLoc(res)
+ # print(self.file, sim)
+ if sim > similarity:
+ return True
+
+ return False
+
+ else:
+ res = cv2.matchTemplate(image, self.image, cv2.TM_CCOEFF_NORMED)
+ _, sim, _, _ = cv2.minMaxLoc(res)
+ # print(self.file, sim)
+ return sim > similarity
+
+ def match_binary(self, image, similarity=0.85):
+ """
+ Use template match after binarization.
+
+ Args:
+ image:
+ similarity (float): 0 to 1.
+
+ Returns:
+ bool: If matches.
+ """
+ if self.is_gif:
+ for template in self.image_binary:
+ # graying
+ image_gray = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY)
+ # binarization
+ _, image_binary = cv2.threshold(image_gray, 0, 255, cv2.THRESH_BINARY | cv2.THRESH_OTSU)
+ # template matching
+ res = cv2.matchTemplate(image_binary, template, cv2.TM_CCOEFF_NORMED)
+ _, sim, _, _ = cv2.minMaxLoc(res)
+ # print(self.file, sim)
+ if sim > similarity:
+ return True
+
+ return False
+
+ else:
+ # graying
+ image_gray = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY)
+ # binarization
+ _, image_binary = cv2.threshold(image_gray, 0, 255, cv2.THRESH_BINARY | cv2.THRESH_OTSU)
+ # template matching
+ res = cv2.matchTemplate(image_binary, self.image_binary, cv2.TM_CCOEFF_NORMED)
+ _, sim, _, _ = cv2.minMaxLoc(res)
+ # print(self.file, sim)
+ return sim > similarity
+
+ def _point_to_button(self, point, image=None, name=None):
+ """
+ Args:
+ point:
+ image (np.ndarray): Screenshot. If provided, load color and image from it.
+ name (str):
+
+ Returns:
+ Button:
+ """
+ if name is None:
+ name = self.name
+ area = area_offset(area=(0, 0, *self.size), offset=point)
+ button = Button(area=area, color=(), button=area, name=name)
+ if image is not None:
+ button.load_color(image)
+ return button
+
+ def match_result(self, image, name=None):
+ """
+ Args:
+ image:
+ name (str):
+
+ Returns:
+ float: Similarity
+ Button:
+ """
+ res = cv2.matchTemplate(image, self.image, cv2.TM_CCOEFF_NORMED)
+ _, sim, _, point = cv2.minMaxLoc(res)
+ # print(self.file, sim)
+
+ button = self._point_to_button(point, image=image, name=name)
+ return sim, button
+
+ def match_multi(self, image, similarity=0.85, threshold=3, name=None):
+ """
+ Args:
+ image:
+ similarity (float): 0 to 1.
+ threshold (int): Distance to delete nearby results.
+ name (str):
+
+ Returns:
+ list[Button]:
+ """
+ raw = image
+ if self.is_gif:
+ result = []
+ for template in self.image:
+ res = cv2.matchTemplate(image, template, cv2.TM_CCOEFF_NORMED)
+ res = np.array(np.where(res > similarity)).T[:, ::-1].tolist()
+ result += res
+ else:
+ result = cv2.matchTemplate(image, self.image, cv2.TM_CCOEFF_NORMED)
+ result = np.array(np.where(result > similarity)).T[:, ::-1]
+
+ # result: np.array([[x0, y0], [x1, y1], ...)
+ result = Points(result).group(threshold=threshold)
+ return [self._point_to_button(point, image=raw, name=name) for point in result]
+
+ def split_server(self):
+ """
+ Split into 4 server specific buttons.
+
+ Returns:
+ dict[str, Button]:
+ """
+ out = {}
+ for s in VALID_SERVER:
+ out[s] = Template(
+ file=self.parse_property(self.raw_file, s),
+ )
+ return out
diff --git a/module/base/timer.py b/module/base/timer.py
new file mode 100644
index 000000000..a36182de0
--- /dev/null
+++ b/module/base/timer.py
@@ -0,0 +1,159 @@
+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 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 000000000..0ebf2f7c6
--- /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 000000000..0c31465b0
--- /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:
+
+ """
+ if self:
+ return self.grids[0]
+ else:
+ 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 000000000..d8d6b5b8d
--- /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 000000000..cf7fadd0c
--- /dev/null
+++ b/module/base/utils/utils.py
@@ -0,0 +1,878 @@
+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(np.int)
+ half_vector = np.round(vector / 2).astype(np.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(np.int)
+ half_vector = np.round(vector / 2).astype(np.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):
+ """
+
+ 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).
+ """
+ return tuple(np.array(area) + np.append(offset, offset))
+
+
+def area_pad(area, pad=10):
+ """
+
+ 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).
+ """
+ return tuple(np.array(area) + np.array([pad, pad, -pad, -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):
+ """
+ Crop image like pillow, when using opencv / numpy.
+ Provides a black background if cropping outside of image.
+
+ Args:
+ image (np.ndarray):
+ area:
+
+ 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].copy()
+ if sum(border) > 0:
+ image = cv2.copyMakeBorder(image, *border, borderType=cv2.BORDER_CONSTANT, value=(0, 0, 0))
+ 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 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(np.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)
+ 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 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 000000000..bff13c554
--- /dev/null
+++ b/module/config/argument/args.json
@@ -0,0 +1,168 @@
+{
+ "Alas": {
+ "Emulator": {
+ "Serial": {
+ "type": "input",
+ "value": "auto",
+ "valuetype": "str"
+ },
+ "PackageName": {
+ "type": "select",
+ "value": "auto",
+ "option": [
+ "auto",
+ "com.miHoYo.hkrpg",
+ "com.HoYoverse.hkrpgoversea",
+ "com.miHoYo.hkrpg.bilibili"
+ ]
+ },
+ "ScreenshotMethod": {
+ "type": "select",
+ "value": "auto",
+ "option": [
+ "auto",
+ "ADB",
+ "ADB_nc",
+ "uiautomator2",
+ "aScreenCap",
+ "aScreenCap_nc",
+ "DroidCast",
+ "DroidCast_raw",
+ "scrcpy"
+ ]
+ },
+ "ControlMethod": {
+ "type": "select",
+ "value": "MaaTouch",
+ "option": [
+ "minitouch",
+ "MaaTouch"
+ ]
+ },
+ "ScreenshotDedithering": {
+ "type": "checkbox",
+ "value": false
+ },
+ "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.3
+ },
+ "CombatScreenshotInterval": {
+ "type": "input",
+ "value": 1.0
+ },
+ "TaskHoardingDuration": {
+ "type": "input",
+ "value": 0
+ },
+ "WhenTaskQueueEmpty": {
+ "type": "select",
+ "value": "goto_main",
+ "option": [
+ "stay_there",
+ "goto_main",
+ "close_game"
+ ]
+ }
+ },
+ "Storage": {
+ "Storage": {
+ "type": "storage",
+ "value": {},
+ "valuetype": "ignore",
+ "display": "disabled"
+ }
+ }
+ },
+ "Restart": {
+ "Scheduler": {
+ "Enable": {
+ "type": "checkbox",
+ "value": true,
+ "display": "disabled"
+ },
+ "NextRun": {
+ "type": "datetime",
+ "value": "2020-01-01 00:00:00",
+ "validate": "datetime"
+ },
+ "Command": {
+ "type": "input",
+ "value": "Restart",
+ "display": "hide"
+ },
+ "ServerUpdate": {
+ "type": "input",
+ "value": "00:00",
+ "display": "hide"
+ }
+ },
+ "Storage": {
+ "Storage": {
+ "type": "storage",
+ "value": {},
+ "valuetype": "ignore",
+ "display": "disabled"
+ }
+ }
+ }
+}
\ 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 000000000..381df3191
--- /dev/null
+++ b/module/config/argument/argument.yaml
@@ -0,0 +1,79 @@
+# --------------------
+# Define arguments.
+# --------------------
+
+# ==================== Alas ====================
+
+Scheduler:
+ Enable: false
+ NextRun: 2020-01-01 00:00:00
+ Command: Alas
+ ServerUpdate:
+ value: 00:00
+ display: hide
+Emulator:
+ Serial:
+ value: auto
+ valuetype: str
+ PackageName:
+ value: auto
+ option: [ auto, ]
+ ScreenshotMethod:
+ value: auto
+ option: [ auto, ADB, ADB_nc, uiautomator2, aScreenCap, aScreenCap_nc, DroidCast, DroidCast_raw, scrcpy ]
+ ControlMethod:
+ value: MaaTouch
+ option: [ minitouch, MaaTouch ]
+ ScreenshotDedithering: false
+ 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
+ TaskHoardingDuration: 0
+ WhenTaskQueueEmpty:
+ value: goto_main
+ option: [ stay_there, goto_main, close_game ]
+
+# ==================== Farm ====================
+
+
+# ==================== Daily ====================
+
+
+# ==================== Tools ====================
+
diff --git a/module/config/argument/default.yaml b/module/config/argument/default.yaml
new file mode 100644
index 000000000..b4bb69a1c
--- /dev/null
+++ b/module/config/argument/default.yaml
@@ -0,0 +1,14 @@
+# --------------------
+# Define default values
+# --------------------
+
+# ==================== Alas ====================
+
+
+# ==================== Farm ====================
+
+
+# ==================== Daily ====================
+
+
+# ==================== Tools ====================
diff --git a/module/config/argument/gui.yaml b/module/config/argument/gui.yaml
new file mode 100644
index 000000000..5866dd269
--- /dev/null
+++ b/module/config/argument/gui.yaml
@@ -0,0 +1,93 @@
+# 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:
+
+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 000000000..8af4fb904
--- /dev/null
+++ b/module/config/argument/menu.json
@@ -0,0 +1,8 @@
+{
+ "Task": {
+ "Alas": [
+ "Alas",
+ "Restart"
+ ]
+ }
+}
\ 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 000000000..eb2282aa6
--- /dev/null
+++ b/module/config/argument/override.yaml
@@ -0,0 +1,21 @@
+# --------------------
+# Define non-modifiable values
+# --------------------
+
+
+# ==================== Alas ====================
+
+Restart:
+ Scheduler:
+ Enable:
+ value: true
+ display: disabled
+ ServerUpdate: 00:00
+
+# ==================== Farm ====================
+
+
+# ==================== Daily ====================
+
+
+# ==================== Tools ====================
diff --git a/module/config/argument/task.yaml b/module/config/argument/task.yaml
new file mode 100644
index 000000000..f391112f0
--- /dev/null
+++ b/module/config/argument/task.yaml
@@ -0,0 +1,21 @@
+# --------------------
+# Define argument group of tasks.
+# --------------------
+
+# ==================== Alas ====================
+
+Alas:
+ - Emulator
+ - EmulatorInfo
+ - Error
+ - Optimization
+Restart:
+ - Scheduler
+
+# ==================== Farm ====================
+
+
+# ==================== Daily ====================
+
+
+# ==================== Tools ====================
diff --git a/module/config/atomicwrites.py b/module/config/atomicwrites.py
new file mode 100644
index 000000000..9922f1a0b
--- /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 000000000..6e2520c81
--- /dev/null
+++ b/module/config/config.py
@@ -0,0 +1,541 @@
+import copy
+import datetime
+import operator
+import threading
+
+from module.base.filter import Filter
+from module.base.utils import SelectedGrids
+from module.config.config_generated import GeneratedConfig
+from module.config.config_manual import ManualConfig
+from module.config.config_updater import ConfigUpdater
+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("Server", self.SERVER)
+ # 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
+ )
+
+ 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()
+ 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(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))
+
+ @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 000000000..78e55c763
--- /dev/null
+++ b/module/config/config_generated.py
@@ -0,0 +1,44 @@
+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
+ Scheduler_NextRun = datetime.datetime(2020, 1, 1, 0, 0)
+ Scheduler_Command = 'Alas'
+ Scheduler_ServerUpdate = '00:00'
+
+ # Group `Emulator`
+ Emulator_Serial = 'auto'
+ Emulator_PackageName = 'auto' # auto, com.miHoYo.hkrpg, com.HoYoverse.hkrpgoversea, com.miHoYo.hkrpg.bilibili
+ Emulator_ScreenshotMethod = 'auto' # auto, ADB, ADB_nc, uiautomator2, aScreenCap, aScreenCap_nc, DroidCast, DroidCast_raw, scrcpy
+ Emulator_ControlMethod = 'MaaTouch' # minitouch, MaaTouch
+ Emulator_ScreenshotDedithering = False
+ 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_TaskHoardingDuration = 0
+ Optimization_WhenTaskQueueEmpty = 'goto_main' # stay_there, goto_main, close_game
+
+ # Group `Storage`
+ Storage_Storage = {}
diff --git a/module/config/config_manual.py b/module/config/config_manual.py
new file mode 100644
index 000000000..e517d95d4
--- /dev/null
+++ b/module/config/config_manual.py
@@ -0,0 +1,53 @@
+from pywebio.io_ctrl import Output
+
+import module.config.server as server
+
+
+class ManualConfig:
+ @property
+ def SERVER(self):
+ return server.server
+
+ SCHEDULER_PRIORITY = """
+ Restart
+ """
+
+ """
+ module.assets
+ """
+ ASSETS_FOLDER = './assets'
+
+ """
+ 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 000000000..f597d61f8
--- /dev/null
+++ b/module/config/config_updater.py
@@ -0,0 +1,505 @@
+import re
+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 to_server, to_package, VALID_PACKAGE, VALID_CHANNEL_PACKAGE
+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')
+
+
+
+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'))
+ 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 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)
+
+ # Define storage group
+ arg = {
+ 'type': 'storage',
+ 'value': {},
+ 'valuetype': 'ignore',
+ 'display': 'disabled',
+ }
+ deep_set(data, keys=['Storage', 'Storage'], 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 task, groups in self.task.items():
+ # 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):
+ if deep_get(v, keys='type') in ['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 task in self.task.keys():
+ 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_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.menu, depth=2):
+ func, group = path
+ deep_load(['Menu', func])
+ deep_load(['Menu', group])
+ for task in data:
+ deep_load([func, 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 = {}
+
+ # Task menu
+ group = ''
+ tasks = []
+ with open(filepath_argument('task'), 'r', encoding='utf-8') as f:
+ for line in f.readlines():
+ line = line.strip('\n')
+ if '=====' in line:
+ if tasks:
+ deep_set(data, keys=f'Task.{group}', value=tasks)
+ group = line.strip('#=- ')
+ tasks = []
+ if group:
+ if line.endswith(':'):
+ tasks.append(line.strip('\n=-#: '))
+ if tasks:
+ deep_set(data, keys=f'Task.{group}', value=tasks)
+
+ return data
+
+ @staticmethod
+ def generate_deploy_template():
+ template = poor_yaml_read(DEPLOY_TEMPLATE)
+ cn = {
+ 'Repository': 'https://e.coding.net/llop18870/alas/AzurLaneAutoScript.git',
+ '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)
+
+ def insert_package(self):
+ option = deep_get(self.argument, keys='Emulator.PackageName.option')
+ option += list(VALID_PACKAGE.keys())
+ option += list(VALID_CHANNEL_PACKAGE.keys())
+ deep_set(self.argument, keys='Emulator.PackageName.option', value=option)
+ deep_set(self.args, keys='Alas.Emulator.PackageName.option', value=option)
+
+ @timer
+ def generate(self):
+ _ = self.args
+ _ = self.menu
+ # _ = self.event
+ # self.insert_event()
+ self.insert_package()
+ # self.insert_server()
+ write_file(filepath_args(), self.args)
+ write_file(filepath_args('menu'), self.menu)
+ self.generate_code()
+ for lang in LANGUAGES:
+ self.generate_i18n(lang)
+ self.generate_deploy_template()
+
+
+class ConfigUpdater:
+ # source, target, (optional)convert_func
+ redirection = [
+ ]
+
+ @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'])
+ if is_template or value is None or value == '' or data['type'] == 'lock' or data.get('display') == 'hide':
+ 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)
+
+ 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
+
+ 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 000000000..c5663d540
--- /dev/null
+++ b/module/config/i18n/en-US.json
@@ -0,0 +1,273 @@
+{
+ "Menu": {
+ "Task": {
+ "name": "",
+ "help": ""
+ },
+ "Alas": {
+ "name": "SRC",
+ "help": ""
+ }
+ },
+ "Task": {
+ "Alas": {
+ "name": "SRC Settings",
+ "help": ""
+ },
+ "Restart": {
+ "name": "Error Handling",
+ "help": ""
+ }
+ },
+ "Scheduler": {
+ "_info": {
+ "name": "Scheduler",
+ "help": ""
+ },
+ "Enable": {
+ "name": "Enable Task",
+ "help": "Join this task to scheduler.\nTask commission, research, reward are force to enable."
+ },
+ "NextRun": {
+ "name": "Next Run",
+ "help": "Updated automatically after completing the task to set next scheduled run, typically not manually modified\nHowever you can force immediate scheduling if you clear this text field"
+ },
+ "Command": {
+ "name": "Command",
+ "help": ""
+ },
+ "ServerUpdate": {
+ "name": "Server Update",
+ "help": "Series of server refresh time(s) as to when this task will next run, this is automatically converted to respective time zone, generally do not need to modify"
+ }
+ },
+ "Emulator": {
+ "_info": {
+ "name": "Emulator Settings",
+ "help": ""
+ },
+ "Serial": {
+ "name": "Serial",
+ "help": "Common emulator Serial can be queried in the list below\nUse \"auto\" to auto-detect emulators, but if multiple emulators are running or use emulators that do not support auto-detect, \"auto\" cannot be used and serial must be filled in manually\nDefault serial for select emulators:\n- BlueStacks 127.0.0.1:5555\n- BlueStacks4 Hyper-V use \"bluestacks4-hyperv\", \"bluestacks4-hyperv-2\" for multi instance, and so on\n- BlueStacks5 Hyper-V use \"bluestacks5-hyperv\", \"bluestacks5-hyperv-1\" for multi instance, and so on\n- NoxPlayer 127.0.0.1:62001\n- NoxPlayer64bit 127.0.0.1:59865\n- MuMuPlayer/MuMuPlayer X 127.0.0.1:7555\n- MemuPlayer 127.0.0.1:21503\n- LDPlayer emulator-5554 or 127.0.0.1:5555\n- WSA use \"wsa-0\" to make the game run in the background, which needs to be controlled or closed by third-party software\nIf there are multiple emulator instances running, the default is reserved for one of them and the others will use different serials to avoid conflicts\nOpen console.bat and run `adb devices` to find them or follow the emulator's official tutorial"
+ },
+ "PackageName": {
+ "name": "Game Server",
+ "help": "Manual select is required if multiple game clients are installed on emulator",
+ "auto": "Auto-detect",
+ "com.miHoYo.hkrpg": "CN",
+ "com.HoYoverse.hkrpgoversea": "OVERSEA",
+ "com.miHoYo.hkrpg.bilibili": "CN com.miHoYo.hkrpg.bilibili"
+ },
+ "ScreenshotMethod": {
+ "name": "Screenshot Method",
+ "help": "When using auto-select, a benchmark will be performed and automatically changed to the fastest screenshot method.\nGeneral speed: DroidCast_raw >> aScreenCap_nc > ADB_nc >>> aScreenCap > uiautomator2 ~= ADB.\nRun Tools - Performance Test to find the fastest method.",
+ "auto": "Auto-select the fastest",
+ "ADB": "ADB ",
+ "ADB_nc": "ADB_nc",
+ "uiautomator2": "uiautomator2",
+ "aScreenCap": "aScreenCap",
+ "aScreenCap_nc": "aScreenCap_nc",
+ "DroidCast": "DroidCast",
+ "DroidCast_raw": "DroidCast_raw",
+ "scrcpy": "scrcpy"
+ },
+ "ControlMethod": {
+ "name": "Control Method",
+ "help": "Speed: MaaTouch = minitouch >>> uiautomator2 ~= ADB\nMaaTouch is recommended",
+ "minitouch": "minitouch",
+ "MaaTouch": "MaaTouch"
+ },
+ "ScreenshotDedithering": {
+ "name": "Image Color De-dithering",
+ "help": "Enable when running Alas on phones"
+ },
+ "AdbRestart": {
+ "name": "Try to restart adb when no device found",
+ "help": ""
+ }
+ },
+ "EmulatorInfo": {
+ "_info": {
+ "name": "Emulator Settings",
+ "help": "The following values are auto-filled according to \"Serial\", if you don’t understand, please don't modify them"
+ },
+ "Emulator": {
+ "name": "Emulator Type",
+ "help": "",
+ "auto": "Auto-detect",
+ "NoxPlayer": "Nox Player",
+ "NoxPlayer64": "Nox Player 64bit",
+ "BlueStacks4": "BlueStacks 4",
+ "BlueStacks5": "BlueStacks 5",
+ "BlueStacks4HyperV": "BlueStacks 4 Hyper-V",
+ "BlueStacks5HyperV": "BlueStacks 5 Hyper-V",
+ "LDPlayer3": "LD Player 3",
+ "LDPlayer4": "LD Player 4",
+ "LDPlayer9": "LD Player 9",
+ "MuMuPlayer": "MuMu Player",
+ "MuMuPlayerX": "MuMu Player X",
+ "MuMuPlayer12": "MuMu Player 12",
+ "MEmuPlayer": "MEmu Player"
+ },
+ "name": {
+ "name": "Emulator Instance Name",
+ "help": ""
+ },
+ "path": {
+ "name": "Emulator Installation Path",
+ "help": ""
+ }
+ },
+ "Error": {
+ "_info": {
+ "name": "Debug Settings",
+ "help": ""
+ },
+ "Restart": {
+ "name": "Restart Game on Error",
+ "help": "",
+ "game": "Restart game",
+ "game_emulator": "Restart emulator and game"
+ },
+ "SaveError": {
+ "name": "Record Exception",
+ "help": "Records exception and log into directory for review or sharing"
+ },
+ "ScreenshotLength": {
+ "name": "Record Screenshot(s)",
+ "help": "Number of screenshots saved when exception occurs"
+ },
+ "OnePushConfig": {
+ "name": "Error notify config",
+ "help": "When Alas cannot handle exception, send a message through Onepush. Configuration document: \nhttps://github.com/LmeSzinc/AzurLaneAutoScript/wiki/Onepush-configuration-%5BEN%5D"
+ }
+ },
+ "Optimization": {
+ "_info": {
+ "name": "Optimization Settings",
+ "help": ""
+ },
+ "ScreenshotInterval": {
+ "name": "Take Screenshots Every X Second(s)",
+ "help": "Minimum interval between 2 screenshots, limited in 0.1 ~ 0.3, can help reduce CPU on high-end PCs"
+ },
+ "CombatScreenshotInterval": {
+ "name": "Take Screenshots Every X Second(s) In Combat",
+ "help": "Minimum interval between 2 screenshots, limited in 0.1 ~ 1.0, can help reduce CPU during battle"
+ },
+ "TaskHoardingDuration": {
+ "name": "Hoard Tasks For X Minute(s)",
+ "help": "By purposely not adding ready tasks to pending, allows for larger subsets to be built and run en masse at a later time\nCan reduce the frequency of operating AL"
+ },
+ "WhenTaskQueueEmpty": {
+ "name": "When Task Queue is Empty",
+ "help": "Close AL when there are no pending tasks, can help reduce CPU",
+ "stay_there": "Stay There",
+ "goto_main": "Goto Main Page",
+ "close_game": "Close Game"
+ }
+ },
+ "Storage": {
+ "_info": {
+ "name": "Task status",
+ "help": "Store internal status of the task. Clear manually when the task is abnormal"
+ },
+ "Storage": {
+ "name": "Storage.Storage.name",
+ "help": "Storage.Storage.help"
+ }
+ },
+ "Gui": {
+ "Aside": {
+ "Install": "Install",
+ "Home": "Home",
+ "Develop": "Develop",
+ "Performance": "Perf.",
+ "Setting": "Settings",
+ "AddAlas": "Add"
+ },
+ "Button": {
+ "Start": "Start",
+ "Stop": "Stop",
+ "ScrollON": "Auto Scroll ON",
+ "ScrollOFF": "Auto Scroll OFF",
+ "ClearLog": "Clear Log",
+ "Setting": "Setting",
+ "CheckUpdate": "Check update",
+ "ClickToUpdate": "Click to update",
+ "RetryUpdate": "Retry update",
+ "CancelUpdate": "Cancel update"
+ },
+ "Toast": {
+ "DisableTranslateMode": "Click here to disable translate mode",
+ "ConfigSaved": "Config saved",
+ "AlasIsRunning": "Scheduler is already running",
+ "ClickToUpdate": "New update available, click here to update"
+ },
+ "Status": {
+ "Running": "Running",
+ "Inactive": "Inactive",
+ "Warning": "Warning",
+ "Updating": "Waiting Update"
+ },
+ "MenuAlas": {
+ "Overview": "Overview",
+ "Log": "Logs"
+ },
+ "MenuDevelop": {
+ "HomePage": "Home",
+ "Translate": "Translate",
+ "Update": "Updater",
+ "Remote": "Remote access",
+ "Utils": "Utils"
+ },
+ "Overview": {
+ "Scheduler": "Scheduler",
+ "Log": "Log",
+ "Running": "Running",
+ "Pending": "Pending",
+ "Waiting": "Waiting",
+ "NoTask": "No Task"
+ },
+ "AddAlas": {
+ "PopupTitle": "Add new config",
+ "NewName": "New name",
+ "CopyFrom": "Copy from existing config",
+ "Confirm": "Add",
+ "FileExist": "A config with the same name exists, please choose another one",
+ "InvalidChar": "Config name cannot contain any of the following characters: .\\/:*?\"<>|",
+ "InvalidPrefixTemplate": "Config name cannot start with 'template'"
+ },
+ "Update": {
+ "UpToDate": "Latest version",
+ "HaveUpdate": "A new version is available",
+ "UpdateStart": "Start update",
+ "UpdateWait": "Waiting for all alas complete current task",
+ "UpdateRun": "Updating",
+ "UpdateSuccess": "Update succeeded, restarting",
+ "UpdateFailed": "Update failed. Logs can be found in ./log/*_gui.txt",
+ "UpdateChecking": "Checking for updates",
+ "UpdateCancel": "Update canceled, restarting Alas",
+ "UpdateFinish": "Update succeeded, please restart manually",
+ "Local": "Local",
+ "Upstream": "Upstream",
+ "Author": "Author",
+ "Time": "Commit time",
+ "Message": "Commit message",
+ "DisabledWarn": "Updater module is disabled. You need to manually restart Alas to update",
+ "DetailedHistory": "Detailed Commit History"
+ },
+ "Remote": {
+ "Running": "Remote access on",
+ "NotRunning": "Not running, server disconnected or offline",
+ "NotEnable": "Disabled, set webui password in deploy.yaml and enable remote access",
+ "EntryPoint": "Entry point:",
+ "ConfigureHint": "Configuration tutorial:",
+ "SSHNotInstall": "No SSH command in your system. Please refer to the tutorial to download or install one"
+ },
+ "Text": {
+ "InvalidFeedBack": "Invalid format. Example: {0}",
+ "Clear": "Clear"
+ }
+ }
+}
\ No newline at end of file
diff --git a/module/config/i18n/ja-JP.json b/module/config/i18n/ja-JP.json
new file mode 100644
index 000000000..0ccb2191f
--- /dev/null
+++ b/module/config/i18n/ja-JP.json
@@ -0,0 +1,273 @@
+{
+ "Menu": {
+ "Task": {
+ "name": "",
+ "help": ""
+ },
+ "Alas": {
+ "name": "Menu.Alas.name",
+ "help": "Menu.Alas.help"
+ }
+ },
+ "Task": {
+ "Alas": {
+ "name": "Task.Alas.name",
+ "help": "Task.Alas.help"
+ },
+ "Restart": {
+ "name": "再起動設定",
+ "help": ""
+ }
+ },
+ "Scheduler": {
+ "_info": {
+ "name": "Scheduler._info.name",
+ "help": "Scheduler._info.help"
+ },
+ "Enable": {
+ "name": "Scheduler.Enable.name",
+ "help": "Scheduler.Enable.help"
+ },
+ "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",
+ "com.miHoYo.hkrpg": "CN",
+ "com.HoYoverse.hkrpgoversea": "OVERSEA",
+ "com.miHoYo.hkrpg.bilibili": "CN com.miHoYo.hkrpg.bilibili"
+ },
+ "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"
+ },
+ "ScreenshotDedithering": {
+ "name": "Emulator.ScreenshotDedithering.name",
+ "help": "Emulator.ScreenshotDedithering.help"
+ },
+ "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"
+ },
+ "TaskHoardingDuration": {
+ "name": "Optimization.TaskHoardingDuration.name",
+ "help": "Optimization.TaskHoardingDuration.help"
+ },
+ "WhenTaskQueueEmpty": {
+ "name": "Optimization.WhenTaskQueueEmpty.name",
+ "help": "Optimization.WhenTaskQueueEmpty.help",
+ "stay_there": "stay_there",
+ "goto_main": "goto_main",
+ "close_game": "close_game"
+ }
+ },
+ "Storage": {
+ "_info": {
+ "name": "Storage._info.name",
+ "help": "Storage._info.help"
+ },
+ "Storage": {
+ "name": "Storage.Storage.name",
+ "help": "Storage.Storage.help"
+ }
+ },
+ "Gui": {
+ "Aside": {
+ "Install": "インストール",
+ "Home": "Gui.Aside.Home",
+ "Develop": "開発",
+ "Performance": "機能",
+ "Setting": "設定",
+ "AddAlas": "追加"
+ },
+ "Button": {
+ "Start": "実行",
+ "Stop": "中止",
+ "ScrollON": "自動スクロール ON",
+ "ScrollOFF": "自動スクロール OFF",
+ "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": "Waiting",
+ "NoTask": "No Task"
+ },
+ "AddAlas": {
+ "PopupTitle": "新しいコンフィグを追加",
+ "NewName": "コンフィグ名",
+ "CopyFrom": "既存コンフィグからコピー",
+ "Confirm": "追加",
+ "FileExist": "既存コンフィグと同じネーミングとなっていますから別名前を入力してください",
+ "InvalidChar": "コンフィグ名には下記の文字を含めることはできません:.\\/:*?\"<>|",
+ "InvalidPrefixTemplate": "設定ファイル名は temlpate で始めることはできません"
+ },
+ "Update": {
+ "UpToDate": "今Alasは最新バージョンです",
+ "HaveUpdate": "新しいアップデータがあります",
+ "UpdateStart": "アップデータ開始",
+ "UpdateWait": "全てのプロセスの完了を待っています",
+ "UpdateRun": "アップデータ中",
+ "UpdateSuccess": "アップデータ完了、再起動しています",
+ "UpdateFailed": "アップデータ失敗、./log/*_gui.txtでログをチェックしてください",
+ "UpdateChecking": "アップデータチェック中",
+ "UpdateCancel": "アップデータ中止、再起動しています",
+ "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/i18n/zh-CN.json b/module/config/i18n/zh-CN.json
new file mode 100644
index 000000000..0d3919468
--- /dev/null
+++ b/module/config/i18n/zh-CN.json
@@ -0,0 +1,273 @@
+{
+ "Menu": {
+ "Task": {
+ "name": "",
+ "help": ""
+ },
+ "Alas": {
+ "name": "SRC",
+ "help": ""
+ }
+ },
+ "Task": {
+ "Alas": {
+ "name": "SRC设置",
+ "help": ""
+ },
+ "Restart": {
+ "name": "异常处理",
+ "help": ""
+ }
+ },
+ "Scheduler": {
+ "_info": {
+ "name": "任务设置",
+ "help": ""
+ },
+ "Enable": {
+ "name": "启用该功能",
+ "help": "将这个任务加入调度器"
+ },
+ "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- 逍遥模拟器 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": "自动检测",
+ "com.miHoYo.hkrpg": "CN",
+ "com.HoYoverse.hkrpgoversea": "OVERSEA",
+ "com.miHoYo.hkrpg.bilibili": "CN Bilibili渠道服 com.miHoYo.hkrpg.bilibili"
+ },
+ "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"
+ },
+ "ScreenshotDedithering": {
+ "name": "去除图片色彩抖动",
+ "help": "在手机上运行时开启"
+ },
+ "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 占用"
+ },
+ "TaskHoardingDuration": {
+ "name": "囤积任务 X 分钟",
+ "help": "能在收菜期间降低操作游戏的频率\n任务触发后,等待 X 分钟,再一次性执行囤积的任务"
+ },
+ "WhenTaskQueueEmpty": {
+ "name": "当任务队列清空后",
+ "help": "无任务时关闭游戏,能在收菜期间降低 CPU 占用",
+ "stay_there": "停在原处",
+ "goto_main": "前往主界面",
+ "close_game": "关闭游戏"
+ }
+ },
+ "Storage": {
+ "_info": {
+ "name": "任务状态",
+ "help": "存放任务内部状态,任务异常时可以手动清除"
+ },
+ "Storage": {
+ "name": "Storage.Storage.name",
+ "help": "Storage.Storage.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": "无任务"
+ },
+ "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/i18n/zh-TW.json b/module/config/i18n/zh-TW.json
new file mode 100644
index 000000000..ae1b27588
--- /dev/null
+++ b/module/config/i18n/zh-TW.json
@@ -0,0 +1,273 @@
+{
+ "Menu": {
+ "Task": {
+ "name": "",
+ "help": ""
+ },
+ "Alas": {
+ "name": "SRC",
+ "help": ""
+ }
+ },
+ "Task": {
+ "Alas": {
+ "name": "SRC設定",
+ "help": ""
+ },
+ "Restart": {
+ "name": "除錯",
+ "help": ""
+ }
+ },
+ "Scheduler": {
+ "_info": {
+ "name": "任務設定",
+ "help": ""
+ },
+ "Enable": {
+ "name": "啟用該功能",
+ "help": "將這個任務加入調度器\n委託、科研、收穫任務是強制打開的"
+ },
+ "NextRun": {
+ "name": "下一次執行時間",
+ "help": "自動計算的數值,不需要手動修改。清空後將立刻執行"
+ },
+ "Command": {
+ "name": "內部任務名稱",
+ "help": ""
+ },
+ "ServerUpdate": {
+ "name": "伺服器更新時間",
+ "help": "一些任務執行成功後,將推遲下一次執行伺服器更新的時間\n自動換算時區,一般不需要修改"
+ }
+ },
+ "Emulator": {
+ "_info": {
+ "name": "模擬器設定",
+ "help": ""
+ },
+ "Serial": {
+ "name": "模擬器 Serial",
+ "help": "常見的模擬器 Serial 可以查詢下方列表\n填 \"auto\" 自動檢測模擬器,多個模擬器正在運行或使用不支持自動檢測的模擬器時無法使用 \"auto\",必須手動填寫\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- 逍遙模擬器 127.0.0.1:21503\n- 雷電模擬器 emulator-5554 或 127.0.0.1:5555\n- WSA,填\"wsa-0\"使遊戲在後臺運行,需要使用第三方軟件操控或關閉\n如果你使用了模擬器的多開功能,他們的 Serial 將不是預設的,可以在 console.bat 中執行 `adb devices` 查詢,或根據模擬器官方的教程填寫"
+ },
+ "PackageName": {
+ "name": "遊戲伺服器",
+ "help": "模擬器上裝有多個遊戲客戶端時,需要手動選擇伺服器",
+ "auto": "自動檢測",
+ "com.miHoYo.hkrpg": "CN",
+ "com.HoYoverse.hkrpgoversea": "OVERSEA",
+ "com.miHoYo.hkrpg.bilibili": "CN com.miHoYo.hkrpg.bilibili"
+ },
+ "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 > Hermit >>> uiautomator2 ~= ADB\n建議選MaaTouch",
+ "minitouch": "minitouch",
+ "MaaTouch": "MaaTouch"
+ },
+ "ScreenshotDedithering": {
+ "name": "去除圖片色彩抖動",
+ "help": "在手機上運行時開啟"
+ },
+ "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 佔用"
+ },
+ "TaskHoardingDuration": {
+ "name": "囤積任務 X 分鐘",
+ "help": "能在收穫期間降低操作遊戲的頻率\n任務觸發後,等待 X 分鐘後,一次性執行佇列中的任務"
+ },
+ "WhenTaskQueueEmpty": {
+ "name": "當任務隊列清空後",
+ "help": "無任務時關閉遊戲,能在收菜期間降低 CPU 佔用",
+ "stay_there": "停在原處",
+ "goto_main": "前往主界面",
+ "close_game": "關閉遊戲"
+ }
+ },
+ "Storage": {
+ "_info": {
+ "name": "任務狀態",
+ "help": "存放任務內部狀態,任務异常時可以手動清除"
+ },
+ "Storage": {
+ "name": "Storage.Storage.name",
+ "help": "Storage.Storage.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": "無任務"
+ },
+ "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 000000000..7499b2247
--- /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'.
+"""
+server = 'cn' # Setting default to cn, will avoid errors when using dev_tools
+
+VALID_SERVER = ['cn', ]
+VALID_PACKAGE = {
+ 'com.miHoYo.hkrpg': 'cn',
+ 'com.HoYoverse.hkrpgoversea': 'oversea'
+}
+VALID_CHANNEL_PACKAGE = {
+ 'com.miHoYo.hkrpg.bilibili': ('cn', 'Bilibili'),
+}
+
+
+def set_server(package_or_server: str):
+ """
+ Change server and this will effect globally,
+ including assets and server specific methods.
+
+ Args:
+ package_or_server: package name or server.
+ """
+ global server
+ server = to_server(package_or_server)
+
+ 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.
+ """
+ if package_or_server in VALID_SERVER:
+ return package_or_server
+ elif package_or_server in VALID_PACKAGE:
+ return VALID_PACKAGE[package_or_server]
+ elif package_or_server in VALID_CHANNEL_PACKAGE:
+ return VALID_CHANNEL_PACKAGE[package_or_server][0]
+ else:
+ return 'cn'
+
+
+def to_package(package_or_server: str) -> str:
+ """
+ Convert package/server to package.
+ """
+ package_or_server = package_or_server.lower()
+ if package_or_server in VALID_PACKAGE:
+ return package_or_server
+
+ for key, value in VALID_PACKAGE.items():
+ if value == package_or_server:
+ return key
+
+ raise ValueError(f'Server invalid: {package_or_server}')
diff --git a/module/config/utils.py b/module/config/utils.py
new file mode 100644
index 000000000..6fbca7479
--- /dev/null
+++ b/module/config/utils.py
@@ -0,0 +1,643 @@
+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', 'ja-JP', 'zh-TW']
+SERVER_TO_LANG = {
+ 'cn': 'zh-CN',
+ 'en': 'en-US',
+ 'jp': 'ja-JP',
+ 'tw': 'zh-TW',
+}
+LANG_TO_SERVER = {v: k for k, v in SERVER_TO_LANG.items()}
+SERVER_TO_TIMEZONE = {
+ 'cn': timedelta(hours=8),
+ 'en': timedelta(hours=-7),
+ 'jp': timedelta(hours=9),
+ 'tw': timedelta(hours=8),
+}
+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}-alas')
+
+ # 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 = ['alas']
+
+ 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 |
+ | `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['value'], bool):
+ return 'checkbox'
+ elif 'option' in kwargs and kwargs['option']:
+ return 'select'
+ 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['cn'])
+
+
+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 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 000000000..0dc38f747
--- /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 000000000..68476b4e5
--- /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 000000000..2dca3db64
--- /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 000000000..a6b45229e
--- /dev/null
+++ b/module/device/app_control.py
@@ -0,0 +1,67 @@
+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
+ _app_u2_family = ['uiautomator2', 'minitouch', 'scrcpy', 'MaaTouch']
+
+ 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 000000000..701d33ed5
--- /dev/null
+++ b/module/device/connection.py
@@ -0,0 +1,848 @@
+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
+
+from module.base.decorator import Config, cached_property, del_cached_property
+from module.base.utils import ensure_time
+from module.config.server import set_server
+from module.device.connection_attr import ConnectionAttr
+from module.device.method.utils import (
+ RETRY_TRIES, remove_shell_warning, retry_sleep,
+ handle_adb_error, PackageNotInstalled,
+ recv_all, possible_reasons,
+ random_port, get_serial_pair)
+from module.exception import RequestHumanTakeover, EmulatorNotRunningError
+from module.logger import logger
+from module.base.utils import SelectedGrids
+
+
+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
+ self.package = self.config.Emulator_PackageName
+ if self.package == 'auto':
+ self.detect_package()
+ # No set_server cause game client and UI language can be different
+ # else:
+ # set_server(self.package)
+ logger.attr('PackageName', self.package)
+ logger.attr('Server', self.config.SERVER)
+
+ @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
+
+ @cached_property
+ def cpu_abi(self) -> str:
+ """
+ Returns:
+ str: arm64-v8a, armeabi-v7a, x86, x86_64
+ """
+ abi = self.adb_shell(['getprop', 'ro.product.cpu.abi']).strip()
+ 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_shell(['getprop', 'ro.build.version.sdk']).strip()
+ 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_shell(['getprop', 'ro.hardware']):
+ return True
+ if 'goldfish' in self.adb_shell(['getprop', 'ro.hardware.audio.primary']):
+ return True
+ 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, keywords=('hkrpg', ), show_log=True):
+ """
+ Args:
+ keywords:
+ show_log:
+
+ Returns:
+ list[str]: List of package names
+ """
+ packages = self.list_package(show_log=show_log)
+ packages = [p for p in packages if any([k in p.lower() for k in keywords])]
+ return packages
+
+ def detect_package(self, keywords=('hkrpg', ), set_config=True):
+ """
+ Show all possible packages with the given keyword on this device.
+ """
+ logger.hr('Detect package')
+ packages = self.list_azurlane_packages(keywords=keywords)
+
+ # 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 {keywords[0]} package found, '
+ f'please confirm {keywords[0]} 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 = self.package
+ # Set server
+ # logger.info('Server changed, release resources')
+ # set_server(self.package)
+ else:
+ logger.critical(
+ f'Multiple {keywords[0]} 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 000000000..66a829342
--- /dev/null
+++ b/module/device/connection_attr.py
@@ -0,0 +1,264 @@
+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
+
+ def serial_check(self):
+ """
+ serial check
+ """
+ # Chinese colon
+ if ':' in self.serial:
+ self.serial = self.serial.replace(':', ':')
+ logger.warning(f'Serial {self.config.Emulator_Serial} is revised to {self.serial}')
+ self.config.Emulator_Serial = self.serial
+ 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):
+ return self.serial == '127.0.0.1:7555'
+
+ @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)\.status\.adb_port"
+ else:
+ parameter_name = rf"bst\.instance\.(Nougat64|Pie64)_{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 000000000..6347234e5
--- /dev/null
+++ b/module/device/control.py
@@ -0,0 +1,170 @@
+from module.base.button import Button
+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
+
+
+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(Button(area=(), color=(), button=area_offset(point_random, p2), name=name))
diff --git a/module/device/device.py b/module/device/device.py
new file mode 100644
index 000000000..36fd54308
--- /dev/null
+++ b/module/device/device.py
@@ -0,0 +1,197 @@
+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()
+ stuck_timer_long = Timer(180, count=180).start()
+ stuck_long_wait_list = ['BATTLE_STATUS_S', 'PAUSE', 'LOGIN_CHECK']
+
+ 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()
+ self.stuck_timer_long.reset()
+
+ def stuck_record_check(self):
+ """
+ Raises:
+ GameStuckError:
+ """
+ reached = self.stuck_timer.reached()
+ reached_long = self.stuck_timer_long.reached()
+
+ if not reached:
+ return False
+ if not reached_long:
+ for button in self.stuck_long_wait_list:
+ if button in self.detect_record:
+ 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 000000000..282447ce2
--- /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 000000000..10ac1110c
--- /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 000000000..39baeb4e5
--- /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 000000000..ad0c39746
--- /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 000000000..c4228e617
--- /dev/null
+++ b/module/device/method/maatouch.py
@@ -0,0 +1,231 @@
+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 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
+
+ @cached_property
+ def maatouch_builder(self):
+ self.maatouch_init()
+ return CommandBuilder(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
+ )
+ 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):
+ content = self.maatouch_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 + self.maatouch_builder.DEFAULT_DELAY)
+ self.maatouch_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()
+ self.maatouch_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()
+ self.maatouch_send()
+
+ @retry
+ def swipe_maatouch(self, p1, p2):
+ points = insert_swipe(p0=p1, p3=p2)
+ builder = self.maatouch_builder
+
+ builder.down(*points[0]).commit()
+ self.maatouch_send()
+
+ for point in points[1:]:
+ builder.move(*point).commit().wait(10)
+ self.maatouch_send()
+
+ builder.up().commit()
+ self.maatouch_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()
+ self.maatouch_send()
+
+ for point in points[1:]:
+ builder.move(*point).commit().wait(10)
+ self.maatouch_send()
+
+ builder.move(*p2).commit().wait(140)
+ builder.move(*p2).commit().wait(140)
+ self.maatouch_send()
+
+ builder.up().commit()
+ self.maatouch_send()
diff --git a/module/device/method/minitouch.py b/module/device/method/minitouch.py
new file mode 100644
index 000000000..ea3eea29c
--- /dev/null
+++ b/module/device/method/minitouch.py
@@ -0,0 +1,569 @@
+import asyncio
+import json
+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(np.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):
+ """
+ Args:
+ device:
+ """
+ self.device = device
+ self.commands = []
+ self.delay = 0
+
+ def convert(self, x, y):
+ max_x, max_y = self.device.max_x, self.device.max_y
+ orientation = self.device.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, contact=0):
+ """ add minitouch command: 'u \n' """
+ self.commands.append(Command('u', contact=contact))
+ return self
+
+ def down(self, x, y, contact=0, pressure=100):
+ """ add minitouch command: 'd \n' """
+ x, y = self.convert(x, y)
+ self.commands.append(Command('d', x=x, y=y, contact=contact, pressure=pressure))
+ return self
+
+ def move(self, x, y, contact=0, pressure=100):
+ """ add minitouch command: 'm \n' """
+ x, y = self.convert(x, y)
+ self.commands.append(Command('m', x=x, y=y, contact=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]
+
+
+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):
+ content = self.minitouch_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 + self.minitouch_builder.DEFAULT_DELAY)
+ self.minitouch_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):
+ content = self.minitouch_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(self.minitouch_builder.delay / 1000 + self.minitouch_builder.DEFAULT_DELAY)
+ self.minitouch_builder.clear()
+
+ @retry
+ def click_minitouch(self, x, y):
+ builder = self.minitouch_builder
+ builder.down(x, y).commit()
+ builder.up().commit()
+ self.minitouch_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()
+ self.minitouch_send()
+
+ @retry
+ def swipe_minitouch(self, p1, p2):
+ points = insert_swipe(p0=p1, p3=p2)
+ builder = self.minitouch_builder
+
+ builder.down(*points[0]).commit()
+ self.minitouch_send()
+
+ for point in points[1:]:
+ builder.move(*point).commit().wait(10)
+ self.minitouch_send()
+
+ builder.up().commit()
+ self.minitouch_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()
+ self.minitouch_send()
+
+ for point in points[1:]:
+ builder.move(*point).commit().wait(10)
+ self.minitouch_send()
+
+ builder.move(*p2).commit().wait(140)
+ builder.move(*p2).commit().wait(140)
+ self.minitouch_send()
+
+ builder.up().commit()
+ self.minitouch_send()
diff --git a/module/device/method/scrcpy/__init__.py b/module/device/method/scrcpy/__init__.py
new file mode 100644
index 000000000..f4b30354b
--- /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 000000000..69dcd2548
--- /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 000000000..3e64314d1
--- /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 000000000..6be3b9587
--- /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 _AdbStreamConnection, 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 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[_AdbStreamConnection] = 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: _AdbStreamConnection = 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 000000000..6f4375b44
--- /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 = 6
+
+ @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 000000000..50eff74f8
--- /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 000000000..dea534a31
--- /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 000000000..18067f177
--- /dev/null
+++ b/module/device/method/utils.py
@@ -0,0 +1,325 @@
+import random
+import re
+import socket
+import time
+
+import uiautomator2 as u2
+from adbutils import AdbTimeout, _AdbStreamConnection
+from lxml import etree
+
+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, _AdbStreamConnection):
+ 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 000000000..56f0ea23f
--- /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 000000000..a3ee2ccba
--- /dev/null
+++ b/module/device/platform/emulator_base.py
@@ -0,0 +1,246 @@
+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)
+ try:
+ return list(iter_folder(folder, is_dir=is_dir, ext=ext))
+ except FileNotFoundError:
+ return []
+
+
+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 000000000..66d067da2
--- /dev/null
+++ b/module/device/platform/emulator_windows.py
@@ -0,0 +1,506 @@
+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:
+ 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
+
+ @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
+ folder = abspath(os.path.join(os.path.dirname(uninstall), 'EmulatorShell'))
+ if os.path.exists(folder):
+ for file in iter_folder(folder, 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 000000000..9f47ceafe
--- /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 000000000..9e7d9b393
--- /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 000000000..633bd2966
--- /dev/null
+++ b/module/device/platform/utils.py
@@ -0,0 +1,49 @@
+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
+ """
+ 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('\\', '/')
diff --git a/module/device/screenshot.py b/module/device/screenshot.py
new file mode 100644
index 000000000..9f1af35b7
--- /dev/null
+++ b/module/device/screenshot.py
@@ -0,0 +1,253 @@
+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):
+ 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 000000000..e9bf713c6
--- /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/logger/__init__.py b/module/logger/__init__.py
new file mode 100644
index 000000000..b584b3c1f
--- /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 000000000..8452138a9
--- /dev/null
+++ b/module/logger/logger.py
@@ -0,0 +1,357 @@
+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 RegexHighlighter, NullHighlighter
+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
+
+
+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