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