Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
830c7bfaa2
|
||
|
|
04f8b2fdc5
|
||
|
|
1ff838bdfa
|
||
|
|
292d1d1e3a
|
||
|
|
ad186c5d34
|
||
|
|
6c007c0e35
|
||
|
|
7e0f33be23
|
||
|
|
f905524fb4
|
||
|
|
47cf57771a
|
||
|
|
b431ab02d3
|
||
|
|
bb1df87280
|
||
|
|
58fc1a2be9
|
||
|
|
089ffc78f6
|
||
|
|
825f2409b9
|
||
|
|
bf3cbeaaba
|
||
|
|
0ca581f1e6
|
||
|
|
4b10dcc7f3
|
||
|
|
f3cecca1f8
|
||
|
|
1bb468c84e
|
||
|
|
41ac4a69f0
|
||
|
|
eaf806e02d
|
||
|
|
d033dd17c7
|
||
|
|
0c6c866142
|
||
|
|
9ca70847f9
|
||
|
|
a76d47b6cf
|
||
|
|
8ddce8d648
|
||
|
|
97ead3487e
|
||
|
|
04083cb309
|
||
|
|
3d32669431
|
||
|
|
eb5e07c9ff
|
||
|
|
6ad9b29ba1
|
||
|
|
9135b74484
|
||
|
|
d7277fb22d
|
||
|
|
7cfd24f042
|
||
|
|
58bd699c3f
|
||
|
|
bdc16b19e1
|
||
|
|
ea2de5d17c
|
||
|
|
380b1f9e6e
|
||
|
|
ed6370b9f9
|
||
|
|
e7b60ba3ea
|
||
|
|
fbc63d0a54
|
||
|
|
148a39b591
|
||
|
|
2073af147b
|
||
|
|
605fde2d7c
|
||
|
|
593799a0a6
|
||
|
|
56875e46b3
|
||
|
|
bfcbf268fc
|
||
|
|
cedc003f08
|
||
|
|
3dc24d56e1
|
||
|
|
6774c2070b
|
||
|
|
0942a9bba7
|
||
|
|
3d32cccc7e
|
||
|
|
30b77d2dbe
|
||
|
|
9922e25d38
|
||
|
|
5fbd775b9a
|
||
|
|
fe2998aea6
|
||
|
|
a2ab36408d
|
||
|
|
88cc9fbd8f
|
||
|
|
6e75e3d19c
|
||
|
|
2f4afcd5e8
|
||
|
|
dcd4b8f0f4
|
||
|
|
b854f1f858
|
||
|
|
576e73b5dc
|
||
|
|
ba4e63c401
|
||
|
|
748fb94562
|
||
|
|
86e0f30e58
|
||
|
|
c275f8cb7e
|
||
|
|
2c4f0bf848
|
||
|
|
744680a93a
|
||
|
|
e560f9fc58
|
||
|
|
237f42ef72
|
||
|
|
f9ac1f79f5
|
||
|
|
266ceac297
|
||
|
|
003ec83a87
|
||
|
|
9b388c554d
|
||
|
|
f420d72906
|
||
|
|
f1ea051b85
|
||
|
|
6d85dfc7ce
|
||
|
|
251b2eada4
|
||
|
|
e513fdde51
|
||
|
|
b95a45496b
|
||
|
|
844d390017
|
||
|
|
bbc0d1352d
|
||
|
|
4a59ec58bd
|
||
|
|
9c4e25fd36
|
||
|
|
2e610bc8ef
|
||
|
|
2789be852e
|
||
|
|
32da27567c
|
||
|
|
9b078b02d7
|
||
|
|
df34d2c1fe
|
||
|
|
5226a9542c
|
||
|
|
091a35009b
|
||
|
|
0f8df69f6b
|
||
|
|
3193c7c327
|
||
|
|
dcd4619d63
|
||
|
|
b9e0f0fc19
|
||
|
|
5a7428ac9b
|
||
|
|
6582754025
|
||
|
|
1e152c4641
|
||
|
|
2d745d41b6
|
||
|
|
bebb68d3b2
|
||
|
|
21a1088ea4
|
||
|
|
1799547606
|
||
|
|
d83ce3bdc3
|
||
|
|
892fabe501
|
||
|
|
fbf2970f88
|
||
|
|
cf52a2fc2d
|
||
|
|
4112509fe3
|
||
|
|
7c3ef72447
|
||
|
|
d817bb15e8
|
||
|
|
e69983ee85
|
||
|
|
dfd0a4086b
|
||
|
|
dda8e6b992
|
||
|
|
dbf39d025a
|
||
|
|
04ce2efbeb
|
||
|
|
1e8686c2a4
|
||
|
|
8c8dd37ce0
|
||
|
|
e45eb6082b
|
||
|
|
cc7905e6ee
|
||
|
|
643a66a0b9
|
||
|
|
dcb15deb4b
|
||
|
|
b535ee5f7e
|
||
|
|
0ad395b949
|
||
|
|
ae19dbeb0d
|
||
|
|
abdaa277f8
|
||
|
|
194043a892
|
||
|
|
9331d9fdf0
|
||
|
|
f4bbc2f2c3
|
||
|
|
47f75460f7
|
||
|
|
10ea7b1034
|
||
|
|
36d6831ce8
|
||
|
|
430d69695c
|
||
|
|
63d238171f
|
||
|
|
94ca975d56
|
||
|
|
c9edcd0a40
|
||
|
|
67bd1d9a1c
|
||
|
|
3d5c2ee537
|
||
|
|
dd8f28621f
|
||
|
|
4c0dd1b7d9
|
||
|
|
99f86e359f
|
||
|
|
07df89653c
|
||
|
|
8af0639490
|
||
|
|
b89637b195
|
||
|
|
b3f4f48251
|
||
|
|
a59cd12c01
|
||
|
|
e9f12b556c
|
||
|
|
07cb9480c2
|
||
|
|
c57d64040a
|
||
|
|
013ba8b749
|
||
|
|
3d8f06db85
|
||
|
|
a640960383
|
||
|
|
c45aad9f67
|
||
|
|
c793672933
|
||
|
|
2504a03680
|
||
|
|
4f56aece00
|
||
|
|
be70c88036
|
||
|
|
b029d9f732
|
||
|
|
feb1e1ae5c
|
||
|
|
415304b7a4
|
||
|
|
2bb2c2f4a4
|
||
|
|
e15eb7a9a5
|
||
|
|
f30deb0614
|
||
|
|
caf74d924d
|
||
|
|
05c30a2e75
|
||
|
|
3f38ac3827
|
||
|
|
4dfdcf42f5
|
||
|
|
5168e4edf0
|
||
|
|
c878ece7c8
|
||
|
|
1831356cee
|
||
|
|
7afc408587
|
||
|
|
77d4b19f9c
|
||
|
|
511a5b40ca
|
||
|
|
9637d6f9ee
|
||
|
|
5b6e25d73b
|
||
|
|
41d9c02331
|
||
|
|
b00676261d
|
||
|
|
db1a470d94
|
||
|
|
622f2a467d
|
||
|
|
090a141307
|
||
|
|
41d29438c1
|
||
|
|
eb59fdb477
|
||
|
|
de3d7b60d7
|
||
|
|
2616a0ebe6
|
||
|
|
3ae8ac3c7a
|
||
|
|
d674c7fd4d
|
||
|
|
b601c324b6
|
||
|
|
22e132db0a
|
||
|
|
4e01310b5f
|
||
|
|
8db00715b1
|
||
|
|
42459df920
|
||
|
|
8eed513497
|
||
|
|
f280648153
|
||
|
|
0b9da16ff3
|
||
|
|
70b784c2f4
|
||
|
|
38b6ce50c6
|
||
|
|
d6e52d7720
|
||
|
|
99862137f1
|
||
|
|
7415c7e217
|
||
|
|
6d2b536050
|
||
|
|
0481463c07
|
||
|
|
810e669118
|
||
|
|
63c464b51a
|
||
|
|
433931477e
|
||
|
|
dbc1789ae1
|
||
|
|
2f4eb5d047
|
||
|
|
ef9dde3da5 | ||
|
|
723aa66e69 | ||
|
|
0b28ed6f89
|
||
|
|
d54ff72565
|
||
|
|
a2bd36f570
|
||
|
|
50c4bc5bba
|
||
|
|
3cef2ee01b
|
||
|
|
0bd294582b
|
||
|
|
49149413d5
|
||
|
|
108f648517
|
||
|
|
aa4afbf6a2
|
||
|
|
d34c082ef3
|
||
|
|
d48427abb2
|
||
|
|
ce8eb1c452
|
||
|
|
d0e6c78cc1
|
||
|
|
75b8b78b24
|
||
|
|
cac19c10c3
|
||
|
|
d806c5e9e7
|
||
|
|
87975760ae
|
||
|
|
6a4b1998fc
|
||
|
|
d7db56eb5a
|
||
|
|
d017d9eefd
|
||
|
|
97c3ed30e9
|
||
|
|
52964c2ff0
|
||
|
|
be44b23d70
|
||
|
|
0590eebd44
|
||
|
|
11f5f1dd64
|
||
|
|
5ffaec0fb1
|
||
|
|
24e9bde5c7
|
||
|
|
64bb8e3a12
|
||
|
|
baddf23df5
|
||
|
|
fc5847d6d8
|
||
|
|
84ac3dc9b8
|
||
|
|
af3e178c88
|
||
|
|
7d084db107
|
||
|
|
fcab832be1
|
||
|
|
e8cf0a3517
|
||
|
|
808a02ddf5
|
||
|
|
5df288b577
|
||
|
|
0a10c61c59
|
||
|
|
247a9a8770
|
||
|
|
63d4b50461
|
||
|
|
8741cb6142
|
||
|
|
091b5c011a
|
||
|
|
c43f8e5458
|
||
|
|
6f6dab1c04
|
||
|
|
f6f2470e5b
|
||
|
|
2275133638
|
||
|
|
da34dc4b7d
|
||
|
|
07a7a19929
|
||
|
|
a7290f253e
|
||
|
|
0b1a4efd09
|
||
|
|
aaa56b928f
|
||
|
|
8dc527b06b
|
||
|
|
004af69d8f
|
||
|
|
a397539c69
|
||
|
|
864ac15282
|
||
|
|
ff2f226713
|
||
|
|
8177d0f56d
|
||
|
|
b606108871
|
||
|
|
be09b39bbe
|
||
|
|
fafb9d5a5c
|
||
|
|
998d52b56c
|
||
|
|
3e6a82764a
|
||
|
|
3cd8161afb
|
@@ -21,6 +21,14 @@ test.py
|
||||
test/
|
||||
note.md
|
||||
MCE/config.json
|
||||
resources/
|
||||
locales/
|
||||
*.pak
|
||||
*.dll
|
||||
*.dat
|
||||
v8_context_snapshot.bin
|
||||
snapshot_blob.bin
|
||||
vk_swiftshader_icd.json
|
||||
|
||||
# Created by .ignore support plugin (hsz.mobi)
|
||||
|
||||
|
||||
@@ -11,6 +11,10 @@ class ArisuAutoSweeper(AzurLaneAutoScript):
|
||||
from tasks.login.login import Login
|
||||
Login(self.config, device=self.device).app_start()
|
||||
|
||||
def stop(self):
|
||||
from tasks.login.login import Login
|
||||
Login(self.config, device=self.device).app_stop()
|
||||
|
||||
def goto_main(self):
|
||||
from tasks.login.login import Login
|
||||
from tasks.base.ui import UI
|
||||
@@ -56,7 +60,7 @@ class ArisuAutoSweeper(AzurLaneAutoScript):
|
||||
|
||||
def momotalk(self):
|
||||
from tasks.momotalk.momotalk import MomoTalk
|
||||
MomoTalk(config=self.config, device=self.device).run()
|
||||
MomoTalk(config=self.config, device=self.device, task="Momotalk").run()
|
||||
|
||||
def mission(self):
|
||||
from tasks.mission.mission import Mission
|
||||
@@ -70,6 +74,10 @@ class ArisuAutoSweeper(AzurLaneAutoScript):
|
||||
from tasks.item.data_update import DataUpdate
|
||||
DataUpdate(config=self.config, device=self.device).run()
|
||||
|
||||
def benchmark(self):
|
||||
from module.daemon.benchmark import run_benchmark
|
||||
run_benchmark(config=self.config)
|
||||
|
||||
if __name__ == '__main__':
|
||||
aas = ArisuAutoSweeper('aas')
|
||||
aas.loop()
|
||||
|
||||
@@ -315,6 +315,16 @@ pre.rich-traceback-code {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.anim-rotate {
|
||||
animation: rotate-keyframes 0.4s linear infinite
|
||||
}
|
||||
|
||||
@keyframes rotate-keyframes {
|
||||
100% {
|
||||
transform: rotate(360deg)
|
||||
}
|
||||
}
|
||||
|
||||
#pywebio-scope-contents {
|
||||
margin-top: 0;
|
||||
overflow-y: auto;
|
||||
@@ -377,6 +387,7 @@ pre.rich-traceback-code {
|
||||
#pywebio-scope-scheduler-bar,
|
||||
#pywebio-scope-log-bar,
|
||||
#pywebio-scope-log,
|
||||
#pywebio-scope-daemon-log-bar,
|
||||
#pywebio-scope-daemon-overview #pywebio-scope-groups {
|
||||
font-weight: 500;
|
||||
margin: 0.3125rem;
|
||||
@@ -407,13 +418,13 @@ pre.rich-traceback-code {
|
||||
#pywebio-scope-dashboard > i {
|
||||
flex-grow: 1;
|
||||
align-self: flex-end;
|
||||
width: 10rem;
|
||||
width: 9.5rem;
|
||||
}
|
||||
|
||||
[id^="pywebio-scope-dashboard-row-"] {
|
||||
display: flex;
|
||||
flex-grow: 1;
|
||||
width: 10rem;
|
||||
width: 9.5rem;
|
||||
}
|
||||
|
||||
.dashboard-icon {
|
||||
@@ -435,16 +446,26 @@ pre.rich-traceback-code {
|
||||
overflow-wrap: break-word;
|
||||
}
|
||||
|
||||
*[style*="--dashboard-bold--"] {
|
||||
font-size: 1rem;
|
||||
font-weight: bold;
|
||||
color: #7a77bb;
|
||||
overflow-wrap: break-word;
|
||||
}
|
||||
|
||||
[id^="pywebio-scope-dashboard-row-"] p {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
[id^="pywebio-scope-dashboard-value-"] {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
align-items: baseline;
|
||||
height: 1.5rem;
|
||||
}
|
||||
|
||||
[id^="pywebio-scope-arg_stored-stored-value-"] p {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
#pywebio-scope-log {
|
||||
line-height: 1.2;
|
||||
@@ -549,19 +570,19 @@ pre.rich-traceback-code {
|
||||
padding-right: .25rem;
|
||||
}
|
||||
|
||||
*[style*="--loading-grow--"] {
|
||||
*[style*="--loading-grow--"] > div {
|
||||
width: 1.5rem;
|
||||
height: 1.5rem;
|
||||
}
|
||||
|
||||
*[style*="--loading-border--"] {
|
||||
*[style*="--loading-border--"] > div {
|
||||
width: 1.5rem;
|
||||
height: 1.5rem;
|
||||
border: .2em solid currentColor;
|
||||
border-right-color: transparent;
|
||||
}
|
||||
|
||||
*[style*="--loading-border-fill--"] {
|
||||
*[style*="--loading-border-fill--"] > div {
|
||||
width: 1.5rem;
|
||||
height: 1.5rem;
|
||||
border: .2em solid currentColor;
|
||||
|
||||
@@ -63,18 +63,18 @@ select {
|
||||
background-image: url("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAMgAAADICAYAAACtWK6eAAAAAXNSR0IArs4c6QAADI1JREFUeF7tnVeTZlUVht9V5Y03lvdapVXeWMUfQL3BC2+8Qa0SkOyQMzPEGUKVoog5Y0TFnANmEQUVVBQjZhBMmAOCAdNrLd1Y37TTPeusc87X55z17tvZ65y9nrWfb+9ePd1t0BABEdiUgImNCIjA5gQkiHaHCGxBQIJoe4iABNEeEIEcAZ0gOW6KKkJAghQptNLMEZAgOW6KKkJAghQptNLMEZAgOW6KKkJAghQptNLMEZAgOW6KKkJAghQptNLMEZAgOW6KKkJAghQptNLMEZAgOW6KKkJAghQptNLMEZAgOW6KKkJAghQptNLMEZAgOW6KKkJAghQptNLMEZAgOW6KKkJAghQptNLMEZAgOW6KKkJAghQptNLMEZAgOW6KKkJAghQptNLMEZAgOW6KKkJAghQptNLMEZAgOW6KKkJAghQptNLMEZAgOW6KKkJAghQptNLMEZAgOW6KKkJAghQptNLMEZAgOW6KKkJAghQptNLMEZAgOW6KKkJAghQptNLMEZAgOW6KKkJAghQptNLMEZAgOW6KKkJAghQptNLMEZAgOW6KKkJAghQptNLMEZAgOW6KKkJAghQptNLMEZAgOW6KKkJAghQptNLMEZAgOW6KKkJAghQptNLMEZAgOW6KKkJAghQptNLMEZAgOW6KKkJAghQptNLMEZAgOW6KKkJAghQptNLMEZAgOW6KKkJAghQptNLMEZAgOW6KKkJAghQptNLMEZisICQfD+AhAB4M4HYA15rZPbk0FTV3AiQfBOBRAA4EcAOAW9axHyYpCMlPAzhoQ1FvBrDHzK6be7G1/m4ESO4EsAvAQ1ci7wTwVDO7vtvTus2enCAkbwPwiC3SOMTM3tUtTc2eKwGS7wTw5C3Wf4CZfXus/CYlCMnLAewOJHuomTk4jQUTIPkOAIfsJ8W7ADxuLEmmJsitAA4I1vwwM3OAGgskQPLtAA4NprbbzK4Izu00bWqC/BnAAztkIEk6wJrL1I5yeFrXmNnBY+Q3NUHuAPDwjok+xcz800ZjAQRIvg3AYR1TudLMTusYE5o+NUHeC+CJoZXvPelwM3OwGjMmkJTDMx7tQ3JqgjwSwCfb9z+6llqSdCU2ofkk3+obPbGk683ssYm4UMikBPEVk/SWXrZDdYSZOWiNGREg+RYAhyeXfLCZXZOM3W/Y5ARpknhrL9uhOtLMHLjGDAj0kIPeAjazd4+Z5iQFkSRjlnw6zyb5ZgBHJFa0Fjl8XZMVpEniffBsh+ooM/MCaEyQAMk3ATgysbR/tZPjPYnYziGTFqRJ4i2/bIfqaDPzQmhMiEAPOf7Z5PBu51rG5AWRJGvZB2t7Cck3Ajgq8cK1yzH5K9YqRJLeAsx2qI4xMy+MxjYSIHk1gKMTS/hHOznel4jtFTKLE+T+DEl6KzDboZIkvbZKv+Aecvzd/0+Wma1djlmdIANJcqyZ+aeYxhoJkHwDgGMSr3Q5vJX7/kTsICGzOkFWJPHWYLZD5T9k4wXTWAMBkq8HcGziVX9rcnwgETtYyCwF8exJeosw26GSJINtoc0f1EOO+9q1alvlmOUVa7UcPSXZYWb+6aYxAgGSr/MfiU082uXwa9Vo/32ky5pme4KsXLe8ZZjtUB1nZl5IjQEJ9JDjr02ODw64nF6Pmr0g7bolSXptg+GCSV4FYEfiiX9p16rJyDH7K9aG65b317MdquPNzAur0YMAydcCOC7xCJfDr1UfSsSOGrKIE2TluuWtxGyH6gQz8wJrJAj0kMN/zNrl+HDitaOHLEqQdt2SJKNvm71fQPI1AI5PvPZP7Vo1STkWdcXacN3yvnu2Q3WimXnBNQIESL4awAmBqRunuBx+cnwkEbu2kMWdICvXLW8xZjtUJ5mZF15jCwI95Li3yfHRqQNerCDtuiVJRtqBJF8F4MTE42cjx2KvWBuuW95yzHaoTjYz3wgaKwRIvhLASQko/svH/Vr1sUTstoQs+gRZuW556zHboZIkw8jxx/YF+WzkKHGCDCTJKWbmn5qlB8lXADg5AcHl8JPj44nYbQ0pcYKsSOKtyGyH6lQz8w1ScvSQ4+4mxyfmCK6UIO0Ld29JZjtUJSUheSWAUxIb/A/tWjVLOUpdsTZ84d5HktPMzDdMiUHy5QBOTSTrcvi16tpE7GRCyp0gK9ctb1FmO1Snm5lvnEWPHnL8vsnhv0Z21qOsIO26JUk22b4kXwYg8xvTf9euVbOXo+wVa8N1y/v52Q7VGWbmG2lRg+RLAZyeSMrl8GvVYv6OZOkTZOW65a3LbIfqTDPzDbWI0UOO3zY5PrUIEC0JCdJAkCwvCcmXADgjscF/065Vi5JDV6wNO4GktzKzHaqzzMw32CwHyRcDODOxeJfDr1X+p7sXN3SC/L8k3tLMdqjONjPfaLMaPeT4dZNj1L9Vvp0wJcg+6JMsIwnJFwE4K7EJFy+Hrlhb7AqS3uLMdqh2mplvvEkPki8EcHZikb9qJ8cNidhZhegE2VoSb3VmO1STlqSHHL9sX5AvXg6dIIHPMpJ9JNllZv4pPalB8gUAdiYW5XL4F+SfScTOMkQnSKBsJL31me1QnWNmviEnMXrI8Ysmx2cnkciaFiFBgqBJegs026GahCQknw9gVzDl1Wk/b9eqUnLoitVxp/SU5Fwz8w26LYPk8wCck3i5y+HXqs8lYmcfohOkYwlJeks026E6z8x8o6519JDjribHjWtd8IReJkESxZiTJCSfC+DcRJo/a9eqsnLoipXYNfeHkPTvH2Q7VOebmW/cUQfJ5wA4L/ESl8OvVTclYhcVohOkRzlJeqs026G6wMx8A48yesjx0ybH50dZ2MweKkF6FmyKkpB8NoDzE6n9pF2rJEeDJ0ESu2hjCElvnWY7VBeamW/oQQbJKwBckHiYy+HXqi8kYhcbIkEGKi1Jb6FmO1SDSNJDjh+3k0NybNgPEmQgQfwxPSXZbWb+6Z8aJJ8F4MJE8I+aHF9MxC4+RIIMXGKS3lLNdqj2mJlv9E6D5OUAdncK+u9kl8OvVTcnYkuESJARykzSW6vZDlUnSXrIcWc7OSTHFntAgowgSLtu9ZHkIjPzU2HLQfKZAPbsb94+/t3l8JPjS4nYUiESZMRyk/RWa7ZDdbGZuQD7HCSfAeCixPLvaHJ8ORFbLkSCjFzyMSTpIccP27VKcgTrLkGCoPpMI+nfl8h2qC4xMz8t/jNIXgbg4sR6XA6/Vt2SiC0bIkHWVHqS3oLt3KFqy7vUzC7rIcftTY6vrCndxbxGgqyxlD0l8V+tc1Biube1a5XkSMCTIAlofUJI+vcr9tuh6vOOlViXw69VXx3oeeUeI0G2oeQkvTW7aYdqoCX9oMnxtYGeV/IxEmSbyj6yJN9v1yrJ0bO+EqQnwD7hJP37GP/rUPV51kqsy+HXqq8P9LzSj5Eg21x+kt6y9dbtEON77eSQHEPQBCBBBgLZ5zEDSfLdJsc3+qxFsXsTkCAT2REkLwHw9ORyXA6/Vn0zGa+wTQhIkAltDZKXAnhaxyV9p50ckqMjuMh0CRKhtMY5HSXxVu6TzOzWNS6x1KskyATLHfylC3cDeIyZfWuCKSxmSRJkoqUkeRWAHZss714AB0qO8YsnQcZnnH4DyUcDuBrAwwA8AMA9APyXuT3BzO5LP1iBYQISJIxKEysSkCAVq66cwwQkSBiVJlYkIEEqVl05hwlIkDAqTaxIQIJUrLpyDhOQIGFUmliRgASpWHXlHCYgQcKoNLEiAQlSserKOUxAgoRRaWJFAhKkYtWVc5iABAmj0sSKBCRIxaor5zABCRJGpYkVCUiQilVXzmECEiSMShMrEpAgFauunMMEJEgYlSZWJCBBKlZdOYcJSJAwKk2sSECCVKy6cg4TkCBhVJpYkYAEqVh15RwmIEHCqDSxIgEJUrHqyjlMQIKEUWliRQISpGLVlXOYgAQJo9LEigQkSMWqK+cwAQkSRqWJFQlIkIpVV85hAhIkjEoTKxKQIBWrrpzDBCRIGJUmViQgQSpWXTmHCUiQMCpNrEhAglSsunIOE5AgYVSaWJGABKlYdeUcJiBBwqg0sSIBCVKx6so5TECChFFpYkUCEqRi1ZVzmIAECaPSxIoEJEjFqivnMAEJEkaliRUJSJCKVVfOYQISJIxKEysSkCAVq66cwwQkSBiVJlYkIEEqVl05hwlIkDAqTaxIQIJUrLpyDhOQIGFUmliRgASpWHXlHCYgQcKoNLEiAQlSserKOUxAgoRRaWJFAhKkYtWVc5iABAmj0sSKBCRIxaor5zCBfwOoUQv2LE3rzAAAAABJRU5ErkJggg==");
|
||||
}
|
||||
|
||||
.state > select {
|
||||
select.state {
|
||||
border-bottom: 0;
|
||||
background-image: none;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.state-bold > select {
|
||||
select.state-bold {
|
||||
font-weight: bold;
|
||||
color: #7a77bb;
|
||||
}
|
||||
|
||||
.state-light > select {
|
||||
select.state-light {
|
||||
color: #777777;
|
||||
}
|
||||
|
||||
@@ -133,12 +133,13 @@ pre.rich-traceback-code {
|
||||
color: #c9d1d9;
|
||||
}
|
||||
|
||||
#pywebio-scope-scheduler-bar,
|
||||
#pywebio-scope-log-bar,
|
||||
#pywebio-scope-log,
|
||||
#pywebio-scope-running,
|
||||
#pywebio-scope-pending,
|
||||
#pywebio-scope-waiting,
|
||||
#pywebio-scope-scheduler-bar,
|
||||
#pywebio-scope-log-bar,
|
||||
#pywebio-scope-log,
|
||||
#pywebio-scope-daemon-log-bar,
|
||||
#pywebio-scope-daemon-overview #pywebio-scope-groups {
|
||||
background-color: #2f3136;
|
||||
border: 1px solid #21262d;
|
||||
|
||||
@@ -64,18 +64,18 @@ select {
|
||||
background-image: url("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAMgAAADICAYAAACtWK6eAAAAAXNSR0IArs4c6QAAC85JREFUeF7tnUezdVURhh+qmDixnGOVVjmxyj+AOsGBEyeGKrMYMGfBCIYqE2LOWVTMOeeMGRQjZhBMmAOCAXO9sE7V+eC711699z5n791vj3vtu/vp9Zy1bh+43zE4TMAEDiRwjNmYgAkcTMCCeHeYwCEELIi3hwlYEO8BE8gR8AmS4+ZVRQhYkCKNdpk5AhYkx82rihCwIEUa7TJzBCxIjptXFSFgQYo02mXmCFiQHDevKkLAghRptMvMEbAgOW5eVYSABSnSaJeZI2BBcty8qggBC1Kk0S4zR8CC5Lh5VRECFqRIo11mjoAFyXHzqiIELEiRRrvMHAELkuPmVUUIWJAijXaZOQIWJMfNq4oQsCBFGu0ycwQsSI6bVxUhYEGKNNpl5ghYkBw3rypCwIIUabTLzBGwIDluXlWEgAUp0miXmSNgQXLcvKoIAQtSpNEuM0fAguS4eVURAhakSKNdZo6ABclx86oiBCxIkUa7zBwBC5Lj5lVFCFiQIo12mTkCFiTHzauKELAgRRrtMnMELEiOm1cVIWBBijTaZeYIWJAcN68qQsCCFGm0y8wRsCA5bl5VhIAFKdJol5kjYEFy3LyqCAELUqTRLjNHwILkuHlVEQIWpEijXWaOgAXJcfOqIgQsSJFGu8wcAQuS4+ZVRQhYkCKNdpk5AhYkx82rihCwIEUa7TJzBCxIjptXFSFgQYo02mXmCFiQHDevKkLAghRptMvMEZizILcAjgOuA1wIfBy4LFemV62AwLWBGwPHA2cD5+1iP8xVkE8DJ1ytqecCpwGfXEGzXUIfgZOBU4Drbi27GLgH8Jm+R/Vlz1GQC4AbHFLG7YC395Xp7AUTeBtw20Pe/0bAd6eqb26CnA6cGij29oDAOdZN4K2APhAPi0uAm08lydwEOR/QJ0Ik7gAIoGOdBN4C6IMwEvpQPSOS2JszN0H+ClyrowhL0gFrQak9cqis9wG3nKK+uQlyEXD9zkLvCAioYx0E3gzog68nXgI8sGdBNHdugrwLuHX05bfy7gQIrGPZBDJyqOLJPiTnJsgNgU+07z96W21JeonNK/9NbaP3vpXGvDfrXRTNn5sgem+N9LITqjsDAu1YFoE3AvqAy4R+99DvIJPEHAVRoRrtZSdUdwEE3LEMAlk5/tv2yTumLHOugliSKbs+n2e/AdCp3xs7kUMvNWdB9H6ag2cnVCcCaoBjngReD+i0743/tJPjnb0LM/lzF0Q1aeSXnVDdFVAjHPMikJXj300OTTt3EksQxJLsZCvs7Ie8DtDp3hs7l2MJV6xtiJp1ZydUdwPUGMd+CZwF6FTvjX+1k+PdvQuH5i/lBNnUqVFgdkJlSYbulmHrs3L8s/0uunM5lnaCjCHJ3QE1yrFbAq8F9AHVG5JDI//39C4cK39pJ8imbo0GsxMq/U82aphjNwReA+iDqTf+0eR4b+/CMfOXKogYaESYnVBZkjF30cHPyspxRbtW7VWOpV6xttsxRJKTADXQMQ2BV7f/Jbb36ZJD16rJ/vORnhda8gmyqVMjw+yE6p6AGukYl0BWjr83Od4/7uvkn7YGQVS9JcnvgbFXngnodO6Nv7Vr1WzkWMMVa7sJmq9nJ1T3AtRYxzACrwJ0KveG5NC16gO9C6fOX8sJsuGkUWJ2QnVvQA125Ahk5dD/Zi05Ppj7sdOuWpsgomVJpt0zR3v6KwGdwr3xl3atmqUca7tibTdHc/fshOo+gBruiBF4BaDTtzckh06OD/Uu3GX+Gk+QDT9915GdUN0XUOMdhxPIynF5k+PDcwe8ZkHE3pJMtwNfDui07Y3FyLHmK9Z20zRyzE6o7gdoIziOJPAyQKdsb+iPj+ta9ZHehfvKX/sJsuGq0WN2QmVJxpHjz+0X8sXIUeUEGUOS+wP61KweLwX0gdEbkkMnx0d7F+47v8oJsuGsUWR2QvUAQBukamTluLTJ8bElgqsmiHqkkWR2QlVVEv1pT52ivfGndq1apBzVrljbzR0iif4GrDZMlXgxoA+G3pAculbpXwZbbFQ8QTbN0ogyO6F6EKCNs/bIyvHHJof+jOyio7IgapwlOXj7vij5F9P/0K5Vi5ej8hVre1tonp+dUD0Y0EZaW7wQ0CnZG5JD16rV/DuS1U+QzQbQ6DI7oXoIoA21lsjK8fsmx6fWAsInyJGdtCTwAkCnYm/8rl2rViWHBbnmNtAoMzuheihXbbClxvMBnYa9ITl0rdI/3b268BXrmi3VSDM7oXoYoI22tMjK8dsmx6T/Vvk+YVqQo9OvJMnzAJ1+vbF6OXzFOnxL6AvB7ITqZEAbb+7xXECnXm/8pp0cZ/cuXFq+T5DDO6ZRZ3ZCNXdJsnL8uv1Cvno5fILEPs6GSHIKoI04t3gOIIF7Q3LoF/LP9i5car5PkFjnNPrMTqgeDmhDziWycvyqyfG5uRSyi/ewIHHKGoFmJ1RzkeTZgE613vhlu1aVksNXrN5tctX3BFlJHgFog+4rngVI1N6QHLpWfb534RryfYL0d1Ej0eyE6pGANuquIyvHJU2OL+z6hefy8yxIrhNLkuSZgE6v3vhFu1aVlcNXrN4tc2S+vj/ITqgeBWjjTh3PAHRq9Ybk0LXqi70L15bvE2RYRzUqzU6oHg1oA08VWTl+3uT40lQvtqTnWpDh3ZqjJE8HdEr1xs/atcpyNHIWpHcLHT1fo9PshOoxgDb0WHEGoNOpNySHrlVf7l245nwLMl53NULNTqjGkiQrx0/byWE5rrYfLMh4guhJQyQ5FdAGz8bTAInWGz9pcpzTu7BCvgUZv8saqWYnVKcB2ui9cTogwXpDcuhadW7vwir5FmSaTmu0mp1Q9UqSlePidnJYjkP2gAWZRhA9dYgkjwW08f9fPBWQUL0hOXRyfKV3YbV8CzJtxzVqzU6oHgdIgIPiKYBE6o2Lmhxf7V1YMd+CTN/1KSTJyvHjdq2yHMG+W5AgqIFp+l4iO6F6PCAhNvFkQKdLb0gOXavO611YOd+C7K77GsFmJlR6wycAEiMrx4VNjq/trtx1/CQLsts+DpFEf1rnhMTrXtCuVZYjAc+CJKANXKLvKyITqoE/5srlkkPXqq+P8bCKz7Ag++m6RrOHTajGeKsfNTm+McbDqj7Dguyv81NK8sN2rbIcA/trQQYCHLhc32NsT6gGPu7K5ZJD16pvjvGw6s+wIPvfARrZajo1RvygnRyWYwyagAUZCeTAx4whyfebHN8a+C5evkXAgsxnO+gLwSclX0dy6Fr17eR6LzuAgAWZ19bQF4JP7Hyl77WTw3J0goukW5AIpd3m9EiiUe5tgPN3+4p1fpoFmWevI3904VLgpsB35lnCOt7Kgsy3j2cCJx3wepcDx1uO6ZtnQaZnPOQn3AQ4C7gecCxwWftjbrcCrhjyYK+NEbAgMU7OKkrAghRtvMuOEbAgMU7OKkrAghRtvMuOEbAgMU7OKkrAghRtvMuOEbAgMU7OKkrAghRtvMuOEbAgMU7OKkrAghRtvMuOEbAgMU7OKkrAghRtvMuOEbAgMU7OKkrAghRtvMuOEbAgMU7OKkrAghRtvMuOEbAgMU7OKkrAghRtvMuOEbAgMU7OKkrAghRtvMuOEbAgMU7OKkrAghRtvMuOEbAgMU7OKkrAghRtvMuOEbAgMU7OKkrAghRtvMuOEbAgMU7OKkrAghRtvMuOEbAgMU7OKkrAghRtvMuOEbAgMU7OKkrAghRtvMuOEbAgMU7OKkrAghRtvMuOEbAgMU7OKkrAghRtvMuOEbAgMU7OKkrAghRtvMuOEbAgMU7OKkrAghRtvMuOEbAgMU7OKkrAghRtvMuOEbAgMU7OKkrAghRtvMuOEbAgMU7OKkrAghRtvMuOEbAgMU7OKkrAghRtvMuOEbAgMU7OKkrAghRtvMuOEbAgMU7OKkrAghRtvMuOEbAgMU7OKkrAghRtvMuOEbAgMU7OKkrAghRtvMuOEbAgMU7OKkrAghRtvMuOEbAgMU7OKkrAghRtvMuOEbAgMU7OKkrAghRtvMuOEfgf9hZk2PNubUAAAAAASUVORK5CYII=");
|
||||
}
|
||||
|
||||
.state > select {
|
||||
select.state {
|
||||
border-bottom: 0;
|
||||
background-image: none;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.state-bold > select {
|
||||
select.state-bold {
|
||||
font-weight: bold;
|
||||
color: #7a77bb;
|
||||
}
|
||||
|
||||
.state-light > select {
|
||||
select.state-light {
|
||||
color: #777777;
|
||||
}
|
||||
|
||||
@@ -133,12 +133,13 @@ pre.rich-traceback-code {
|
||||
border: 1px solid lightgrey;
|
||||
}
|
||||
|
||||
#pywebio-scope-scheduler-bar,
|
||||
#pywebio-scope-log-bar,
|
||||
#pywebio-scope-log,
|
||||
#pywebio-scope-running,
|
||||
#pywebio-scope-pending,
|
||||
#pywebio-scope-waiting,
|
||||
#pywebio-scope-scheduler-bar,
|
||||
#pywebio-scope-log-bar,
|
||||
#pywebio-scope-log,
|
||||
#pywebio-scope-daemon-log-bar,
|
||||
#pywebio-scope-daemon-overview #pywebio-scope-groups {
|
||||
background-color: white;
|
||||
border: 1px solid lightgrey;
|
||||
|
||||
|
After Width: | Height: | Size: 5.0 KiB |
|
After Width: | Height: | Size: 6.1 KiB |
|
After Width: | Height: | Size: 5.3 KiB |
|
After Width: | Height: | Size: 5.7 KiB |
|
After Width: | Height: | Size: 6.6 KiB |
|
After Width: | Height: | Size: 4.6 KiB |
|
After Width: | Height: | Size: 5.5 KiB |
|
After Width: | Height: | Size: 6.2 KiB |
|
After Width: | Height: | Size: 5.5 KiB |
|
After Width: | Height: | Size: 6.5 KiB |
|
After Width: | Height: | Size: 6.0 KiB |
|
Before Width: | Height: | Size: 6.3 KiB After Width: | Height: | Size: 5.8 KiB |
|
After Width: | Height: | Size: 4.9 KiB |
|
Before Width: | Height: | Size: 9.1 KiB After Width: | Height: | Size: 6.2 KiB |
|
Before Width: | Height: | Size: 9.6 KiB After Width: | Height: | Size: 6.5 KiB |
|
Before Width: | Height: | Size: 9.1 KiB After Width: | Height: | Size: 7.1 KiB |
|
Before Width: | Height: | Size: 10 KiB After Width: | Height: | Size: 7.0 KiB |
|
Before Width: | Height: | Size: 7.2 KiB After Width: | Height: | Size: 8.0 KiB |
|
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 8.1 KiB |
|
After Width: | Height: | Size: 6.4 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 5.1 KiB After Width: | Height: | Size: 5.2 KiB |
|
Before Width: | Height: | Size: 7.2 KiB After Width: | Height: | Size: 7.1 KiB |
|
Before Width: | Height: | Size: 6.1 KiB After Width: | Height: | Size: 5.8 KiB |
@@ -157,6 +157,14 @@ Deploy:
|
||||
# '["alas"]' specified "alas" config
|
||||
# '["alas","alas2"]' specified "alas" "alas2" configs
|
||||
Run: null
|
||||
# SSL support
|
||||
# Only effective when both parameters below are set
|
||||
# --ssl-key. Path to SSL key file
|
||||
# [Default] null (no SSL)
|
||||
WebuiSSLKey: null
|
||||
# --ssl-cert. Path to SSL cert file
|
||||
# [Default] null (no SSL)
|
||||
WebuiSSLCert: null
|
||||
# To update app.asar
|
||||
# [In most cases] true
|
||||
AppAsarUpdate: true
|
||||
|
||||
@@ -157,6 +157,14 @@ Deploy:
|
||||
# '["alas"]' specified "alas" config
|
||||
# '["alas","alas2"]' specified "alas" "alas2" configs
|
||||
Run: null
|
||||
# SSL support
|
||||
# Only effective when both parameters below are set
|
||||
# --ssl-key. Path to SSL key file
|
||||
# [Default] null (no SSL)
|
||||
WebuiSSLKey: null
|
||||
# --ssl-cert. Path to SSL cert file
|
||||
# [Default] null (no SSL)
|
||||
WebuiSSLCert: null
|
||||
# To update app.asar
|
||||
# [In most cases] true
|
||||
AppAsarUpdate: true
|
||||
|
||||
@@ -250,11 +250,8 @@
|
||||
}
|
||||
},
|
||||
"Momotalk": {
|
||||
"Scheduler": {
|
||||
"Enable": false,
|
||||
"NextRun": "2020-01-01 00:00:00",
|
||||
"Command": "Momotalk",
|
||||
"ServerUpdate": "04:00"
|
||||
"Momotalk": {
|
||||
"Enable": true
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,10 @@
|
||||
import os
|
||||
import time
|
||||
import typing as t
|
||||
from typing import Iterable
|
||||
|
||||
from deploy.Windows.config import DeployConfig
|
||||
from deploy.Windows.logger import Progress, logger
|
||||
from deploy.Windows.utils import DataProcessInfo, cached_property, iter_process
|
||||
from deploy.Windows.utils import cached_property, iter_process
|
||||
|
||||
|
||||
class AlasManager(DeployConfig):
|
||||
@@ -19,43 +19,45 @@ class AlasManager(DeployConfig):
|
||||
def self_pid(self):
|
||||
return os.getpid()
|
||||
|
||||
def list_process(self) -> t.List[DataProcessInfo]:
|
||||
def list_process(self) -> "list[tuple[int, list[str]]]":
|
||||
logger.info('List process')
|
||||
process = list(iter_process())
|
||||
logger.info(f'Found {len(process)} processes')
|
||||
return process
|
||||
process_data = list(iter_process())
|
||||
logger.info(f'Found {len(process_data)} processes')
|
||||
return process_data
|
||||
|
||||
def iter_process_by_names(self, names, in_alas=False) -> t.Iterable[DataProcessInfo]:
|
||||
def iter_process_by_names(self, names, in_alas=False) -> "Iterable[int]":
|
||||
"""
|
||||
Args:
|
||||
names (str, list[str]): process name, such as 'alas.exe'
|
||||
in_alas (bool): If the output process must in Alas
|
||||
|
||||
Yields:
|
||||
DataProcessInfo:
|
||||
pid:
|
||||
"""
|
||||
if not isinstance(names, list):
|
||||
names = [names]
|
||||
try:
|
||||
for proc in self.list_process():
|
||||
for pid, cmdline in self.list_process():
|
||||
if pid == self.self_pid:
|
||||
continue
|
||||
exe = cmdline[0]
|
||||
name = os.path.basename(exe)
|
||||
if not (name and name in names):
|
||||
continue
|
||||
|
||||
if not (proc.name and proc.name in names):
|
||||
continue
|
||||
if proc.pid == self.self_pid:
|
||||
continue
|
||||
if in_alas:
|
||||
cmdline = proc.cmdline.replace(r"\\", "/").replace("\\", "/")
|
||||
exe = exe.replace(r"\\", "/").replace("\\", "/")
|
||||
for folder in self.alas_folder:
|
||||
if folder in cmdline:
|
||||
yield proc
|
||||
if folder in exe:
|
||||
yield pid
|
||||
else:
|
||||
yield proc
|
||||
yield pid
|
||||
except Exception as e:
|
||||
logger.info(str(e))
|
||||
return False
|
||||
|
||||
def kill_process(self, process: DataProcessInfo):
|
||||
self.execute(f'taskkill /f /t /pid {process.pid}', allow_failure=True, output=False)
|
||||
def kill_process(self, pid: int):
|
||||
self.execute(f'taskkill /f /t /pid {pid}', allow_failure=True, output=False)
|
||||
|
||||
def alas_kill(self):
|
||||
for _ in range(10):
|
||||
|
||||
@@ -0,0 +1,630 @@
|
||||
import os
|
||||
import random
|
||||
import string
|
||||
import time
|
||||
from typing import Iterable, Union
|
||||
|
||||
IS_WINDOWS = os.name == 'nt'
|
||||
# Max attempt if another process is reading/writing, effective only on Windows
|
||||
WINDOWS_MAX_ATTEMPT = 5
|
||||
# Base time to wait between retries (seconds)
|
||||
WINDOWS_RETRY_DELAY = 0.05
|
||||
|
||||
|
||||
def random_id():
|
||||
"""
|
||||
Returns:
|
||||
str: Random ID, like "sTD2kF"
|
||||
"""
|
||||
# 6 random letter (62^6 combinations) would be enough
|
||||
return ''.join(random.sample(string.ascii_letters + string.digits, 6))
|
||||
|
||||
|
||||
def is_tmp_file(file: str) -> bool:
|
||||
"""
|
||||
Check if a filename is tmp file
|
||||
"""
|
||||
# Check suffix first to reduce regex calls
|
||||
if not file.endswith('.tmp'):
|
||||
return False
|
||||
# Check temp file format
|
||||
dot = file[-11:-10]
|
||||
if not dot:
|
||||
return False
|
||||
rid = file[-10:-4]
|
||||
return rid.isalnum()
|
||||
|
||||
|
||||
def to_tmp_file(file: str) -> str:
|
||||
"""
|
||||
Convert a filename or directory name to tmp
|
||||
filename -> filename.sTD2kF.tmp
|
||||
"""
|
||||
suffix = random_id()
|
||||
return f'{file}.{suffix}.tmp'
|
||||
|
||||
|
||||
def to_nontmp_file(file: str) -> str:
|
||||
"""
|
||||
Convert a tmp filename or directory name to original file
|
||||
filename.sTD2kF.tmp -> filename
|
||||
"""
|
||||
if is_tmp_file(file):
|
||||
return file[:-11]
|
||||
else:
|
||||
return file
|
||||
|
||||
|
||||
def windows_attempt_delay(attempt: int) -> float:
|
||||
"""
|
||||
Exponential Backoff if file is in use on Windows
|
||||
|
||||
Args:
|
||||
attempt: Current attempt, starting from 0
|
||||
|
||||
Returns:
|
||||
float: Seconds to wait
|
||||
"""
|
||||
return 2 ** attempt * WINDOWS_RETRY_DELAY
|
||||
|
||||
|
||||
def replace_tmp(tmp: str, file: str):
|
||||
"""
|
||||
Replace temp file to file
|
||||
|
||||
Raises:
|
||||
PermissionError: (Windows only) If another process is still reading the file and all retries failed
|
||||
FileNotFoundError: If tmp file gets deleted unexpectedly
|
||||
"""
|
||||
if IS_WINDOWS:
|
||||
# PermissionError on Windows if another process is reading
|
||||
last_error = None
|
||||
for attempt in range(WINDOWS_MAX_ATTEMPT):
|
||||
try:
|
||||
# Atomic operation
|
||||
os.replace(tmp, file)
|
||||
# success
|
||||
return
|
||||
except PermissionError as e:
|
||||
last_error = e
|
||||
delay = windows_attempt_delay(attempt)
|
||||
time.sleep(delay)
|
||||
continue
|
||||
except FileNotFoundError:
|
||||
# tmp file gets deleted unexpectedly
|
||||
raise
|
||||
except Exception as e:
|
||||
last_error = e
|
||||
break
|
||||
else:
|
||||
# Linux and Mac allow existing reading
|
||||
try:
|
||||
# Atomic operation
|
||||
os.replace(tmp, file)
|
||||
# success
|
||||
return
|
||||
except FileNotFoundError:
|
||||
raise
|
||||
except Exception as e:
|
||||
last_error = e
|
||||
|
||||
# Clean up tmp file on failure
|
||||
try:
|
||||
os.unlink(tmp)
|
||||
except FileNotFoundError:
|
||||
# tmp file already get deleted
|
||||
pass
|
||||
except:
|
||||
pass
|
||||
if last_error is not None:
|
||||
raise last_error from None
|
||||
|
||||
|
||||
def atomic_replace(replace_from: str, replace_to: str):
|
||||
"""
|
||||
Replace file or directory
|
||||
|
||||
Raises:
|
||||
PermissionError: (Windows only) If another process is still reading the file and all retries failed
|
||||
FileNotFoundError:
|
||||
"""
|
||||
if IS_WINDOWS:
|
||||
# PermissionError on Windows if another process is reading
|
||||
last_error = None
|
||||
for attempt in range(WINDOWS_MAX_ATTEMPT):
|
||||
try:
|
||||
# Atomic operation
|
||||
os.replace(replace_from, replace_to)
|
||||
# success
|
||||
return
|
||||
except PermissionError as e:
|
||||
last_error = e
|
||||
delay = windows_attempt_delay(attempt)
|
||||
time.sleep(delay)
|
||||
continue
|
||||
except FileNotFoundError:
|
||||
raise
|
||||
except Exception as e:
|
||||
last_error = e
|
||||
break
|
||||
if last_error is not None:
|
||||
raise last_error from None
|
||||
else:
|
||||
# Linux and Mac
|
||||
os.replace(replace_from, replace_to)
|
||||
|
||||
|
||||
def file_write(file: str, data: Union[str, bytes]):
|
||||
"""
|
||||
Write data into file, auto create directory
|
||||
Auto determines write mode based on the type of data.
|
||||
"""
|
||||
if isinstance(data, str):
|
||||
mode = 'w'
|
||||
encoding = 'utf-8'
|
||||
newline = ''
|
||||
elif isinstance(data, bytes):
|
||||
mode = 'wb'
|
||||
encoding = None
|
||||
newline = None
|
||||
# Create memoryview as Pathlib do
|
||||
data = memoryview(data)
|
||||
else:
|
||||
typename = str(type(data))
|
||||
if typename == "<class 'numpy.ndarray'>":
|
||||
mode = 'wb'
|
||||
encoding = None
|
||||
newline = None
|
||||
else:
|
||||
mode = 'w'
|
||||
encoding = 'utf-8'
|
||||
newline = ''
|
||||
|
||||
try:
|
||||
# Write temp file
|
||||
with open(file, mode=mode, encoding=encoding, newline=newline) as f:
|
||||
f.write(data)
|
||||
# Ensure data flush to disk
|
||||
f.flush()
|
||||
os.fsync(f.fileno())
|
||||
except FileNotFoundError:
|
||||
# Create parent directory
|
||||
directory = os.path.dirname(file)
|
||||
if directory:
|
||||
os.makedirs(directory, exist_ok=True)
|
||||
# Write again
|
||||
with open(file, mode=mode, encoding=encoding, newline=newline) as f:
|
||||
f.write(data)
|
||||
# Ensure data flush to disk
|
||||
f.flush()
|
||||
os.fsync(f.fileno())
|
||||
|
||||
|
||||
def file_write_stream(file: str, data_generator):
|
||||
"""
|
||||
Only creates a file if the generator yields at least one data chunk.
|
||||
Auto determines write mode based on the type of first chunk.
|
||||
|
||||
Args:
|
||||
file: Target file path
|
||||
data_generator: An iterable that yields data chunks (str or bytes)
|
||||
"""
|
||||
# Convert generator to iterator to ensure we can peek at first chunk
|
||||
data_iter = iter(data_generator)
|
||||
|
||||
# Try to get the first chunk
|
||||
try:
|
||||
first_chunk = next(data_iter)
|
||||
except StopIteration:
|
||||
# Generator is empty, no file will be created
|
||||
return
|
||||
|
||||
# Determine mode, encoding and newline from first chunk
|
||||
if isinstance(first_chunk, str):
|
||||
mode = 'w'
|
||||
encoding = 'utf-8'
|
||||
newline = ''
|
||||
elif isinstance(first_chunk, bytes):
|
||||
mode = 'wb'
|
||||
encoding = None
|
||||
newline = None
|
||||
else:
|
||||
# Default to text mode for other types
|
||||
mode = 'w'
|
||||
encoding = 'utf-8'
|
||||
newline = ''
|
||||
|
||||
try:
|
||||
# Write temp file
|
||||
with open(file, mode=mode, encoding=encoding, newline=newline) as f:
|
||||
f.write(first_chunk)
|
||||
for chunk in data_iter:
|
||||
f.write(chunk)
|
||||
# Ensure data flush to disk
|
||||
f.flush()
|
||||
os.fsync(f.fileno())
|
||||
except FileNotFoundError:
|
||||
# Create parent directory
|
||||
directory = os.path.dirname(file)
|
||||
if directory:
|
||||
os.makedirs(directory, exist_ok=True)
|
||||
# Write again
|
||||
with open(file, mode=mode, encoding=encoding, newline=newline) as f:
|
||||
f.write(first_chunk)
|
||||
for chunk in data_iter:
|
||||
f.write(chunk)
|
||||
# Ensure data flush to disk
|
||||
f.flush()
|
||||
os.fsync(f.fileno())
|
||||
|
||||
|
||||
def atomic_write(
|
||||
file: str,
|
||||
data: Union[str, bytes],
|
||||
):
|
||||
"""
|
||||
Atomic file write with minimal IO operation
|
||||
and handles cases where file might be read by another process.
|
||||
|
||||
os.replace() is an atomic operation among all OS,
|
||||
we write to temp file then do os.replace()
|
||||
|
||||
Args:
|
||||
file:
|
||||
data:
|
||||
"""
|
||||
tmp = to_tmp_file(file)
|
||||
file_write(tmp, data)
|
||||
replace_tmp(tmp, file)
|
||||
|
||||
|
||||
def atomic_write_stream(
|
||||
file: str,
|
||||
data_generator,
|
||||
):
|
||||
"""
|
||||
Atomic file write with streaming data support.
|
||||
Handles cases where file might be read by another process.
|
||||
|
||||
os.replace() is an atomic operation among all OS,
|
||||
we write to temp file then do os.replace()
|
||||
|
||||
Args:
|
||||
file: Target file path
|
||||
data_generator: An iterable that yields data chunks (str or bytes)
|
||||
"""
|
||||
tmp = to_tmp_file(file)
|
||||
file_write_stream(tmp, data_generator)
|
||||
replace_tmp(tmp, file)
|
||||
|
||||
|
||||
def file_read_text(
|
||||
file: str,
|
||||
encoding: str = 'utf-8',
|
||||
errors: str = 'strict'
|
||||
) -> str:
|
||||
"""
|
||||
Args:
|
||||
file:
|
||||
encoding:
|
||||
errors: 'strict', 'ignore', 'replace' and any other errors mode in open()
|
||||
"""
|
||||
try:
|
||||
with open(file, mode='r', encoding=encoding, errors=errors) as f:
|
||||
return f.read()
|
||||
except FileNotFoundError:
|
||||
return ''
|
||||
|
||||
|
||||
def file_read_text_stream(
|
||||
file: str,
|
||||
encoding: str = 'utf-8',
|
||||
errors: str = 'strict',
|
||||
chunk_size: int = 8192
|
||||
) -> Iterable[str]:
|
||||
"""
|
||||
Args:
|
||||
file:
|
||||
encoding:
|
||||
errors: 'strict', 'ignore', 'replace' and any other errors mode in open()
|
||||
chunk_size:
|
||||
"""
|
||||
try:
|
||||
with open(file, mode='r', encoding=encoding, errors=errors) as f:
|
||||
while 1:
|
||||
chunk = f.read(chunk_size)
|
||||
if not chunk:
|
||||
return
|
||||
yield chunk
|
||||
except FileNotFoundError:
|
||||
return
|
||||
|
||||
|
||||
def file_read_bytes(file: str) -> bytes:
|
||||
"""
|
||||
Args:
|
||||
file:
|
||||
"""
|
||||
try:
|
||||
# No python-side buffering when reading the entire file to speedup reading
|
||||
# https://github.com/python/cpython/pull/122111
|
||||
with open(file, mode='rb', buffering=0) as f:
|
||||
return f.read()
|
||||
except FileNotFoundError:
|
||||
return b''
|
||||
|
||||
|
||||
def file_read_bytes_stream(file: str, chunk_size: int = 8192) -> Iterable[bytes]:
|
||||
"""
|
||||
Args:
|
||||
file:
|
||||
chunk_size:
|
||||
"""
|
||||
try:
|
||||
with open(file, mode='rb') as f:
|
||||
while 1:
|
||||
chunk = f.read(chunk_size)
|
||||
if not chunk:
|
||||
return
|
||||
yield chunk
|
||||
except FileNotFoundError:
|
||||
return
|
||||
|
||||
|
||||
def atomic_read_text(
|
||||
file: str,
|
||||
encoding: str = 'utf-8',
|
||||
errors: str = 'strict'
|
||||
) -> str:
|
||||
"""
|
||||
Atomic file read with minimal IO operation
|
||||
|
||||
Args:
|
||||
file:
|
||||
encoding:
|
||||
errors: 'strict', 'ignore', 'replace' and any other errors mode in open()
|
||||
"""
|
||||
if IS_WINDOWS:
|
||||
# PermissionError on Windows if another process is replacing
|
||||
last_error = None
|
||||
for attempt in range(WINDOWS_MAX_ATTEMPT):
|
||||
try:
|
||||
return file_read_text(file, encoding=encoding, errors=errors)
|
||||
except PermissionError as e:
|
||||
last_error = e
|
||||
delay = windows_attempt_delay(attempt)
|
||||
time.sleep(delay)
|
||||
continue
|
||||
if last_error is not None:
|
||||
raise last_error from None
|
||||
else:
|
||||
# Linux and Mac allow reading while replacing
|
||||
return file_read_text(file, encoding=encoding, errors=errors)
|
||||
|
||||
|
||||
def atomic_read_text_stream(
|
||||
file: str,
|
||||
encoding: str = 'utf-8',
|
||||
errors: str = 'strict',
|
||||
chunk_size: int = 8192
|
||||
) -> Iterable[str]:
|
||||
"""
|
||||
Args:
|
||||
file:
|
||||
encoding:
|
||||
errors: 'strict', 'ignore', 'replace' and any other errors mode in open()
|
||||
chunk_size:
|
||||
"""
|
||||
if IS_WINDOWS:
|
||||
# PermissionError on Windows if another process is replacing
|
||||
last_error = None
|
||||
for attempt in range(WINDOWS_MAX_ATTEMPT):
|
||||
try:
|
||||
yield from file_read_text_stream(file, encoding=encoding, errors=errors, chunk_size=chunk_size)
|
||||
return
|
||||
except PermissionError as e:
|
||||
last_error = e
|
||||
delay = windows_attempt_delay(attempt)
|
||||
time.sleep(delay)
|
||||
continue
|
||||
if last_error is not None:
|
||||
raise last_error from None
|
||||
else:
|
||||
# Linux and Mac allow reading while replacing
|
||||
yield from file_read_text_stream(file, encoding=encoding, errors=errors, chunk_size=chunk_size)
|
||||
return
|
||||
|
||||
|
||||
def atomic_read_bytes(file: str) -> bytes:
|
||||
"""
|
||||
Atomic file read with minimal IO operation
|
||||
"""
|
||||
if IS_WINDOWS:
|
||||
# PermissionError on Windows if another process is replacing
|
||||
last_error = None
|
||||
for attempt in range(WINDOWS_MAX_ATTEMPT):
|
||||
try:
|
||||
return file_read_bytes(file)
|
||||
except PermissionError as e:
|
||||
last_error = e
|
||||
delay = windows_attempt_delay(attempt)
|
||||
time.sleep(delay)
|
||||
continue
|
||||
if last_error is not None:
|
||||
raise last_error from None
|
||||
else:
|
||||
# Linux and Mac allow reading while replacing
|
||||
return file_read_bytes(file)
|
||||
|
||||
|
||||
def atomic_read_bytes_stream(file: str, chunk_size: int = 8192) -> Iterable[bytes]:
|
||||
"""
|
||||
Args:
|
||||
file:
|
||||
chunk_size:
|
||||
"""
|
||||
if IS_WINDOWS:
|
||||
# PermissionError on Windows if another process is replacing
|
||||
last_error = None
|
||||
for attempt in range(WINDOWS_MAX_ATTEMPT):
|
||||
try:
|
||||
yield from file_read_bytes_stream(file, chunk_size=chunk_size)
|
||||
return
|
||||
except PermissionError as e:
|
||||
last_error = e
|
||||
delay = windows_attempt_delay(attempt)
|
||||
time.sleep(delay)
|
||||
continue
|
||||
if last_error is not None:
|
||||
raise last_error from None
|
||||
else:
|
||||
# Linux and Mac allow reading while replacing
|
||||
yield from file_read_bytes_stream(file, chunk_size=chunk_size)
|
||||
return
|
||||
|
||||
|
||||
def file_remove(file: str):
|
||||
"""
|
||||
Remove a file non-atomic
|
||||
"""
|
||||
try:
|
||||
os.unlink(file)
|
||||
except FileNotFoundError:
|
||||
# If file not exist, just no need to remove
|
||||
pass
|
||||
|
||||
|
||||
def atomic_remove(file: str):
|
||||
"""
|
||||
Atomic file remove
|
||||
|
||||
Args:
|
||||
file:
|
||||
"""
|
||||
if IS_WINDOWS:
|
||||
# PermissionError on Windows if another process is replacing
|
||||
last_error = None
|
||||
for attempt in range(WINDOWS_MAX_ATTEMPT):
|
||||
try:
|
||||
return file_remove(file)
|
||||
except PermissionError as e:
|
||||
last_error = e
|
||||
delay = windows_attempt_delay(attempt)
|
||||
time.sleep(delay)
|
||||
continue
|
||||
if last_error is not None:
|
||||
raise last_error from None
|
||||
else:
|
||||
# Linux and Mac allow deleting while another process is reading
|
||||
# The directory entry is removed but the storage allocated to the file is not made available
|
||||
# until the original file is no longer in use.
|
||||
return file_remove(file)
|
||||
|
||||
|
||||
def folder_rmtree(folder, may_symlinks=True):
|
||||
"""
|
||||
Recursively remove a folder and its content
|
||||
|
||||
Args:
|
||||
folder:
|
||||
may_symlinks: Default to True
|
||||
False if you already know it's not a symlink
|
||||
|
||||
Returns:
|
||||
bool: If success
|
||||
"""
|
||||
try:
|
||||
# If it's a symlinks, unlink it
|
||||
if may_symlinks and os.path.islink(folder):
|
||||
file_remove(folder)
|
||||
return True
|
||||
# Iter folder
|
||||
with os.scandir(folder) as entries:
|
||||
for entry in entries:
|
||||
if entry.is_dir(follow_symlinks=False):
|
||||
folder_rmtree(entry.path, may_symlinks=False)
|
||||
else:
|
||||
# File or symlink
|
||||
# Just remove the symlink, not what it points to
|
||||
try:
|
||||
file_remove(entry.path)
|
||||
except PermissionError:
|
||||
# Another process is reading/writing
|
||||
pass
|
||||
|
||||
except FileNotFoundError:
|
||||
# directory to clean up does not exist, no need to clean up
|
||||
return True
|
||||
except NotADirectoryError:
|
||||
file_remove(folder)
|
||||
return True
|
||||
|
||||
# Remove empty folder
|
||||
# May raise OSError if it's still not empty
|
||||
try:
|
||||
os.rmdir(folder)
|
||||
return True
|
||||
except FileNotFoundError:
|
||||
return True
|
||||
except NotADirectoryError:
|
||||
file_remove(folder)
|
||||
return True
|
||||
except OSError:
|
||||
return False
|
||||
|
||||
|
||||
def atomic_rmtree(folder: str):
|
||||
"""
|
||||
Atomic folder rmtree
|
||||
Rename folder as temp folder and remove it,
|
||||
folder can be removed by atomic_failure_cleanup at next startup if remove gets interrupted
|
||||
"""
|
||||
tmp = to_tmp_file(folder)
|
||||
try:
|
||||
atomic_replace(folder, tmp)
|
||||
except FileNotFoundError:
|
||||
# Folder not exist, no need to rmtree
|
||||
return
|
||||
folder_rmtree(tmp)
|
||||
|
||||
|
||||
def atomic_failure_cleanup(folder: str, recursive: bool = False):
|
||||
"""
|
||||
Cleanup remaining temp file under given path.
|
||||
In most cases there should be no remaining temp files unless write process get interrupted.
|
||||
|
||||
This method should only be called at startup
|
||||
to avoid deleting temp files that another process is writing.
|
||||
"""
|
||||
try:
|
||||
with os.scandir(folder) as entries:
|
||||
for entry in entries:
|
||||
if is_tmp_file(entry.name):
|
||||
try:
|
||||
# Delete temp file or directory
|
||||
if entry.is_dir(follow_symlinks=False):
|
||||
folder_rmtree(entry.path, may_symlinks=False)
|
||||
else:
|
||||
file_remove(entry.path)
|
||||
except PermissionError:
|
||||
# Another process is reading/writing
|
||||
pass
|
||||
except:
|
||||
pass
|
||||
else:
|
||||
if recursive:
|
||||
try:
|
||||
if entry.is_dir(follow_symlinks=False):
|
||||
# Normal directory
|
||||
atomic_failure_cleanup(entry.path, recursive=True)
|
||||
except:
|
||||
pass
|
||||
|
||||
except FileNotFoundError:
|
||||
# directory to clean up does not exist, no need to clean up
|
||||
pass
|
||||
except NotADirectoryError:
|
||||
file_remove(folder)
|
||||
except:
|
||||
# Ignore all failures, it doesn't matter if tmp files still exist
|
||||
pass
|
||||
@@ -1,6 +1,7 @@
|
||||
import copy
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
from typing import Optional, Union
|
||||
|
||||
from deploy.Windows.logger import logger
|
||||
@@ -56,6 +57,8 @@ class ConfigModel:
|
||||
# Webui
|
||||
WebuiHost: str = "0.0.0.0"
|
||||
WebuiPort: int = 23467
|
||||
WebuiSSLKey: Optional[str] = None
|
||||
WebuiSSLCert: Optional[str] = None
|
||||
Language: str = "en-US"
|
||||
Theme: str = "default"
|
||||
DpiScaling: bool = True
|
||||
@@ -77,42 +80,52 @@ class DeployConfig(ConfigModel):
|
||||
self.config_template = {}
|
||||
self.read()
|
||||
|
||||
self.set_repo()
|
||||
|
||||
self.write()
|
||||
self.show_config()
|
||||
|
||||
def set_repo(self):
|
||||
# Bypass webui.config.DeployConfig.__setattr__()
|
||||
# Don't write these into deploy.yaml
|
||||
if self.Repository == 'cn':
|
||||
super().__setattr__('Repository', 'https://git.yoursfunny.top/YoursFunny/ArisuAutoSweeper.git')
|
||||
if self.Repository == 'global':
|
||||
super().__setattr__('Repository', 'https://github.com/TheFunny/ArisuAutoSweeper')
|
||||
|
||||
def show_config(self):
|
||||
logger.hr("Show deploy config", 1)
|
||||
for k, v in self.config.items():
|
||||
if k in ("Password", "SSHUser"):
|
||||
continue
|
||||
if self.config_template[k] == v:
|
||||
if self.config_template.get(k) == v:
|
||||
continue
|
||||
logger.info(f"{k}: {v}")
|
||||
|
||||
logger.info(f"Rest of the configs are the same as default")
|
||||
|
||||
def read(self):
|
||||
"""
|
||||
Read and update deploy config, copy `self.configs` to properties.
|
||||
"""
|
||||
self.config = poor_yaml_read(DEPLOY_TEMPLATE)
|
||||
self.config_template = copy.deepcopy(self.config)
|
||||
self.config.update(poor_yaml_read(self.file))
|
||||
origin = poor_yaml_read(self.file)
|
||||
self.config.update(origin)
|
||||
|
||||
for key, value in self.config.items():
|
||||
if hasattr(self, key):
|
||||
super().__setattr__(key, value)
|
||||
|
||||
self.config_redirect()
|
||||
|
||||
if self.config != origin:
|
||||
self.write()
|
||||
|
||||
def write(self):
|
||||
poor_yaml_write(self.config, self.file)
|
||||
|
||||
def config_redirect(self):
|
||||
"""
|
||||
Redirect deploy config, must be called after each `read()`
|
||||
"""
|
||||
# Bypass webui.config.DeployConfig.__setattr__()
|
||||
# Don't write these into deploy.yaml
|
||||
if self.Repository == 'cn':
|
||||
super().__setattr__('Repository', 'https://git.yoursfunny.top/YoursFunny/ArisuAutoSweeper.git')
|
||||
if self.Repository == 'global':
|
||||
super().__setattr__('Repository', 'https://github.com/TheFunny/ArisuAutoSweeper')
|
||||
|
||||
def filepath(self, path):
|
||||
"""
|
||||
Args:
|
||||
@@ -144,7 +157,7 @@ class DeployConfig(ConfigModel):
|
||||
if os.path.exists(exe):
|
||||
return exe
|
||||
|
||||
logger.warning(f'AdbExecutable: {exe} does not exists, use `adb` instead')
|
||||
logger.warning(f'AdbExecutable: {exe} does not exist, use `adb` instead')
|
||||
return 'adb'
|
||||
|
||||
@cached_property
|
||||
@@ -153,12 +166,21 @@ class DeployConfig(ConfigModel):
|
||||
if os.path.exists(exe):
|
||||
return exe
|
||||
|
||||
logger.warning(f'GitExecutable: {exe} does not exists, use `git` instead')
|
||||
logger.warning(f'GitExecutable: {exe} does not exist, use `git` instead')
|
||||
return 'git'
|
||||
|
||||
@cached_property
|
||||
def python(self) -> str:
|
||||
return self.filepath(self.PythonExecutable)
|
||||
# No need to read PythonExecutable
|
||||
# since you run this code with python, current python is the python
|
||||
|
||||
# exe = self.filepath(self.PythonExecutable)
|
||||
# if os.path.exists(exe):
|
||||
# return exe
|
||||
|
||||
current = sys.executable.replace("\\", "/")
|
||||
# logger.warning(f'PythonExecutable: {exe} does not exist, use current python instead: {current}')
|
||||
return current
|
||||
|
||||
@cached_property
|
||||
def requirements_file(self) -> str:
|
||||
|
||||
@@ -2,6 +2,7 @@ import asyncio
|
||||
import filecmp
|
||||
import os
|
||||
import shutil
|
||||
import sys
|
||||
import typing as t
|
||||
from dataclasses import dataclass
|
||||
|
||||
@@ -9,7 +10,8 @@ from deploy.Windows.alas import AlasManager
|
||||
from deploy.Windows.logger import logger
|
||||
from deploy.Windows.utils import cached_property
|
||||
|
||||
asyncio.set_event_loop_policy(asyncio.WindowsProactorEventLoopPolicy())
|
||||
if sys.platform.startswith('win'):
|
||||
asyncio.set_event_loop_policy(asyncio.WindowsProactorEventLoopPolicy())
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -140,10 +142,13 @@ class EmulatorManager(AlasManager):
|
||||
for adb in replace:
|
||||
logger.info(f'Replacing {adb}')
|
||||
bak = self.adb_path_to_backup(adb, new_backup=True)
|
||||
logger.info(f'{adb} -----> {bak}')
|
||||
shutil.move(adb, bak)
|
||||
logger.info(f'{self.adb} -----> {adb}')
|
||||
shutil.copy(self.adb, adb)
|
||||
try:
|
||||
logger.info(f'{adb} -----> {bak}')
|
||||
shutil.move(adb, bak)
|
||||
logger.info(f'{self.adb} -----> {adb}')
|
||||
shutil.copy(self.adb, adb)
|
||||
except OSError as e:
|
||||
logger.warning(f'Failed to replace {adb}, {e}')
|
||||
|
||||
def adb_recover(self):
|
||||
"""
|
||||
|
||||
@@ -59,17 +59,34 @@ def patch_uiautomator2():
|
||||
https://tool.appetizer.io is added to bypass the wall in China but https://tool.appetizer.io is slow outside of CN
|
||||
plus some CN users cannot access it for unknown reason.
|
||||
|
||||
So we patch `uiautomator2/init.py` to a local assets cache `uiautomator2cache/cache`.
|
||||
1. So we patch `uiautomator2/init.py` to a local assets cache `uiautomator2cache/cache`.
|
||||
appdir = os.path.join(os.path.expanduser('~'), '.uiautomator2')
|
||||
to:
|
||||
appdir = os.path.join(__file__, '../../uiautomator2cache')
|
||||
|
||||
And we also remove minicap installations since emulators doesn't need it.
|
||||
2. And we also remove minicap installations since emulators doesn't need it.
|
||||
for url in self.minicap_urls:
|
||||
self.push_url(url)
|
||||
to:
|
||||
for url in []:
|
||||
self.push_url(url)
|
||||
|
||||
3. Fix atx_agent_url so ARM Mac can have correct ATX installed
|
||||
```
|
||||
@property
|
||||
def atx_agent_url(self):
|
||||
files = {
|
||||
'armeabi-v7a': 'atx-agent_{v}_linux_armv7.tar.gz',
|
||||
'arm64-v8a': 'atx-agent_{v}_linux_armv7.tar.gz',
|
||||
'armeabi': 'atx-agent_{v}_linux_armv6.tar.gz',
|
||||
'x86': 'atx-agent_{v}_linux_386.tar.gz',
|
||||
'x86_64': 'atx-agent_{v}_linux_386.tar.gz',
|
||||
}
|
||||
```
|
||||
where
|
||||
'arm64-v8a': 'atx-agent_{v}_linux_armv7.tar.gz',
|
||||
to
|
||||
'arm64-v8a': 'atx-agent_{v}_linux_arm64.tar.gz',
|
||||
"""
|
||||
init_file = './toolkit/Lib/site-packages/uiautomator2/init.py'
|
||||
cache_dir = './toolkit/Lib/site-packages/uiautomator2cache/cache'
|
||||
@@ -92,6 +109,17 @@ def patch_uiautomator2():
|
||||
else:
|
||||
logger.info(f'{init_file} minicap_urls no need to patch')
|
||||
|
||||
# Patch atx_agent_url
|
||||
res = re.search(r"'arm64-v8a': 'atx-agent_\{v}_linux_armv7.tar.gz'", content)
|
||||
if res:
|
||||
content = re.sub(r"'arm64-v8a': 'atx-agent_\{v}_linux_armv7.tar.gz'",
|
||||
"'arm64-v8a': 'atx-agent_{v}_linux_arm64.tar.gz'",
|
||||
content)
|
||||
modified = True
|
||||
logger.info(f'{init_file} atx_agent_url patched')
|
||||
else:
|
||||
logger.info(f'{init_file} atx_agent_url no need to patch')
|
||||
|
||||
# Patch appdir
|
||||
if os.path.exists(cache_dir):
|
||||
res = re.search(r'appdir ?=(.*)\n', content)
|
||||
|
||||
@@ -5,7 +5,7 @@ from dataclasses import dataclass
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from deploy.Windows.config import DeployConfig
|
||||
from deploy.Windows.logger import logger, Progress
|
||||
from deploy.Windows.logger import Progress, logger
|
||||
from deploy.Windows.utils import cached_property
|
||||
|
||||
|
||||
@@ -46,9 +46,15 @@ class PipManager(DeployConfig):
|
||||
return f'"{self.python}" -m pip'
|
||||
|
||||
@cached_property
|
||||
def python_site_packages(self):
|
||||
return os.path.abspath(os.path.join(self.python, '../Lib/site-packages')) \
|
||||
.replace(r"\\", "/").replace("\\", "/")
|
||||
def python_site_packages(self) -> str:
|
||||
import site
|
||||
paths = site.getsitepackages()
|
||||
# site-packages should be site-packages folder
|
||||
for path in paths:
|
||||
if path.endswith('site-packages'):
|
||||
return path
|
||||
# Otherwise pick first
|
||||
return paths[0]
|
||||
|
||||
@cached_property
|
||||
def set_installed_dependency(self) -> t.Set[DataDependency]:
|
||||
|
||||
@@ -157,6 +157,14 @@ Deploy:
|
||||
# '["alas"]' specified "alas" config
|
||||
# '["alas","alas2"]' specified "alas" "alas2" configs
|
||||
Run: null
|
||||
# SSL support
|
||||
# Only effective when both parameters below are set
|
||||
# --ssl-key. Path to SSL key file
|
||||
# [Default] null (no SSL)
|
||||
WebuiSSLKey: null
|
||||
# --ssl-cert. Path to SSL cert file
|
||||
# [Default] null (no SSL)
|
||||
WebuiSSLCert: null
|
||||
# To update app.asar
|
||||
# [In most cases] true
|
||||
AppAsarUpdate: true
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import os
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from typing import Callable, Generic, Iterable, TypeVar
|
||||
|
||||
from deploy.Windows.atomic import atomic_read_text, atomic_write
|
||||
|
||||
T = TypeVar("T")
|
||||
|
||||
DEPLOY_CONFIG = './config/deploy.yaml'
|
||||
@@ -64,29 +65,26 @@ def poor_yaml_read(file):
|
||||
Returns:
|
||||
dict:
|
||||
"""
|
||||
if not os.path.exists(file):
|
||||
return {}
|
||||
|
||||
content = atomic_read_text(file)
|
||||
data = {}
|
||||
regex = re.compile(r'^(.*?):(.*?)$')
|
||||
with open(file, 'r', encoding='utf-8') as f:
|
||||
for line in f.readlines():
|
||||
line = line.strip('\n\r\t ').replace('\\', '/')
|
||||
if line.startswith('#'):
|
||||
continue
|
||||
result = re.match(regex, line)
|
||||
if result:
|
||||
k, v = result.group(1), result.group(2).strip('\n\r\t\' ')
|
||||
if v:
|
||||
if v.lower() == 'null':
|
||||
v = None
|
||||
elif v.lower() == 'false':
|
||||
v = False
|
||||
elif v.lower() == 'true':
|
||||
v = True
|
||||
elif v.isdigit():
|
||||
v = int(v)
|
||||
data[k] = v
|
||||
for line in content.splitlines():
|
||||
line = line.strip('\n\r\t ').replace('\\', '/')
|
||||
if line.startswith('#'):
|
||||
continue
|
||||
result = re.match(regex, line)
|
||||
if result:
|
||||
k, v = result.group(1), result.group(2).strip('\n\r\t\' ')
|
||||
if v:
|
||||
if v.lower() == 'null':
|
||||
v = None
|
||||
elif v.lower() == 'false':
|
||||
v = False
|
||||
elif v.lower() == 'true':
|
||||
v = True
|
||||
elif v.isdigit():
|
||||
v = int(v)
|
||||
data[k] = v
|
||||
|
||||
return data
|
||||
|
||||
@@ -98,8 +96,8 @@ def poor_yaml_write(data, file, template_file=DEPLOY_TEMPLATE):
|
||||
file (str):
|
||||
template_file (str):
|
||||
"""
|
||||
with open(template_file, 'r', encoding='utf-8') as f:
|
||||
text = f.read().replace('\\', '/')
|
||||
text = atomic_read_text(template_file)
|
||||
text = text.replace('\\', '/')
|
||||
|
||||
for key, value in data.items():
|
||||
if value is None:
|
||||
@@ -110,38 +108,15 @@ def poor_yaml_write(data, file, template_file=DEPLOY_TEMPLATE):
|
||||
value = "false"
|
||||
text = re.sub(f'{key}:.*?\n', f'{key}: {value}\n', text)
|
||||
|
||||
with open(file, 'w', encoding='utf-8', newline='') as f:
|
||||
f.write(text)
|
||||
atomic_write(file, text)
|
||||
|
||||
|
||||
@dataclass
|
||||
class DataProcessInfo:
|
||||
proc: object # psutil.Process or psutil._pswindows.Process
|
||||
pid: int
|
||||
|
||||
@cached_property
|
||||
def name(self):
|
||||
name = self.proc.name()
|
||||
return name
|
||||
|
||||
@cached_property
|
||||
def cmdline(self):
|
||||
try:
|
||||
cmdline = self.proc.cmdline()
|
||||
except:
|
||||
# psutil.AccessDenied
|
||||
cmdline = []
|
||||
cmdline = ' '.join(cmdline).replace(r'\\', '/').replace('\\', '/')
|
||||
return cmdline
|
||||
|
||||
def __str__(self):
|
||||
# Don't print `proc`, it will take some time to get process properties
|
||||
return f'DataProcessInfo(name="{self.name}", pid={self.pid}, cmdline="{self.cmdline}")'
|
||||
|
||||
__repr__ = __str__
|
||||
|
||||
|
||||
def iter_process() -> Iterable[DataProcessInfo]:
|
||||
def iter_process() -> "Iterable[tuple[int, list[str]]]":
|
||||
"""
|
||||
Yields:
|
||||
int: pid
|
||||
list[str]: cmdline, and it's guaranteed to have at least one element
|
||||
"""
|
||||
try:
|
||||
import psutil
|
||||
except ModuleNotFoundError:
|
||||
@@ -151,16 +126,48 @@ def iter_process() -> Iterable[DataProcessInfo]:
|
||||
# Since this is a one-time-usage, we access psutil._psplatform.Process directly
|
||||
# to bypass the call of psutil.Process.is_running().
|
||||
# This only costs about 0.017s.
|
||||
# If you do psutil.process_iter(['pid', 'cmdline']) it will take over 1s
|
||||
import psutil._psutil_windows as cetx
|
||||
for pid in psutil.pids():
|
||||
# 0 and 4 are always represented in taskmgr and process-hacker
|
||||
if pid == 0 or pid == 4:
|
||||
continue
|
||||
try:
|
||||
# This would be fast on psutil<=5.9.8 taking overall time 0.027s
|
||||
# but taking 0.39s on psutil>=6.0.0
|
||||
cmdline = cetx.proc_cmdline(pid, use_peb=True)
|
||||
except (psutil.AccessDenied, psutil.NoSuchProcess, OSError):
|
||||
# psutil.AccessDenied
|
||||
# NoSuchProcess: process no longer exists (pid=xxx)
|
||||
# ProcessLookupError: [Errno 3] assume no such process (originated from psutil_pid_is_running -> 0)
|
||||
# OSError: [WinError 87] 参数错误。: '(originated from ReadProcessMemory)'
|
||||
continue
|
||||
|
||||
# Validate cmdline
|
||||
if not cmdline:
|
||||
continue
|
||||
try:
|
||||
exe = cmdline[0]
|
||||
except IndexError:
|
||||
continue
|
||||
# \??\C:\Windows\system32\conhost.exe
|
||||
if exe.startswith(r'\??'):
|
||||
continue
|
||||
yield pid, cmdline
|
||||
else:
|
||||
# No optimizations yet
|
||||
for pid in psutil.pids():
|
||||
proc = psutil._psplatform.Process(pid)
|
||||
yield DataProcessInfo(
|
||||
proc=proc,
|
||||
pid=proc.pid,
|
||||
)
|
||||
else:
|
||||
# This will cost about 0.45s, even `attr` is given.
|
||||
for proc in psutil.process_iter():
|
||||
yield DataProcessInfo(
|
||||
proc=proc,
|
||||
pid=proc.pid,
|
||||
)
|
||||
try:
|
||||
cmdline = proc.cmdline()
|
||||
except (psutil.AccessDenied, psutil.NoSuchProcess, OSError):
|
||||
continue
|
||||
|
||||
# Validate cmdline
|
||||
if not cmdline:
|
||||
continue
|
||||
try:
|
||||
cmdline[0]
|
||||
except IndexError:
|
||||
continue
|
||||
yield pid, cmdline
|
||||
|
||||
@@ -13,6 +13,8 @@ from deploy.Windows.pip import PipManager
|
||||
|
||||
class Installer(GitManager, PipManager, AdbManager, AppManager, AlasManager):
|
||||
def install(self):
|
||||
from deploy.Windows.atomic import atomic_failure_cleanup
|
||||
atomic_failure_cleanup('./config')
|
||||
try:
|
||||
self.git_install()
|
||||
self.alas_kill()
|
||||
|
||||
@@ -3,20 +3,61 @@ import re
|
||||
import typing as t
|
||||
from dataclasses import dataclass
|
||||
|
||||
import cv2
|
||||
import numpy as np
|
||||
from tqdm import tqdm
|
||||
|
||||
from module.base.code_generator import CodeGenerator
|
||||
from module.base.utils import SelectedGrids, area_limit, area_pad, get_bbox, get_color, image_size, load_image
|
||||
from module.base.utils import SelectedGrids, area_center, area_limit, area_pad, corner2area, get_bbox, get_color, \
|
||||
image_size, load_image
|
||||
from module.config.config_manual import ManualConfig as AzurLaneConfig
|
||||
from module.config.deep import deep_get, deep_iter, deep_set
|
||||
from module.config.server import VALID_LANG
|
||||
from module.config.utils import deep_get, deep_iter, deep_set, iter_folder
|
||||
from module.config.utils import iter_folder
|
||||
from module.logger import logger
|
||||
|
||||
SHARE_SERVER = 'share'
|
||||
ASSET_SERVER = [SHARE_SERVER] + VALID_LANG
|
||||
|
||||
|
||||
def parse_grid(image):
|
||||
"""
|
||||
Args:
|
||||
image:
|
||||
|
||||
Returns:
|
||||
dict: Key: Grid position (x, y)
|
||||
Value: Area on image
|
||||
"""
|
||||
image = cv2.inRange(image, (127, 127, 127), (255, 255, 255))
|
||||
contours, _ = cv2.findContours(image, cv2.RETR_TREE, cv2.CHAIN_APPROX_SIMPLE)
|
||||
dic_rect = {}
|
||||
for corners in contours:
|
||||
area = corner2area(corners.reshape(4, 2)) + (0, 0, 1, 1)
|
||||
center = area_center(area)
|
||||
dic_rect[center] = area
|
||||
# for k, v in dic_rect.items():
|
||||
# print(k, v)
|
||||
|
||||
dic_grid = {}
|
||||
prev_y = -100
|
||||
grid_y = -1
|
||||
stack_center = []
|
||||
for center in sorted(dic_rect.keys(), key=lambda x: x[1]):
|
||||
if center[1] > prev_y + 3:
|
||||
for x, c in enumerate(sorted(stack_center, key=lambda x: x[0])):
|
||||
dic_grid[(x, grid_y)] = tuple(dic_rect[c].astype(int))
|
||||
grid_y += 1
|
||||
stack_center = []
|
||||
stack_center.append(center)
|
||||
prev_y = center[1]
|
||||
for x, c in enumerate(sorted(stack_center, key=lambda x: x[0])):
|
||||
dic_grid[(x, grid_y)] = tuple(dic_rect[c].astype(int))
|
||||
# for k, v in dic_grid.items():
|
||||
# print(k, v)
|
||||
return dic_grid
|
||||
|
||||
|
||||
class AssetsImage:
|
||||
REGEX_ASSETS = re.compile(
|
||||
f'^{AzurLaneConfig.ASSETS_FOLDER}/'
|
||||
@@ -24,7 +65,7 @@ class AssetsImage:
|
||||
f'(?P<module>[a-zA-Z0-9_/]+?)/'
|
||||
f'(?P<assets>\w+)'
|
||||
f'(?P<frame>\.\d+)?'
|
||||
f'(?P<attr>\.AREA|\.SEARCH|\.COLOR|\.BUTTON)?'
|
||||
f'(?P<attr>\.AREA|\.SEARCH|\.COLOR|\.BUTTON|\.GRID)?'
|
||||
f'\.png$'
|
||||
)
|
||||
|
||||
@@ -46,6 +87,7 @@ class AssetsImage:
|
||||
self.assets = ''
|
||||
self.frame = 1
|
||||
self.attr = ''
|
||||
self.posi = None
|
||||
|
||||
if res:
|
||||
self.valid = True
|
||||
@@ -66,6 +108,7 @@ class AssetsImage:
|
||||
|
||||
self.bbox: t.Tuple = ()
|
||||
self.mean: t.Tuple = ()
|
||||
self.grids = {}
|
||||
|
||||
def parse(self):
|
||||
image = load_image(self.file)
|
||||
@@ -79,6 +122,10 @@ class AssetsImage:
|
||||
mean = tuple(np.rint(mean).astype(int))
|
||||
self.bbox = bbox
|
||||
self.mean = mean
|
||||
|
||||
if self.attr == 'GRID':
|
||||
self.grids = parse_grid(image)
|
||||
|
||||
return bbox, mean
|
||||
|
||||
def __str__(self):
|
||||
@@ -87,13 +134,40 @@ class AssetsImage:
|
||||
else:
|
||||
return f'AssetsImage(file={self.file}, valid={self.valid})'
|
||||
|
||||
@property
|
||||
def is_GRID(self):
|
||||
return self.attr == 'GRID'
|
||||
|
||||
@property
|
||||
def is_base(self):
|
||||
return self.attr == ''
|
||||
|
||||
def iter_grids(self):
|
||||
frame = 0
|
||||
for posi, rect in self.grids.items():
|
||||
frame += 1
|
||||
image = AssetsImage(self.file)
|
||||
image.attr = ''
|
||||
image.bbox = rect
|
||||
image.mean = self.mean
|
||||
image.frame = frame
|
||||
image.posi = posi
|
||||
yield image
|
||||
|
||||
|
||||
def iter_images():
|
||||
for server in ASSET_SERVER:
|
||||
for path, folders, files in os.walk(os.path.join(AzurLaneConfig.ASSETS_FOLDER, server)):
|
||||
for file in files:
|
||||
file = os.path.join(path, file).replace('\\', '/')
|
||||
yield AssetsImage(file)
|
||||
if not file.startswith('.'):
|
||||
file = os.path.join(path, file).replace('\\', '/')
|
||||
yield AssetsImage(file)
|
||||
|
||||
|
||||
def iter_grids(images):
|
||||
for image in images:
|
||||
for grid in image.iter_grids():
|
||||
yield grid
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -103,11 +177,17 @@ class DataAssets:
|
||||
server: str
|
||||
frame: int
|
||||
file: str = ''
|
||||
posi = None
|
||||
area: t.Tuple[int, int, int, int] = ()
|
||||
search: t.Tuple[int, int, int, int] = ()
|
||||
color: t.Tuple[int, int, int] = ()
|
||||
button: t.Tuple[int, int, int, int] = ()
|
||||
|
||||
has_raw_area = False
|
||||
has_raw_search = False
|
||||
has_raw_color = False
|
||||
has_raw_button = False
|
||||
|
||||
@staticmethod
|
||||
def area_to_search(area):
|
||||
area = area_pad(area, pad=-20)
|
||||
@@ -120,7 +200,6 @@ class DataAssets:
|
||||
Product DataAssets from AssetsImage with attr=""
|
||||
"""
|
||||
data = cls(module=image.module, assets=image.assets, server=image.server, frame=image.frame, file=image.file)
|
||||
data.load_image(image)
|
||||
return data
|
||||
|
||||
def load_image(self, image: AssetsImage):
|
||||
@@ -129,14 +208,21 @@ class DataAssets:
|
||||
self.area = image.bbox
|
||||
self.color = image.mean
|
||||
self.button = image.bbox
|
||||
self.posi = image.posi
|
||||
elif image.attr == 'AREA':
|
||||
self.area = image.bbox
|
||||
self.has_raw_area = True
|
||||
elif image.attr == 'SEARCH':
|
||||
self.search = image.bbox
|
||||
self.has_raw_search = True
|
||||
elif image.attr == 'COLOR':
|
||||
self.color = image.mean
|
||||
self.has_raw_color = True
|
||||
elif image.attr == 'BUTTON':
|
||||
self.button = image.bbox
|
||||
self.has_raw_button = True
|
||||
elif image.attr == 'GRID':
|
||||
pass
|
||||
else:
|
||||
logger.warning(f'Trying to load an image with unknown attribute: {image}')
|
||||
|
||||
@@ -151,17 +237,22 @@ def iter_assets():
|
||||
for image in tqdm(images):
|
||||
image.parse()
|
||||
|
||||
images += list(iter_grids(images))
|
||||
|
||||
# Validate images
|
||||
images = SelectedGrids(images).select(valid=True)
|
||||
images.create_index('module', 'assets', 'server', 'frame', 'attr')
|
||||
for image in images.filter(lambda x: bool(x.attr)):
|
||||
image: AssetsImage = image
|
||||
if not images.indexed_select(image.module, image.assets, image.server, image.frame, ''):
|
||||
logger.warning(f'Attribute assets has no parent assets: {image.file}')
|
||||
image.valid = False
|
||||
if not images.indexed_select(image.module, image.assets, image.server, 1, ''):
|
||||
logger.warning(f'Attribute assets has no first frame: {image.file}')
|
||||
image.valid = False
|
||||
if image.is_GRID:
|
||||
pass
|
||||
else:
|
||||
if not images.indexed_select(image.module, image.assets, image.server, image.frame, ''):
|
||||
logger.warning(f'Attribute assets has no parent assets: {image.file}')
|
||||
image.valid = False
|
||||
if not images.indexed_select(image.module, image.assets, image.server, 1, ''):
|
||||
logger.warning(f'Attribute assets has no first frame: {image.file}')
|
||||
image.valid = False
|
||||
if image.attr == 'SEARCH' and image.frame > 1:
|
||||
logger.warning(f'Attribute SEARCH with frame > 1 is not allowed: {image.file}')
|
||||
image.valid = False
|
||||
@@ -170,31 +261,34 @@ def iter_assets():
|
||||
# Convert to DataAssets
|
||||
data = {}
|
||||
for image in images:
|
||||
if image.attr == '':
|
||||
if image.is_base:
|
||||
row = DataAssets.product(image)
|
||||
row.load_image(image)
|
||||
deep_set(data, keys=[image.module, image.assets, image.server, image.frame], value=row)
|
||||
# Load attribute images
|
||||
for image in images:
|
||||
if image.attr != '':
|
||||
if not image.is_base:
|
||||
row = deep_get(data, keys=[image.module, image.assets, image.server, image.frame])
|
||||
row.load_image(image)
|
||||
# Set `search`
|
||||
for path, frames in deep_iter(data, depth=3):
|
||||
print(path, frames)
|
||||
# If `search` attribute is set in the first frame, apply to all
|
||||
first = frames[1]
|
||||
if first.search:
|
||||
for frame in frames.values():
|
||||
# print(path, frames)
|
||||
for frame in frames.values():
|
||||
# Generate `search` from `area`
|
||||
if not frame.has_raw_search:
|
||||
frame.search = DataAssets.area_to_search(frame.area)
|
||||
# If an attribute is set in the first frame, apply to all
|
||||
first: DataAssets = frames[1]
|
||||
for frame in frames.values():
|
||||
# frame: DataAssets = frame
|
||||
if not frame.has_raw_area and first.has_raw_area:
|
||||
frame.area = first.area
|
||||
if not frame.has_raw_search and first.has_raw_search:
|
||||
frame.search = first.search
|
||||
else:
|
||||
for frame in frames.values():
|
||||
if frame.search:
|
||||
# Follow frame specific `search`
|
||||
pass
|
||||
else:
|
||||
# Generate `search` from `area`
|
||||
frame.search = DataAssets.area_to_search(frame.area)
|
||||
if not frame.has_raw_color and first.has_raw_color:
|
||||
frame.color = first.color
|
||||
if not frame.has_raw_button and first.has_raw_button:
|
||||
frame.button = first.button
|
||||
|
||||
return data
|
||||
|
||||
@@ -240,6 +334,8 @@ def generate_code():
|
||||
gen.ObjectAttr(key='search', value=frame.search)
|
||||
gen.ObjectAttr(key='color', value=frame.color)
|
||||
gen.ObjectAttr(key='button', value=frame.button)
|
||||
if frame.posi is not None:
|
||||
gen.ObjectAttr(key='posi', value=frame.posi)
|
||||
elif len(frames) == 1:
|
||||
frame = frames[0]
|
||||
with gen.ObjectAttr(key=server, value=gen.Object(object_class='Button')):
|
||||
@@ -248,6 +344,8 @@ def generate_code():
|
||||
gen.ObjectAttr(key='search', value=frame.search)
|
||||
gen.ObjectAttr(key='color', value=frame.color)
|
||||
gen.ObjectAttr(key='button', value=frame.button)
|
||||
if frame.posi is not None:
|
||||
gen.ObjectAttr(key='posi', value=frame.posi)
|
||||
else:
|
||||
gen.ObjectAttr(key=server, value=None)
|
||||
gen.write(os.path.join(output, f'assets_{module.replace("/", "_")}.py'))
|
||||
|
||||
@@ -2,8 +2,8 @@ import os
|
||||
from datetime import datetime
|
||||
|
||||
from PIL import Image
|
||||
|
||||
from pynput import keyboard
|
||||
|
||||
from module.config.config import AzurLaneConfig
|
||||
from module.config.utils import alas_instance
|
||||
from module.device.connection import Connection, ConnectionAttr
|
||||
@@ -50,12 +50,12 @@ def handle_sensitive_info(image):
|
||||
|
||||
_ = EmptyConnection()
|
||||
name = input(
|
||||
'输入alas配置文件名称,或者模拟器serial,或者模拟器端口号: (默认输入 "alas"):\n'
|
||||
'例如:"alas", "127.0.0.1:16384", "7555"\n'
|
||||
'输入aas配置文件名称,或者模拟器serial,或者模拟器端口号: (默认输入 "aas"):\n'
|
||||
'例如:"aas", "127.0.0.1:16384", "7555"\n'
|
||||
)
|
||||
name = name.strip().strip('"').strip()
|
||||
if not name:
|
||||
name = 'alas'
|
||||
name = 'aas'
|
||||
if name.isdigit():
|
||||
name = f'127.0.0.1:{name}'
|
||||
if name in alas_instance():
|
||||
|
||||
@@ -40,6 +40,12 @@ def func(ev: threading.Event):
|
||||
parser.add_argument(
|
||||
"--electron", action="store_true", help="Runs by electron client."
|
||||
)
|
||||
parser.add_argument(
|
||||
"--ssl-key", dest="ssl_key", type=str, help="SSL key file path for HTTPS support"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--ssl-cert", type=str, help="SSL certificate file path for HTTPS support"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--run",
|
||||
nargs="+",
|
||||
@@ -50,11 +56,15 @@ def func(ev: threading.Event):
|
||||
|
||||
host = args.host or State.deploy_config.WebuiHost or "0.0.0.0"
|
||||
port = args.port or int(State.deploy_config.WebuiPort) or 23467
|
||||
ssl_key = args.ssl_key or State.deploy_config.WebuiSSLKey
|
||||
ssl_cert = args.ssl_cert or State.deploy_config.WebuiSSLCert
|
||||
ssl = ssl_key is not None and ssl_cert is not None
|
||||
State.electron = args.electron
|
||||
|
||||
logger.hr("Launcher config")
|
||||
logger.attr("Host", host)
|
||||
logger.attr("Port", port)
|
||||
logger.attr("SSL", ssl)
|
||||
logger.attr("Electron", args.electron)
|
||||
logger.attr("Reload", ev is not None)
|
||||
|
||||
@@ -64,7 +74,16 @@ def func(ev: threading.Event):
|
||||
from module.logger.logger import console_hdlr
|
||||
logger.removeHandler(console_hdlr)
|
||||
|
||||
uvicorn.run("module.webui.app:app", host=host, port=port, factory=True)
|
||||
if ssl_cert is None and ssl_key is not None:
|
||||
logger.error("SSL key provided without certificate. Please provide both SSL key and certificate.")
|
||||
elif ssl_key is None and ssl_cert is not None:
|
||||
logger.error("SSL certificate provided without key. Please provide both SSL key and certificate.")
|
||||
|
||||
if ssl:
|
||||
uvicorn.run("module.webui.app:app", host=host, port=port, factory=True, ssl_keyfile=ssl_key,
|
||||
ssl_certfile=ssl_cert)
|
||||
else:
|
||||
uvicorn.run("module.webui.app:app", host=host, port=port, factory=True)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -20,7 +20,7 @@ def run_print_test():
|
||||
run()
|
||||
|
||||
|
||||
def run_set(modify=t.List[str]) -> t.Dict[str, str]:
|
||||
def run_set(modify: t.List[str]) -> t.Dict[str, str]:
|
||||
data = {}
|
||||
for kv in modify:
|
||||
if "=" in kv:
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import os
|
||||
import re
|
||||
import platform
|
||||
import subprocess
|
||||
import threading
|
||||
import time
|
||||
from datetime import datetime, timedelta
|
||||
@@ -7,16 +7,14 @@ from datetime import datetime, timedelta
|
||||
import inflection
|
||||
from cached_property import cached_property
|
||||
|
||||
from MCE.custom_widgets.ctkmessagebox import CTkMessagebox
|
||||
from module.base.decorator import del_cached_property
|
||||
from module.config.config import AzurLaneConfig, TaskEnd
|
||||
from module.config.utils import deep_get, deep_set
|
||||
from module.config.deep import deep_get, deep_set
|
||||
from module.exception import *
|
||||
from module.logger import logger
|
||||
from module.logger import logger, save_error_log
|
||||
from module.notify import handle_notify
|
||||
|
||||
from MCE.custom_widgets.ctkmessagebox import CTkMessagebox
|
||||
import subprocess
|
||||
import platform
|
||||
|
||||
class AzurLaneAutoScript:
|
||||
stop_event: threading.Event = None
|
||||
@@ -66,9 +64,22 @@ class AzurLaneAutoScript:
|
||||
logger.exception(e)
|
||||
exit(1)
|
||||
|
||||
def restart(self):
|
||||
raise NotImplemented
|
||||
|
||||
def start(self):
|
||||
raise NotImplemented
|
||||
|
||||
def stop(self):
|
||||
raise NotImplemented
|
||||
|
||||
def goto_main(self):
|
||||
raise NotImplemented
|
||||
|
||||
def run(self, command):
|
||||
try:
|
||||
self.device.screenshot()
|
||||
self.device.screenshot_tracking.clear()
|
||||
self.__getattribute__(command)()
|
||||
return True
|
||||
except TaskEnd:
|
||||
@@ -76,61 +87,68 @@ class AzurLaneAutoScript:
|
||||
except GameNotRunningError as e:
|
||||
logger.warning(e)
|
||||
self.config.task_call('Restart')
|
||||
return True
|
||||
return False
|
||||
except (GameStuckError, GameTooManyClickError) as e:
|
||||
logger.error(e)
|
||||
self.save_error_log()
|
||||
logger.warning(f'Game stuck, {self.device.package} will be restarted in 10 seconds')
|
||||
logger.warning('If you are playing by hand, please stop Alas')
|
||||
logger.warning('If you are playing by hand, please stop AAS')
|
||||
self.config.task_call('Restart')
|
||||
self.device.sleep(10)
|
||||
return False
|
||||
except GameBugError as e:
|
||||
logger.warning(e)
|
||||
self.save_error_log()
|
||||
logger.warning('An error has occurred in Azur Lane game client, Alas is unable to handle')
|
||||
logger.warning('An error has occurred in Blue Archive game client, AAS is unable to handle')
|
||||
logger.warning(f'Restarting {self.device.package} to fix it')
|
||||
self.config.task_call('Restart')
|
||||
self.device.sleep(10)
|
||||
return False
|
||||
except GamePageUnknownError:
|
||||
logger.info('Game server may be under maintenance or network may be broken, check server status now')
|
||||
# logger.info('Game server may be under maintenance or network may be broken, check server status now')
|
||||
self.checker.check_now()
|
||||
if self.checker.is_available():
|
||||
logger.critical('Game page unknown')
|
||||
self.save_error_log()
|
||||
handle_notify(
|
||||
self.config.Error_OnePushConfig,
|
||||
title=f"Alas <{self.config_name}> crashed",
|
||||
title=f"AAS <{self.config_name}> crashed",
|
||||
content=f"<{self.config_name}> GamePageUnknownError",
|
||||
)
|
||||
exit(1)
|
||||
else:
|
||||
self.checker.wait_until_available()
|
||||
return False
|
||||
except HandledError as e:
|
||||
logger.error(e)
|
||||
return False
|
||||
except ScriptError as e:
|
||||
logger.critical(e)
|
||||
logger.exception(e)
|
||||
self.error_postprocess()
|
||||
logger.critical('This is likely to be a mistake of developers, but sometimes just random issues')
|
||||
self.save_error_log()
|
||||
handle_notify(
|
||||
self.config.Error_OnePushConfig,
|
||||
title=f"Alas <{self.config_name}> crashed",
|
||||
title=f"AAS <{self.config_name}> crashed",
|
||||
content=f"<{self.config_name}> ScriptError",
|
||||
)
|
||||
exit(1)
|
||||
except RequestHumanTakeover:
|
||||
logger.critical('Request human takeover')
|
||||
self.error_postprocess()
|
||||
handle_notify(
|
||||
self.config.Error_OnePushConfig,
|
||||
title=f"Alas <{self.config_name}> crashed",
|
||||
title=f"AAS <{self.config_name}> crashed",
|
||||
content=f"<{self.config_name}> RequestHumanTakeover",
|
||||
)
|
||||
exit(1)
|
||||
except Exception as e:
|
||||
logger.exception(e)
|
||||
self.error_postprocess()
|
||||
self.save_error_log()
|
||||
handle_notify(
|
||||
self.config.Error_OnePushConfig,
|
||||
title=f"Alas <{self.config_name}> crashed",
|
||||
title=f"AAS <{self.config_name}> crashed",
|
||||
content=f"<{self.config_name}> Exception occured",
|
||||
)
|
||||
exit(1)
|
||||
@@ -140,29 +158,13 @@ class AzurLaneAutoScript:
|
||||
Save last 60 screenshots in ./log/error/<timestamp>
|
||||
Save logs to ./log/error/<timestamp>/log.txt
|
||||
"""
|
||||
from module.base.utils import save_image
|
||||
from module.handler.sensitive_info import (handle_sensitive_image, handle_sensitive_logs)
|
||||
if self.config.Error_SaveError:
|
||||
if not os.path.exists('./log/error'):
|
||||
os.mkdir('./log/error')
|
||||
folder = f'./log/error/{int(time.time() * 1000)}'
|
||||
logger.warning(f'Saving error: {folder}')
|
||||
os.mkdir(folder)
|
||||
for data in self.device.screenshot_deque:
|
||||
image_time = datetime.strftime(data['time'], '%Y-%m-%d_%H-%M-%S-%f')
|
||||
image = handle_sensitive_image(data['image'])
|
||||
save_image(image, f'{folder}/{image_time}.png')
|
||||
with open(logger.log_file, 'r', encoding='utf-8') as f:
|
||||
lines = f.readlines()
|
||||
start = 0
|
||||
for index, line in enumerate(lines):
|
||||
line = line.strip(' \r\t\n')
|
||||
if re.match('^═{15,}$', line):
|
||||
start = index
|
||||
lines = lines[start - 2:]
|
||||
lines = handle_sensitive_logs(lines)
|
||||
with open(f'{folder}/log.txt', 'w', encoding='utf-8') as f:
|
||||
f.writelines(lines)
|
||||
save_error_log(config=self.config, device=self.device)
|
||||
|
||||
def error_postprocess(self):
|
||||
"""
|
||||
Do something when error occurred
|
||||
"""
|
||||
pass
|
||||
|
||||
def wait_until(self, future):
|
||||
"""
|
||||
@@ -210,13 +212,16 @@ class AzurLaneAutoScript:
|
||||
method = self.config.Optimization_WhenTaskQueueEmpty
|
||||
if method == 'close_game':
|
||||
logger.info('Close game during wait')
|
||||
self.device.app_stop()
|
||||
self.run('stop')
|
||||
release_resources()
|
||||
self.device.release_during_wait()
|
||||
if not self.wait_until(task.next_run):
|
||||
del_cached_property(self, 'config')
|
||||
continue
|
||||
self.run('start')
|
||||
if task.command != 'Restart':
|
||||
self.config.task_call('Restart')
|
||||
del_cached_property(self, 'config')
|
||||
continue
|
||||
elif method == 'goto_main':
|
||||
logger.info('Goto main page during wait')
|
||||
self.run('goto_main')
|
||||
@@ -232,6 +237,31 @@ class AzurLaneAutoScript:
|
||||
if not self.wait_until(task.next_run):
|
||||
del_cached_property(self, 'config')
|
||||
continue
|
||||
elif method == 'close_emulator':
|
||||
logger.info('Close emulator during wait')
|
||||
self.run('stop')
|
||||
release_resources()
|
||||
self.device.release_during_wait()
|
||||
# 关闭模拟器
|
||||
try:
|
||||
self.device.emulator_stop()
|
||||
logger.info('Emulator stopped successfully')
|
||||
except Exception as e:
|
||||
logger.warning(f'Failed to stop emulator: {e}')
|
||||
if not self.wait_until(task.next_run):
|
||||
del_cached_property(self, 'config')
|
||||
del_cached_property(self, 'device')
|
||||
continue
|
||||
if task.command == 'Restart':
|
||||
del_cached_property(self, 'config')
|
||||
del_cached_property(self, 'device')
|
||||
continue
|
||||
# 重新启动模拟器
|
||||
if task.command != 'Restart':
|
||||
self.config.task_call('Restart')
|
||||
del_cached_property(self, 'config')
|
||||
del_cached_property(self, 'device')
|
||||
continue
|
||||
elif method == 'exit_aas':
|
||||
if abs(task.next_run - datetime.now()) >= timedelta(minutes=2): # ensure tactical challenge is fully ran
|
||||
self.config.Optimization_WhenTaskQueueEmpty = 'goto_main'
|
||||
@@ -293,7 +323,7 @@ class AzurLaneAutoScript:
|
||||
if self.stop_event is not None:
|
||||
if self.stop_event.is_set():
|
||||
logger.info("Update event detected")
|
||||
logger.info(f"Alas [{self.config_name}] exited.")
|
||||
logger.info(f"AAS [{self.config_name}] exited.")
|
||||
break
|
||||
# Check game server maintenance
|
||||
self.checker.wait_until_available()
|
||||
@@ -309,6 +339,7 @@ class AzurLaneAutoScript:
|
||||
task = self.get_next_task()
|
||||
# Init device and change server
|
||||
_ = self.device
|
||||
self.device.config = self.config
|
||||
# Skip first restart
|
||||
if self.is_first_task and task == 'Restart':
|
||||
logger.info('Skip task `Restart` at scheduler start')
|
||||
@@ -338,7 +369,7 @@ class AzurLaneAutoScript:
|
||||
logger.critical('Request human takeover')
|
||||
handle_notify(
|
||||
self.config.Error_OnePushConfig,
|
||||
title=f"Alas <{self.config_name}> crashed",
|
||||
title=f"AAS <{self.config_name}> crashed",
|
||||
content=f"<{self.config_name}> RequestHumanTakeover\nTask `{task}` failed 3 or more times.",
|
||||
)
|
||||
exit(1)
|
||||
|
||||
@@ -4,7 +4,9 @@ from module.base.timer import Timer
|
||||
from module.base.utils import *
|
||||
from module.config.config import AzurLaneConfig
|
||||
from module.device.device import Device
|
||||
from module.device.method.utils import HierarchyButton
|
||||
from module.logger import logger
|
||||
from module.webui.setting import cached_class_property
|
||||
|
||||
|
||||
class ModuleBase:
|
||||
@@ -26,6 +28,8 @@ class ModuleBase:
|
||||
"""
|
||||
if isinstance(config, AzurLaneConfig):
|
||||
self.config = config
|
||||
if task is not None:
|
||||
self.config.init_task(task)
|
||||
elif isinstance(config, str):
|
||||
self.config = AzurLaneConfig(config, task=task)
|
||||
else:
|
||||
@@ -45,6 +49,111 @@ class ModuleBase:
|
||||
|
||||
self.interval_timer = {}
|
||||
|
||||
@cached_class_property
|
||||
def worker(self):
|
||||
"""
|
||||
A thread pool to run things at background
|
||||
|
||||
Examples:
|
||||
```
|
||||
def func(image):
|
||||
logger.info('Update thread start')
|
||||
with self.config.multi_set():
|
||||
self.dungeon_get_simuni_point(image)
|
||||
self.dungeon_update_stamina(image)
|
||||
ModuleBase.worker.submit(func, self.device.image)
|
||||
```
|
||||
"""
|
||||
logger.hr('Creating worker')
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
pool = ThreadPoolExecutor(1)
|
||||
return pool
|
||||
|
||||
def loop(self, skip_first=True, timeout=None):
|
||||
"""
|
||||
A syntactic sugar to start a state loop
|
||||
|
||||
Args:
|
||||
skip_first (bool): Usually to be True to reuse the previous screenshot
|
||||
timeout (int | float | Timer): Seconds of timeout or a Timer object
|
||||
|
||||
Yields:
|
||||
np.ndarray: screenshot
|
||||
|
||||
Examples:
|
||||
# state machine that handle clicking until destination
|
||||
for _ in self.loop():
|
||||
if self.appear(...):
|
||||
break
|
||||
if self.appear_then_click(...):
|
||||
continue
|
||||
|
||||
Examples:
|
||||
# state machine with timeout
|
||||
for _ in self.loop(timeout=2):
|
||||
if self.appear(...):
|
||||
logger.info('Wait success')
|
||||
break
|
||||
else:
|
||||
logger.warning('Wait timeout')
|
||||
"""
|
||||
if timeout is not None:
|
||||
if isinstance(timeout, Timer):
|
||||
timeout.reset()
|
||||
else:
|
||||
timeout = Timer.from_seconds(timeout).start()
|
||||
|
||||
while 1:
|
||||
if timeout is not None:
|
||||
if timeout.reached():
|
||||
return
|
||||
|
||||
if skip_first:
|
||||
skip_first = False
|
||||
else:
|
||||
self.device.screenshot()
|
||||
|
||||
try:
|
||||
yield self.device.image
|
||||
except AttributeError:
|
||||
self.device.screenshot()
|
||||
yield self.device.image
|
||||
|
||||
def loop_hierarchy(self, skip_first=True):
|
||||
"""
|
||||
A syntactic sugar to start a hierarchy state loop
|
||||
|
||||
Args:
|
||||
skip_first (bool): Usually to be True to reuse the previous hierarchy
|
||||
|
||||
Yields:
|
||||
etree._Element: hierarchy
|
||||
"""
|
||||
while 1:
|
||||
if skip_first:
|
||||
skip_first = False
|
||||
else:
|
||||
self.device.dump_hierarchy()
|
||||
yield self.device.hierarchy
|
||||
|
||||
def loop_screenshot_hierarchy(self, skip_first=True):
|
||||
"""
|
||||
A syntactic sugar to start a state loop that takes screenshots and dump hierarchy
|
||||
|
||||
Args:
|
||||
skip_first (bool): Usually to be True to reuse the previous screenshot
|
||||
|
||||
Yields:
|
||||
tuple[np.ndarray, etree._Element]: screenshot, hierarchy
|
||||
"""
|
||||
while 1:
|
||||
if skip_first:
|
||||
skip_first = False
|
||||
else:
|
||||
self.device.screenshot()
|
||||
self.device.dump_hierarchy()
|
||||
yield self.device.image, self.device.hierarchy
|
||||
|
||||
def match_template(self, button, interval=0, similarity=0.85):
|
||||
"""
|
||||
Args:
|
||||
@@ -75,6 +184,36 @@ class ModuleBase:
|
||||
|
||||
return appear
|
||||
|
||||
def match_template_luma(self, button, interval=0, similarity=0.85):
|
||||
"""
|
||||
Args:
|
||||
button (ButtonWrapper):
|
||||
interval (int, float): interval between two active events.
|
||||
similarity (int, float): 0 to 1.
|
||||
|
||||
Returns:
|
||||
bool:
|
||||
|
||||
Examples:
|
||||
Image detection:
|
||||
```
|
||||
self.device.screenshot()
|
||||
self.appear(Button(area=(...), color=(...), button=(...))
|
||||
self.appear(Template(file='...')
|
||||
```
|
||||
"""
|
||||
self.device.stuck_record_add(button)
|
||||
|
||||
if interval and not self.interval_is_reached(button, interval=interval):
|
||||
return False
|
||||
|
||||
appear = button.match_template_luma(self.device.image, similarity=similarity)
|
||||
|
||||
if appear and interval:
|
||||
self.interval_reset(button, interval=interval)
|
||||
|
||||
return appear
|
||||
|
||||
def match_color(self, button, interval=0, threshold=10):
|
||||
"""
|
||||
Args:
|
||||
@@ -120,9 +259,56 @@ class ModuleBase:
|
||||
|
||||
return appear
|
||||
|
||||
appear = match_template
|
||||
def xpath(self, xpath) -> HierarchyButton:
|
||||
if isinstance(xpath, str):
|
||||
return HierarchyButton(self.device.hierarchy, xpath)
|
||||
else:
|
||||
return xpath
|
||||
|
||||
def xpath_appear(self, xpath: str, interval=0):
|
||||
button = self.xpath(xpath)
|
||||
|
||||
self.device.stuck_record_add(button)
|
||||
|
||||
if interval and not self.interval_is_reached(button, interval=interval):
|
||||
return False
|
||||
|
||||
appear = bool(button)
|
||||
|
||||
if appear and interval:
|
||||
self.interval_reset(button, interval=interval)
|
||||
|
||||
return appear
|
||||
|
||||
def appear(self, button, interval=0, similarity=0.85):
|
||||
"""
|
||||
Args:
|
||||
button (Button, ButtonWrapper, HierarchyButton, str):
|
||||
interval (int, float): interval between two active events.
|
||||
|
||||
Returns:
|
||||
bool:
|
||||
|
||||
Examples:
|
||||
Template match:
|
||||
```
|
||||
self.device.screenshot()
|
||||
self.appear(POPUP_CONFIRM)
|
||||
```
|
||||
|
||||
Hierarchy detection (detect elements with xpath):
|
||||
```
|
||||
self.device.dump_hierarchy()
|
||||
self.appear('//*[@resource-id="..."]')
|
||||
```
|
||||
"""
|
||||
if isinstance(button, (HierarchyButton, str)):
|
||||
return self.xpath_appear(button, interval=interval)
|
||||
else:
|
||||
return self.match_template(button, interval=interval, similarity=similarity)
|
||||
|
||||
def appear_then_click(self, button, interval=5, similarity=0.85):
|
||||
button = self.xpath(button)
|
||||
appear = self.appear(button, interval=interval, similarity=similarity)
|
||||
if appear:
|
||||
self.device.click(button)
|
||||
@@ -210,7 +396,7 @@ class ModuleBase:
|
||||
Returns:
|
||||
Button: Or None if nothing matched.
|
||||
"""
|
||||
image = color_similarity_2d(self.image_crop(area), color=color)
|
||||
image = color_similarity_2d(self.image_crop(area, copy=False), color=color)
|
||||
points = np.array(np.where(image > color_threshold)).T[:, ::-1]
|
||||
if points.shape[0] < encourage ** 2:
|
||||
# Not having enough pixels to match
|
||||
@@ -219,7 +405,26 @@ class ModuleBase:
|
||||
point = fit_points(points, mod=image_size(image), encourage=encourage)
|
||||
point = ensure_int(point + area[:2])
|
||||
button_area = area_offset((-encourage, -encourage, encourage, encourage), offset=point)
|
||||
return ClickButton(button=button_area, name=name)
|
||||
return ClickButton(area=button_area, name=name)
|
||||
|
||||
def get_interval_timer(self, button, interval=5, renew=False) -> Timer:
|
||||
if hasattr(button, 'name'):
|
||||
name = button.name
|
||||
elif callable(button):
|
||||
name = button.__name__
|
||||
else:
|
||||
name = str(button)
|
||||
|
||||
try:
|
||||
timer = self.interval_timer[name]
|
||||
if renew and timer.limit != interval:
|
||||
timer = Timer(interval)
|
||||
self.interval_timer[name] = timer
|
||||
return timer
|
||||
except KeyError:
|
||||
timer = Timer(interval)
|
||||
self.interval_timer[name] = timer
|
||||
return timer
|
||||
|
||||
def interval_reset(self, button, interval=5):
|
||||
if isinstance(button, (list, tuple)):
|
||||
@@ -228,10 +433,7 @@ class ModuleBase:
|
||||
return
|
||||
|
||||
if button is not None:
|
||||
if button.name in self.interval_timer:
|
||||
self.interval_timer[button.name].reset()
|
||||
else:
|
||||
self.interval_timer[button.name] = Timer(interval).reset()
|
||||
self.get_interval_timer(button, interval=interval).reset()
|
||||
|
||||
def interval_clear(self, button, interval=5):
|
||||
if isinstance(button, (list, tuple)):
|
||||
@@ -240,19 +442,10 @@ class ModuleBase:
|
||||
return
|
||||
|
||||
if button is not None:
|
||||
if button.name in self.interval_timer:
|
||||
self.interval_timer[button.name].clear()
|
||||
else:
|
||||
self.interval_timer[button.name] = Timer(interval).clear()
|
||||
self.get_interval_timer(button, interval=interval).clear()
|
||||
|
||||
def interval_is_reached(self, button, interval=5):
|
||||
if button.name in self.interval_timer:
|
||||
if self.interval_timer[button.name].limit != interval:
|
||||
self.interval_timer[button.name] = Timer(interval)
|
||||
else:
|
||||
self.interval_timer[button.name] = Timer(interval)
|
||||
|
||||
return self.interval_timer[button.name].reached()
|
||||
return self.get_interval_timer(button, interval=interval, renew=True).reached()
|
||||
|
||||
_image_file = ''
|
||||
|
||||
@@ -282,3 +475,31 @@ class ModuleBase:
|
||||
"""
|
||||
server_.set_lang(lang)
|
||||
logger.attr('Lang', self.config.LANG)
|
||||
|
||||
def screenshot_tracking_add(self):
|
||||
"""
|
||||
Add a tracking image, image will be saved
|
||||
"""
|
||||
if not self.config.Error_SaveError:
|
||||
return
|
||||
|
||||
logger.info('screenshot_tracking_add')
|
||||
data = self.device.screenshot_deque[-1]
|
||||
image = data['image']
|
||||
now = data['time']
|
||||
|
||||
def image_encode(im, ti):
|
||||
import io
|
||||
from module.handler.sensitive_info import handle_sensitive_image
|
||||
|
||||
output = io.BytesIO()
|
||||
im = handle_sensitive_image(im)
|
||||
Image.fromarray(im, mode='RGB').save(output, format='png')
|
||||
output.seek(0)
|
||||
|
||||
self.device.screenshot_tracking.append({
|
||||
'time': ti,
|
||||
'image': output
|
||||
})
|
||||
|
||||
ModuleBase.worker.submit(image_encode, image, now)
|
||||
|
||||
@@ -6,7 +6,7 @@ from module.exception import ScriptError
|
||||
|
||||
|
||||
class Button(Resource):
|
||||
def __init__(self, file, area, search, color, button):
|
||||
def __init__(self, file, area, search, color, button, posi=None):
|
||||
"""
|
||||
Args:
|
||||
file: Filepath to an assets
|
||||
@@ -20,6 +20,7 @@ class Button(Resource):
|
||||
self.search: t.Tuple[int, int, int, int] = search
|
||||
self.color: t.Tuple[int, int, int] = color
|
||||
self._button: t.Tuple[int, int, int, int] = button
|
||||
self.posi: t.Optional[t.Tuple[int, int]] = posi
|
||||
|
||||
self.resource_add(self.file)
|
||||
self._button_offset: t.Tuple[int, int] = (0, 0)
|
||||
@@ -28,18 +29,60 @@ class Button(Resource):
|
||||
def button(self):
|
||||
return area_offset(self._button, self._button_offset)
|
||||
|
||||
def load_offset(self, button):
|
||||
self._button_offset = button._button_offset
|
||||
def load_offset(self, offset):
|
||||
"""
|
||||
Args:
|
||||
offset (Button | ButtonWrapper | tuple[int, int]):
|
||||
"""
|
||||
if isinstance(offset, ButtonWrapper):
|
||||
offset = offset.matched_button._button_offset
|
||||
elif isinstance(offset, Button):
|
||||
offset = offset._button_offset
|
||||
self._button_offset = offset
|
||||
|
||||
def load_search(self, search):
|
||||
"""
|
||||
Args:
|
||||
search (Button | ButtonWrapper | tuple[int, int]):
|
||||
"""
|
||||
if isinstance(search, ButtonWrapper):
|
||||
search = search.search
|
||||
elif isinstance(search, Button):
|
||||
search = search.search
|
||||
self.search = search
|
||||
|
||||
def clear_offset(self):
|
||||
self._button_offset = (0, 0)
|
||||
|
||||
def is_offset_in(self, x=0, y=0):
|
||||
"""
|
||||
Args:
|
||||
x:
|
||||
y:
|
||||
|
||||
Returns:
|
||||
bool: If _button_offset is in (-x, -y, x, y)
|
||||
"""
|
||||
if x:
|
||||
if self._button_offset[0] < -x or self._button_offset[0] > x:
|
||||
return False
|
||||
if y:
|
||||
if self._button_offset[1] < -y or self._button_offset[1] > y:
|
||||
return False
|
||||
return True
|
||||
|
||||
@cached_property
|
||||
def image(self):
|
||||
return load_image(self.file, self.area)
|
||||
|
||||
@cached_property
|
||||
def image_luma(self):
|
||||
return rgb2luma(self.image)
|
||||
|
||||
|
||||
def resource_release(self):
|
||||
del_cached_property(self, 'image')
|
||||
del_cached_property(self, 'image_luma')
|
||||
self.clear_offset()
|
||||
|
||||
def __str__(self):
|
||||
@@ -92,6 +135,29 @@ class Button(Resource):
|
||||
image = crop(image, self.search, copy=False)
|
||||
res = cv2.matchTemplate(self.image, image, cv2.TM_CCOEFF_NORMED)
|
||||
_, sim, _, point = cv2.minMaxLoc(res)
|
||||
self._button_offset = np.array(point) + self.search[:2] - self.area[:2]
|
||||
|
||||
return sim > similarity
|
||||
|
||||
def match_template_luma(self, image, similarity=0.85, direct_match=False) -> bool:
|
||||
"""
|
||||
Detects assets by template matching.
|
||||
|
||||
To Some buttons, its location may not be static, `_button_offset` will be set.
|
||||
|
||||
Args:
|
||||
image: Screenshot.
|
||||
similarity (float): 0-1.
|
||||
direct_match: True to ignore `self.search`
|
||||
|
||||
Returns:
|
||||
bool.
|
||||
"""
|
||||
if not direct_match:
|
||||
image = crop(image, self.search, copy=False)
|
||||
image = rgb2luma(image)
|
||||
res = cv2.matchTemplate(self.image_luma, image, cv2.TM_CCOEFF_NORMED)
|
||||
_, sim, _, point = cv2.minMaxLoc(res)
|
||||
|
||||
self._button_offset = np.array(point) + self.search[:2] - self.area[:2]
|
||||
return sim > similarity
|
||||
@@ -134,7 +200,7 @@ class Button(Resource):
|
||||
Returns:
|
||||
bool.
|
||||
"""
|
||||
matched = self.match_template(image, similarity=similarity, direct_match=direct_match)
|
||||
matched = self.match_template_luma(image, similarity=similarity, direct_match=direct_match)
|
||||
if not matched:
|
||||
return False
|
||||
|
||||
@@ -208,6 +274,13 @@ class ButtonWrapper(Resource):
|
||||
return True
|
||||
return False
|
||||
|
||||
def match_template_luma(self, image, similarity=0.85, direct_match=False) -> bool:
|
||||
for assets in self.buttons:
|
||||
if assets.match_template_luma(image, similarity=similarity, direct_match=direct_match):
|
||||
self._matched_button = assets
|
||||
return True
|
||||
return False
|
||||
|
||||
def match_multi_template(self, image, similarity=0.85, threshold=5, direct_match=False):
|
||||
"""
|
||||
Detects assets by template matching, return multiple results
|
||||
@@ -284,10 +357,8 @@ class ButtonWrapper(Resource):
|
||||
Load offset from another button.
|
||||
|
||||
Args:
|
||||
button (Button, ButtonWrapper):
|
||||
button (Button | ButtonWrapper | tuple[int, int]):
|
||||
"""
|
||||
if isinstance(button, ButtonWrapper):
|
||||
button = button.matched_button
|
||||
for b in self.iter_buttons():
|
||||
b.load_offset(button)
|
||||
|
||||
@@ -295,16 +366,55 @@ class ButtonWrapper(Resource):
|
||||
for b in self.iter_buttons():
|
||||
b.clear_offset()
|
||||
|
||||
def load_search(self, area):
|
||||
def is_offset_in(self, x=0, y=0):
|
||||
"""
|
||||
Args:
|
||||
x:
|
||||
y:
|
||||
|
||||
Returns:
|
||||
bool: If _button_offset is in (-x, -y, x, y)
|
||||
"""
|
||||
return self.matched_button.is_offset_in(x=x, y=y)
|
||||
|
||||
def load_search(self, search):
|
||||
"""
|
||||
Set `search` attribute.
|
||||
Note that this method is irreversible.
|
||||
|
||||
Args:
|
||||
area:
|
||||
search (Button | ButtonWrapper | tuple[int, int, int, int]):
|
||||
"""
|
||||
for b in self.iter_buttons():
|
||||
b.search = area
|
||||
b.load_search(search)
|
||||
|
||||
def set_search_offset(self, offset):
|
||||
"""
|
||||
Compatible with Alas’ `offset` attribute
|
||||
In ALAS:
|
||||
if self.appear(BUTTON, offset=(20, 20)):
|
||||
pass
|
||||
In SRC:
|
||||
BUTTON.set_search_offset((20, 20))
|
||||
if self.appear(BUTTON):
|
||||
pass
|
||||
Note that `search` attribute will be set, and it's irreversible.
|
||||
|
||||
Args:
|
||||
offset (tuple): (x, y) or (left, up, right, bottom)
|
||||
"""
|
||||
if len(offset) == 2:
|
||||
left, up, right, bottom = -offset[0], -offset[1], offset[0], offset[1]
|
||||
else:
|
||||
left, up, right, bottom = offset
|
||||
for b in self.iter_buttons():
|
||||
upper_left_x, upper_left_y, bottom_right_x, bottom_right_y = b.area
|
||||
b.search = (
|
||||
upper_left_x + left,
|
||||
upper_left_y + up,
|
||||
bottom_right_x + right,
|
||||
bottom_right_y + bottom,
|
||||
)
|
||||
|
||||
|
||||
class ClickButton:
|
||||
|
||||
@@ -33,23 +33,33 @@ class TabWrapper:
|
||||
self.suffix += suffix
|
||||
|
||||
|
||||
class VariableWrapper:
|
||||
def __init__(self, name):
|
||||
self.name = name
|
||||
|
||||
def __repr__(self):
|
||||
return str(self.name)
|
||||
|
||||
__str__ = __repr__
|
||||
|
||||
|
||||
class CodeGenerator:
|
||||
def __init__(self):
|
||||
self.tab_count = 0
|
||||
self.lines = []
|
||||
|
||||
def generate(self) -> t.Iterable[str]:
|
||||
yield ''
|
||||
|
||||
def add(self, line, comment=False, newline=True):
|
||||
self.lines.append(self._line_with_tabs(line, comment=comment, newline=newline))
|
||||
|
||||
def generate(self) -> t.Iterable[str]:
|
||||
return ''.join(self.lines)
|
||||
|
||||
def print(self):
|
||||
lines = ''.join(self.lines)
|
||||
lines = self.generate()
|
||||
print(lines)
|
||||
|
||||
def write(self, file: str = None):
|
||||
lines = ''.join(self.lines)
|
||||
lines = self.generate()
|
||||
with open(file, 'w', encoding='utf-8', newline='') as f:
|
||||
f.write(lines)
|
||||
|
||||
@@ -77,7 +87,10 @@ class CodeGenerator:
|
||||
return TabWrapper(self)
|
||||
|
||||
def Empty(self):
|
||||
self.add('')
|
||||
self.lines.append('\n')
|
||||
|
||||
def Pass(self):
|
||||
self.add('pass')
|
||||
|
||||
def Import(self, text, empty=2):
|
||||
for line in text.strip().split('\n'):
|
||||
@@ -86,6 +99,9 @@ class CodeGenerator:
|
||||
for _ in range(empty):
|
||||
self.Empty()
|
||||
|
||||
def Variable(self, name):
|
||||
return VariableWrapper(name)
|
||||
|
||||
def Value(self, key=None, value=None, type_=None, **kwargs):
|
||||
if key is not None:
|
||||
if type_ is not None:
|
||||
@@ -117,7 +133,7 @@ class CodeGenerator:
|
||||
if key is not None:
|
||||
return TabWrapper(self, prefix=str(key) + ' = [', suffix=']')
|
||||
else:
|
||||
return TabWrapper(self, prefix='[', suffix=']', newline=False)
|
||||
return TabWrapper(self, prefix='[', suffix=']')
|
||||
|
||||
def ListItem(self, value):
|
||||
if isinstance(value, TabWrapper):
|
||||
@@ -131,7 +147,7 @@ class CodeGenerator:
|
||||
if key is not None:
|
||||
return TabWrapper(self, prefix=str(key) + ' = {', suffix='}')
|
||||
else:
|
||||
return TabWrapper(self, prefix='{', suffix='}', newline=False)
|
||||
return TabWrapper(self, prefix='{', suffix='}')
|
||||
|
||||
def DictItem(self, key=None, value=None):
|
||||
if isinstance(value, TabWrapper):
|
||||
@@ -147,7 +163,7 @@ class CodeGenerator:
|
||||
if key is not None:
|
||||
return TabWrapper(self, prefix=f'{key} = {object_class}(', suffix=')')
|
||||
else:
|
||||
return TabWrapper(self, prefix=f'{object_class}(', suffix=')', newline=False)
|
||||
return TabWrapper(self, prefix=f'{object_class}(', suffix=')')
|
||||
|
||||
def ObjectAttr(self, key=None, value=None):
|
||||
if isinstance(value, TabWrapper):
|
||||
@@ -179,3 +195,33 @@ Value = generator.Value
|
||||
Comment = generator.Comment
|
||||
Dict = generator.Dict
|
||||
DictItem = generator.DictItem
|
||||
|
||||
|
||||
class MarkdownGenerator:
|
||||
def __init__(self, column: t.List[str]):
|
||||
self.rows = [column]
|
||||
|
||||
def add_row(self, row):
|
||||
self.rows.append([str(ele) for ele in row])
|
||||
|
||||
def product_line(self, row, max_width):
|
||||
row = [ele.ljust(width) for ele, width in zip(row, max_width)]
|
||||
row = ' | '.join(row)
|
||||
row = '| ' + row + ' |'
|
||||
return row
|
||||
|
||||
def generate(self) -> t.List[str]:
|
||||
import numpy as np
|
||||
width = np.array([
|
||||
[len(ele) for ele in row] for row in self.rows
|
||||
])
|
||||
max_width = np.max(width, axis=0)
|
||||
dash = ['-' * width for width in max_width]
|
||||
|
||||
rows = [
|
||||
self.product_line(self.rows[0], max_width),
|
||||
self.product_line(dash, max_width),
|
||||
] + [
|
||||
self.product_line(row, max_width) for row in self.rows[1:]
|
||||
]
|
||||
return rows
|
||||
|
||||
@@ -123,6 +123,18 @@ def has_cached_property(obj, name):
|
||||
return name in obj.__dict__
|
||||
|
||||
|
||||
def set_cached_property(obj, name, value):
|
||||
"""
|
||||
Set a cached property.
|
||||
|
||||
Args:
|
||||
obj:
|
||||
name (str):
|
||||
value:
|
||||
"""
|
||||
obj.__dict__[name] = value
|
||||
|
||||
|
||||
def function_drop(rate=0.5, default=None):
|
||||
"""
|
||||
Drop function calls to simulate random emulator stuck, for testing purpose.
|
||||
|
||||
@@ -78,5 +78,11 @@ def release_resources(next_task=''):
|
||||
# logger.info(f'Release {obj}')
|
||||
obj.resource_release()
|
||||
|
||||
# If no task, check in-game text language again at next run
|
||||
# cause user may change it
|
||||
if not next_task:
|
||||
from tasks.base.main_page import MainPage
|
||||
MainPage._lang_checked = False
|
||||
|
||||
# Useless in most cases, but just call it
|
||||
# gc.collect()
|
||||
@@ -0,0 +1,110 @@
|
||||
import threading
|
||||
from typing import Type, TypeVar
|
||||
|
||||
T = TypeVar('T')
|
||||
|
||||
|
||||
class Singleton(type):
|
||||
"""
|
||||
A metaclass for creating a global singleton.
|
||||
|
||||
Any class using this metaclass will have only one instance.
|
||||
Subclasses will have their own unique singleton instance.
|
||||
This implementation is thread-safe.
|
||||
"""
|
||||
|
||||
def __init__(cls, name, bases, dct):
|
||||
super().__init__(name, bases, dct)
|
||||
cls.__instances = None
|
||||
cls.__lock = threading.Lock()
|
||||
|
||||
def __call__(cls: Type[T], *args, **kwargs) -> T:
|
||||
# return cached instance directly
|
||||
instance = cls.__instances
|
||||
if instance is not None:
|
||||
return instance
|
||||
|
||||
# create new instance
|
||||
with cls.__lock:
|
||||
# another thread may have created while we are waiting
|
||||
instance = cls.__instances
|
||||
if instance is not None:
|
||||
return instance
|
||||
|
||||
# create
|
||||
instance = super().__call__(*args, **kwargs)
|
||||
cls.__instances = instance
|
||||
return instance
|
||||
|
||||
def singleton_clear_all(cls):
|
||||
"""
|
||||
Remove all instances
|
||||
"""
|
||||
with cls.__lock:
|
||||
cls.__instances = None
|
||||
|
||||
|
||||
class SingletonNamed(type):
|
||||
"""
|
||||
A metaclass for creating a named singleton.
|
||||
|
||||
Instances are created based on the first argument provided to the constructor.
|
||||
Each class will have its own separate cache of named instances.
|
||||
This implementation is thread-safe.
|
||||
"""
|
||||
|
||||
def __init__(cls, name, bases, dct):
|
||||
super().__init__(name, bases, dct)
|
||||
cls.__instances = {}
|
||||
cls.__lock = threading.Lock()
|
||||
|
||||
def __call__(cls: Type[T], name, *args, **kwargs) -> T:
|
||||
# return cached instance directly
|
||||
try:
|
||||
return cls.__instances[name]
|
||||
except KeyError:
|
||||
pass
|
||||
|
||||
# create new instance
|
||||
with cls.__lock:
|
||||
# another thread may have created while we are waiting
|
||||
try:
|
||||
return cls.__instances[name]
|
||||
except KeyError:
|
||||
pass
|
||||
|
||||
# create
|
||||
instance = super().__call__(name, *args, **kwargs)
|
||||
cls.__instances[name] = instance
|
||||
return instance
|
||||
|
||||
def singleton_remove(cls, name):
|
||||
"""
|
||||
Remove a specific instance.
|
||||
Instance will be re-created, the nest time it is requested.
|
||||
|
||||
Returns:
|
||||
bool: If removed
|
||||
"""
|
||||
# delete from dict is threadsafe
|
||||
try:
|
||||
del cls.__instances[name]
|
||||
return True
|
||||
except KeyError:
|
||||
return False
|
||||
|
||||
def singleton_clear_all(cls):
|
||||
"""
|
||||
Remove all instances
|
||||
"""
|
||||
with cls.__lock:
|
||||
cls.__instances.clear()
|
||||
|
||||
def singleton_instances(cls):
|
||||
"""
|
||||
Access all instances directly
|
||||
|
||||
Returns:
|
||||
dict:
|
||||
"""
|
||||
return cls.__instances
|
||||
@@ -1,137 +1,168 @@
|
||||
import time
|
||||
from datetime import datetime, timedelta
|
||||
from datetime import datetime
|
||||
from functools import wraps
|
||||
from time import sleep, time
|
||||
|
||||
|
||||
def timer(function):
|
||||
"""
|
||||
Decorator to time a function, for debug only
|
||||
"""
|
||||
|
||||
@wraps(function)
|
||||
def function_timer(*args, **kwargs):
|
||||
t0 = time.time()
|
||||
|
||||
start = time()
|
||||
result = function(*args, **kwargs)
|
||||
t1 = time.time()
|
||||
print('%s: %s s' % (function.__name__, str(round(t1 - t0, 10))))
|
||||
cost = time() - start
|
||||
print(f'{function.__name__}: {cost:.10f} s')
|
||||
return result
|
||||
|
||||
return function_timer
|
||||
|
||||
|
||||
def future_time(string):
|
||||
def now():
|
||||
"""
|
||||
Args:
|
||||
string (str): Such as 14:59.
|
||||
|
||||
Returns:
|
||||
datetime.datetime: Time with given hour, minute in the future.
|
||||
Get datatime now without timezone
|
||||
"""
|
||||
hour, minute = [int(x) for x in string.split(':')]
|
||||
future = datetime.now().replace(hour=hour, minute=minute, second=0, microsecond=0)
|
||||
future = future + timedelta(days=1) if future < datetime.now() else future
|
||||
return future
|
||||
return datetime.now().replace(microsecond=0)
|
||||
|
||||
|
||||
def past_time(string):
|
||||
def nowtz():
|
||||
"""
|
||||
Args:
|
||||
string (str): Such as 14:59.
|
||||
|
||||
Returns:
|
||||
datetime.datetime: Time with given hour, minute in the past.
|
||||
Get datatime now with timezone
|
||||
"""
|
||||
hour, minute = [int(x) for x in string.split(':')]
|
||||
past = datetime.now().replace(hour=hour, minute=minute, second=0, microsecond=0)
|
||||
past = past - timedelta(days=1) if past > datetime.now() else past
|
||||
return past
|
||||
|
||||
|
||||
def future_time_range(string):
|
||||
"""
|
||||
Args:
|
||||
string (str): Such as 23:30-06:30.
|
||||
|
||||
Returns:
|
||||
tuple(datetime.datetime): (time start, time end).
|
||||
"""
|
||||
start, end = [future_time(s) for s in string.split('-')]
|
||||
if start > end:
|
||||
start = start - timedelta(days=1)
|
||||
return start, end
|
||||
|
||||
|
||||
def time_range_active(time_range):
|
||||
"""
|
||||
Args:
|
||||
time_range(tuple(datetime.datetime)): (time start, time end).
|
||||
|
||||
Returns:
|
||||
bool:
|
||||
"""
|
||||
return time_range[0] < datetime.now() < time_range[1]
|
||||
return datetime.now().replace(microsecond=0).astimezone()
|
||||
|
||||
|
||||
class Timer:
|
||||
def __init__(self, limit, count=0):
|
||||
"""
|
||||
Dual timer for time count and access count.
|
||||
Access count can provide robustness on slow devices where screenshot time cost > timer.limit
|
||||
|
||||
Args:
|
||||
limit (int, float): Timer limit
|
||||
count (int): Timer reach confirm count. Default to 0.
|
||||
When using a structure like this, must set a count.
|
||||
Otherwise it goes wrong, if screenshot time cost greater than limit.
|
||||
|
||||
if self.appear(MAIN_CHECK):
|
||||
if confirm_timer.reached():
|
||||
pass
|
||||
else:
|
||||
confirm_timer.reset()
|
||||
|
||||
Also, It's a good idea to set `count`, to make alas run more stable on slow computers.
|
||||
Expected speed is 0.35 second / screenshot.
|
||||
limit (int | float): Timer limit
|
||||
count (int): Timer access count. Default to 0.
|
||||
"""
|
||||
self.limit = limit
|
||||
self.count = count
|
||||
self._current = 0
|
||||
self._reach_count = count
|
||||
self._start = 0.
|
||||
self._access = 0
|
||||
|
||||
@classmethod
|
||||
def from_seconds(cls, limit, speed=0.5):
|
||||
"""
|
||||
Create timer from given seconds
|
||||
|
||||
Args:
|
||||
limit (int | float):
|
||||
speed (int | float): Approximate screenshot time cost
|
||||
if time cost > 0.5s, device is considered slow
|
||||
"""
|
||||
count = int(limit / speed)
|
||||
return cls(limit, count=count)
|
||||
|
||||
def start(self):
|
||||
if not self.started():
|
||||
self._current = time.time()
|
||||
self._reach_count = 0
|
||||
"""
|
||||
Start current timer.
|
||||
If timer not started, reached() always return True. So we can have fast first try on:
|
||||
|
||||
interval = Timer(2)
|
||||
while 1:
|
||||
if interval.reached():
|
||||
pass
|
||||
"""
|
||||
if self._start <= 0:
|
||||
self._start = time()
|
||||
self._access = 0
|
||||
|
||||
return self
|
||||
|
||||
def started(self):
|
||||
return bool(self._current)
|
||||
|
||||
def current(self):
|
||||
"""
|
||||
Returns:
|
||||
float
|
||||
bool:
|
||||
"""
|
||||
if self.started():
|
||||
return time.time() - self._current
|
||||
return self._start > 0
|
||||
|
||||
def current_time(self):
|
||||
"""
|
||||
Returns:
|
||||
float:
|
||||
"""
|
||||
if self._start > 0:
|
||||
diff = time() - self._start
|
||||
if diff < 0:
|
||||
diff = 0.
|
||||
return diff
|
||||
else:
|
||||
return 0.
|
||||
|
||||
def set_current(self, current, count=0):
|
||||
self._current = time.time() - current
|
||||
self._reach_count = count
|
||||
def current_count(self):
|
||||
"""
|
||||
Returns:
|
||||
int:
|
||||
"""
|
||||
return self._access
|
||||
|
||||
def set(self, current=None, count=None, speed=0.5):
|
||||
"""
|
||||
Set internal state directly
|
||||
|
||||
Args:
|
||||
current (int, float):
|
||||
count (int):
|
||||
speed (int, float):
|
||||
"""
|
||||
if current is not None:
|
||||
if count is not None:
|
||||
# set both
|
||||
self._start = time() - current
|
||||
self._access = count
|
||||
else:
|
||||
# set current only, calculate count
|
||||
count = int(current / speed)
|
||||
self._start = time() - current
|
||||
self._access = count
|
||||
else:
|
||||
if count is not None:
|
||||
# set count only
|
||||
self._access = count
|
||||
else:
|
||||
# nothing to set
|
||||
pass
|
||||
return self
|
||||
|
||||
def add_count(self):
|
||||
self._access += 1
|
||||
return self
|
||||
|
||||
def reached(self):
|
||||
"""
|
||||
Returns:
|
||||
bool
|
||||
bool:
|
||||
"""
|
||||
self._reach_count += 1
|
||||
return time.time() - self._current > self.limit and self._reach_count > self.count
|
||||
# each reached() call is consider as an access
|
||||
self._access += 1
|
||||
if self._start > 0:
|
||||
return self._access > self.count and time() - self._start > self.limit
|
||||
else:
|
||||
# not started, return True for fast first try
|
||||
return True
|
||||
|
||||
def reset(self):
|
||||
self._current = time.time()
|
||||
self._reach_count = 0
|
||||
"""
|
||||
Reset the timer as if it just started
|
||||
"""
|
||||
self._start = time()
|
||||
self._access = 0
|
||||
return self
|
||||
|
||||
def clear(self):
|
||||
self._current = 0
|
||||
self._reach_count = self.count
|
||||
"""
|
||||
Reset the timer as if it never started
|
||||
"""
|
||||
self._start = 0.
|
||||
self._access = self.count
|
||||
return self
|
||||
|
||||
def reached_and_reset(self):
|
||||
@@ -149,15 +180,16 @@ class Timer:
|
||||
"""
|
||||
Wait until timer reached.
|
||||
"""
|
||||
diff = self._current + self.limit - time.time()
|
||||
diff = self._start + self.limit - time()
|
||||
if diff > 0:
|
||||
time.sleep(diff)
|
||||
sleep(diff)
|
||||
|
||||
def show(self):
|
||||
from module.logger import logger
|
||||
logger.info(str(self))
|
||||
|
||||
def __str__(self):
|
||||
return f'Timer(limit={round(self.current(), 3)}/{self.limit}, count={self._reach_count}/{self.count})'
|
||||
# Timer(limit=2.351/3, count=4/6)
|
||||
return f'Timer(limit={round(self.current_time(), 3)}/{self.limit}, count={self._access}/{self.count})'
|
||||
|
||||
__repr__ = __str__
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import random
|
||||
import re
|
||||
|
||||
import cv2
|
||||
@@ -8,8 +9,9 @@ REGEX_NODE = re.compile(r'(-?[A-Za-z]+)(-?\d+)')
|
||||
|
||||
|
||||
def random_normal_distribution_int(a, b, n=3):
|
||||
"""Generate a normal distribution int within the interval. Use the average value of several random numbers to
|
||||
simulate normal distribution.
|
||||
"""
|
||||
Generate a normal distribution int within the interval.
|
||||
Use the average value of several random numbers to simulate normal distribution.
|
||||
|
||||
Args:
|
||||
a (int): The minimum of the interval.
|
||||
@@ -19,9 +21,13 @@ def random_normal_distribution_int(a, b, n=3):
|
||||
Returns:
|
||||
int
|
||||
"""
|
||||
a = round(a)
|
||||
b = round(b)
|
||||
if a < b:
|
||||
output = np.mean(np.random.randint(a, b, size=n))
|
||||
return int(output.round())
|
||||
total = 0
|
||||
for _ in range(n):
|
||||
total += random.randint(a, b)
|
||||
return round(total / n)
|
||||
else:
|
||||
return b
|
||||
|
||||
@@ -274,6 +280,20 @@ def area_size(area):
|
||||
)
|
||||
|
||||
|
||||
def area_center(area):
|
||||
"""
|
||||
Get the center of an area
|
||||
|
||||
Args:
|
||||
area: (upper_left_x, upper_left_y, bottom_right_x, bottom_right_y)
|
||||
|
||||
Returns:
|
||||
tuple: (x, y)
|
||||
"""
|
||||
x1, y1, x2, y2 = area
|
||||
return (x1 + x2) / 2, (y1 + y2) / 2
|
||||
|
||||
|
||||
def point_limit(point, area):
|
||||
"""
|
||||
Limit point in an area.
|
||||
@@ -497,6 +517,22 @@ def location2node(location):
|
||||
return col2name(x) + str(y)
|
||||
|
||||
|
||||
def xywh2xyxy(area):
|
||||
"""
|
||||
Convert (x, y, width, height) to (x1, y1, x2, y2)
|
||||
"""
|
||||
x, y, w, h = area
|
||||
return x, y, x + w, y + h
|
||||
|
||||
|
||||
def xyxy2xywh(area):
|
||||
"""
|
||||
Convert (x1, y1, x2, y2) to (x, y, width, height)
|
||||
"""
|
||||
x1, y1, x2, y2 = area
|
||||
return min(x1, x2), min(y1, y2), abs(x2 - x1), abs(y2 - y1)
|
||||
|
||||
|
||||
def load_image(file, area=None):
|
||||
"""
|
||||
Load an image like pillow and drop alpha channel.
|
||||
@@ -508,13 +544,17 @@ def load_image(file, area=None):
|
||||
Returns:
|
||||
np.ndarray:
|
||||
"""
|
||||
image = Image.open(file)
|
||||
if area is not None:
|
||||
image = image.crop(area)
|
||||
image = np.array(image)
|
||||
channel = image.shape[2] if len(image.shape) > 2 else 1
|
||||
if channel > 3:
|
||||
image = image[:, :, :3].copy()
|
||||
# always remember to close Image object
|
||||
with Image.open(file) as f:
|
||||
if area is not None:
|
||||
f = f.crop(area)
|
||||
|
||||
image = np.array(f)
|
||||
|
||||
channel = image_channel(image)
|
||||
if channel == 4:
|
||||
image = cv2.cvtColor(image, cv2.COLOR_RGBA2RGB)
|
||||
|
||||
return image
|
||||
|
||||
|
||||
@@ -531,6 +571,19 @@ def save_image(image, file):
|
||||
Image.fromarray(image).save(file)
|
||||
|
||||
|
||||
def copy_image(src):
|
||||
"""
|
||||
Equivalent to image.copy() but a little bit faster
|
||||
|
||||
Time cost to copy a 1280*720*3 image:
|
||||
image.copy() 0.743ms
|
||||
copy_image(image) 0.639ms
|
||||
"""
|
||||
dst = np.empty_like(src)
|
||||
cv2.copyTo(src, None, dst)
|
||||
return dst
|
||||
|
||||
|
||||
def crop(image, area, copy=True):
|
||||
"""
|
||||
Crop image like pillow, when using opencv / numpy.
|
||||
@@ -544,16 +597,72 @@ def crop(image, area, copy=True):
|
||||
Returns:
|
||||
np.ndarray:
|
||||
"""
|
||||
x1, y1, x2, y2 = map(int, map(round, area))
|
||||
h, w = image.shape[:2]
|
||||
border = np.maximum((0 - y1, y2 - h, 0 - x1, x2 - w), 0)
|
||||
x1, y1, x2, y2 = np.maximum((x1, y1, x2, y2), 0)
|
||||
# map(round, area)
|
||||
x1, y1, x2, y2 = area
|
||||
x1 = round(x1)
|
||||
y1 = round(y1)
|
||||
x2 = round(x2)
|
||||
y2 = round(y2)
|
||||
# h, w = image.shape[:2]
|
||||
shape = image.shape
|
||||
h = shape[0]
|
||||
w = shape[1]
|
||||
# top, bottom, left, right
|
||||
# border = np.maximum((0 - y1, y2 - h, 0 - x1, x2 - w), 0)
|
||||
overflow = False
|
||||
if y1 >= 0:
|
||||
top = 0
|
||||
if y1 >= h:
|
||||
overflow = True
|
||||
else:
|
||||
top = -y1
|
||||
if y2 > h:
|
||||
bottom = y2 - h
|
||||
else:
|
||||
bottom = 0
|
||||
if y2 <= 0:
|
||||
overflow = True
|
||||
if x1 >= 0:
|
||||
left = 0
|
||||
if x1 >= w:
|
||||
overflow = True
|
||||
else:
|
||||
left = -x1
|
||||
if x2 > w:
|
||||
right = x2 - w
|
||||
else:
|
||||
right = 0
|
||||
if x2 <= 0:
|
||||
overflow = True
|
||||
# If overflowed, return empty image
|
||||
if overflow:
|
||||
if len(shape) == 2:
|
||||
size = (y2 - y1, x2 - x1)
|
||||
else:
|
||||
size = (y2 - y1, x2 - x1, shape[2])
|
||||
return np.zeros(size, dtype=image.dtype)
|
||||
# x1, y1, x2, y2 = np.maximum((x1, y1, x2, y2), 0)
|
||||
if x1 < 0:
|
||||
x1 = 0
|
||||
if y1 < 0:
|
||||
y1 = 0
|
||||
if x2 < 0:
|
||||
x2 = 0
|
||||
if y2 < 0:
|
||||
y2 = 0
|
||||
# crop image
|
||||
image = image[y1:y2, x1:x2]
|
||||
if sum(border) > 0:
|
||||
image = cv2.copyMakeBorder(image, *border, borderType=cv2.BORDER_CONSTANT, value=(0, 0, 0))
|
||||
if copy:
|
||||
image = image.copy()
|
||||
return image
|
||||
# if border
|
||||
if top or bottom or left or right:
|
||||
if len(shape) == 2:
|
||||
value = 0
|
||||
else:
|
||||
value = tuple(0 for _ in range(image.shape[2]))
|
||||
return cv2.copyMakeBorder(image, top, bottom, left, right, borderType=cv2.BORDER_CONSTANT, value=value)
|
||||
elif copy:
|
||||
return copy_image(image)
|
||||
else:
|
||||
return image
|
||||
|
||||
|
||||
def resize(image, size):
|
||||
@@ -611,17 +720,29 @@ def image_paste(image, background, origin):
|
||||
|
||||
def rgb2gray(image):
|
||||
"""
|
||||
gray = ( MAX(r, g, b) + MIN(r, g, b)) / 2
|
||||
|
||||
Args:
|
||||
image (np.ndarray): Shape (height, width, channel)
|
||||
|
||||
Returns:
|
||||
np.ndarray: Shape (height, width)
|
||||
"""
|
||||
# r, g, b = cv2.split(image)
|
||||
# return cv2.add(
|
||||
# cv2.multiply(cv2.max(cv2.max(r, g), b), 0.5),
|
||||
# cv2.multiply(cv2.min(cv2.min(r, g), b), 0.5)
|
||||
# )
|
||||
r, g, b = cv2.split(image)
|
||||
return cv2.add(
|
||||
cv2.multiply(cv2.max(cv2.max(r, g), b), 0.5),
|
||||
cv2.multiply(cv2.min(cv2.min(r, g), b), 0.5)
|
||||
)
|
||||
maximum = cv2.max(r, g)
|
||||
cv2.min(r, g, dst=r)
|
||||
cv2.max(maximum, b, dst=maximum)
|
||||
cv2.min(r, b, dst=r)
|
||||
# minimum = r
|
||||
cv2.convertScaleAbs(maximum, alpha=0.5, dst=maximum)
|
||||
cv2.convertScaleAbs(r, alpha=0.5, dst=r)
|
||||
cv2.add(maximum, r, dst=maximum)
|
||||
return maximum
|
||||
|
||||
|
||||
def rgb2hsv(image):
|
||||
@@ -636,7 +757,7 @@ def rgb2hsv(image):
|
||||
np.ndarray: Hue (0~360), Saturation (0~100), Value (0~100).
|
||||
"""
|
||||
image = cv2.cvtColor(image, cv2.COLOR_RGB2HSV).astype(float)
|
||||
image *= (360 / 180, 100 / 255, 100 / 255)
|
||||
cv2.multiply(image, (360 / 180, 100 / 255, 100 / 255, 0), dst=image)
|
||||
return image
|
||||
|
||||
|
||||
@@ -684,40 +805,133 @@ def get_color(image, area):
|
||||
return color[:3]
|
||||
|
||||
|
||||
class ImageNotSupported(Exception):
|
||||
"""
|
||||
Raised if we can't perform image calculation on this image
|
||||
"""
|
||||
pass
|
||||
|
||||
|
||||
def get_bbox(image, threshold=0):
|
||||
"""
|
||||
A numpy implementation of the getbbox() in pillow.
|
||||
Get outbound box of the content in image
|
||||
A opencv implementation of the getbbox() in pillow
|
||||
|
||||
Args:
|
||||
image (np.ndarray): Screenshot.
|
||||
threshold (int): Color <= threshold will be considered black
|
||||
image (np.ndarray):
|
||||
threshold (int):
|
||||
color > threshold will be considered as content
|
||||
color <= threshold will be considered background
|
||||
|
||||
Returns:
|
||||
tuple: (upper_left_x, upper_left_y, bottom_right_x, bottom_right_y)
|
||||
tuple[int, int, int, int]: area
|
||||
|
||||
Raises:
|
||||
ImageNotSupported: if failed to get bbox
|
||||
"""
|
||||
if image_channel(image) == 3:
|
||||
image = np.max(image, axis=2)
|
||||
x = np.where(np.max(image, axis=0) > threshold)[0]
|
||||
y = np.where(np.max(image, axis=1) > threshold)[0]
|
||||
return x[0], y[0], x[-1] + 1, y[-1] + 1
|
||||
channel = image_channel(image)
|
||||
# convert to grayscale
|
||||
if channel == 3:
|
||||
# RGB
|
||||
mask = cv2.cvtColor(image, cv2.COLOR_RGB2GRAY)
|
||||
cv2.threshold(mask, threshold, 255, cv2.THRESH_BINARY, dst=mask)
|
||||
elif channel == 0:
|
||||
# grayscale
|
||||
_, mask = cv2.threshold(image, threshold, 255, cv2.THRESH_BINARY)
|
||||
elif channel == 4:
|
||||
# RGBA
|
||||
mask = cv2.cvtColor(image, cv2.COLOR_RGBA2GRAY)
|
||||
cv2.threshold(mask, threshold, 255, cv2.THRESH_BINARY, dst=mask)
|
||||
else:
|
||||
raise ImageNotSupported(f'shape={image.shape}')
|
||||
|
||||
# find bbox
|
||||
contours, _ = cv2.findContours(mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
|
||||
min_y, min_x = mask.shape
|
||||
max_x = 0
|
||||
max_y = 0
|
||||
# all black
|
||||
if not contours:
|
||||
raise ImageNotSupported(f'Cannot get bbox from a pure black image')
|
||||
for contour in contours:
|
||||
# x, y, w, h
|
||||
x1, y1, x2, y2 = cv2.boundingRect(contour)
|
||||
x2 += x1
|
||||
y2 += y1
|
||||
if x1 < min_x:
|
||||
min_x = x1
|
||||
if y1 < min_y:
|
||||
min_y = y1
|
||||
if x2 > max_x:
|
||||
max_x = x2
|
||||
if y2 > max_y:
|
||||
max_y = y2
|
||||
if min_x < max_x and min_y < max_y:
|
||||
return min_x, min_y, max_x, max_y
|
||||
else:
|
||||
# This shouldn't happen
|
||||
raise ImageNotSupported(f'Empty bbox {(min_x, min_y, max_x, max_y)}')
|
||||
|
||||
|
||||
def get_bbox_reversed(image, threshold=0):
|
||||
def get_bbox_reversed(image, threshold=255):
|
||||
"""
|
||||
Similar to `get_bbox` but for black contents on white background.
|
||||
Get outbound box of the content in image
|
||||
A opencv implementation of the getbbox() in pillow
|
||||
|
||||
Args:
|
||||
image (np.ndarray): Screenshot.
|
||||
threshold (int): Color >= threshold will be considered white
|
||||
image (np.ndarray):
|
||||
threshold (int):
|
||||
color < threshold will be considered as content
|
||||
color >= threshold will be considered background
|
||||
|
||||
Returns:
|
||||
tuple: (upper_left_x, upper_left_y, bottom_right_x, bottom_right_y)
|
||||
tuple[int, int, int, int]: area
|
||||
|
||||
Raises:
|
||||
ImageNotSupported: if failed to get bbox
|
||||
"""
|
||||
if image_channel(image) == 3:
|
||||
image = np.min(image, axis=2)
|
||||
x = np.where(np.min(image, axis=0) < threshold)[0]
|
||||
y = np.where(np.min(image, axis=1) < threshold)[0]
|
||||
return x[0], y[0], x[-1] + 1, y[-1] + 1
|
||||
channel = image_channel(image)
|
||||
# convert to grayscale
|
||||
if channel == 3:
|
||||
# RGB
|
||||
mask = cv2.cvtColor(image, cv2.COLOR_RGB2GRAY)
|
||||
cv2.threshold(mask, 0, threshold, cv2.THRESH_BINARY, dst=mask)
|
||||
elif channel == 0:
|
||||
# grayscale
|
||||
mask = cv2.threshold(image, 0, threshold, cv2.THRESH_BINARY)
|
||||
elif channel == 4:
|
||||
# RGBA
|
||||
mask = cv2.cvtColor(image, cv2.COLOR_RGBA2GRAY)
|
||||
cv2.threshold(mask, 0, threshold, cv2.THRESH_BINARY, dst=mask)
|
||||
else:
|
||||
raise ImageNotSupported(f'shape={image.shape}')
|
||||
|
||||
# find bbox
|
||||
contours, _ = cv2.findContours(mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
|
||||
min_y, min_x = mask.shape
|
||||
max_x = 0
|
||||
max_y = 0
|
||||
# all black
|
||||
if not contours:
|
||||
raise ImageNotSupported(f'Cannot get bbox from a pure black image')
|
||||
for contour in contours:
|
||||
# x, y, w, h
|
||||
x1, y1, x2, y2 = cv2.boundingRect(contour)
|
||||
x2 += x1
|
||||
y2 += y1
|
||||
if x1 < min_x:
|
||||
min_x = x1
|
||||
if y1 < min_y:
|
||||
min_y = y1
|
||||
if x2 > max_x:
|
||||
max_x = x2
|
||||
if y2 > max_y:
|
||||
max_y = y2
|
||||
if min_x < max_x and min_y < max_y:
|
||||
return min_x, min_y, max_x, max_y
|
||||
else:
|
||||
# This shouldn't happen
|
||||
raise ImageNotSupported(f'Empty bbox {(min_x, min_y, max_x, max_y)}')
|
||||
|
||||
|
||||
def color_similarity(color1, color2):
|
||||
@@ -729,13 +943,35 @@ def color_similarity(color1, color2):
|
||||
Returns:
|
||||
int:
|
||||
"""
|
||||
diff = np.array(color1).astype(int) - np.array(color2).astype(int)
|
||||
diff = np.max(np.maximum(diff, 0)) - np.min(np.minimum(diff, 0))
|
||||
# print(color1, color2)
|
||||
# diff = np.array(color1).astype(int) - np.array(color2).astype(int)
|
||||
# diff = np.max(np.maximum(diff, 0)) - np.min(np.minimum(diff, 0))
|
||||
diff_r = color1[0] - color2[0]
|
||||
diff_g = color1[1] - color2[1]
|
||||
diff_b = color1[2] - color2[2]
|
||||
|
||||
max_positive = 0
|
||||
max_negative = 0
|
||||
if diff_r > max_positive:
|
||||
max_positive = diff_r
|
||||
elif diff_r < max_negative:
|
||||
max_negative = diff_r
|
||||
if diff_g > max_positive:
|
||||
max_positive = diff_g
|
||||
elif diff_g < max_negative:
|
||||
max_negative = diff_g
|
||||
if diff_b > max_positive:
|
||||
max_positive = diff_b
|
||||
elif diff_b < max_negative:
|
||||
max_negative = diff_b
|
||||
|
||||
diff = max_positive - max_negative
|
||||
return diff
|
||||
|
||||
|
||||
def color_similar(color1, color2, threshold=10):
|
||||
"""Consider two colors are similar, if tolerance lesser or equal threshold.
|
||||
"""
|
||||
Consider two colors are similar, if tolerance lesser or equal threshold.
|
||||
Tolerance = Max(Positive(difference_rgb)) + Max(- Negative(difference_rgb))
|
||||
The same as the tolerance in Photoshop.
|
||||
|
||||
@@ -748,8 +984,28 @@ def color_similar(color1, color2, threshold=10):
|
||||
bool: True if two colors are similar.
|
||||
"""
|
||||
# print(color1, color2)
|
||||
diff = np.array(color1).astype(int) - np.array(color2).astype(int)
|
||||
diff = np.max(np.maximum(diff, 0)) - np.min(np.minimum(diff, 0))
|
||||
# diff = np.array(color1).astype(int) - np.array(color2).astype(int)
|
||||
# diff = np.max(np.maximum(diff, 0)) - np.min(np.minimum(diff, 0))
|
||||
diff_r = color1[0] - color2[0]
|
||||
diff_g = color1[1] - color2[1]
|
||||
diff_b = color1[2] - color2[2]
|
||||
|
||||
max_positive = 0
|
||||
max_negative = 0
|
||||
if diff_r > max_positive:
|
||||
max_positive = diff_r
|
||||
elif diff_r < max_negative:
|
||||
max_negative = diff_r
|
||||
if diff_g > max_positive:
|
||||
max_positive = diff_g
|
||||
elif diff_g < max_negative:
|
||||
max_negative = diff_g
|
||||
if diff_b > max_positive:
|
||||
max_positive = diff_b
|
||||
elif diff_b < max_negative:
|
||||
max_negative = diff_b
|
||||
|
||||
diff = max_positive - max_negative
|
||||
return diff <= threshold
|
||||
|
||||
|
||||
@@ -777,11 +1033,24 @@ def color_similarity_2d(image, color):
|
||||
Returns:
|
||||
np.ndarray: uint8
|
||||
"""
|
||||
r, g, b = cv2.split(cv2.subtract(image, (*color, 0)))
|
||||
positive = cv2.max(cv2.max(r, g), b)
|
||||
r, g, b = cv2.split(cv2.subtract((*color, 0), image))
|
||||
negative = cv2.max(cv2.max(r, g), b)
|
||||
return cv2.subtract(255, cv2.add(positive, negative))
|
||||
# r, g, b = cv2.split(cv2.subtract(image, (*color, 0)))
|
||||
# positive = cv2.max(cv2.max(r, g), b)
|
||||
# r, g, b = cv2.split(cv2.subtract((*color, 0), image))
|
||||
# negative = cv2.max(cv2.max(r, g), b)
|
||||
# return cv2.subtract(255, cv2.add(positive, negative))
|
||||
diff = cv2.subtract(image, (*color, 0))
|
||||
r, g, b = cv2.split(diff)
|
||||
cv2.max(r, g, dst=r)
|
||||
cv2.max(r, b, dst=r)
|
||||
positive = r
|
||||
cv2.subtract((*color, 0), image, dst=diff)
|
||||
r, g, b = cv2.split(diff)
|
||||
cv2.max(r, g, dst=r)
|
||||
cv2.max(r, b, dst=r)
|
||||
negative = r
|
||||
cv2.add(positive, negative, dst=positive)
|
||||
cv2.subtract(255, positive, dst=positive)
|
||||
return positive
|
||||
|
||||
|
||||
def extract_letters(image, letter=(255, 255, 255), threshold=128):
|
||||
@@ -795,11 +1064,25 @@ def extract_letters(image, letter=(255, 255, 255), threshold=128):
|
||||
Returns:
|
||||
np.ndarray: Shape (height, width)
|
||||
"""
|
||||
r, g, b = cv2.split(cv2.subtract(image, (*letter, 0)))
|
||||
positive = cv2.max(cv2.max(r, g), b)
|
||||
r, g, b = cv2.split(cv2.subtract((*letter, 0), image))
|
||||
negative = cv2.max(cv2.max(r, g), b)
|
||||
return cv2.multiply(cv2.add(positive, negative), 255.0 / threshold)
|
||||
# r, g, b = cv2.split(cv2.subtract(image, (*letter, 0)))
|
||||
# positive = cv2.max(cv2.max(r, g), b)
|
||||
# r, g, b = cv2.split(cv2.subtract((*letter, 0), image))
|
||||
# negative = cv2.max(cv2.max(r, g), b)
|
||||
# return cv2.multiply(cv2.add(positive, negative), 255.0 / threshold)
|
||||
diff = cv2.subtract(image, (*letter, 0))
|
||||
r, g, b = cv2.split(diff)
|
||||
cv2.max(r, g, dst=r)
|
||||
cv2.max(r, b, dst=r)
|
||||
positive = r
|
||||
cv2.subtract((*letter, 0), image, dst=diff)
|
||||
r, g, b = cv2.split(diff)
|
||||
cv2.max(r, g, dst=r)
|
||||
cv2.max(r, b, dst=r)
|
||||
negative = r
|
||||
cv2.add(positive, negative, dst=positive)
|
||||
if threshold != 255:
|
||||
cv2.convertScaleAbs(positive, alpha=255.0 / threshold, dst=positive)
|
||||
return positive
|
||||
|
||||
|
||||
def extract_white_letters(image, threshold=128):
|
||||
@@ -813,10 +1096,23 @@ def extract_white_letters(image, threshold=128):
|
||||
Returns:
|
||||
np.ndarray: Shape (height, width)
|
||||
"""
|
||||
# minimum = cv2.min(cv2.min(r, g), b)
|
||||
# maximum = cv2.max(cv2.max(r, g), b)
|
||||
# return cv2.multiply(cv2.add(maximum, cv2.subtract(maximum, minimum)), 255.0 / threshold)
|
||||
r, g, b = cv2.split(cv2.subtract((255, 255, 255, 0), image))
|
||||
minimum = cv2.min(cv2.min(r, g), b)
|
||||
maximum = cv2.max(cv2.max(r, g), b)
|
||||
return cv2.multiply(cv2.add(maximum, cv2.subtract(maximum, minimum)), 255.0 / threshold)
|
||||
maximum = cv2.max(r, g)
|
||||
cv2.min(r, g, dst=r)
|
||||
cv2.max(maximum, b, dst=maximum)
|
||||
cv2.min(r, b, dst=r)
|
||||
# minimum = r
|
||||
|
||||
cv2.convertScaleAbs(maximum, alpha=0.5, dst=maximum)
|
||||
cv2.convertScaleAbs(r, alpha=0.5, dst=r)
|
||||
cv2.subtract(maximum, r, dst=r)
|
||||
cv2.add(maximum, r, dst=maximum)
|
||||
if threshold != 255:
|
||||
cv2.convertScaleAbs(maximum, alpha=255.0 / threshold, dst=maximum)
|
||||
return maximum
|
||||
|
||||
|
||||
def color_mapping(image, max_multiply=2):
|
||||
@@ -835,7 +1131,9 @@ def color_mapping(image, max_multiply=2):
|
||||
low, high = np.min(image), np.max(image)
|
||||
multiply = min(255 / (high - low), max_multiply)
|
||||
add = (255 - multiply * (low + high)) / 2
|
||||
image = cv2.add(cv2.multiply(image, multiply), add)
|
||||
# image = cv2.add(cv2.multiply(image, multiply), add)
|
||||
cv2.multiply(image, multiply, dst=image)
|
||||
cv2.add(image, add, dst=image)
|
||||
image[image > 255] = 255
|
||||
image[image < 0] = 0
|
||||
return image.astype(np.uint8)
|
||||
@@ -895,7 +1193,7 @@ def color_bar_percentage(image, area, prev_color, reverse=False, starter=0, thre
|
||||
Returns:
|
||||
float: 0 to 1.
|
||||
"""
|
||||
image = crop(image, area)
|
||||
image = crop(image, area, copy=False)
|
||||
image = image[:, ::-1, :] if reverse else image
|
||||
length = image.shape[1]
|
||||
prev_index = starter
|
||||
@@ -914,6 +1212,9 @@ def color_bar_percentage(image, area, prev_color, reverse=False, starter=0, thre
|
||||
prev_row = bar[:, prev_index] > 255 - threshold
|
||||
if not prev_row.size:
|
||||
return prev_index / length
|
||||
prev_color = np.mean(image[:, prev_index], axis=0)
|
||||
# Look back 5px to get average color
|
||||
left = max(prev_index - 5, 0)
|
||||
mask = np.where(bar[:, left:prev_index + 1] > 255 - threshold)
|
||||
prev_color = np.mean(image[:, left:prev_index + 1][mask], axis=0)
|
||||
|
||||
return 0.
|
||||
|
||||
@@ -41,9 +41,10 @@
|
||||
"aScreenCap_nc",
|
||||
"DroidCast",
|
||||
"DroidCast_raw",
|
||||
"scrcpy"
|
||||
],
|
||||
"display": "hide"
|
||||
"scrcpy",
|
||||
"nemu_ipc",
|
||||
"ldopengl"
|
||||
]
|
||||
},
|
||||
"ControlMethod": {
|
||||
"type": "select",
|
||||
@@ -74,6 +75,7 @@
|
||||
"LDPlayer3",
|
||||
"LDPlayer4",
|
||||
"LDPlayer9",
|
||||
"LDPlayer14",
|
||||
"MuMuPlayer",
|
||||
"MuMuPlayerX",
|
||||
"MuMuPlayer12",
|
||||
@@ -130,6 +132,7 @@
|
||||
"stay_there",
|
||||
"goto_main",
|
||||
"close_game",
|
||||
"close_emulator",
|
||||
"exit_aas",
|
||||
"exit_emulator",
|
||||
"exit_aas_emulator",
|
||||
@@ -1062,28 +1065,10 @@
|
||||
}
|
||||
},
|
||||
"Momotalk": {
|
||||
"Scheduler": {
|
||||
"Momotalk": {
|
||||
"Enable": {
|
||||
"type": "checkbox",
|
||||
"value": false,
|
||||
"option": [
|
||||
true,
|
||||
false
|
||||
]
|
||||
},
|
||||
"NextRun": {
|
||||
"type": "datetime",
|
||||
"value": "2020-01-01 00:00:00",
|
||||
"validate": "datetime"
|
||||
},
|
||||
"Command": {
|
||||
"type": "input",
|
||||
"value": "Momotalk",
|
||||
"display": "hide"
|
||||
},
|
||||
"ServerUpdate": {
|
||||
"type": "input",
|
||||
"value": "04:00",
|
||||
"value": true,
|
||||
"display": "hide"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,7 +26,7 @@ Emulator:
|
||||
option: [ auto, jp, en, zht ]
|
||||
ScreenshotMethod:
|
||||
value: auto
|
||||
option: [ auto, ADB, ADB_nc, uiautomator2, aScreenCap, aScreenCap_nc, DroidCast, DroidCast_raw, scrcpy ]
|
||||
option: [ auto, ADB, ADB_nc, uiautomator2, aScreenCap, aScreenCap_nc, DroidCast, DroidCast_raw, scrcpy, nemu_ipc, ldopengl, ]
|
||||
ControlMethod:
|
||||
value: MaaTouch
|
||||
option: [ minitouch, MaaTouch ]
|
||||
@@ -45,6 +45,7 @@ EmulatorInfo:
|
||||
LDPlayer3,
|
||||
LDPlayer4,
|
||||
LDPlayer9,
|
||||
LDPlayer14,
|
||||
MuMuPlayer,
|
||||
MuMuPlayerX,
|
||||
MuMuPlayer12,
|
||||
@@ -71,7 +72,7 @@ Optimization:
|
||||
CombatScreenshotInterval: 1.0
|
||||
WhenTaskQueueEmpty:
|
||||
value: goto_main
|
||||
option: [ stay_there, goto_main, close_game, exit_aas, exit_emulator, exit_aas_emulator, shutdown ]
|
||||
option: [ stay_there, goto_main, close_game, close_emulator, exit_aas, exit_emulator, exit_aas_emulator, shutdown ]
|
||||
|
||||
# ==================== Daily ====================
|
||||
|
||||
@@ -267,3 +268,10 @@ ItemStorage:
|
||||
stored: StoredTacticalChallengeTicket
|
||||
order: 6
|
||||
color: "#7ac8e5"
|
||||
|
||||
# ==================== Momotalk ====================
|
||||
|
||||
Momotalk:
|
||||
Enable:
|
||||
value: true
|
||||
display: hide
|
||||
@@ -4,6 +4,9 @@
|
||||
|
||||
# ==================== Alas ====================
|
||||
|
||||
Alas:
|
||||
Emulator:
|
||||
ScreenshotMethod: scrcpy
|
||||
Cafe:
|
||||
Scheduler:
|
||||
Enable: true
|
||||
|
||||
@@ -61,6 +61,7 @@ Dashboard:
|
||||
HoursAgo:
|
||||
DaysAgo:
|
||||
LongTimeAgo:
|
||||
EtaDays:
|
||||
|
||||
AddAlas:
|
||||
PopupTitle:
|
||||
|
||||
@@ -33,7 +33,13 @@
|
||||
"tasks": [
|
||||
"Circle",
|
||||
"Task",
|
||||
"Mail",
|
||||
"Mail"
|
||||
]
|
||||
},
|
||||
"Tool": {
|
||||
"menu": "collapse",
|
||||
"page": "tool",
|
||||
"tasks": [
|
||||
"Momotalk"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -7,7 +7,6 @@
|
||||
|
||||
Alas:
|
||||
Emulator:
|
||||
ScreenshotMethod: scrcpy
|
||||
ControlMethod: MaaTouch
|
||||
Optimization:
|
||||
ScreenshotInterval: 0.2
|
||||
|
||||
@@ -80,5 +80,12 @@ Reward:
|
||||
- Scheduler
|
||||
Mail:
|
||||
- Scheduler
|
||||
|
||||
# ==================== Tool ====================
|
||||
|
||||
Tool:
|
||||
menu: 'collapse'
|
||||
page: 'tool'
|
||||
tasks:
|
||||
Momotalk:
|
||||
- Scheduler
|
||||
- Momotalk
|
||||
@@ -1,236 +0,0 @@
|
||||
"""
|
||||
Copy-pasted from
|
||||
https://github.com/untitaker/python-atomicwrites
|
||||
"""
|
||||
import contextlib
|
||||
import io
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
|
||||
try:
|
||||
import fcntl
|
||||
except ImportError:
|
||||
fcntl = None
|
||||
|
||||
# `fspath` was added in Python 3.6
|
||||
try:
|
||||
from os import fspath
|
||||
except ImportError:
|
||||
fspath = None
|
||||
|
||||
__version__ = '1.4.1'
|
||||
|
||||
PY2 = sys.version_info[0] == 2
|
||||
|
||||
text_type = unicode if PY2 else str # noqa
|
||||
|
||||
|
||||
def _path_to_unicode(x):
|
||||
if not isinstance(x, text_type):
|
||||
return x.decode(sys.getfilesystemencoding())
|
||||
return x
|
||||
|
||||
|
||||
DEFAULT_MODE = "wb" if PY2 else "w"
|
||||
|
||||
_proper_fsync = os.fsync
|
||||
|
||||
if sys.platform != 'win32':
|
||||
if hasattr(fcntl, 'F_FULLFSYNC'):
|
||||
def _proper_fsync(fd):
|
||||
# https://lists.apple.com/archives/darwin-dev/2005/Feb/msg00072.html
|
||||
# https://developer.apple.com/library/mac/documentation/Darwin/Reference/ManPages/man2/fsync.2.html
|
||||
# https://github.com/untitaker/python-atomicwrites/issues/6
|
||||
fcntl.fcntl(fd, fcntl.F_FULLFSYNC)
|
||||
|
||||
|
||||
def _sync_directory(directory):
|
||||
# Ensure that filenames are written to disk
|
||||
fd = os.open(directory, 0)
|
||||
try:
|
||||
_proper_fsync(fd)
|
||||
finally:
|
||||
os.close(fd)
|
||||
|
||||
|
||||
def _replace_atomic(src, dst):
|
||||
os.rename(src, dst)
|
||||
_sync_directory(os.path.normpath(os.path.dirname(dst)))
|
||||
|
||||
|
||||
def _move_atomic(src, dst):
|
||||
os.link(src, dst)
|
||||
os.unlink(src)
|
||||
|
||||
src_dir = os.path.normpath(os.path.dirname(src))
|
||||
dst_dir = os.path.normpath(os.path.dirname(dst))
|
||||
_sync_directory(dst_dir)
|
||||
if src_dir != dst_dir:
|
||||
_sync_directory(src_dir)
|
||||
else:
|
||||
from ctypes import windll, WinError
|
||||
|
||||
_MOVEFILE_REPLACE_EXISTING = 0x1
|
||||
_MOVEFILE_WRITE_THROUGH = 0x8
|
||||
_windows_default_flags = _MOVEFILE_WRITE_THROUGH
|
||||
|
||||
|
||||
def _handle_errors(rv):
|
||||
if not rv:
|
||||
raise WinError()
|
||||
|
||||
|
||||
def _replace_atomic(src, dst):
|
||||
_handle_errors(windll.kernel32.MoveFileExW(
|
||||
_path_to_unicode(src), _path_to_unicode(dst),
|
||||
_windows_default_flags | _MOVEFILE_REPLACE_EXISTING
|
||||
))
|
||||
|
||||
|
||||
def _move_atomic(src, dst):
|
||||
_handle_errors(windll.kernel32.MoveFileExW(
|
||||
_path_to_unicode(src), _path_to_unicode(dst),
|
||||
_windows_default_flags
|
||||
))
|
||||
|
||||
|
||||
def replace_atomic(src, dst):
|
||||
'''
|
||||
Move ``src`` to ``dst``. If ``dst`` exists, it will be silently
|
||||
overwritten.
|
||||
|
||||
Both paths must reside on the same filesystem for the operation to be
|
||||
atomic.
|
||||
'''
|
||||
return _replace_atomic(src, dst)
|
||||
|
||||
|
||||
def move_atomic(src, dst):
|
||||
'''
|
||||
Move ``src`` to ``dst``. There might a timewindow where both filesystem
|
||||
entries exist. If ``dst`` already exists, :py:exc:`FileExistsError` will be
|
||||
raised.
|
||||
|
||||
Both paths must reside on the same filesystem for the operation to be
|
||||
atomic.
|
||||
'''
|
||||
return _move_atomic(src, dst)
|
||||
|
||||
|
||||
class AtomicWriter(object):
|
||||
'''
|
||||
A helper class for performing atomic writes. Usage::
|
||||
|
||||
with AtomicWriter(path).open() as f:
|
||||
f.write(...)
|
||||
|
||||
:param path: The destination filepath. May or may not exist.
|
||||
:param mode: The filemode for the temporary file. This defaults to `wb` in
|
||||
Python 2 and `w` in Python 3.
|
||||
:param overwrite: If set to false, an error is raised if ``path`` exists.
|
||||
Errors are only raised after the file has been written to. Either way,
|
||||
the operation is atomic.
|
||||
:param open_kwargs: Keyword-arguments to pass to the underlying
|
||||
:py:func:`open` call. This can be used to set the encoding when opening
|
||||
files in text-mode.
|
||||
|
||||
If you need further control over the exact behavior, you are encouraged to
|
||||
subclass.
|
||||
'''
|
||||
|
||||
def __init__(self, path, mode=DEFAULT_MODE, overwrite=False,
|
||||
**open_kwargs):
|
||||
if 'a' in mode:
|
||||
raise ValueError(
|
||||
'Appending to an existing file is not supported, because that '
|
||||
'would involve an expensive `copy`-operation to a temporary '
|
||||
'file. Open the file in normal `w`-mode and copy explicitly '
|
||||
'if that\'s what you\'re after.'
|
||||
)
|
||||
if 'x' in mode:
|
||||
raise ValueError('Use the `overwrite`-parameter instead.')
|
||||
if 'w' not in mode:
|
||||
raise ValueError('AtomicWriters can only be written to.')
|
||||
|
||||
# Attempt to convert `path` to `str` or `bytes`
|
||||
if fspath is not None:
|
||||
path = fspath(path)
|
||||
|
||||
self._path = path
|
||||
self._mode = mode
|
||||
self._overwrite = overwrite
|
||||
self._open_kwargs = open_kwargs
|
||||
|
||||
def open(self):
|
||||
'''
|
||||
Open the temporary file.
|
||||
'''
|
||||
return self._open(self.get_fileobject)
|
||||
|
||||
@contextlib.contextmanager
|
||||
def _open(self, get_fileobject):
|
||||
f = None # make sure f exists even if get_fileobject() fails
|
||||
try:
|
||||
success = False
|
||||
with get_fileobject(**self._open_kwargs) as f:
|
||||
yield f
|
||||
self.sync(f)
|
||||
self.commit(f)
|
||||
success = True
|
||||
finally:
|
||||
if not success:
|
||||
try:
|
||||
self.rollback(f)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def get_fileobject(self, suffix="", prefix=tempfile.gettempprefix(),
|
||||
dir=None, **kwargs):
|
||||
'''Return the temporary file to use.'''
|
||||
if dir is None:
|
||||
dir = os.path.normpath(os.path.dirname(self._path))
|
||||
descriptor, name = tempfile.mkstemp(suffix=suffix, prefix=prefix,
|
||||
dir=dir)
|
||||
# io.open() will take either the descriptor or the name, but we need
|
||||
# the name later for commit()/replace_atomic() and couldn't find a way
|
||||
# to get the filename from the descriptor.
|
||||
os.close(descriptor)
|
||||
kwargs['mode'] = self._mode
|
||||
kwargs['file'] = name
|
||||
return io.open(**kwargs)
|
||||
|
||||
def sync(self, f):
|
||||
'''responsible for clearing as many file caches as possible before
|
||||
commit'''
|
||||
f.flush()
|
||||
_proper_fsync(f.fileno())
|
||||
|
||||
def commit(self, f):
|
||||
'''Move the temporary file to the target location.'''
|
||||
if self._overwrite:
|
||||
replace_atomic(f.name, self._path)
|
||||
else:
|
||||
move_atomic(f.name, self._path)
|
||||
|
||||
def rollback(self, f):
|
||||
'''Clean up all temporary resources.'''
|
||||
os.unlink(f.name)
|
||||
|
||||
|
||||
def atomic_write(path, writer_cls=AtomicWriter, **cls_kwargs):
|
||||
'''
|
||||
Simple atomic writes. This wraps :py:class:`AtomicWriter`::
|
||||
|
||||
with atomic_write(path) as f:
|
||||
f.write(...)
|
||||
|
||||
:param path: The target path to write to.
|
||||
:param writer_cls: The writer class to use. This parameter is useful if you
|
||||
subclassed :py:class:`AtomicWriter` to change some behavior and want to
|
||||
use that new subclass.
|
||||
|
||||
Additional keyword arguments are passed to the writer class. See
|
||||
:py:class:`AtomicWriter`.
|
||||
'''
|
||||
return writer_cls(path, **cls_kwargs).open()
|
||||
@@ -1,18 +1,17 @@
|
||||
import copy
|
||||
import datetime
|
||||
import operator
|
||||
import threading
|
||||
|
||||
import pywebio
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from module.base.decorator import cached_property, del_cached_property
|
||||
from module.base.filter import Filter
|
||||
from module.config.config_generated import GeneratedConfig
|
||||
from module.config.config_manual import ManualConfig
|
||||
from module.config.config_updater import ConfigUpdater
|
||||
from module.config.config_updater import ConfigUpdater, ensure_time, get_server_next_update, nearest_future
|
||||
from module.config.deep import deep_get, deep_set
|
||||
from module.config.stored.classes import iter_attribute
|
||||
from module.config.stored.stored_generated import StoredGenerated
|
||||
from module.config.utils import *
|
||||
from module.config.utils import DEFAULT_TIME, dict_to_kv, filepath_config, path_to_arg
|
||||
from module.config.watcher import ConfigWatcher
|
||||
from module.exception import RequestHumanTakeover, ScriptError
|
||||
from module.logger import logger
|
||||
@@ -106,17 +105,22 @@ class AzurLaneConfig(ConfigUpdater, ManualConfig, GeneratedConfig, ConfigWatcher
|
||||
logger.info("Using template config, which is read only")
|
||||
self.auto_update = False
|
||||
self.task = name_to_function("template")
|
||||
self.init_task(task)
|
||||
|
||||
def init_task(self, task=None):
|
||||
if self.is_template_config:
|
||||
return
|
||||
|
||||
self.load()
|
||||
if task is None:
|
||||
# Bind `Alas` by default which includes emulator settings.
|
||||
task = name_to_function("Alas")
|
||||
else:
|
||||
self.load()
|
||||
if task is None:
|
||||
# Bind `Alas` by default which includes emulator settings.
|
||||
task = name_to_function("Alas")
|
||||
else:
|
||||
# Bind a specific task for debug purpose.
|
||||
task = name_to_function(task)
|
||||
self.bind(task)
|
||||
self.task = task
|
||||
self.save()
|
||||
# Bind a specific task for debug purpose.
|
||||
task = name_to_function(task)
|
||||
self.bind(task)
|
||||
self.task = task
|
||||
self.save()
|
||||
|
||||
def load(self):
|
||||
self.data = self.read_file(self.config_name)
|
||||
@@ -129,13 +133,17 @@ class AzurLaneConfig(ConfigUpdater, ManualConfig, GeneratedConfig, ConfigWatcher
|
||||
"""
|
||||
Args:
|
||||
func (str, Function): Function to run
|
||||
func_list (set): Set of tasks to be bound
|
||||
func_list (list[str]): List of tasks to be bound
|
||||
"""
|
||||
if func_list is None:
|
||||
func_list = ["Alas"]
|
||||
if isinstance(func, Function):
|
||||
func = func.command
|
||||
func_list.append(func)
|
||||
# func_list: ["Alas", <task>, *func_list]
|
||||
if func_list is None:
|
||||
func_list = []
|
||||
if func not in func_list:
|
||||
func_list.insert(0, func)
|
||||
if "Alas" not in func_list:
|
||||
func_list.insert(0, "Alas")
|
||||
logger.info(f"Bind task {func_list}")
|
||||
|
||||
# Bind arguments
|
||||
@@ -172,6 +180,10 @@ class AzurLaneConfig(ConfigUpdater, ManualConfig, GeneratedConfig, ConfigWatcher
|
||||
self.data, keys="Alas.Optimization.CloseGameDuringWait", default=False
|
||||
)
|
||||
|
||||
@property
|
||||
def is_actual_task(self):
|
||||
return self.task.command.lower() not in ['alas', 'template']
|
||||
|
||||
@cached_property
|
||||
def stored(self) -> StoredGenerated:
|
||||
stored = StoredGenerated()
|
||||
@@ -279,6 +291,7 @@ class AzurLaneConfig(ConfigUpdater, ManualConfig, GeneratedConfig, ConfigWatcher
|
||||
deep_set(self.data, keys=f"{task}.Scheduler.NextRun", value=now)
|
||||
|
||||
limit_next_run(['BattlePass'], limit=now + timedelta(days=31, seconds=-1))
|
||||
limit_next_run(['Weekly'], limit=now + timedelta(days=7, seconds=-1))
|
||||
limit_next_run(self.args.keys(), limit=now + timedelta(hours=24, seconds=-1))
|
||||
|
||||
def override(self, **kwargs):
|
||||
@@ -485,19 +498,18 @@ class AzurLaneConfig(ConfigUpdater, ManualConfig, GeneratedConfig, ConfigWatcher
|
||||
def is_task_enabled(self, task):
|
||||
return bool(self.cross_get(keys=[task, 'Scheduler', 'Enable'], default=False))
|
||||
|
||||
def update_daily_quests(self):
|
||||
"""
|
||||
Raises:
|
||||
TaskEnd: Call task `DailyQuest` and stop current task
|
||||
"""
|
||||
if self.stored.DailyActivity.is_expired():
|
||||
logger.info('DailyActivity expired, call task to update')
|
||||
self.task_call('DailyQuest')
|
||||
self.task_stop()
|
||||
if self.stored.DailyQuest.is_expired():
|
||||
logger.info('DailyQuest expired, call task to update')
|
||||
self.task_call('DailyQuest')
|
||||
self.task_stop()
|
||||
# def update_daily_quests(self):
|
||||
# """
|
||||
# Raises:
|
||||
# TaskEnd: Call task `DailyQuest` and stop current task
|
||||
# """
|
||||
# with self.multi_set():
|
||||
# if self.stored.DailyActivity.is_expired():
|
||||
# logger.info('DailyActivity expired')
|
||||
# self.stored.DailyActivity.clear()
|
||||
# if self.stored.DailyQuest.is_expired():
|
||||
# logger.info('DailyQuest expired')
|
||||
# self.stored.DailyQuest.clear()
|
||||
|
||||
@property
|
||||
def DEVICE_SCREENSHOT_METHOD(self):
|
||||
|
||||
@@ -19,12 +19,12 @@ class GeneratedConfig:
|
||||
Emulator_Serial = 'auto'
|
||||
Emulator_PackageName = 'auto' # auto, JP-Official, OVERSEA-TWHKMO, OVERSEA-Korea, OVERSEA-Asia, OVERSEA-America, OVERSEA-Global
|
||||
Emulator_GameLanguage = 'auto' # auto, jp, en, zht
|
||||
Emulator_ScreenshotMethod = 'auto' # auto, ADB, ADB_nc, uiautomator2, aScreenCap, aScreenCap_nc, DroidCast, DroidCast_raw, scrcpy
|
||||
Emulator_ScreenshotMethod = 'auto' # auto, ADB, ADB_nc, uiautomator2, aScreenCap, aScreenCap_nc, DroidCast, DroidCast_raw, scrcpy, nemu_ipc, ldopengl
|
||||
Emulator_ControlMethod = 'MaaTouch' # minitouch, MaaTouch
|
||||
Emulator_AdbRestart = False
|
||||
|
||||
# Group `EmulatorInfo`
|
||||
EmulatorInfo_Emulator = 'auto' # auto, NoxPlayer, NoxPlayer64, BlueStacks4, BlueStacks5, BlueStacks4HyperV, BlueStacks5HyperV, LDPlayer3, LDPlayer4, LDPlayer9, MuMuPlayer, MuMuPlayerX, MuMuPlayer12, MEmuPlayer
|
||||
EmulatorInfo_Emulator = 'auto' # auto, NoxPlayer, NoxPlayer64, BlueStacks4, BlueStacks5, BlueStacks4HyperV, BlueStacks5HyperV, LDPlayer3, LDPlayer4, LDPlayer9, LDPlayer14, MuMuPlayer, MuMuPlayerX, MuMuPlayer12, MEmuPlayer
|
||||
EmulatorInfo_name = None
|
||||
EmulatorInfo_path = None
|
||||
|
||||
@@ -37,7 +37,7 @@ class GeneratedConfig:
|
||||
# Group `Optimization`
|
||||
Optimization_ScreenshotInterval = 0.3
|
||||
Optimization_CombatScreenshotInterval = 1.0
|
||||
Optimization_WhenTaskQueueEmpty = 'goto_main' # stay_there, goto_main, close_game, exit_aas, exit_emulator, exit_aas_emulator, shutdown
|
||||
Optimization_WhenTaskQueueEmpty = 'goto_main' # stay_there, goto_main, close_game, close_emulator, exit_aas, exit_emulator, exit_aas_emulator, shutdown
|
||||
|
||||
# Group `Cafe`
|
||||
Cafe_Reward = True
|
||||
@@ -166,3 +166,6 @@ class GeneratedConfig:
|
||||
ItemStorage_BountyTicket = {}
|
||||
ItemStorage_ScrimmageTicket = {}
|
||||
ItemStorage_TacticalChallengeTicket = {}
|
||||
|
||||
# Group `Momotalk`
|
||||
Momotalk_Enable = True
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import typing as t
|
||||
from copy import deepcopy
|
||||
|
||||
from cached_property import cached_property
|
||||
|
||||
from deploy.Windows.utils import DEPLOY_TEMPLATE, poor_yaml_read, poor_yaml_write
|
||||
from module.base.timer import timer
|
||||
from module.config.deep import deep_default, deep_get, deep_iter, deep_set
|
||||
from module.config.server import VALID_SERVER
|
||||
from module.config.utils import *
|
||||
|
||||
@@ -452,20 +454,19 @@ class ConfigUpdater:
|
||||
dict:
|
||||
"""
|
||||
new = {}
|
||||
type_lock = {'lock', 'state'}
|
||||
type_stored = {'stored', 'dict'}
|
||||
|
||||
def deep_load(keys):
|
||||
data = deep_get(self.args, keys=keys, default={})
|
||||
for keys, data in deep_iter(self.args, depth=3):
|
||||
value = deep_get(old, keys=keys, default=data['value'])
|
||||
typ = data['type']
|
||||
display = data.get('display')
|
||||
if (is_template or value is None or value == ''
|
||||
or typ in ['lock', 'state'] or (display == 'hide' and typ != 'stored')):
|
||||
if is_template or value is None or value == '' \
|
||||
or typ in type_lock or (display == 'hide' and typ not in type_stored):
|
||||
value = data['value']
|
||||
value = parse_value(value, data=data)
|
||||
deep_set(new, keys=keys, value=value)
|
||||
|
||||
for path, _ in deep_iter(self.args, depth=3):
|
||||
deep_load(path)
|
||||
|
||||
# if not is_template:
|
||||
# new = self.config_redirect(old, new)
|
||||
@@ -569,6 +570,65 @@ class ConfigUpdater:
|
||||
# set_daily('Use_Consumables_1_time', 'achievable')
|
||||
# return data
|
||||
|
||||
def save_callback(self, key: str, value: t.Any) -> t.Iterable[t.Tuple[str, t.Any]]:
|
||||
"""
|
||||
Args:
|
||||
key: Key path in config json, such as "Main.Emotion.Fleet1Value"
|
||||
value: Value set by user, such as "98"
|
||||
|
||||
Yields:
|
||||
str: Key path to set config json, such as "Main.Emotion.Fleet1Record"
|
||||
any: Value to set, such as "2020-01-01 00:00:00"
|
||||
"""
|
||||
# if key.startswith('Dungeon.Dungeon') or key.startswith('Dungeon.DungeonDaily'):
|
||||
# from tasks.dungeon.keywords.dungeon import DungeonList
|
||||
# from module.exception import ScriptError
|
||||
# try:
|
||||
# dungeon = DungeonList.find(value)
|
||||
# except ScriptError:
|
||||
# return
|
||||
# if key.endswith('Name'):
|
||||
# if dungeon.is_Calyx_Golden:
|
||||
# yield 'Dungeon.Dungeon.NameAtDoubleCalyx', value
|
||||
# yield 'Dungeon.DungeonDaily.CalyxGolden', value
|
||||
# elif dungeon.is_Calyx_Crimson:
|
||||
# yield 'Dungeon.Dungeon.NameAtDoubleCalyx', value
|
||||
# yield 'Dungeon.DungeonDaily.CalyxCrimson', value
|
||||
# elif dungeon.is_Stagnant_Shadow:
|
||||
# yield 'Dungeon.DungeonDaily.StagnantShadow', value
|
||||
# elif dungeon.is_Cavern_of_Corrosion:
|
||||
# yield 'Dungeon.Dungeon.NameAtDoubleRelic', value
|
||||
# yield 'Dungeon.DungeonDaily.CavernOfCorrosion', value
|
||||
# elif key.endswith('NameAtDoubleCalyx'):
|
||||
# if dungeon.is_Calyx_Golden:
|
||||
# yield 'Dungeon.DungeonDaily.CalyxGolden', value
|
||||
# elif dungeon.is_Calyx_Crimson:
|
||||
# yield 'Dungeon.DungeonDaily.CalyxCrimson', value
|
||||
# elif key.endswith('NameAtDoubleRelic'):
|
||||
# yield 'Dungeon.DungeonDaily.CavernOfCorrosion', value
|
||||
# elif key.endswith('CavernOfCorrosion'):
|
||||
# yield 'Dungeon.Dungeon.NameAtDoubleRelic', value
|
||||
yield '', None
|
||||
|
||||
def iter_hidden_args(self, data) -> t.Iterator[str]:
|
||||
"""
|
||||
Args:
|
||||
data (dict): config
|
||||
|
||||
Yields:
|
||||
str: Arg path that should be hidden
|
||||
"""
|
||||
# if deep_get(data, 'Rogue.RoguePath.PresetResonanceFilter') != 'custom':
|
||||
# yield 'Rogue.RoguePath.CustomResonanceFilter'
|
||||
yield ''
|
||||
|
||||
def get_hidden_args(self, data) -> t.Set[str]:
|
||||
"""
|
||||
Return a set of hidden args
|
||||
"""
|
||||
out = list(self.iter_hidden_args(data))
|
||||
return set(out)
|
||||
|
||||
def read_file(self, config_name, is_template=False):
|
||||
"""
|
||||
Read and update config file.
|
||||
|
||||
@@ -0,0 +1,533 @@
|
||||
from collections import deque
|
||||
|
||||
# deep_* functions are used for access nested dictionary.
|
||||
# They target for high performance so code are complicated to read
|
||||
# In general performance practise, time costs are as below:
|
||||
# - When key exists
|
||||
# try: dict[key] except KeyError << dict.get(key) < if key in dict: dict[key]
|
||||
# - When not key exists
|
||||
# if key in dict: dict[key] < dict.get(key) <<< try: dict[key] except KeyError
|
||||
|
||||
OP_ADD = 'add'
|
||||
OP_SET = 'set'
|
||||
OP_DEL = 'del'
|
||||
|
||||
|
||||
def deep_get(d, keys, default=None):
|
||||
"""
|
||||
Get value from nested dict and list
|
||||
https://stackoverflow.com/questions/25833613/safe-method-to-get-value-of-nested-dictionary
|
||||
|
||||
Args:
|
||||
d (dict):
|
||||
keys (list[str], str): Such as ['Scheduler', 'NextRun', 'value']
|
||||
default: Default return if key not found.
|
||||
|
||||
Returns:
|
||||
Value on given keys
|
||||
"""
|
||||
# 240 + 30 * depth (ns)
|
||||
if type(keys) is str:
|
||||
keys = keys.split('.')
|
||||
|
||||
try:
|
||||
for k in keys:
|
||||
d = d[k]
|
||||
return d
|
||||
# No such key
|
||||
except KeyError:
|
||||
return default
|
||||
# No such key
|
||||
except IndexError:
|
||||
return default
|
||||
# Input `keys` is not iterable or input `d` is not dict
|
||||
# list indices must be integers or slices, not str
|
||||
except TypeError:
|
||||
return default
|
||||
|
||||
|
||||
def deep_get_with_error(d, keys):
|
||||
"""
|
||||
Get value from nested dict and list, raise KeyError if key not exists
|
||||
|
||||
Args:
|
||||
d (dict):
|
||||
keys (list[str], str): Such as ['Scheduler', 'NextRun', 'value']
|
||||
|
||||
Returns:
|
||||
Value on given keys
|
||||
|
||||
Raises:
|
||||
KeyError: If key not exists
|
||||
"""
|
||||
# 240 + 30 * depth (ns)
|
||||
if type(keys) is str:
|
||||
keys = keys.split('.')
|
||||
|
||||
try:
|
||||
for k in keys:
|
||||
d = d[k]
|
||||
return d
|
||||
# No such key
|
||||
# except KeyError:
|
||||
# raise
|
||||
# No such key
|
||||
except IndexError:
|
||||
raise KeyError
|
||||
# Input `keys` is not iterable or input `d` is not dict
|
||||
# list indices must be integers or slices, not str
|
||||
except TypeError:
|
||||
raise KeyError
|
||||
|
||||
|
||||
def deep_exist(d, keys):
|
||||
"""
|
||||
Check if keys exists in nested dict or list
|
||||
|
||||
Args:
|
||||
d (dict):
|
||||
keys (str, list): Such as `Scheduler.NextRun.value`
|
||||
|
||||
Returns:
|
||||
bool: If key exists
|
||||
"""
|
||||
# 240 + 30 * depth (ns)
|
||||
if type(keys) is str:
|
||||
keys = keys.split('.')
|
||||
|
||||
try:
|
||||
for k in keys:
|
||||
d = d[k]
|
||||
return True
|
||||
# No such key
|
||||
except KeyError:
|
||||
return False
|
||||
# No such key
|
||||
except IndexError:
|
||||
return False
|
||||
# Input `keys` is not iterable or input `d` is not dict
|
||||
# list indices must be integers or slices, not str
|
||||
except TypeError:
|
||||
return False
|
||||
|
||||
|
||||
def deep_set(d, keys, value):
|
||||
"""
|
||||
Set value into nested dict safely, imitating deep_get().
|
||||
Can only set dict
|
||||
"""
|
||||
# 150 * depth (ns)
|
||||
if type(keys) is str:
|
||||
keys = keys.split('.')
|
||||
|
||||
first = True
|
||||
exist = True
|
||||
prev_d = None
|
||||
prev_k = None
|
||||
prev_k2 = None
|
||||
try:
|
||||
for k in keys:
|
||||
if first:
|
||||
prev_d = d
|
||||
prev_k = k
|
||||
first = False
|
||||
continue
|
||||
try:
|
||||
# if key in dict: dict[key] > dict.get > dict.setdefault > try dict[key] except
|
||||
if exist and prev_k in d:
|
||||
prev_d = d
|
||||
d = d[prev_k]
|
||||
else:
|
||||
exist = False
|
||||
new = {}
|
||||
d[prev_k] = new
|
||||
d = new
|
||||
except TypeError:
|
||||
# `d` is not dict
|
||||
exist = False
|
||||
d = {}
|
||||
prev_d[prev_k2] = {prev_k: d}
|
||||
|
||||
prev_k2 = prev_k
|
||||
prev_k = k
|
||||
# prev_k2, prev_k = prev_k, k
|
||||
# Input `keys` is not iterable
|
||||
except TypeError:
|
||||
return
|
||||
|
||||
# Last key, set value
|
||||
try:
|
||||
d[prev_k] = value
|
||||
return
|
||||
# Last value `d` is not dict
|
||||
except TypeError:
|
||||
prev_d[prev_k2] = {prev_k: value}
|
||||
return
|
||||
|
||||
|
||||
def deep_default(d, keys, value):
|
||||
"""
|
||||
Set value into nested dict safely, imitating deep_get().
|
||||
Can only set dict
|
||||
"""
|
||||
# 150 * depth (ns)
|
||||
if type(keys) is str:
|
||||
keys = keys.split('.')
|
||||
|
||||
first = True
|
||||
exist = True
|
||||
prev_d = None
|
||||
prev_k = None
|
||||
prev_k2 = None
|
||||
try:
|
||||
for k in keys:
|
||||
if first:
|
||||
prev_d = d
|
||||
prev_k = k
|
||||
first = False
|
||||
continue
|
||||
try:
|
||||
# if key in dict: dict[key] > dict.get > dict.setdefault > try dict[key] except
|
||||
if exist and prev_k in d:
|
||||
prev_d = d
|
||||
d = d[prev_k]
|
||||
else:
|
||||
exist = False
|
||||
new = {}
|
||||
d[prev_k] = new
|
||||
d = new
|
||||
except TypeError:
|
||||
# `d` is not dict
|
||||
exist = False
|
||||
d = {}
|
||||
prev_d[prev_k2] = {prev_k: d}
|
||||
|
||||
prev_k2 = prev_k
|
||||
prev_k = k
|
||||
# prev_k2, prev_k = prev_k, k
|
||||
# Input `keys` is not iterable
|
||||
except TypeError:
|
||||
return
|
||||
|
||||
# Last key, set value
|
||||
try:
|
||||
d.setdefault(prev_k, value)
|
||||
return
|
||||
# Last value `d` is not dict
|
||||
except AttributeError:
|
||||
prev_d[prev_k2] = {prev_k: value}
|
||||
return
|
||||
|
||||
|
||||
def deep_pop(d, keys, default=None):
|
||||
"""
|
||||
Pop value from nested dict and list
|
||||
"""
|
||||
if type(keys) is str:
|
||||
keys = keys.split('.')
|
||||
|
||||
try:
|
||||
for k in keys[:-1]:
|
||||
d = d[k]
|
||||
# No `pop(k, default)` so it can pop list
|
||||
return d.pop(keys[-1])
|
||||
# No such key
|
||||
except KeyError:
|
||||
return default
|
||||
# Input `keys` is not iterable or input `d` is not dict
|
||||
# list indices must be integers or slices, not str
|
||||
except TypeError:
|
||||
return default
|
||||
# Input `keys` out of index
|
||||
except IndexError:
|
||||
return default
|
||||
# Last `d` is not dict
|
||||
except AttributeError:
|
||||
return default
|
||||
|
||||
|
||||
def deep_iter_depth1(data):
|
||||
"""
|
||||
Equivalent to data.items() but suppress error if data is not a dict
|
||||
|
||||
Args:
|
||||
data:
|
||||
|
||||
Yields:
|
||||
Any: Key
|
||||
Any: Value
|
||||
"""
|
||||
try:
|
||||
for k, v in data.items():
|
||||
yield k, v
|
||||
return
|
||||
except AttributeError:
|
||||
# `data` is not dict
|
||||
return
|
||||
|
||||
|
||||
def deep_iter_depth2(data):
|
||||
"""
|
||||
Iter key and value in nested dict of depth 2
|
||||
A simplified deep_iter
|
||||
|
||||
Args:
|
||||
data:
|
||||
|
||||
Yields:
|
||||
Any: Key1
|
||||
Any: Key2
|
||||
Any: Value
|
||||
"""
|
||||
try:
|
||||
for k1, v1 in data.items():
|
||||
if type(v1) is dict:
|
||||
for k2, v2 in v1.items():
|
||||
yield k1, k2, v2
|
||||
except AttributeError:
|
||||
# `data` is not dict
|
||||
return
|
||||
|
||||
|
||||
def deep_iter(data, min_depth=None, depth=3):
|
||||
"""
|
||||
Iter key and value in nested dict
|
||||
300us on alas.json depth=3 (530+ rows)
|
||||
Can only iter dict
|
||||
|
||||
Args:
|
||||
data:
|
||||
min_depth:
|
||||
depth:
|
||||
|
||||
Yields:
|
||||
list[str]: Key path
|
||||
Any: Value
|
||||
"""
|
||||
if min_depth is None:
|
||||
min_depth = depth
|
||||
assert 1 <= min_depth <= depth
|
||||
|
||||
# Equivalent to dict.items()
|
||||
try:
|
||||
if depth == 1:
|
||||
for k, v in data.items():
|
||||
yield [k], v
|
||||
return
|
||||
# Iter first depth
|
||||
elif min_depth == 1:
|
||||
q = deque()
|
||||
for k, v in data.items():
|
||||
key = [k]
|
||||
if type(v) is dict:
|
||||
q.append((key, v))
|
||||
else:
|
||||
yield key, v
|
||||
# Iter target depth only
|
||||
else:
|
||||
q = deque()
|
||||
for k, v in data.items():
|
||||
key = [k]
|
||||
if type(v) is dict:
|
||||
q.append((key, v))
|
||||
except AttributeError:
|
||||
# `data` is not dict
|
||||
return
|
||||
|
||||
# Iter depths
|
||||
current = 2
|
||||
while current <= depth:
|
||||
new_q = deque()
|
||||
# max depth
|
||||
if current == depth:
|
||||
for key, data in q:
|
||||
for k, v in data.items():
|
||||
yield key + [k], v
|
||||
# in target depth
|
||||
elif min_depth <= current < depth:
|
||||
for key, data in q:
|
||||
for k, v in data.items():
|
||||
subkey = key + [k]
|
||||
if type(v) is dict:
|
||||
new_q.append((subkey, v))
|
||||
else:
|
||||
yield subkey, v
|
||||
# Haven't reached min depth
|
||||
else:
|
||||
for key, data in q:
|
||||
for k, v in data.items():
|
||||
subkey = key + [k]
|
||||
if type(v) is dict:
|
||||
new_q.append((subkey, v))
|
||||
q = new_q
|
||||
current += 1
|
||||
|
||||
|
||||
def deep_values(data, min_depth=None, depth=3):
|
||||
"""
|
||||
Iter value in nested dict
|
||||
300us on alas.json depth=3 (530+ rows)
|
||||
Can only iter dict
|
||||
|
||||
Args:
|
||||
data:
|
||||
min_depth:
|
||||
depth:
|
||||
|
||||
Yields:
|
||||
Any: Value
|
||||
"""
|
||||
if min_depth is None:
|
||||
min_depth = depth
|
||||
assert 1 <= min_depth <= depth
|
||||
|
||||
# Equivalent to dict.items()
|
||||
try:
|
||||
if depth == 1:
|
||||
for v in data.values():
|
||||
yield v
|
||||
return
|
||||
# Iter first depth
|
||||
elif min_depth == 1:
|
||||
q = deque()
|
||||
for v in data.values():
|
||||
if type(v) is dict:
|
||||
q.append(v)
|
||||
else:
|
||||
yield v
|
||||
# Iter target depth only
|
||||
else:
|
||||
q = deque()
|
||||
for v in data.values():
|
||||
if type(v) is dict:
|
||||
q.append(v)
|
||||
except AttributeError:
|
||||
# `data` is not dict
|
||||
return
|
||||
|
||||
# Iter depths
|
||||
current = 2
|
||||
while current <= depth:
|
||||
new_q = deque()
|
||||
# max depth
|
||||
if current == depth:
|
||||
for data in q:
|
||||
for v in data.values():
|
||||
yield v
|
||||
# in target depth
|
||||
elif min_depth <= current < depth:
|
||||
for data in q:
|
||||
for v in data.values():
|
||||
if type(v) is dict:
|
||||
new_q.append(v)
|
||||
else:
|
||||
yield v
|
||||
# Haven't reached min depth
|
||||
else:
|
||||
for data in q:
|
||||
for v in data.values():
|
||||
if type(v) is dict:
|
||||
new_q.append(v)
|
||||
q = new_q
|
||||
current += 1
|
||||
|
||||
|
||||
def deep_iter_diff(before, after):
|
||||
"""
|
||||
Iter diff between 2 dict.
|
||||
Pretty fast to compare 2 deeply nested dict,
|
||||
time cost increases with the number of differences.
|
||||
|
||||
Args:
|
||||
before:
|
||||
after:
|
||||
|
||||
Yields:
|
||||
list[str]: Key path
|
||||
Any: Value in before, or None if not exists
|
||||
Any: Value in after, or None if not exists
|
||||
"""
|
||||
if before == after:
|
||||
return
|
||||
if type(before) is not dict or type(after) is not dict:
|
||||
yield [], before, after
|
||||
return
|
||||
|
||||
queue = deque([([], before, after)])
|
||||
while True:
|
||||
new_queue = deque()
|
||||
for path, d1, d2 in queue:
|
||||
keys1 = set(d1.keys())
|
||||
keys2 = set(d2.keys())
|
||||
for key in keys1.union(keys2):
|
||||
try:
|
||||
val2 = d2[key]
|
||||
except KeyError:
|
||||
# Safe to access d1[key], because key came from the union of both
|
||||
# If it's not in d2 then it's in d1
|
||||
yield path + [key], d1[key], None
|
||||
continue
|
||||
try:
|
||||
val1 = d1[key]
|
||||
except KeyError:
|
||||
yield path + [key], None, val2
|
||||
continue
|
||||
# Compare dict first, which is pretty fast
|
||||
if val1 != val2:
|
||||
if type(val1) is dict and type(val2) is dict:
|
||||
new_queue.append((path + [key], val1, val2))
|
||||
else:
|
||||
yield path + [key], val1, val2
|
||||
queue = new_queue
|
||||
if not queue:
|
||||
break
|
||||
|
||||
|
||||
def deep_iter_patch(before, after):
|
||||
"""
|
||||
Iter patch event from before to after, like creating a json-patch
|
||||
Pretty fast to compare 2 deeply nested dict,
|
||||
time cost increases with the number of differences.
|
||||
|
||||
Args:
|
||||
before:
|
||||
after:
|
||||
|
||||
Yields:
|
||||
str: OP_ADD, OP_SET, OP_DEL
|
||||
list[str]: Key path
|
||||
Any: Value in after,
|
||||
or None of event is OP_DEL
|
||||
"""
|
||||
if before == after:
|
||||
return
|
||||
if type(before) is not dict or type(after) is not dict:
|
||||
yield OP_SET, [], after
|
||||
return
|
||||
|
||||
queue = deque([([], before, after)])
|
||||
while True:
|
||||
new_queue = deque()
|
||||
for path, d1, d2 in queue:
|
||||
keys1 = set(d1.keys())
|
||||
keys2 = set(d2.keys())
|
||||
for key in keys1.union(keys2):
|
||||
try:
|
||||
val2 = d2[key]
|
||||
except KeyError:
|
||||
yield OP_DEL, path + [key], None
|
||||
continue
|
||||
try:
|
||||
val1 = d1[key]
|
||||
except KeyError:
|
||||
yield OP_ADD, path + [key], val2
|
||||
continue
|
||||
# Compare dict first, which is pretty fast
|
||||
if val1 != val2:
|
||||
if type(val1) is dict and type(val2) is dict:
|
||||
new_queue.append((path + [key], val1, val2))
|
||||
else:
|
||||
yield OP_SET, path + [key], val2
|
||||
queue = new_queue
|
||||
if not queue:
|
||||
break
|
||||
@@ -15,6 +15,10 @@
|
||||
"Reward": {
|
||||
"name": "Reward",
|
||||
"help": ""
|
||||
},
|
||||
"Tool": {
|
||||
"name": "Tools",
|
||||
"help": ""
|
||||
}
|
||||
},
|
||||
"Task": {
|
||||
@@ -138,7 +142,9 @@
|
||||
"aScreenCap_nc": "aScreenCap_nc",
|
||||
"DroidCast": "DroidCast",
|
||||
"DroidCast_raw": "DroidCast_raw",
|
||||
"scrcpy": "scrcpy"
|
||||
"scrcpy": "scrcpy",
|
||||
"nemu_ipc": "nemu_ipc",
|
||||
"ldopengl": "ldopengl"
|
||||
},
|
||||
"ControlMethod": {
|
||||
"name": "Control Method",
|
||||
@@ -169,6 +175,7 @@
|
||||
"LDPlayer3": "LD Player 3",
|
||||
"LDPlayer4": "LD Player 4",
|
||||
"LDPlayer9": "LD Player 9",
|
||||
"LDPlayer14": "LD Player 14",
|
||||
"MuMuPlayer": "MuMu Player",
|
||||
"MuMuPlayerX": "MuMu Player X",
|
||||
"MuMuPlayer12": "MuMu Player 12",
|
||||
@@ -226,6 +233,7 @@
|
||||
"stay_there": "Stay There",
|
||||
"goto_main": "Goto Main Page",
|
||||
"close_game": "Close Game",
|
||||
"close_emulator": "Close Emulator",
|
||||
"exit_aas": "Exit AAS",
|
||||
"exit_emulator": "Exit Emulator",
|
||||
"exit_aas_emulator": "Exit AAS & Emulator",
|
||||
@@ -849,6 +857,16 @@
|
||||
"help": ""
|
||||
}
|
||||
},
|
||||
"Momotalk": {
|
||||
"_info": {
|
||||
"name": "Momotalk",
|
||||
"help": "Tools need to stop the scheduler and then run independently"
|
||||
},
|
||||
"Enable": {
|
||||
"name": "",
|
||||
"help": ""
|
||||
}
|
||||
},
|
||||
"Gui": {
|
||||
"Aside": {
|
||||
"Install": "Install",
|
||||
@@ -908,7 +926,8 @@
|
||||
"MinutesAgo": "{time}min ago",
|
||||
"HoursAgo": "{time}h ago",
|
||||
"DaysAgo": "{time}d ago",
|
||||
"LongTimeAgo": "long time ago"
|
||||
"LongTimeAgo": "long time ago",
|
||||
"EtaDays": "ETA {time}d"
|
||||
},
|
||||
"AddAlas": {
|
||||
"PopupTitle": "Add new config",
|
||||
|
||||
@@ -15,6 +15,10 @@
|
||||
"Reward": {
|
||||
"name": "收菜",
|
||||
"help": ""
|
||||
},
|
||||
"Tool": {
|
||||
"name": "工具",
|
||||
"help": ""
|
||||
}
|
||||
},
|
||||
"Task": {
|
||||
@@ -138,7 +142,9 @@
|
||||
"aScreenCap_nc": "aScreenCap_nc",
|
||||
"DroidCast": "DroidCast",
|
||||
"DroidCast_raw": "DroidCast_raw",
|
||||
"scrcpy": "scrcpy"
|
||||
"scrcpy": "scrcpy",
|
||||
"nemu_ipc": "nemu_ipc",
|
||||
"ldopengl": "ldopengl"
|
||||
},
|
||||
"ControlMethod": {
|
||||
"name": "模拟器控制方案",
|
||||
@@ -169,6 +175,7 @@
|
||||
"LDPlayer3": "雷电模拟器3",
|
||||
"LDPlayer4": "雷电模拟器4",
|
||||
"LDPlayer9": "雷电模拟器9",
|
||||
"LDPlayer14": "雷电模拟器14",
|
||||
"MuMuPlayer": "MuMu模拟器",
|
||||
"MuMuPlayerX": "MuMu模拟器X",
|
||||
"MuMuPlayer12": "MuMu模拟器12",
|
||||
@@ -226,6 +233,7 @@
|
||||
"stay_there": "停在原处",
|
||||
"goto_main": "前往主界面",
|
||||
"close_game": "关闭游戏",
|
||||
"close_emulator": "关闭模拟器",
|
||||
"exit_aas": "关闭 AAS",
|
||||
"exit_emulator": "关闭模拟器",
|
||||
"exit_aas_emulator": "关闭 AAS 和模拟器",
|
||||
@@ -849,6 +857,16 @@
|
||||
"help": ""
|
||||
}
|
||||
},
|
||||
"Momotalk": {
|
||||
"_info": {
|
||||
"name": "Momotalk",
|
||||
"help": "工具需要停止调度器再单独运行"
|
||||
},
|
||||
"Enable": {
|
||||
"name": "",
|
||||
"help": ""
|
||||
}
|
||||
},
|
||||
"Gui": {
|
||||
"Aside": {
|
||||
"Install": "安装",
|
||||
@@ -908,7 +926,8 @@
|
||||
"MinutesAgo": "{time}分钟前",
|
||||
"HoursAgo": "{time}小时前",
|
||||
"DaysAgo": "{time}天前",
|
||||
"LongTimeAgo": "很久以前"
|
||||
"LongTimeAgo": "很久以前",
|
||||
"EtaDays": "剩余{time}天"
|
||||
},
|
||||
"AddAlas": {
|
||||
"PopupTitle": "添加新配置",
|
||||
|
||||
@@ -16,6 +16,11 @@ VALID_SERVER = {
|
||||
}
|
||||
VALID_PACKAGE = set(list(VALID_SERVER.values()))
|
||||
|
||||
DICT_PACKAGE_TO_ACTIVITY = {
|
||||
'com.YostarJP.BlueArchive': 'com.yostarjp.bluearchive.MxUnityPlayerActivity',
|
||||
'com.nexon.bluearchive': 'com.nexon.bluearchive.MxUnityPlayerActivity',
|
||||
}
|
||||
|
||||
|
||||
def set_lang(lang_: str):
|
||||
"""
|
||||
@@ -32,15 +37,18 @@ def set_lang(lang_: str):
|
||||
release_resources()
|
||||
|
||||
|
||||
def to_server(package_or_server: str) -> str:
|
||||
def to_server(package_or_server: str, before: str = '') -> str:
|
||||
"""
|
||||
Convert package/server to server.
|
||||
To unknown packages, consider they are a CN channel servers.
|
||||
"""
|
||||
# Can't distinguish different regions of oversea servers,
|
||||
# assume it's 'OVERSEA-Asia'
|
||||
# if package_or_server == 'com.HoYoverse.hkrpgoversea':
|
||||
# return 'OVERSEA-Asia'
|
||||
if package_or_server == 'com.nexon.bluearchive':
|
||||
# Can't distinguish different regions of oversea servers, but we try to reuse old value
|
||||
if before in ['OVERSEA-Asia', 'OVERSEA-America', 'OVERSEA-Korea', 'OVERSEA-TWHKMO', 'OVERSEA-Global']:
|
||||
return before
|
||||
else:
|
||||
# otherwise assume it's 'OVERSEA-Asia'
|
||||
return 'OVERSEA-Asia'
|
||||
|
||||
for key, value in VALID_SERVER.items():
|
||||
if value == package_or_server:
|
||||
|
||||
@@ -2,7 +2,8 @@ from datetime import datetime
|
||||
from functools import cached_property as functools_cached_property
|
||||
|
||||
from module.base.decorator import cached_property
|
||||
from module.config.utils import DEFAULT_TIME, deep_get, get_server_last_monday_update, get_server_last_update
|
||||
from module.config.deep import deep_get
|
||||
from module.config.utils import DEFAULT_TIME, get_server_last_monday_update, get_server_last_update
|
||||
|
||||
|
||||
# from module.exception import ScriptError
|
||||
@@ -134,6 +135,9 @@ class StoredExpiredAtMonday0400(StoredBase):
|
||||
class StoredInt(StoredBase):
|
||||
value = 0
|
||||
|
||||
def clear(self):
|
||||
self.value = 0
|
||||
|
||||
|
||||
class StoredCounter(StoredBase):
|
||||
value = 0
|
||||
@@ -148,6 +152,9 @@ class StoredCounter(StoredBase):
|
||||
self.value = value
|
||||
self.total = total
|
||||
|
||||
def clear(self):
|
||||
self.value = 0
|
||||
|
||||
def to_counter(self) -> str:
|
||||
return f'{self.value}/{self.total}'
|
||||
|
||||
@@ -157,6 +164,9 @@ class StoredCounter(StoredBase):
|
||||
def get_remain(self) -> int:
|
||||
return self.total - self.value
|
||||
|
||||
def add(self, value=1):
|
||||
self.value += value
|
||||
|
||||
@cached_property
|
||||
def _attrs(self) -> dict:
|
||||
attrs = super()._attrs
|
||||
|
||||
@@ -5,10 +5,9 @@ import string
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
import yaml
|
||||
from filelock import FileLock
|
||||
|
||||
import module.config.server as server_
|
||||
from module.config.atomicwrites import atomic_write
|
||||
from deploy.Windows.atomic import atomic_read_bytes, atomic_read_text, atomic_write
|
||||
|
||||
LANGUAGES = ['zh-CN', 'en-US']
|
||||
SERVER_TO_TIMEZONE = {
|
||||
@@ -67,33 +66,23 @@ def read_file(file):
|
||||
Returns:
|
||||
dict, list:
|
||||
"""
|
||||
folder = os.path.dirname(file)
|
||||
if not os.path.exists(folder):
|
||||
os.mkdir(folder)
|
||||
|
||||
if not os.path.exists(file):
|
||||
return {}
|
||||
|
||||
_, ext = os.path.splitext(file)
|
||||
lock = FileLock(f"{file}.lock")
|
||||
with lock:
|
||||
print(f'read: {file}')
|
||||
if ext == '.yaml':
|
||||
with open(file, mode='r', encoding='utf-8') as f:
|
||||
s = f.read()
|
||||
data = list(yaml.safe_load_all(s))
|
||||
if len(data) == 1:
|
||||
data = data[0]
|
||||
if not data:
|
||||
data = {}
|
||||
return data
|
||||
elif ext == '.json':
|
||||
with open(file, mode='r', encoding='utf-8') as f:
|
||||
s = f.read()
|
||||
return json.loads(s)
|
||||
else:
|
||||
print(f'Unsupported config file extension: {ext}')
|
||||
print(f'read: {file}')
|
||||
if file.endswith('.json'):
|
||||
content = atomic_read_bytes(file)
|
||||
if not content:
|
||||
return {}
|
||||
return json.loads(content)
|
||||
elif file.endswith('.yaml'):
|
||||
content = atomic_read_text(file)
|
||||
data = list(yaml.safe_load_all(content))
|
||||
if len(data) == 1:
|
||||
data = data[0]
|
||||
if not data:
|
||||
data = {}
|
||||
return data
|
||||
else:
|
||||
print(f'Unsupported config file extension: {file}')
|
||||
return {}
|
||||
|
||||
|
||||
def write_file(file, data):
|
||||
@@ -104,28 +93,20 @@ def write_file(file, data):
|
||||
file (str):
|
||||
data (dict, list):
|
||||
"""
|
||||
folder = os.path.dirname(file)
|
||||
if not os.path.exists(folder):
|
||||
os.mkdir(folder)
|
||||
|
||||
_, ext = os.path.splitext(file)
|
||||
lock = FileLock(f"{file}.lock")
|
||||
with lock:
|
||||
print(f'write: {file}')
|
||||
if ext == '.yaml':
|
||||
with atomic_write(file, overwrite=True, encoding='utf-8', newline='') as f:
|
||||
if isinstance(data, list):
|
||||
yaml.safe_dump_all(data, f, default_flow_style=False, encoding='utf-8', allow_unicode=True,
|
||||
sort_keys=False)
|
||||
else:
|
||||
yaml.safe_dump(data, f, default_flow_style=False, encoding='utf-8', allow_unicode=True,
|
||||
sort_keys=False)
|
||||
elif ext == '.json':
|
||||
with atomic_write(file, overwrite=True, encoding='utf-8', newline='') as f:
|
||||
s = json.dumps(data, indent=2, ensure_ascii=False, sort_keys=False, default=str)
|
||||
f.write(s)
|
||||
print(f'write: {file}')
|
||||
if file.endswith('.json'):
|
||||
content = json.dumps(data, indent=2, ensure_ascii=False, sort_keys=False, default=str)
|
||||
atomic_write(file, content)
|
||||
elif file.endswith('.yaml'):
|
||||
if isinstance(data, list):
|
||||
content = yaml.safe_dump_all(
|
||||
data, default_flow_style=False, encoding='utf-8', allow_unicode=True, sort_keys=False)
|
||||
else:
|
||||
print(f'Unsupported config file extension: {ext}')
|
||||
content = yaml.safe_dump(
|
||||
data, default_flow_style=False, encoding='utf-8', allow_unicode=True, sort_keys=False)
|
||||
atomic_write(file, content)
|
||||
else:
|
||||
print(f'Unsupported config file extension: {file}')
|
||||
|
||||
|
||||
def iter_folder(folder, is_dir=False, ext=None):
|
||||
@@ -189,101 +170,6 @@ def alas_instance():
|
||||
return out
|
||||
|
||||
|
||||
def deep_get(d, keys, default=None):
|
||||
"""
|
||||
Get values in dictionary safely.
|
||||
https://stackoverflow.com/questions/25833613/safe-method-to-get-value-of-nested-dictionary
|
||||
|
||||
Args:
|
||||
d (dict):
|
||||
keys (str, list): Such as `Scheduler.NextRun.value`
|
||||
default: Default return if key not found.
|
||||
|
||||
Returns:
|
||||
|
||||
"""
|
||||
if isinstance(keys, str):
|
||||
keys = keys.split('.')
|
||||
assert type(keys) is list
|
||||
if d is None:
|
||||
return default
|
||||
if not keys:
|
||||
return d
|
||||
return deep_get(d.get(keys[0]), keys[1:], default)
|
||||
|
||||
|
||||
def deep_set(d, keys, value):
|
||||
"""
|
||||
Set value into dictionary safely, imitating deep_get().
|
||||
"""
|
||||
if isinstance(keys, str):
|
||||
keys = keys.split('.')
|
||||
assert type(keys) is list
|
||||
if not keys:
|
||||
return value
|
||||
if not isinstance(d, dict):
|
||||
d = {}
|
||||
d[keys[0]] = deep_set(d.get(keys[0], {}), keys[1:], value)
|
||||
return d
|
||||
|
||||
|
||||
def deep_pop(d, keys, default=None):
|
||||
"""
|
||||
Pop value from dictionary safely, imitating deep_get().
|
||||
"""
|
||||
if isinstance(keys, str):
|
||||
keys = keys.split('.')
|
||||
assert type(keys) is list
|
||||
if not isinstance(d, dict):
|
||||
return default
|
||||
if not keys:
|
||||
return default
|
||||
elif len(keys) == 1:
|
||||
return d.pop(keys[0], default)
|
||||
return deep_pop(d.get(keys[0]), keys[1:], default)
|
||||
|
||||
|
||||
def deep_default(d, keys, value):
|
||||
"""
|
||||
Set default value into dictionary safely, imitating deep_get().
|
||||
Value is set only when the dict doesn't contain such keys.
|
||||
"""
|
||||
if isinstance(keys, str):
|
||||
keys = keys.split('.')
|
||||
assert type(keys) is list
|
||||
if not keys:
|
||||
if d:
|
||||
return d
|
||||
else:
|
||||
return value
|
||||
if not isinstance(d, dict):
|
||||
d = {}
|
||||
d[keys[0]] = deep_default(d.get(keys[0], {}), keys[1:], value)
|
||||
return d
|
||||
|
||||
|
||||
def deep_iter(data, depth=0, current_depth=1):
|
||||
"""
|
||||
Iter a dictionary safely.
|
||||
|
||||
Args:
|
||||
data (dict):
|
||||
depth (int): Maximum depth to iter
|
||||
current_depth (int):
|
||||
|
||||
Returns:
|
||||
list: Key path
|
||||
Any:
|
||||
"""
|
||||
if isinstance(data, dict) \
|
||||
and (depth and current_depth <= depth):
|
||||
for key, value in data.items():
|
||||
for child_path, child_value in deep_iter(value, depth=depth, current_depth=current_depth + 1):
|
||||
yield [key] + child_path, child_value
|
||||
else:
|
||||
yield [], data
|
||||
|
||||
|
||||
def parse_value(value, data):
|
||||
"""
|
||||
Convert a string to float, int, datetime, if possible.
|
||||
@@ -543,7 +429,7 @@ def get_server_last_monday_update(daily_trigger):
|
||||
Returns:
|
||||
datetime.datetime
|
||||
"""
|
||||
update = get_server_next_update(daily_trigger)
|
||||
update = get_server_last_update(daily_trigger)
|
||||
diff = update.weekday()
|
||||
update = update - timedelta(days=diff)
|
||||
return update
|
||||
|
||||
@@ -5,8 +5,7 @@ import numpy as np
|
||||
from rich.table import Table
|
||||
from rich.text import Text
|
||||
|
||||
from module.base.utils import float2str as float2str_
|
||||
from module.base.utils import random_rectangle_point
|
||||
from module.base.utils import float2str as float2str_, random_rectangle_point
|
||||
from module.daemon.daemon_base import DaemonBase
|
||||
from module.exception import RequestHumanTakeover
|
||||
from module.logger import logger
|
||||
@@ -38,7 +37,7 @@ class Benchmark(DaemonBase):
|
||||
record = []
|
||||
|
||||
for n in range(1, self.TEST_TOTAL + 1):
|
||||
start = time.time()
|
||||
start = time.perf_counter()
|
||||
|
||||
try:
|
||||
func(*args, **kwargs)
|
||||
@@ -51,7 +50,7 @@ class Benchmark(DaemonBase):
|
||||
logger.warning(f'Benchmark tests failed on func: {func.__name__}')
|
||||
return 'Failed'
|
||||
|
||||
cost = time.time() - start
|
||||
cost = time.perf_counter() - start
|
||||
logger.attr(
|
||||
f'{str(n).rjust(2, "0")}/{self.TEST_TOTAL}',
|
||||
f'{float2str(cost)}'
|
||||
@@ -68,17 +67,19 @@ class Benchmark(DaemonBase):
|
||||
if not isinstance(cost, (float, int)):
|
||||
return Text(cost, style="bold bright_red")
|
||||
|
||||
if cost < 0.10:
|
||||
if cost < 0.025:
|
||||
return Text('Insane Fast', style="bold bright_green")
|
||||
if cost < 0.100:
|
||||
return Text('Ultra Fast', style="bold bright_green")
|
||||
if cost < 0.20:
|
||||
if cost < 0.200:
|
||||
return Text('Very Fast', style="bright_green")
|
||||
if cost < 0.30:
|
||||
if cost < 0.300:
|
||||
return Text('Fast', style="green")
|
||||
if cost < 0.50:
|
||||
if cost < 0.500:
|
||||
return Text('Medium', style="yellow")
|
||||
if cost < 0.75:
|
||||
if cost < 0.750:
|
||||
return Text('Slow', style="red")
|
||||
if cost < 1.00:
|
||||
if cost < 1.000:
|
||||
return Text('Very Slow', style="bright_red")
|
||||
return Text('Ultra Slow', style="bold bright_red")
|
||||
|
||||
@@ -87,11 +88,11 @@ class Benchmark(DaemonBase):
|
||||
if not isinstance(cost, (float, int)):
|
||||
return Text(cost, style="bold bright_red")
|
||||
|
||||
if cost < 0.1:
|
||||
if cost < 0.100:
|
||||
return Text('Fast', style="bright_green")
|
||||
if cost < 0.2:
|
||||
if cost < 0.200:
|
||||
return Text('Medium', style="yellow")
|
||||
if cost < 0.4:
|
||||
if cost < 0.400:
|
||||
return Text('Slow', style="red")
|
||||
return Text('Very Slow', style="bright_red")
|
||||
|
||||
@@ -162,22 +163,27 @@ class Benchmark(DaemonBase):
|
||||
if click_result:
|
||||
self.show(test='Control', data=click_result, evaluate_func=self.evaluate_click)
|
||||
fastest = sorted(click_result, key=lambda item: compare(item))[0]
|
||||
# Prefer MaaTouch if both minitouch and MaaTouch are fastest
|
||||
if 'MaaTouch' in click and fastest[0] == 'minitouch':
|
||||
fastest[0] = 'MaaTouch'
|
||||
logger.info(f'Recommend control method: {fastest[0]} ({float2str(fastest[1])})')
|
||||
fastest_click = fastest[0]
|
||||
|
||||
return fastest_screenshot, fastest_click
|
||||
|
||||
def get_test_methods(self) -> t.Tuple[t.Tuple[str], t.Tuple[str]]:
|
||||
device = self.config.Benchmark_DeviceType
|
||||
# device == 'emulator'
|
||||
# device = self.config.Benchmark_DeviceType
|
||||
device = 'emulator'
|
||||
screenshot = ['ADB', 'ADB_nc', 'uiautomator2', 'aScreenCap', 'aScreenCap_nc', 'DroidCast', 'DroidCast_raw']
|
||||
click = ['ADB', 'uiautomator2', 'minitouch']
|
||||
click = ['ADB', 'uiautomator2', 'minitouch', 'maatouch']
|
||||
|
||||
def remove(*args):
|
||||
return [l for l in screenshot if l not in args]
|
||||
|
||||
# No ascreencap on Android > 9
|
||||
if device in ['emulator_android_12', 'android_phone_12']:
|
||||
sdk = self.device.sdk_ver
|
||||
logger.info(f'sdk_ver: {sdk}')
|
||||
if not (21 <= sdk <= 28):
|
||||
screenshot = remove('aScreenCap', 'aScreenCap_nc')
|
||||
# No nc loopback
|
||||
if device in ['plone_cloud_with_adb']:
|
||||
@@ -186,8 +192,16 @@ class Benchmark(DaemonBase):
|
||||
if device == 'android_phone_vmos':
|
||||
screenshot = ['ADB', 'aScreenCap', 'DroidCast', 'DroidCast_raw']
|
||||
click = ['ADB', 'Hermit', 'MaaTouch']
|
||||
# Droidcast on SDK 23 (Android 6.0) to SDK 32 (Android 12)
|
||||
if not (23 <= sdk <= 32):
|
||||
screenshot = remove('DroidCast', 'DroidCast_raw')
|
||||
|
||||
scene = self.config.Benchmark_TestScene
|
||||
if self.device.nemu_ipc_available():
|
||||
screenshot.append('nemu_ipc')
|
||||
if self.device.ldopengl_available():
|
||||
screenshot.append('ldopengl')
|
||||
|
||||
scene = 'screenshot_click'
|
||||
if 'screenshot' not in scene:
|
||||
screenshot = []
|
||||
if 'click' not in scene:
|
||||
@@ -203,8 +217,8 @@ class Benchmark(DaemonBase):
|
||||
logger.critical('Request human takeover')
|
||||
return
|
||||
|
||||
logger.attr('DeviceType', self.config.Benchmark_DeviceType)
|
||||
logger.attr('TestScene', self.config.Benchmark_TestScene)
|
||||
# logger.attr('DeviceType', self.config.Benchmark_DeviceType)
|
||||
# logger.attr('TestScene', self.config.Benchmark_TestScene)
|
||||
screenshot, click = self.get_test_methods()
|
||||
self.benchmark(screenshot, click)
|
||||
|
||||
@@ -224,6 +238,10 @@ class Benchmark(DaemonBase):
|
||||
screenshot = remove('aScreenCap', 'aScreenCap_nc')
|
||||
if self.device.is_chinac_phone_cloud:
|
||||
screenshot = remove('ADB_nc', 'aScreenCap_nc')
|
||||
if self.device.nemu_ipc_available():
|
||||
screenshot.append('nemu_ipc')
|
||||
if self.device.ldopengl_available():
|
||||
screenshot.append('ldopengl')
|
||||
screenshot = tuple(screenshot)
|
||||
|
||||
self.TEST_TOTAL = 3
|
||||
@@ -233,6 +251,15 @@ class Benchmark(DaemonBase):
|
||||
return method
|
||||
|
||||
|
||||
def run_benchmark(config):
|
||||
try:
|
||||
Benchmark(config, task='Benchmark').run()
|
||||
return True
|
||||
except RequestHumanTakeover:
|
||||
logger.critical('Request human takeover')
|
||||
return False
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
b = Benchmark('alas', task='Benchmark')
|
||||
b = Benchmark('src', task='Benchmark')
|
||||
b.run()
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
from lxml import etree
|
||||
|
||||
from module.base.timer import Timer
|
||||
from module.device.method.adb import Adb
|
||||
from module.device.method.uiautomator_2 import Uiautomator2
|
||||
from module.device.method.utils import HierarchyButton
|
||||
from module.device.method.wsa import WSA
|
||||
from module.exception import ScriptError
|
||||
from module.logger import logger
|
||||
|
||||
|
||||
@@ -12,8 +14,9 @@ class AppControl(Adb, WSA, Uiautomator2):
|
||||
# Use ADB for all
|
||||
# See https://github.com/openatx/uiautomator2/issues/565
|
||||
_app_u2_family = []
|
||||
_hierarchy_interval = Timer(0.1)
|
||||
|
||||
def app_is_running(self) -> bool:
|
||||
def app_current(self) -> str:
|
||||
method = self.config.Emulator_ControlMethod
|
||||
if self.is_wsa:
|
||||
package = self.app_current_wsa()
|
||||
@@ -21,8 +24,11 @@ class AppControl(Adb, WSA, Uiautomator2):
|
||||
package = self.app_current_uiautomator2()
|
||||
else:
|
||||
package = self.app_current_adb()
|
||||
|
||||
package = package.strip(' \t\r\n')
|
||||
return package
|
||||
|
||||
def app_is_running(self) -> bool:
|
||||
package = self.app_current()
|
||||
logger.attr('Package_name', package)
|
||||
return package == self.package
|
||||
|
||||
@@ -44,16 +50,36 @@ class AppControl(Adb, WSA, Uiautomator2):
|
||||
else:
|
||||
self.app_stop_adb()
|
||||
|
||||
def hierarchy_timer_set(self, interval=None):
|
||||
if interval is None:
|
||||
interval = 0.1
|
||||
elif isinstance(interval, (int, float)):
|
||||
# No limitation for manual set in code
|
||||
pass
|
||||
else:
|
||||
logger.warning(f'Unknown hierarchy interval: {interval}')
|
||||
raise ScriptError(f'Unknown hierarchy interval: {interval}')
|
||||
|
||||
if interval != self._hierarchy_interval.limit:
|
||||
logger.info(f'Hierarchy interval set to {interval}s')
|
||||
self._hierarchy_interval.limit = interval
|
||||
|
||||
def dump_hierarchy(self) -> etree._Element:
|
||||
"""
|
||||
Returns:
|
||||
etree._Element: Select elements with `self.hierarchy.xpath('//*[@text="Hermit"]')` for example.
|
||||
"""
|
||||
method = self.config.Emulator_ControlMethod
|
||||
if method in AppControl._app_u2_family:
|
||||
self.hierarchy = self.dump_hierarchy_uiautomator2()
|
||||
else:
|
||||
self.hierarchy = self.dump_hierarchy_adb()
|
||||
self._hierarchy_interval.wait()
|
||||
self._hierarchy_interval.reset()
|
||||
|
||||
# method = self.config.Emulator_ControlMethod
|
||||
# if method in AppControl._app_u2_family:
|
||||
# self.hierarchy = self.dump_hierarchy_uiautomator2()
|
||||
# else:
|
||||
# self.hierarchy = self.dump_hierarchy_adb()
|
||||
|
||||
# Using uiautomator2
|
||||
self.hierarchy = self.dump_hierarchy_uiautomator2()
|
||||
return self.hierarchy
|
||||
|
||||
def xpath_to_button(self, xpath: str) -> HierarchyButton:
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import ipaddress
|
||||
import json
|
||||
import logging
|
||||
import platform
|
||||
import re
|
||||
import socket
|
||||
import subprocess
|
||||
@@ -12,12 +12,17 @@ from adbutils import AdbClient, AdbDevice, AdbTimeout, ForwardItem, ReverseItem
|
||||
from adbutils.errors import AdbError
|
||||
|
||||
import module.config.server as server_
|
||||
from module.base.decorator import Config, cached_property, del_cached_property
|
||||
from module.base.decorator import Config, cached_property, del_cached_property, run_once
|
||||
from module.base.timer import Timer
|
||||
from module.base.utils import SelectedGrids, ensure_time
|
||||
from module.config.deep import deep_get
|
||||
from module.device.connection_attr import ConnectionAttr
|
||||
from module.device.method.utils import (
|
||||
PackageNotInstalled, RETRY_TRIES, get_serial_pair, handle_adb_error,
|
||||
possible_reasons, random_port, recv_all, remove_shell_warning, retry_sleep)
|
||||
from module.device.env import IS_LINUX, IS_MACINTOSH, IS_WINDOWS
|
||||
from module.device.method.pool import WORKER_POOL
|
||||
from module.device.method.remove_warning import remove_shell_warning
|
||||
from module.device.method.utils import (PackageNotInstalled, RETRY_TRIES, get_serial_pair, handle_adb_error,
|
||||
handle_unknown_host_service, possible_reasons, random_port, recv_all,
|
||||
retry_sleep)
|
||||
from module.exception import EmulatorNotRunningError, RequestHumanTakeover
|
||||
from module.logger import logger
|
||||
|
||||
@@ -33,7 +38,7 @@ def retry(func):
|
||||
for _ in range(RETRY_TRIES):
|
||||
try:
|
||||
if callable(init):
|
||||
retry_sleep(_)
|
||||
time.sleep(retry_sleep(_))
|
||||
init()
|
||||
return func(self, *args, **kwargs)
|
||||
# Can't handle
|
||||
@@ -50,6 +55,10 @@ def retry(func):
|
||||
if handle_adb_error(e):
|
||||
def init():
|
||||
self.adb_reconnect()
|
||||
elif handle_unknown_host_service(e):
|
||||
def init():
|
||||
self.adb_start_server()
|
||||
self.adb_reconnect()
|
||||
else:
|
||||
break
|
||||
# Package not installed
|
||||
@@ -84,6 +93,18 @@ class AdbDeviceWithStatus(AdbDevice):
|
||||
def __bool__(self):
|
||||
return True
|
||||
|
||||
@cached_property
|
||||
def port(self) -> int:
|
||||
try:
|
||||
return int(self.serial.split(':')[1])
|
||||
except (IndexError, ValueError):
|
||||
return 0
|
||||
|
||||
@cached_property
|
||||
def may_mumu12_family(self):
|
||||
# 127.0.0.1:16XXX
|
||||
return 16384 <= self.port <= 17408
|
||||
|
||||
|
||||
class Connection(ConnectionAttr):
|
||||
def __init__(self, config):
|
||||
@@ -96,7 +117,7 @@ class Connection(ConnectionAttr):
|
||||
self.detect_device()
|
||||
|
||||
# Connect
|
||||
self.adb_connect(self.serial)
|
||||
self.adb_connect(wait_device=False)
|
||||
logger.attr('AdbDevice', self.adb)
|
||||
|
||||
# Package
|
||||
@@ -130,8 +151,18 @@ class Connection(ConnectionAttr):
|
||||
"""
|
||||
cmd = list(map(str, cmd))
|
||||
cmd = [self.adb_binary, '-s', self.serial] + cmd
|
||||
logger.info(f'Execute: {cmd}')
|
||||
return self.subprocess_run(cmd, timeout=timeout)
|
||||
|
||||
def subprocess_run(self, cmd, timeout=10):
|
||||
"""
|
||||
Args:
|
||||
cmd (list):
|
||||
timeout (int):
|
||||
|
||||
Returns:
|
||||
str:
|
||||
"""
|
||||
logger.info(f'Execute: {cmd}')
|
||||
# Use shell=True to disable console window when using GUI.
|
||||
# Although, there's still a window when you stop running in GUI, which cause by gooey.
|
||||
# To disable it, edit gooey/gui/util/taskkill.py
|
||||
@@ -148,11 +179,21 @@ class Connection(ConnectionAttr):
|
||||
|
||||
@Config.when(DEVICE_OVER_HTTP=True)
|
||||
def adb_command(self, cmd, timeout=10):
|
||||
logger.warning(
|
||||
f'adb_command() is not available when connecting over http: {self.serial}, '
|
||||
logger.critical(
|
||||
f'Trying to execute {cmd}, '
|
||||
f'but adb_command() is not available when connecting over http: {self.serial}, '
|
||||
)
|
||||
raise RequestHumanTakeover
|
||||
|
||||
def adb_start_server(self):
|
||||
"""
|
||||
Use `adb devices` as `adb start-server`, result is actually useless
|
||||
Start ADB using subprocess instead of connecting via socket to kill the other ADBs
|
||||
"""
|
||||
stdout = self.subprocess_run([self.adb_binary, 'devices'])
|
||||
logger.info(stdout)
|
||||
return stdout
|
||||
|
||||
@Config.when(DEVICE_OVER_HTTP=False)
|
||||
def adb_shell(self, cmd, stream=False, recvall=True, timeout=10, rstrip=True):
|
||||
"""
|
||||
@@ -233,6 +274,7 @@ class Connection(ConnectionAttr):
|
||||
return self.adb_shell(['getprop', name]).strip()
|
||||
|
||||
@cached_property
|
||||
@retry
|
||||
def cpu_abi(self) -> str:
|
||||
"""
|
||||
Returns:
|
||||
@@ -244,6 +286,7 @@ class Connection(ConnectionAttr):
|
||||
return abi
|
||||
|
||||
@cached_property
|
||||
@retry
|
||||
def sdk_ver(self) -> int:
|
||||
"""
|
||||
Android SDK/API levels, see https://apilevels.com/
|
||||
@@ -257,6 +300,7 @@ class Connection(ConnectionAttr):
|
||||
return 0
|
||||
|
||||
@cached_property
|
||||
@retry
|
||||
def is_avd(self):
|
||||
if get_serial_pair(self.serial)[0] is None:
|
||||
return False
|
||||
@@ -266,14 +310,72 @@ class Connection(ConnectionAttr):
|
||||
return True
|
||||
return False
|
||||
|
||||
@cached_property
|
||||
@retry
|
||||
def is_waydroid(self):
|
||||
res = self.adb_getprop('ro.product.brand')
|
||||
logger.attr('ro.product.brand', res)
|
||||
return 'waydroid' in res.lower()
|
||||
|
||||
@cached_property
|
||||
@retry
|
||||
def is_bluestacks_air(self):
|
||||
# BlueStacks Air is the Mac version of BlueStacks
|
||||
if not IS_MACINTOSH:
|
||||
return False
|
||||
# 127.0.0.1:5555 + 10*n, assume 32 instances at max
|
||||
if not (5555 <= self.port <= 5875):
|
||||
return False
|
||||
# [bst.installed_images]: [Tiramisu64]
|
||||
# [bst.instance]: [Tiramisu64]
|
||||
# Tiramisu64 is Android 13 and BlueStacks Air is the only BlueStacks version that uses Android 13
|
||||
res = self.adb_getprop('bst.installed_images')
|
||||
logger.attr('bst.installed_images', res)
|
||||
if 'Tiramisu64' in res:
|
||||
return True
|
||||
return False
|
||||
|
||||
@cached_property
|
||||
@retry
|
||||
def is_mumu_pro(self):
|
||||
# MuMU Pro is the Mac version of MuMu
|
||||
if not IS_MACINTOSH:
|
||||
return False
|
||||
if not self.is_mumu_family:
|
||||
return False
|
||||
logger.attr('is_mumu_pro', True)
|
||||
return True
|
||||
|
||||
@cached_property
|
||||
@retry
|
||||
def nemud_app_keep_alive(self) -> str:
|
||||
res = self.adb_getprop('nemud.app_keep_alive')
|
||||
logger.attr('nemud.app_keep_alive', res)
|
||||
return res
|
||||
|
||||
@cached_property
|
||||
@retry
|
||||
def nemud_player_version(self) -> str:
|
||||
# [nemud.player_product_version]: [3.8.27.2950]
|
||||
res = self.adb_getprop('nemud.player_version')
|
||||
logger.attr('nemud.player_version', res)
|
||||
return res
|
||||
|
||||
@cached_property
|
||||
@retry
|
||||
def nemud_player_engine(self) -> str:
|
||||
# NEMUX or MACPRO
|
||||
res = self.adb_getprop('nemud.player_engine')
|
||||
logger.attr('nemud.player_engine', res)
|
||||
return res
|
||||
|
||||
def check_mumu_app_keep_alive(self):
|
||||
if not self.is_mumu_family:
|
||||
return False
|
||||
|
||||
res = self.adb_getprop('nemud.app_keep_alive')
|
||||
logger.attr('nemud.app_keep_alive', res)
|
||||
res = self.nemud_app_keep_alive
|
||||
if res == '':
|
||||
# Empry property, might not be a mumu emulator or might be an old mumu
|
||||
# Empty property, probably MuMu6 or MuMu12 version < 3.5.6
|
||||
return True
|
||||
elif res == 'false':
|
||||
# Disabled
|
||||
@@ -286,6 +388,34 @@ class Connection(ConnectionAttr):
|
||||
logger.warning(f'Invalid nemud.app_keep_alive value: {res}')
|
||||
return False
|
||||
|
||||
@cached_property
|
||||
def is_mumu_over_version_400(self) -> bool:
|
||||
if not self.is_mumu_family:
|
||||
return False
|
||||
# >= 4.0 has no info in getprop
|
||||
if self.nemud_player_version == '':
|
||||
return True
|
||||
return False
|
||||
|
||||
@cached_property
|
||||
def is_mumu_over_version_356(self) -> bool:
|
||||
"""
|
||||
Returns:
|
||||
bool: If MuMu12 version >= 3.5.6,
|
||||
which has nemud.app_keep_alive and always be a vertical device
|
||||
MuMu PRO on mac has the same feature
|
||||
"""
|
||||
if not self.is_mumu_family:
|
||||
return False
|
||||
if self.is_mumu_over_version_400:
|
||||
return True
|
||||
if self.nemud_app_keep_alive != '':
|
||||
return True
|
||||
if IS_MACINTOSH:
|
||||
if 'MACPRO' in self.nemud_player_engine:
|
||||
return True
|
||||
return False
|
||||
|
||||
@cached_property
|
||||
def _nc_server_host_port(self):
|
||||
"""
|
||||
@@ -301,21 +431,26 @@ class Connection(ConnectionAttr):
|
||||
return host, port, host, self.config.REVERSE_SERVER_PORT
|
||||
# For emulators, listen on current host
|
||||
if self.is_emulator or self.is_over_http:
|
||||
# Mac emulators
|
||||
if self.is_bluestacks_air or self.is_mumu_pro:
|
||||
logger.info(f'Connecting to local emulator, using host 127.0.0.1')
|
||||
port = random_port(self.config.FORWARD_PORT_RANGE)
|
||||
return '127.0.0.1', port, "10.0.2.2", port
|
||||
# Get host IP
|
||||
try:
|
||||
host = socket.gethostbyname(socket.gethostname())
|
||||
except socket.gaierror as e:
|
||||
logger.error(e)
|
||||
logger.error(f'Unknown host name: {socket.gethostname()}')
|
||||
host = '127.0.0.1'
|
||||
if platform.system() == 'Linux' and host == '127.0.1.1':
|
||||
# Fixup linux AVD host
|
||||
if IS_LINUX and host == '127.0.1.1':
|
||||
host = '127.0.0.1'
|
||||
logger.info(f'Connecting to local emulator, using host {host}')
|
||||
port = random_port(self.config.FORWARD_PORT_RANGE)
|
||||
|
||||
# For AVD instance
|
||||
if self.is_avd:
|
||||
return host, port, "10.0.2.2", port
|
||||
|
||||
return host, port, host, port
|
||||
# For local network devices, listen on the host under the same network as target device
|
||||
if self.is_network_device:
|
||||
@@ -355,15 +490,21 @@ class Connection(ConnectionAttr):
|
||||
Returns:
|
||||
list[str]: ['nc'] or ['busybox', 'nc']
|
||||
"""
|
||||
sdk = self.sdk_ver
|
||||
logger.info(f'sdk_ver: {sdk}')
|
||||
if sdk >= 28:
|
||||
# Android 9 emulators does not have `nc`, try `busybox nc`
|
||||
# BlueStacks Pie (Android 9) has `nc` but cannot send data, try `busybox nc` first
|
||||
trial = [
|
||||
['busybox', 'nc'],
|
||||
['nc'],
|
||||
]
|
||||
if self.is_emulator:
|
||||
sdk = self.sdk_ver
|
||||
logger.info(f'sdk_ver: {sdk}')
|
||||
if sdk >= 28:
|
||||
# LD Player 9 does not have `nc`, try `busybox nc`
|
||||
# BlueStacks Pie (Android 9) has `nc` but cannot send data, try `busybox nc` first
|
||||
trial = [
|
||||
['busybox', 'nc'],
|
||||
['nc'],
|
||||
]
|
||||
else:
|
||||
trial = [
|
||||
['nc'],
|
||||
['busybox', 'nc'],
|
||||
]
|
||||
else:
|
||||
trial = [
|
||||
['nc'],
|
||||
@@ -371,8 +512,9 @@ class Connection(ConnectionAttr):
|
||||
]
|
||||
for command in trial:
|
||||
# About 3ms
|
||||
result = self.adb_shell(command)
|
||||
# Result should be command help if success
|
||||
# nc: bad argument count (see "nc --help")
|
||||
result = self.adb_shell(command)
|
||||
# `/system/bin/sh: nc: not found`
|
||||
if 'not found' in result:
|
||||
continue
|
||||
@@ -459,6 +601,22 @@ class Connection(ConnectionAttr):
|
||||
self.adb.forward(forward.local, forward.remote)
|
||||
return port
|
||||
|
||||
def _adb_reverse_transport(self, remote: str, local: str, norebind: bool = False):
|
||||
"""
|
||||
Backport fixes from https://github.com/openatx/adbutils/pull/116
|
||||
Don't use self.adb.reverse(), use this method.
|
||||
"""
|
||||
args = ["reverse:forward"]
|
||||
if norebind:
|
||||
args.append("norebind")
|
||||
args.append(remote + ";" + local)
|
||||
cmd = ":".join(args)
|
||||
with self.adb_client._connect() as c:
|
||||
c.send_command(f'host:transport:{self.serial}')
|
||||
c.check_okay()
|
||||
c.send_command(cmd)
|
||||
c.check_okay()
|
||||
|
||||
def adb_reverse(self, remote):
|
||||
port = 0
|
||||
for reverse in self.adb.reverse_list():
|
||||
@@ -468,45 +626,66 @@ class Connection(ConnectionAttr):
|
||||
port = int(reverse.local[4:])
|
||||
else:
|
||||
logger.info(f'Remove redundant forward: {reverse}')
|
||||
self.adb_forward_remove(reverse.local)
|
||||
self.adb_reverse_remove(reverse.remote)
|
||||
|
||||
if port:
|
||||
return port
|
||||
else:
|
||||
# Create new reverse
|
||||
port = random_port(self.config.FORWARD_PORT_RANGE)
|
||||
reverse = ReverseItem(f'tcp:{port}', remote)
|
||||
reverse = ReverseItem(remote, f'tcp:{port}')
|
||||
logger.info(f'Create reverse: {reverse}')
|
||||
self.adb.reverse(reverse.local, reverse.remote)
|
||||
self._adb_reverse_transport(reverse.remote, reverse.local)
|
||||
return port
|
||||
|
||||
def adb_forward_remove(self, local):
|
||||
"""
|
||||
Equivalent to `adb -s <serial> forward --remove <local>`
|
||||
No error raised when removing a non-existent forward
|
||||
|
||||
More about the commands send to ADB server, see:
|
||||
https://cs.android.com/android/platform/superproject/+/master:packages/modules/adb/SERVICES.TXT
|
||||
|
||||
Args:
|
||||
local (str): Such as 'tcp:2437'
|
||||
"""
|
||||
with self.adb_client._connect() as c:
|
||||
list_cmd = f"host-serial:{self.serial}:killforward:{local}"
|
||||
c.send_command(list_cmd)
|
||||
c.check_okay()
|
||||
try:
|
||||
with self.adb_client._connect() as c:
|
||||
list_cmd = f"host-serial:{self.serial}:killforward:{local}"
|
||||
c.send_command(list_cmd)
|
||||
c.check_okay()
|
||||
except AdbError as e:
|
||||
# No error raised when removing a non-existed forward
|
||||
# adbutils.errors.AdbError: listener 'tcp:8888' not found
|
||||
msg = str(e)
|
||||
if re.search(r'listener .*? not found', msg):
|
||||
logger.warning(f'{type(e).__name__}: {msg}')
|
||||
else:
|
||||
raise
|
||||
|
||||
def adb_reverse_remove(self, local):
|
||||
"""
|
||||
Equivalent to `adb -s <serial> reverse --remove <local>`
|
||||
No error raised when removing a non-existent reverse
|
||||
|
||||
Args:
|
||||
local (str): Such as 'tcp:2437'
|
||||
"""
|
||||
with self.adb_client._connect() as c:
|
||||
c.send_command(f"host:transport:{self.serial}")
|
||||
c.check_okay()
|
||||
list_cmd = f"reverse:killforward:{local}"
|
||||
c.send_command(list_cmd)
|
||||
c.check_okay()
|
||||
try:
|
||||
with self.adb_client._connect() as c:
|
||||
c.send_command(f"host:transport:{self.serial}")
|
||||
c.check_okay()
|
||||
list_cmd = f"reverse:killforward:{local}"
|
||||
c.send_command(list_cmd)
|
||||
c.check_okay()
|
||||
except AdbError as e:
|
||||
# No error raised when removing a non-existed forward
|
||||
# adbutils.errors.AdbError: listener 'tcp:8888' not found
|
||||
msg = str(e)
|
||||
if re.search(r'listener .*? not found', msg):
|
||||
logger.warning(f'{type(e).__name__}: {msg}')
|
||||
else:
|
||||
raise
|
||||
|
||||
def adb_push(self, local, remote):
|
||||
"""
|
||||
@@ -520,8 +699,40 @@ class Connection(ConnectionAttr):
|
||||
cmd = ['push', local, remote]
|
||||
return self.adb_command(cmd)
|
||||
|
||||
def _wait_device_appear(self, serial, first_devices=None):
|
||||
"""
|
||||
Args:
|
||||
serial:
|
||||
first_devices (list[AdbDeviceWithStatus]):
|
||||
|
||||
Returns:
|
||||
bool: If appear
|
||||
"""
|
||||
# Wait a little longer than 5s
|
||||
timeout = Timer(5.2).start()
|
||||
first_log = True
|
||||
while 1:
|
||||
if first_devices is not None:
|
||||
devices = first_devices
|
||||
first_devices = None
|
||||
else:
|
||||
devices = self.list_device()
|
||||
# Check if device appear
|
||||
for device in devices:
|
||||
if device.serial == serial and device.status == 'device':
|
||||
return True
|
||||
# Delay and check later
|
||||
if timeout.reached():
|
||||
break
|
||||
if first_log:
|
||||
logger.info(f'Waiting device appear: {serial}')
|
||||
first_log = False
|
||||
time.sleep(0.05)
|
||||
|
||||
return False
|
||||
|
||||
@Config.when(DEVICE_OVER_HTTP=False)
|
||||
def adb_connect(self, serial):
|
||||
def adb_connect(self, wait_device=True):
|
||||
"""
|
||||
Connect to a serial, try 3 times at max.
|
||||
If there's an old ADB server running while Alas is using a newer one, which happens on Chinese emulators,
|
||||
@@ -529,70 +740,150 @@ class Connection(ConnectionAttr):
|
||||
|
||||
Args:
|
||||
serial (str):
|
||||
wait_device: True to wait emulator-* and android devices appear
|
||||
|
||||
Returns:
|
||||
bool: If success
|
||||
"""
|
||||
# Disconnect offline device before connecting
|
||||
for device in self.list_device():
|
||||
devices = self.list_device()
|
||||
for device in devices:
|
||||
if device.status == 'offline':
|
||||
logger.warning(f'Device {serial} is offline, disconnect it before connecting')
|
||||
self.adb_disconnect(serial)
|
||||
logger.warning(f'Device {device.serial} is offline, disconnect it before connecting')
|
||||
msg = self.adb_client.disconnect(device.serial)
|
||||
if msg:
|
||||
logger.info(msg)
|
||||
elif device.status == 'unauthorized':
|
||||
logger.error(f'Device {serial} is unauthorized, please accept ADB debugging on your device')
|
||||
logger.error(f'Device {device.serial} is unauthorized, please accept ADB debugging on your device')
|
||||
elif device.status == 'device':
|
||||
pass
|
||||
else:
|
||||
logger.warning(f'Device {serial} is is having a unknown status: {device.status}')
|
||||
logger.warning(f'Device {device.serial} is is having a unknown status: {device.status}')
|
||||
|
||||
# Skip for emulator-5554
|
||||
if 'emulator-' in serial:
|
||||
logger.info(f'"{serial}" is a `emulator-*` serial, skip adb connect')
|
||||
# Skip connecting emulator-5554 and android phones, as they should be auto connected once plugged in
|
||||
if 'emulator-' in self.serial:
|
||||
if wait_device:
|
||||
if self._wait_device_appear(self.serial, first_devices=devices):
|
||||
logger.info(f'Serial {self.serial} connected')
|
||||
return True
|
||||
else:
|
||||
logger.info(f'Serial {self.serial} is not connected')
|
||||
logger.info(f'"{self.serial}" is a `emulator-*` serial, skip adb connect')
|
||||
return True
|
||||
if re.match(r'^[a-zA-Z0-9]+$', serial):
|
||||
logger.info(f'"{serial}" seems to be a Android serial, skip adb connect')
|
||||
if re.match(r'^[a-zA-Z0-9]+$', self.serial):
|
||||
if wait_device:
|
||||
if self._wait_device_appear(self.serial, first_devices=devices):
|
||||
logger.info(f'Serial {self.serial} connected')
|
||||
return True
|
||||
else:
|
||||
logger.info(f'Serial {self.serial} is not connected')
|
||||
logger.info(f'"{self.serial}" seems to be a Android serial, skip adb connect')
|
||||
return True
|
||||
|
||||
# Try to connect
|
||||
for _ in range(3):
|
||||
msg = self.adb_client.connect(serial)
|
||||
msg = self.adb_client.connect(self.serial)
|
||||
logger.info(msg)
|
||||
# Connected to 127.0.0.1:59865
|
||||
# Already connected to 127.0.0.1:59865
|
||||
if 'connected' in msg:
|
||||
# Connected to 127.0.0.1:59865
|
||||
# Already connected to 127.0.0.1:59865
|
||||
return True
|
||||
# bad port number '598265' in '127.0.0.1:598265'
|
||||
elif 'bad port' in msg:
|
||||
# bad port number '598265' in '127.0.0.1:598265'
|
||||
logger.error(msg)
|
||||
possible_reasons('Serial incorrect, might be a typo')
|
||||
raise RequestHumanTakeover
|
||||
# cannot connect to 127.0.0.1:55555:
|
||||
# No connection could be made because the target machine actively refused it. (10061)
|
||||
elif '(10061)' in msg:
|
||||
# cannot connect to 127.0.0.1:55555:
|
||||
# No connection could be made because the target machine actively refused it. (10061)
|
||||
logger.info(msg)
|
||||
# MuMu12 may switch serial if port is occupied
|
||||
# Brute force connect nearby ports to handle serial switches
|
||||
if self.is_mumu12_family:
|
||||
before = self.serial
|
||||
serial_list = [self.serial.replace(str(self.port), str(self.port + offset))
|
||||
for offset in [1, -1, 2, -2]]
|
||||
self.adb_brute_force_connect(serial_list)
|
||||
self.detect_device()
|
||||
if self.serial != before:
|
||||
return True
|
||||
run_once(self.check_mumu_bridge_network)()
|
||||
# No such device
|
||||
logger.warning('No such device exists, please restart the emulator or set a correct serial')
|
||||
raise EmulatorNotRunningError
|
||||
|
||||
# Failed to connect
|
||||
logger.warning(f'Failed to connect {serial} after 3 trial, assume connected')
|
||||
logger.warning(f'Failed to connect {self.serial} after 3 trial, assume connected')
|
||||
self.detect_device()
|
||||
return False
|
||||
|
||||
def adb_brute_force_connect(self, serial_list):
|
||||
"""
|
||||
Args:
|
||||
serial_list (list[str]):
|
||||
"""
|
||||
|
||||
def connect(s):
|
||||
try:
|
||||
msg = self.adb_client.connect(s)
|
||||
except Exception:
|
||||
return ''
|
||||
logger.info(msg)
|
||||
return msg
|
||||
|
||||
with WORKER_POOL.wait_jobs() as pool:
|
||||
for serial in serial_list:
|
||||
pool.start_thread_soon(connect, serial)
|
||||
|
||||
def check_mumu_bridge_network(self):
|
||||
"""
|
||||
Returns:
|
||||
bool: True if success to check, False if check is skipped
|
||||
"""
|
||||
if not self.is_mumu12_family:
|
||||
return True
|
||||
if not hasattr(self, 'find_emulator_instance'):
|
||||
return False
|
||||
# Assume PlatformBase inherited this class
|
||||
instance = self.find_emulator_instance(
|
||||
serial=self.serial,
|
||||
)
|
||||
if instance is None:
|
||||
logger.warning(f'Failed to check check_mumu_bridge_network, emulator instance not found')
|
||||
return False
|
||||
file = instance.mumu_vms_config('customer_config.json')
|
||||
try:
|
||||
with open(file, mode='r', encoding='utf-8') as f:
|
||||
s = f.read()
|
||||
data = json.loads(s)
|
||||
except FileNotFoundError:
|
||||
logger.warning(f'Failed to check check_mumu_bridge_network, file {file} not exists')
|
||||
return False
|
||||
value = deep_get(data, keys='customer.network_bridge_opened', default=None)
|
||||
logger.attr('customer.network_bridge_opened', value)
|
||||
if str(value).lower() == 'true':
|
||||
logger.critical('Please turn off "Network Bridging" in the settings of MuMuPlayer')
|
||||
logger.critical('请在MuMU模拟器设置中关闭 网络桥接')
|
||||
raise RequestHumanTakeover
|
||||
return True
|
||||
|
||||
@Config.when(DEVICE_OVER_HTTP=True)
|
||||
def adb_connect(self, serial):
|
||||
def adb_connect(self, wait_device=True):
|
||||
# No adb connect if over http
|
||||
return True
|
||||
|
||||
def adb_disconnect(self, serial):
|
||||
msg = self.adb_client.disconnect(serial)
|
||||
if msg:
|
||||
logger.info(msg)
|
||||
|
||||
def release_resource(self):
|
||||
del_cached_property(self, 'hermit_session')
|
||||
del_cached_property(self, 'droidcast_session')
|
||||
del_cached_property(self, 'minitouch_builder')
|
||||
del_cached_property(self, '_minitouch_builder')
|
||||
del_cached_property(self, '_maatouch_builder')
|
||||
del_cached_property(self, 'reverse_server')
|
||||
|
||||
def adb_disconnect(self):
|
||||
msg = self.adb_client.disconnect(self.serial)
|
||||
if msg:
|
||||
logger.info(msg)
|
||||
self.release_resource()
|
||||
|
||||
def adb_restart(self):
|
||||
"""
|
||||
Reboot adb client
|
||||
@@ -602,6 +893,7 @@ class Connection(ConnectionAttr):
|
||||
self.adb_client.server_kill()
|
||||
# Init adb client
|
||||
del_cached_property(self, 'adb_client')
|
||||
self.release_resource()
|
||||
_ = self.adb_client
|
||||
|
||||
@Config.when(DEVICE_OVER_HTTP=False)
|
||||
@@ -613,11 +905,11 @@ class Connection(ConnectionAttr):
|
||||
# Restart Adb
|
||||
self.adb_restart()
|
||||
# Connect to device
|
||||
self.adb_connect(self.serial)
|
||||
self.adb_connect()
|
||||
self.detect_device()
|
||||
else:
|
||||
self.adb_disconnect(self.serial)
|
||||
self.adb_connect(self.serial)
|
||||
self.adb_disconnect()
|
||||
self.adb_connect()
|
||||
self.detect_device()
|
||||
|
||||
@Config.when(DEVICE_OVER_HTTP=True)
|
||||
@@ -751,23 +1043,45 @@ class Connection(ConnectionAttr):
|
||||
If serial=='auto' and only 1 device detected, use it
|
||||
"""
|
||||
logger.hr('Detect device')
|
||||
logger.info('Here are the available devices, '
|
||||
'copy to Alas.Emulator.Serial to use it or set Alas.Emulator.Serial="auto"')
|
||||
devices = self.list_device()
|
||||
available = SelectedGrids([])
|
||||
devices = SelectedGrids([])
|
||||
|
||||
# Show available devices
|
||||
available = devices.select(status='device')
|
||||
for device in available:
|
||||
logger.info(device.serial)
|
||||
if not len(available):
|
||||
logger.info('No available devices')
|
||||
@run_once
|
||||
def brute_force_connect():
|
||||
logger.info('Brute force connect')
|
||||
from deploy.Windows.emulator import EmulatorManager
|
||||
manager = EmulatorManager()
|
||||
manager.brute_force_connect()
|
||||
|
||||
# Show unavailable devices if having any
|
||||
unavailable = devices.delete(available)
|
||||
if len(unavailable):
|
||||
logger.info('Here are the devices detected but unavailable')
|
||||
for device in unavailable:
|
||||
logger.info(f'{device.serial} ({device.status})')
|
||||
for _ in range(2):
|
||||
logger.info('Here are the available devices, '
|
||||
'copy to Alas.Emulator.Serial to use it or set Alas.Emulator.Serial="auto"')
|
||||
devices = self.list_device()
|
||||
|
||||
# Show available devices
|
||||
available = devices.select(status='device')
|
||||
for device in available:
|
||||
logger.info(device.serial)
|
||||
if not len(available):
|
||||
logger.info('No available devices')
|
||||
|
||||
# Show unavailable devices if having any
|
||||
unavailable = devices.delete(available)
|
||||
if len(unavailable):
|
||||
logger.info('Here are the devices detected but unavailable')
|
||||
for device in unavailable:
|
||||
logger.info(f'{device.serial} ({device.status})')
|
||||
|
||||
# brute_force_connect
|
||||
if self.config.Emulator_Serial == 'auto' and available.count == 0:
|
||||
logger.warning(f'No available device found')
|
||||
if IS_WINDOWS:
|
||||
brute_force_connect()
|
||||
continue
|
||||
else:
|
||||
break
|
||||
else:
|
||||
break
|
||||
|
||||
# Auto device detection
|
||||
if self.config.Emulator_Serial == 'auto':
|
||||
@@ -777,7 +1091,16 @@ class Connection(ConnectionAttr):
|
||||
raise RequestHumanTakeover
|
||||
elif available.count == 1:
|
||||
logger.info(f'Auto device detection found only one device, using it')
|
||||
self.serial = devices[0].serial
|
||||
self.config.Emulator_Serial = self.serial = available[0].serial
|
||||
del_cached_property(self, 'adb')
|
||||
elif available.count == 2 \
|
||||
and available.select(serial='127.0.0.1:7555') \
|
||||
and available.select(may_mumu12_family=True):
|
||||
logger.info(f'Auto device detection found MuMu12 device, using it')
|
||||
# For MuMu12 serials like 127.0.0.1:7555 and 127.0.0.1:16384
|
||||
# ignore 7555 use 16384
|
||||
remain = available.select(may_mumu12_family=True).first_or_none()
|
||||
self.config.Emulator_Serial = self.serial = remain.serial
|
||||
del_cached_property(self, 'adb')
|
||||
else:
|
||||
logger.critical('Multiple devices found, auto device detection cannot decide which to choose, '
|
||||
@@ -786,6 +1109,7 @@ class Connection(ConnectionAttr):
|
||||
|
||||
# Handle LDPlayer
|
||||
# LDPlayer serial jumps between `127.0.0.1:5555+{X}` and `emulator-5554+{X}`
|
||||
# No config write since it's dynamic
|
||||
port_serial, emu_serial = get_serial_pair(self.serial)
|
||||
if port_serial and emu_serial:
|
||||
# Might be LDPlayer, check connected devices
|
||||
@@ -812,6 +1136,58 @@ class Connection(ConnectionAttr):
|
||||
f'Using serial: {emu_serial}')
|
||||
self.serial = emu_serial
|
||||
|
||||
# Redirect MuMu12 from 127.0.0.1:7555 to 127.0.0.1:16xxx
|
||||
if self.serial == '127.0.0.1:7555':
|
||||
for _ in range(2):
|
||||
mumu12 = available.select(may_mumu12_family=True)
|
||||
if mumu12.count == 1:
|
||||
emu_serial = mumu12.first_or_none().serial
|
||||
logger.warning(f'Redirect MuMu12 {self.serial} to {emu_serial}')
|
||||
self.config.Emulator_Serial = self.serial = emu_serial
|
||||
break
|
||||
elif mumu12.count >= 2:
|
||||
logger.warning(f'Multiple MuMu12 serial found, cannot redirect')
|
||||
break
|
||||
else:
|
||||
# Only 127.0.0.1:7555
|
||||
if self.is_mumu_over_version_356:
|
||||
# is_mumu_over_version_356 and nemud_app_keep_alive was cached
|
||||
# Acceptable since it's the same device
|
||||
logger.warning(f'Device {self.serial} is MuMu12 but corresponding port not found')
|
||||
if IS_WINDOWS:
|
||||
brute_force_connect()
|
||||
devices = self.list_device()
|
||||
# Show available devices
|
||||
available = devices.select(status='device')
|
||||
for device in available:
|
||||
logger.info(device.serial)
|
||||
if not len(available):
|
||||
logger.info('No available devices')
|
||||
continue
|
||||
else:
|
||||
# MuMu6
|
||||
break
|
||||
|
||||
# MuMu12 uses 127.0.0.1:16385 if port 16384 is occupied, auto redirect
|
||||
# No config write since it's dynamic
|
||||
if self.is_mumu12_family:
|
||||
matched = False
|
||||
for device in available.select(may_mumu12_family=True):
|
||||
if device.port == self.port:
|
||||
# Exact match
|
||||
matched = True
|
||||
break
|
||||
if not matched:
|
||||
for device in available.select(may_mumu12_family=True):
|
||||
if -2 <= device.port - self.port <= 2:
|
||||
# Port switched
|
||||
logger.info(f'MuMu12 serial switched {self.serial} -> {device.serial}')
|
||||
del_cached_property(self, 'port')
|
||||
del_cached_property(self, 'is_mumu12_family')
|
||||
del_cached_property(self, 'is_mumu_family')
|
||||
self.serial = device.serial
|
||||
break
|
||||
|
||||
@retry
|
||||
def list_package(self, show_log=True):
|
||||
"""
|
||||
@@ -833,7 +1209,7 @@ class Connection(ConnectionAttr):
|
||||
packages = re.findall(r'package:([^\s]+)', output)
|
||||
return packages
|
||||
|
||||
def list_azurlane_packages(self, show_log=True):
|
||||
def list_known_packages(self, show_log=True):
|
||||
"""
|
||||
Args:
|
||||
show_log:
|
||||
@@ -850,7 +1226,7 @@ class Connection(ConnectionAttr):
|
||||
Show all possible packages with the given keyword on this device.
|
||||
"""
|
||||
logger.hr('Detect package')
|
||||
packages = self.list_azurlane_packages()
|
||||
packages = self.list_known_packages()
|
||||
|
||||
# Show packages
|
||||
logger.info(f'Here are the available packages in device "{self.serial}", '
|
||||
@@ -871,10 +1247,12 @@ class Connection(ConnectionAttr):
|
||||
self.package = packages[0]
|
||||
# Set config
|
||||
if set_config:
|
||||
self.config.Emulator_PackageName = server_.to_server(self.package)
|
||||
self.config.Emulator_PackageName = server_.to_server(
|
||||
self.package, before=self.config.Emulator_PackageName)
|
||||
# Set server
|
||||
# logger.info('Server changed, release resources')
|
||||
# set_server(self.package)
|
||||
return
|
||||
else:
|
||||
logger.critical(
|
||||
f'Multiple Blue Archive packages found, auto package detection cannot decide which to choose, '
|
||||
|
||||
@@ -7,7 +7,7 @@ from adbutils import AdbClient, AdbDevice
|
||||
|
||||
from module.base.decorator import cached_property
|
||||
from module.config.config import AzurLaneConfig
|
||||
from module.config.utils import deep_iter
|
||||
from module.device.method.utils import get_serial_pair
|
||||
from module.exception import RequestHumanTakeover
|
||||
from module.logger import logger
|
||||
|
||||
@@ -49,27 +49,47 @@ class ConnectionAttr:
|
||||
self.serial_check()
|
||||
self.config.DEVICE_OVER_HTTP = self.is_over_http
|
||||
|
||||
|
||||
@staticmethod
|
||||
def revise_serial(serial):
|
||||
serial = serial.replace(' ', '')
|
||||
def revise_serial(serial: str):
|
||||
"""
|
||||
Tons of fool-proof fixes to handle manual serial input
|
||||
To load a serial:
|
||||
serial = SerialStr.revise_serial(serial)
|
||||
"""
|
||||
serial = serial.strip().replace(' ', '')
|
||||
# 127。0。0。1:5555
|
||||
serial = serial.replace('。', '.').replace(',', '.').replace(',', '.').replace(':', ':')
|
||||
# 127.0.0.1.5555
|
||||
serial = serial.replace('127.0.0.1.', '127.0.0.1:')
|
||||
# 5555,16384 (actually "5555.16384" because replace(',', '.'))
|
||||
if '.' in serial:
|
||||
left, _, right = serial.partition('.')
|
||||
try:
|
||||
left = int(left)
|
||||
right = int(right)
|
||||
if 5500 < left < 6000 and 16300 < right < 20000:
|
||||
serial = str(right)
|
||||
except ValueError:
|
||||
pass
|
||||
# 16384
|
||||
try:
|
||||
port = int(serial)
|
||||
if 1000 < port < 65536:
|
||||
serial = f'127.0.0.1:{port}'
|
||||
except ValueError:
|
||||
pass
|
||||
if serial.isdigit():
|
||||
try:
|
||||
port = int(serial)
|
||||
if 1000 < port < 65536:
|
||||
serial = f'127.0.0.1:{port}'
|
||||
except ValueError:
|
||||
pass
|
||||
# 夜神模拟器 127.0.0.1:62001
|
||||
# MuMu模拟器12127.0.0.1:16384
|
||||
if '模拟' in serial:
|
||||
import re
|
||||
res = re.search(r'(127\.\d+\.\d+\.\d+:\d+)', serial)
|
||||
if res:
|
||||
serial = res.group(1)
|
||||
# 12127.0.0.1:16384
|
||||
serial = serial.replace('12127.0.0.1', '127.0.0.1')
|
||||
# auto127.0.0.1:16384
|
||||
serial = serial.replace('auto127.0.0.1', '127.0.0.1').replace('autoemulator', 'emulator')
|
||||
return str(serial)
|
||||
|
||||
def serial_check(self):
|
||||
@@ -123,11 +143,40 @@ class ConnectionAttr:
|
||||
def is_wsa(self):
|
||||
return bool(re.match(r'^wsa', self.serial))
|
||||
|
||||
@cached_property
|
||||
def port(self) -> int:
|
||||
port_serial, _ = get_serial_pair(self.serial)
|
||||
if port_serial is None:
|
||||
port_serial = self.serial
|
||||
try:
|
||||
return int(port_serial.split(':')[1])
|
||||
except (IndexError, ValueError):
|
||||
return 0
|
||||
|
||||
@cached_property
|
||||
def is_mumu12_family(self):
|
||||
# 127.0.0.1:16384 + 32*n, assume 32 instances at max
|
||||
return 16384 <= self.port <= 17408
|
||||
|
||||
@cached_property
|
||||
def is_mumu_family(self):
|
||||
# 127.0.0.1:7555
|
||||
# 127.0.0.1:16384 + 32*n
|
||||
return self.serial == '127.0.0.1:7555' or self.serial.startswith('127.0.0.1:16')
|
||||
return self.serial == '127.0.0.1:7555' or self.is_mumu12_family
|
||||
|
||||
@cached_property
|
||||
def is_ldplayer_bluestacks_family(self):
|
||||
# Note that LDPlayer and BlueStacks have the same serial range
|
||||
# 127.0.0.1:5555 + 2*n, assume 32 instances at max
|
||||
return self.serial.startswith('emulator-') or 5555 <= self.port <= 5619
|
||||
|
||||
@cached_property
|
||||
def is_nox_family(self):
|
||||
return 62001 <= self.port <= 63025
|
||||
|
||||
@cached_property
|
||||
def is_vmos(self):
|
||||
return 5667 <= self.port <= 5699
|
||||
|
||||
@cached_property
|
||||
def is_emulator(self):
|
||||
@@ -137,6 +186,10 @@ class ConnectionAttr:
|
||||
def is_network_device(self):
|
||||
return bool(re.match(r'\d+\.\d+\.\d+\.\d+:\d+', self.serial))
|
||||
|
||||
@cached_property
|
||||
def is_local_network_device(self):
|
||||
return bool(re.match(r'192\.168\.\d+\.\d+:\d+', self.serial))
|
||||
|
||||
@cached_property
|
||||
def is_over_http(self):
|
||||
return bool(re.match(r"^https?://", self.serial))
|
||||
@@ -173,7 +226,8 @@ class ConnectionAttr:
|
||||
rf"SOFTWARE\BlueStacks_bgp64_hyperv\Guests\{folder_name}\Config") as key:
|
||||
port = QueryValueEx(key, "BstAdbPort")[0]
|
||||
except FileNotFoundError:
|
||||
logger.error(rf'Unable to find registry HKEY_LOCAL_MACHINE\SOFTWARE\BlueStacks_bgp64_hyperv\Guests\{folder_name}\Config')
|
||||
logger.error(
|
||||
rf'Unable to find registry HKEY_LOCAL_MACHINE\SOFTWARE\BlueStacks_bgp64_hyperv\Guests\{folder_name}\Config')
|
||||
logger.error('Please confirm that your are using BlueStack 4 hyper-v and not regular BlueStacks 4')
|
||||
logger.error(r'Please check if there is any other emulator instances under '
|
||||
r'registry HKEY_LOCAL_MACHINE\SOFTWARE\BlueStacks_bgp64_hyperv\Guests')
|
||||
|
||||
@@ -5,13 +5,12 @@ 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.nemu_ipc import NemuIpc
|
||||
from module.device.method.scrcpy import Scrcpy
|
||||
from module.logger import logger
|
||||
|
||||
import numpy as np
|
||||
|
||||
|
||||
class Control(Hermit, Minitouch, Scrcpy, MaaTouch):
|
||||
class Control(Hermit, Minitouch, Scrcpy, MaaTouch, NemuIpc):
|
||||
def handle_control_check(self, button):
|
||||
# Will be overridden in Device
|
||||
pass
|
||||
@@ -24,6 +23,7 @@ class Control(Hermit, Minitouch, Scrcpy, MaaTouch):
|
||||
'minitouch': self.click_minitouch,
|
||||
'Hermit': self.click_hermit,
|
||||
'MaaTouch': self.click_maatouch,
|
||||
'nemu_ipc': self.click_nemu_ipc,
|
||||
}
|
||||
|
||||
def click(self, button, control_check=True):
|
||||
@@ -50,7 +50,7 @@ class Control(Hermit, Minitouch, Scrcpy, MaaTouch):
|
||||
self.handle_control_check(button)
|
||||
click_timer = Timer(0.1)
|
||||
for _ in range(n):
|
||||
remain = ensure_time(interval) - click_timer.current()
|
||||
remain = ensure_time(interval) - click_timer.current_time()
|
||||
if remain > 0:
|
||||
self.sleep(remain)
|
||||
click_timer.reset()
|
||||
@@ -80,6 +80,8 @@ class Control(Hermit, Minitouch, Scrcpy, MaaTouch):
|
||||
self.long_click_scrcpy(x, y, duration)
|
||||
elif method == 'MaaTouch':
|
||||
self.long_click_maatouch(x, y, duration)
|
||||
elif method == 'nemu_ipc':
|
||||
self.long_click_nemu_ipc(x, y, duration)
|
||||
else:
|
||||
self.swipe_adb((x, y), (x, y), duration)
|
||||
|
||||
@@ -88,13 +90,9 @@ class Control(Hermit, Minitouch, Scrcpy, MaaTouch):
|
||||
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':
|
||||
if 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':
|
||||
elif method in ['minitouch', 'MaaTouch', 'scrcpy', 'nemu_ipc']:
|
||||
logger.info('Swipe %s -> %s' % (point2str(*p1), point2str(*p2)))
|
||||
else:
|
||||
# ADB needs to be slow, or swipe doesn't work
|
||||
@@ -116,6 +114,8 @@ class Control(Hermit, Minitouch, Scrcpy, MaaTouch):
|
||||
self.swipe_scrcpy(p1, p2)
|
||||
elif method == 'MaaTouch':
|
||||
self.swipe_maatouch(p1, p2)
|
||||
elif method == 'nemu_ipc':
|
||||
self.swipe_nemu_ipc(p1, p2)
|
||||
else:
|
||||
self.swipe_adb(p1, p2, duration=duration)
|
||||
|
||||
@@ -165,11 +165,13 @@ class Control(Hermit, Minitouch, Scrcpy, MaaTouch):
|
||||
self.drag_scrcpy(p1, p2, point_random=point_random)
|
||||
elif method == 'MaaTouch':
|
||||
self.drag_maatouch(p1, p2, point_random=point_random)
|
||||
elif method == 'nemu_ipc':
|
||||
self.drag_nemu_ipc(p1, p2, point_random=point_random)
|
||||
else:
|
||||
logger.warning(f'Control method {method} does not support drag well, '
|
||||
f'falling back to ADB swipe may cause unexpected behaviour')
|
||||
self.swipe_adb(p1, p2, duration=ensure_time(swipe_duration * 2))
|
||||
self.click(ClickButton(button=area_offset(point_random, p2), name=name))
|
||||
self.click(ClickButton(area=area_offset(point_random, p2), name=name))
|
||||
|
||||
# just used in cafe
|
||||
def pinch(self, box=(35, 130, 1250, 560), name='PINCH'):
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
import sys
|
||||
from collections import deque
|
||||
import collections
|
||||
|
||||
from lxml import etree
|
||||
|
||||
from module.device.env import IS_WINDOWS
|
||||
# Patch pkg_resources before importing adbutils and uiautomator2
|
||||
from module.device.pkg_resources import get_distribution
|
||||
|
||||
# Just avoid being removed by import optimization
|
||||
_ = get_distribution
|
||||
|
||||
from module.base.timer import Timer
|
||||
from module.device.app_control import AppControl
|
||||
@@ -14,24 +22,62 @@ from module.exception import (
|
||||
)
|
||||
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
|
||||
|
||||
def show_function_call():
|
||||
"""
|
||||
INFO 21:07:31.554 │ Function calls:
|
||||
<string> L1 <module>
|
||||
spawn.py L116 spawn_main()
|
||||
spawn.py L129 _main()
|
||||
process.py L314 _bootstrap()
|
||||
process.py L108 run()
|
||||
process_manager.py L149 run_process()
|
||||
alas.py L285 loop()
|
||||
alas.py L69 run()
|
||||
src.py L55 rogue()
|
||||
rogue.py L36 run()
|
||||
rogue.py L18 rogue_once()
|
||||
entry.py L335 rogue_world_enter()
|
||||
path.py L193 rogue_path_select()
|
||||
"""
|
||||
import os
|
||||
import traceback
|
||||
stack = traceback.extract_stack()
|
||||
func_list = []
|
||||
for row in stack:
|
||||
filename, line_number, function_name, _ = row
|
||||
filename = os.path.basename(filename)
|
||||
# /tasks/character/switch.py:64 character_update()
|
||||
func_list.append([filename, str(line_number), function_name])
|
||||
max_filename = max([len(row[0]) for row in func_list])
|
||||
max_linenum = max([len(row[1]) for row in func_list]) + 1
|
||||
|
||||
def format_(file, line, func):
|
||||
file = file.rjust(max_filename, " ")
|
||||
line = f'L{line}'.rjust(max_linenum, " ")
|
||||
if not func.startswith('<'):
|
||||
func = f'{func}()'
|
||||
return f'{file} {line} {func}'
|
||||
|
||||
func_list = [f'\n{format_(*row)}' for row in func_list]
|
||||
logger.info('Function calls:' + ''.join(func_list))
|
||||
|
||||
|
||||
class Device(Screenshot, Control, AppControl, Platform):
|
||||
class Device(Screenshot, Control, AppControl):
|
||||
_screen_size_checked = False
|
||||
detect_record = set()
|
||||
click_record = deque(maxlen=15)
|
||||
click_record = collections.deque(maxlen=15)
|
||||
stuck_timer = Timer(60, count=60).start()
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
for _ in range(2):
|
||||
for trial in range(4):
|
||||
try:
|
||||
super().__init__(*args, **kwargs)
|
||||
break
|
||||
except EmulatorNotRunningError:
|
||||
if trial >= 3:
|
||||
logger.critical('Failed to start emulator after 3 trial')
|
||||
raise RequestHumanTakeover
|
||||
# Try to start emulator
|
||||
if self.emulator_instance is not None:
|
||||
self.emulator_start()
|
||||
@@ -40,14 +86,26 @@ class Device(Screenshot, Control, AppControl, Platform):
|
||||
f'No emulator with serial "{self.config.Emulator_Serial}" found, '
|
||||
f'please set a correct serial'
|
||||
)
|
||||
raise
|
||||
raise RequestHumanTakeover
|
||||
|
||||
# Auto-fill emulator info
|
||||
if IS_WINDOWS and self.config.EmulatorInfo_Emulator == 'auto':
|
||||
_ = self.emulator_instance
|
||||
|
||||
self.screenshot_interval_set()
|
||||
self.method_check()
|
||||
|
||||
# Auto-select the fastest screenshot method
|
||||
if not self.config.is_template_config and self.config.Emulator_ScreenshotMethod == 'auto':
|
||||
self.run_simple_screenshot_benchmark()
|
||||
|
||||
# Early init
|
||||
if self.config.is_actual_task:
|
||||
if self.config.Emulator_ControlMethod == 'MaaTouch':
|
||||
self.early_maatouch_init()
|
||||
if self.config.Emulator_ControlMethod == 'minitouch':
|
||||
self.early_minitouch_init()
|
||||
|
||||
def run_simple_screenshot_benchmark(self):
|
||||
"""
|
||||
Perform a screenshot method benchmark, test 3 times on each method.
|
||||
@@ -61,7 +119,44 @@ class Device(Screenshot, Control, AppControl, Platform):
|
||||
bench = Benchmark(config=self.config, device=self)
|
||||
method = bench.run_simple_screenshot_benchmark()
|
||||
# Set
|
||||
self.config.Emulator_ScreenshotMethod = method
|
||||
with self.config.multi_set():
|
||||
self.config.Emulator_ScreenshotMethod = method
|
||||
# if method == 'nemu_ipc':
|
||||
# self.config.Emulator_ControlMethod = 'nemu_ipc'
|
||||
|
||||
def method_check(self):
|
||||
"""
|
||||
Check combinations of screenshot method and control methods
|
||||
"""
|
||||
# nemu_ipc should be together
|
||||
# if self.config.Emulator_ScreenshotMethod == 'nemu_ipc' and self.config.Emulator_ControlMethod != 'nemu_ipc':
|
||||
# logger.warning('When using nemu_ipc, both screenshot and control should use nemu_ipc')
|
||||
# self.config.Emulator_ControlMethod = 'nemu_ipc'
|
||||
# if self.config.Emulator_ScreenshotMethod != 'nemu_ipc' and self.config.Emulator_ControlMethod == 'nemu_ipc':
|
||||
# logger.warning('When not using nemu_ipc, both screenshot and control should not use nemu_ipc')
|
||||
# self.config.Emulator_ControlMethod = 'minitouch'
|
||||
# Allow Hermit on VMOS only
|
||||
if self.config.Emulator_ControlMethod == 'Hermit' and not self.is_vmos:
|
||||
logger.warning('ControlMethod Hermit is allowed on VMOS only')
|
||||
self.config.Emulator_ControlMethod = 'MaaTouch'
|
||||
if self.config.Emulator_ScreenshotMethod == 'ldopengl' \
|
||||
and self.config.Emulator_ControlMethod == 'minitouch':
|
||||
logger.warning('Use MaaTouch on ldplayer')
|
||||
self.config.Emulator_ControlMethod = 'MaaTouch'
|
||||
|
||||
# Fallback to auto if nemu_ipc and ldopengl are selected on non-corresponding emulators
|
||||
if self.config.Emulator_ScreenshotMethod == 'nemu_ipc':
|
||||
if not (self.is_emulator and self.is_mumu_family):
|
||||
logger.warning('ScreenshotMethod nemu_ipc is available on MuMu Player 12 only, fallback to auto')
|
||||
self.config.Emulator_ScreenshotMethod = 'auto'
|
||||
if self.config.Emulator_ScreenshotMethod == 'ldopengl':
|
||||
if not (self.is_emulator and self.is_ldplayer_bluestacks_family):
|
||||
logger.warning('ScreenshotMethod ldopengl is available on LD Player only, fallback to auto')
|
||||
self.config.Emulator_ScreenshotMethod = 'auto'
|
||||
if not IS_WINDOWS and self.config.Emulator_ScreenshotMethod in ['nemu_ipc', 'ldopengl']:
|
||||
logger.warning(f'ScreenshotMethod {self.config.Emulator_ScreenshotMethod} is available on Windows only, '
|
||||
f'fallback to auto')
|
||||
self.config.Emulator_ScreenshotMethod = 'auto'
|
||||
|
||||
def screenshot(self):
|
||||
"""
|
||||
@@ -82,11 +177,27 @@ class Device(Screenshot, Control, AppControl, Platform):
|
||||
|
||||
return self.image
|
||||
|
||||
def dump_hierarchy(self) -> etree._Element:
|
||||
self.stuck_record_check()
|
||||
return super().dump_hierarchy()
|
||||
|
||||
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()
|
||||
if self.config.Emulator_ScreenshotMethod == 'nemu_ipc':
|
||||
self.nemu_ipc_release()
|
||||
|
||||
def get_orientation(self):
|
||||
"""
|
||||
Callbacks when orientation changed.
|
||||
"""
|
||||
o = super().get_orientation()
|
||||
|
||||
self.on_orientation_change_maatouch()
|
||||
|
||||
return o
|
||||
|
||||
def stuck_record_add(self, button):
|
||||
self.detect_record.add(str(button))
|
||||
@@ -104,6 +215,7 @@ class Device(Screenshot, Control, AppControl, Platform):
|
||||
if not reached:
|
||||
return False
|
||||
|
||||
show_function_call()
|
||||
logger.warning('Wait too long')
|
||||
logger.warning(f'Waiting for {self.detect_record}')
|
||||
self.stuck_record_clear()
|
||||
@@ -155,11 +267,13 @@ class Device(Screenshot, Control, AppControl, Platform):
|
||||
count[key] = count.get(key, 0) + 1
|
||||
count = sorted(count.items(), key=lambda item: item[1])
|
||||
if count[0][1] >= 12:
|
||||
show_function_call()
|
||||
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:
|
||||
show_function_call()
|
||||
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()
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
import sys
|
||||
|
||||
IS_WINDOWS = sys.platform == 'win32'
|
||||
IS_MACINTOSH = sys.platform == 'darwin'
|
||||
IS_LINUX = sys.platform == 'linux'
|
||||
@@ -1,16 +1,18 @@
|
||||
import re
|
||||
import time
|
||||
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.config.server import DICT_PACKAGE_TO_ACTIVITY
|
||||
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.device.method.remove_warning import remove_screenshot_warning
|
||||
from module.device.method.utils import (ImageTruncated, PackageNotInstalled, RETRY_TRIES, handle_adb_error,
|
||||
handle_unknown_host_service, retry_sleep)
|
||||
from module.exception import RequestHumanTakeover, ScriptError
|
||||
from module.logger import logger
|
||||
|
||||
@@ -26,7 +28,7 @@ def retry(func):
|
||||
for _ in range(RETRY_TRIES):
|
||||
try:
|
||||
if callable(init):
|
||||
retry_sleep(_)
|
||||
time.sleep(retry_sleep(_))
|
||||
init()
|
||||
return func(self, *args, **kwargs)
|
||||
# Can't handle
|
||||
@@ -43,6 +45,10 @@ def retry(func):
|
||||
if handle_adb_error(e):
|
||||
def init():
|
||||
self.adb_reconnect()
|
||||
elif handle_unknown_host_service(e):
|
||||
def init():
|
||||
self.adb_start_server()
|
||||
self.adb_reconnect()
|
||||
else:
|
||||
break
|
||||
# Package not installed
|
||||
@@ -115,10 +121,7 @@ class Adb(Connection):
|
||||
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')
|
||||
screenshot = remove_screenshot_warning(screenshot)
|
||||
|
||||
image = np.frombuffer(screenshot, np.uint8)
|
||||
if image is None:
|
||||
@@ -128,7 +131,7 @@ class Adb(Connection):
|
||||
if image is None:
|
||||
raise ImageTruncated('Empty image after cv2.imdecode')
|
||||
|
||||
image = cv2.cvtColor(image, cv2.COLOR_BGR2RGB)
|
||||
cv2.cvtColor(image, cv2.COLOR_BGR2RGB, dst=image)
|
||||
if image is None:
|
||||
raise ImageTruncated('Empty image after cv2.cvtColor')
|
||||
|
||||
@@ -161,6 +164,7 @@ class Adb(Connection):
|
||||
@Config.when(DEVICE_OVER_HTTP=True)
|
||||
def screenshot_adb(self):
|
||||
data = self.adb_shell(['screencap'], stream=True)
|
||||
data = remove_screenshot_warning(data)
|
||||
if len(data) < 500:
|
||||
logger.warning(f'Unexpected screenshot: {data}')
|
||||
|
||||
@@ -169,6 +173,7 @@ class Adb(Connection):
|
||||
@retry
|
||||
def screenshot_adb_nc(self):
|
||||
data = self.adb_shell_nc(['screencap'])
|
||||
data = remove_screenshot_warning(data)
|
||||
if len(data) < 500:
|
||||
logger.warning(f'Unexpected screenshot: {data}')
|
||||
|
||||
@@ -229,7 +234,7 @@ class Adb(Connection):
|
||||
raise OSError("Couldn't get focused app")
|
||||
|
||||
@retry
|
||||
def app_start_adb(self, package_name=None, allow_failure=False):
|
||||
def _app_start_adb_monkey(self, package_name=None, allow_failure=False):
|
||||
"""
|
||||
Args:
|
||||
package_name (str):
|
||||
@@ -237,6 +242,9 @@ class Adb(Connection):
|
||||
|
||||
Returns:
|
||||
bool: If success to start
|
||||
|
||||
Raises:
|
||||
PackageNotInstalled:
|
||||
"""
|
||||
if not package_name:
|
||||
package_name = self.package
|
||||
@@ -253,26 +261,125 @@ class Adb(Connection):
|
||||
raise PackageNotInstalled(package_name)
|
||||
elif 'inaccessible' in result:
|
||||
# /system/bin/sh: monkey: inaccessible or not found
|
||||
pass
|
||||
return False
|
||||
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:
|
||||
@retry
|
||||
def _app_start_adb_am(self, package_name=None, activity_name=None, allow_failure=False):
|
||||
"""
|
||||
Args:
|
||||
package_name (str):
|
||||
activity_name (str):
|
||||
allow_failure (bool):
|
||||
|
||||
Returns:
|
||||
bool: If success to start
|
||||
|
||||
Raises:
|
||||
PackageNotInstalled:
|
||||
"""
|
||||
if not package_name:
|
||||
package_name = self.package
|
||||
if not activity_name:
|
||||
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:
|
||||
# com.bilibili.azurlane/com.manjuu.azurlane.MainActivity
|
||||
activity_name = res.group(1)
|
||||
try:
|
||||
activity_name = activity_name.split('/')[-1]
|
||||
except IndexError:
|
||||
logger.error(f'No activity name from {activity_name}')
|
||||
return False
|
||||
else:
|
||||
if allow_failure:
|
||||
return False
|
||||
else:
|
||||
logger.error(result)
|
||||
raise PackageNotInstalled(package_name)
|
||||
|
||||
cmd = ['am', 'start', '-a', 'android.intent.action.MAIN', '-c',
|
||||
'android.intent.category.LAUNCHER', '-n', f'{package_name}/{activity_name}']
|
||||
if self.is_local_network_device and self.is_waydroid:
|
||||
cmd += ['--windowingMode', '4']
|
||||
ret = self.adb_shell(cmd)
|
||||
# Invalid activity
|
||||
# Starting: Intent { act=android.intent.action.MAIN cat=[android.intent.category.LAUNCHER] cmp=... }
|
||||
# Error type 3
|
||||
# Error: Activity class {.../...} does not exist.
|
||||
if 'Error: Activity class' in ret:
|
||||
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])
|
||||
logger.error(ret)
|
||||
return False
|
||||
# Already running
|
||||
# Warning: Activity not started, intent has been delivered to currently running top-most instance.
|
||||
if 'Warning: Activity not started' in ret:
|
||||
logger.info('App activity is already started')
|
||||
return True
|
||||
# Starting: Intent { act=android.intent.action.MAIN cat=[android.intent.category.LAUNCHER] cmp=com.YoStarEN.AzurLane/com.manjuu.azurlane.MainActivity }
|
||||
# java.lang.SecurityException: Permission Denial: starting Intent { act=android.intent.action.MAIN cat=[android.intent.category.LAUNCHER] flg=0x10000000 cmp=com.YoStarEN.AzurLane/com.manjuu.azurlane.MainActivity } from null (pid=5140, uid=2000) not exported from uid 10064
|
||||
# at android.os.Parcel.readException(Parcel.java:1692)
|
||||
# at android.os.Parcel.readException(Parcel.java:1645)
|
||||
# at android.app.ActivityManagerProxy.startActivityAsUser(ActivityManagerNative.java:3152)
|
||||
# at com.android.commands.am.Am.runStart(Am.java:643)
|
||||
# at com.android.commands.am.Am.onRun(Am.java:394)
|
||||
# at com.android.internal.os.BaseCommand.run(BaseCommand.java:51)
|
||||
# at com.android.commands.am.Am.main(Am.java:124)
|
||||
# at com.android.internal.os.RuntimeInit.nativeFinishInit(Native Method)
|
||||
# at com.android.internal.os.RuntimeInit.main(RuntimeInit.java:290)
|
||||
if 'Permission Denial' in ret:
|
||||
if allow_failure:
|
||||
return False
|
||||
else:
|
||||
logger.error(ret)
|
||||
logger.error('Permission Denial while starting app, probably because activity invalid')
|
||||
return False
|
||||
# Success
|
||||
# Starting: Intent...
|
||||
return True
|
||||
|
||||
# No @retry decorator since _app_start_adb_am and _app_start_adb_monkey have @retry already
|
||||
# @retry
|
||||
def app_start_adb(self, package_name=None, activity_name=None, allow_failure=False):
|
||||
"""
|
||||
Args:
|
||||
package_name (str):
|
||||
If None, to get from config
|
||||
activity_name (str):
|
||||
If None, to get from DICT_PACKAGE_TO_ACTIVITY
|
||||
If still None, launch from monkey
|
||||
If monkey failed, fetch activity name and launch from am
|
||||
allow_failure (bool):
|
||||
True for no PackageNotInstalled raising, just return False
|
||||
|
||||
Returns:
|
||||
bool: If success to start
|
||||
|
||||
Raises:
|
||||
PackageNotInstalled:
|
||||
"""
|
||||
if not package_name:
|
||||
package_name = self.package
|
||||
if not activity_name:
|
||||
activity_name = DICT_PACKAGE_TO_ACTIVITY.get(package_name)
|
||||
|
||||
if activity_name:
|
||||
if self._app_start_adb_am(package_name, activity_name, allow_failure):
|
||||
return True
|
||||
if self._app_start_adb_monkey(package_name, allow_failure):
|
||||
return True
|
||||
if self._app_start_adb_am(package_name, activity_name, allow_failure):
|
||||
return True
|
||||
|
||||
logger.error('app_start_adb: All trials failed')
|
||||
return False
|
||||
|
||||
@retry
|
||||
def app_stop_adb(self, package_name=None):
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import os
|
||||
import time
|
||||
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.device.method.utils import (ImageTruncated, RETRY_TRIES, handle_adb_error, handle_unknown_host_service,
|
||||
retry_sleep)
|
||||
from module.exception import RequestHumanTakeover, ScriptError
|
||||
from module.logger import logger
|
||||
|
||||
@@ -27,7 +27,7 @@ def retry(func):
|
||||
for _ in range(RETRY_TRIES):
|
||||
try:
|
||||
if callable(init):
|
||||
retry_sleep(_)
|
||||
time.sleep(retry_sleep(_))
|
||||
init()
|
||||
return func(self, *args, **kwargs)
|
||||
# Can't handle
|
||||
@@ -50,6 +50,10 @@ def retry(func):
|
||||
if handle_adb_error(e):
|
||||
def init():
|
||||
self.adb_reconnect()
|
||||
elif handle_unknown_host_service(e):
|
||||
def init():
|
||||
self.adb_start_server()
|
||||
self.adb_reconnect()
|
||||
else:
|
||||
break
|
||||
# ImageTruncated
|
||||
@@ -152,7 +156,8 @@ class AScreenCap(Connection):
|
||||
|
||||
_, uncompressed_size, _, width, height = compressed_data_header
|
||||
channel = 3
|
||||
data = lz4.block.decompress(raw_compressed_data[20:], uncompressed_size=uncompressed_size)
|
||||
from lz4.block import decompress
|
||||
data = decompress(raw_compressed_data[20:], uncompressed_size=uncompressed_size)
|
||||
|
||||
image = np.frombuffer(data, dtype=np.uint8)
|
||||
if image is None:
|
||||
@@ -165,31 +170,34 @@ class AScreenCap(Connection):
|
||||
# ValueError: cannot reshape array of size 0 into shape (720,1280,4)
|
||||
raise ImageTruncated(str(e))
|
||||
|
||||
# flip without `dst=image`
|
||||
# np.frombuffer creates a read-only memory view, we need to create a writable copy here
|
||||
image = cv2.flip(image, 0)
|
||||
if image is None:
|
||||
raise ImageTruncated('Empty image after cv2.flip')
|
||||
|
||||
image = cv2.cvtColor(image, cv2.COLOR_BGR2RGB)
|
||||
cv2.cvtColor(image, cv2.COLOR_BGR2RGB, dst=image)
|
||||
if image is None:
|
||||
raise ImageTruncated('Empty image after cv2.cvtColor')
|
||||
|
||||
return image
|
||||
|
||||
def __process_screenshot(self, screenshot):
|
||||
from lz4.block import LZ4BlockError
|
||||
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:
|
||||
except 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')
|
||||
raise ImageTruncated(f'cannot load screenshot')
|
||||
|
||||
@retry
|
||||
def screenshot_ascreencap(self):
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import time
|
||||
import typing as t
|
||||
from functools import wraps
|
||||
|
||||
@@ -6,11 +7,11 @@ 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.decorator import 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.device.method.uiautomator_2 import ProcessInfo, Uiautomator2
|
||||
from module.device.method.utils import (
|
||||
ImageTruncated, PackageNotInstalled, RETRY_TRIES, handle_adb_error, handle_unknown_host_service, retry_sleep)
|
||||
from module.exception import RequestHumanTakeover
|
||||
from module.logger import logger
|
||||
|
||||
@@ -30,7 +31,7 @@ def retry(func):
|
||||
for _ in range(RETRY_TRIES):
|
||||
try:
|
||||
if callable(init):
|
||||
retry_sleep(_)
|
||||
time.sleep(retry_sleep(_))
|
||||
init()
|
||||
return func(self, *args, **kwargs)
|
||||
# Can't handle
|
||||
@@ -47,6 +48,10 @@ def retry(func):
|
||||
if handle_adb_error(e):
|
||||
def init():
|
||||
self.adb_reconnect()
|
||||
elif handle_unknown_host_service(e):
|
||||
def init():
|
||||
self.adb_start_server()
|
||||
self.adb_reconnect()
|
||||
else:
|
||||
break
|
||||
# Package not installed
|
||||
@@ -91,10 +96,12 @@ def retry(func):
|
||||
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_raw, a modified version of DroidCast sending raw bitmap and png, https://github.com/Torther/DroidCastS
|
||||
"""
|
||||
|
||||
_droidcast_port: int = 0
|
||||
droidcast_width: int = 0
|
||||
droidcast_height: int = 0
|
||||
|
||||
@cached_property
|
||||
def droidcast_session(self):
|
||||
@@ -103,106 +110,147 @@ class DroidCast(Uiautomator2):
|
||||
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
|
||||
"""
|
||||
Check APIs from source code:
|
||||
https://github.com/Torther/DroidCast_raw/blob/DroidCast_raw/app/src/main/java/ink/mol/droidcast_raw/KtMain.kt
|
||||
Available APIs:
|
||||
- /screenshot
|
||||
To get a RGB565 bitmap
|
||||
- /preview
|
||||
To get PNG screenshots.
|
||||
"""
|
||||
|
||||
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.
|
||||
def droidcast_url(self, url='/preview'):
|
||||
if self.is_mumu_over_version_356:
|
||||
w, h = self.droidcast_width, self.droidcast_height
|
||||
if self.orientation == 0:
|
||||
return f'http://127.0.0.1:{self._droidcast_port}{url}?width={w}&height={h}'
|
||||
elif self.orientation == 1:
|
||||
return f'http://127.0.0.1:{self._droidcast_port}{url}?width={h}&height={w}'
|
||||
else:
|
||||
# logger.warning('DroidCast receives invalid device orientation')
|
||||
pass
|
||||
|
||||
return f'http://127.0.0.1:{self._droidcast_port}{url}'
|
||||
|
||||
def droidcast_raw_url(self, url='/screenshot'):
|
||||
if self.is_mumu_over_version_356:
|
||||
w, h = self.droidcast_width, self.droidcast_height
|
||||
if self.orientation == 0:
|
||||
return f'http://127.0.0.1:{self._droidcast_port}{url}?width={w}&height={h}'
|
||||
elif self.orientation == 1:
|
||||
return f'http://127.0.0.1:{self._droidcast_port}{url}?width={h}&height={w}'
|
||||
else:
|
||||
# logger.warning('DroidCast receives invalid device orientation')
|
||||
pass
|
||||
|
||||
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')
|
||||
logger.hr('DroidCast init')
|
||||
self.droidcast_stop()
|
||||
self._droidcast_update_resolution()
|
||||
|
||||
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
|
||||
# DroidCast_raw-release-1.0.apk
|
||||
# CLASSPATH=/data/local/tmp/DroidCast_raw.apk app_process / ink.mol.droidcast_raw.Main > /dev/null
|
||||
# adb shell CLASSPATH=/data/local/tmp/DroidCast_raw.apk app_process / ink.mol.droidcast_raw.Main
|
||||
resp = self.u2_shell_background([
|
||||
'CLASSPATH=/data/local/tmp/DroidCast.apk',
|
||||
'CLASSPATH=/data/local/tmp/DroidCast_raw.apk',
|
||||
'app_process',
|
||||
'/',
|
||||
'com.rayworks.droidcast.Main',
|
||||
'ink.mol.droidcast_raw.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()
|
||||
if self.config.DROIDCAST_VERSION == 'DroidCast':
|
||||
logger.attr('DroidCast', self.droidcast_url())
|
||||
self.droidcast_wait_startup()
|
||||
elif self.config.DROIDCAST_VERSION == 'DroidCast_raw':
|
||||
logger.attr('DroidCast_raw', self.droidcast_raw_url())
|
||||
self.droidcast_wait_startup()
|
||||
else:
|
||||
logger.error(f'Unknown DROIDCAST_VERSION: {self.config.DROIDCAST_VERSION}')
|
||||
|
||||
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()
|
||||
def _droidcast_update_resolution(self):
|
||||
if self.is_mumu_over_version_356:
|
||||
logger.info('Update droidcast resolution')
|
||||
w, h = self.resolution_uiautomator2(cal_rotation=False)
|
||||
self.get_orientation()
|
||||
# 720, 1280
|
||||
# mumu12 > 3.5.6 is always a vertical device
|
||||
self.droidcast_width, self.droidcast_height = w, h
|
||||
logger.info(f'Droicast resolution: {(w, h)}')
|
||||
|
||||
@retry
|
||||
def screenshot_droidcast(self):
|
||||
self.config.DROIDCAST_VERSION = 'DroidCast'
|
||||
image = self.droidcast_session.get(self.droidcast_url(), timeout=3).content
|
||||
if self.is_mumu_over_version_356:
|
||||
if not self.droidcast_width or not self.droidcast_height:
|
||||
self._droidcast_update_resolution()
|
||||
|
||||
resp = self.droidcast_session.get(self.droidcast_url(), timeout=3)
|
||||
|
||||
if resp.status_code == 404:
|
||||
raise DroidCastVersionIncompatible('DroidCast server does not have /preview')
|
||||
image = resp.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`')
|
||||
if image.size < 500:
|
||||
logger.warning(f'Unexpected screenshot: {resp.content}')
|
||||
|
||||
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)
|
||||
cv2.cvtColor(image, cv2.COLOR_BGR2RGB, dst=image)
|
||||
if image is None:
|
||||
raise ImageTruncated('Empty image after cv2.cvtColor')
|
||||
|
||||
if self.is_mumu_over_version_356:
|
||||
if self.orientation == 1:
|
||||
image = cv2.rotate(image, cv2.ROTATE_90_CLOCKWISE)
|
||||
|
||||
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
|
||||
shape = (720, 1280)
|
||||
if self.is_mumu_over_version_356:
|
||||
if not self.droidcast_width or not self.droidcast_height:
|
||||
self._droidcast_update_resolution()
|
||||
if self.droidcast_height and self.droidcast_width:
|
||||
shape = (self.droidcast_height, self.droidcast_width)
|
||||
|
||||
rotate = self.is_mumu_over_version_356 and self.orientation == 1
|
||||
|
||||
image = self.droidcast_session.get(self.droidcast_raw_url(), timeout=3).content
|
||||
# DroidCast_raw returns a RGB565 bitmap
|
||||
|
||||
try:
|
||||
arr = np.frombuffer(image, dtype=np.uint16).reshape((720, 1280))
|
||||
arr = np.frombuffer(image, dtype=np.uint16)
|
||||
if rotate:
|
||||
arr = arr.reshape(shape)
|
||||
# arr = cv2.rotate(arr, cv2.ROTATE_90_CLOCKWISE)
|
||||
# A little bit faster?
|
||||
arr = cv2.transpose(arr)
|
||||
cv2.flip(arr, 1, dst=arr)
|
||||
else:
|
||||
arr = arr.reshape(shape)
|
||||
except ValueError as e:
|
||||
if len(image) < 500:
|
||||
logger.warning(f'Unexpected screenshot: {image}')
|
||||
# Try to load as `DroidCast`
|
||||
image = np.frombuffer(image, np.uint8)
|
||||
if image is not None:
|
||||
@@ -227,13 +275,17 @@ class DroidCast(Uiautomator2):
|
||||
# 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))
|
||||
# The same as the code above but costs about 2.7ms instead of 16ms.
|
||||
# Note that cv2.convertScaleAbs is 5x fast as cv2.multiply, cv2.add is 8x fast as cv2.convertScaleAbs
|
||||
# Note that cv2.convertScaleAbs includes rounding
|
||||
tmp = np.empty_like(arr)
|
||||
cv2.bitwise_and(arr, 0b1111100000000000, dst=tmp)
|
||||
r = cv2.convertScaleAbs(tmp, alpha=0.0040283203125) # 0.00390625 * 1.03125
|
||||
cv2.bitwise_and(arr, 0b0000011111100000, dst=tmp)
|
||||
g = cv2.convertScaleAbs(tmp, alpha=0.126953125) # 0.125 * 1.015625
|
||||
cv2.bitwise_and(arr, 0b0000000000011111, dst=tmp)
|
||||
b = cv2.convertScaleAbs(tmp, alpha=8.25) # 8 * 1.03125
|
||||
|
||||
image = cv2.merge([r, g, b])
|
||||
|
||||
return image
|
||||
@@ -262,13 +314,12 @@ class DroidCast(Uiautomator2):
|
||||
|
||||
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.
|
||||
Stop DroidCast processes and remove DroidCast APK.
|
||||
DroidCast hasn'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]:
|
||||
"""
|
||||
@@ -280,10 +331,12 @@ class DroidCast(Uiautomator2):
|
||||
yield proc
|
||||
if 'com.torther.droidcasts.Main' in proc.cmdline:
|
||||
yield proc
|
||||
if 'ink.mol.droidcast_raw.Main' in proc.cmdline:
|
||||
yield proc
|
||||
|
||||
def droidcast_stop(self):
|
||||
"""
|
||||
Stop all DroidCast processes.
|
||||
Stop DroidCast processes.
|
||||
"""
|
||||
logger.info('Stopping DroidCast')
|
||||
for proc in self._iter_droidcast_proc():
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import json
|
||||
import time
|
||||
from functools import wraps
|
||||
|
||||
import requests
|
||||
@@ -8,8 +9,8 @@ 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.device.method.utils import (HierarchyButton, RETRY_TRIES, handle_adb_error, handle_unknown_host_service,
|
||||
retry_sleep)
|
||||
from module.exception import RequestHumanTakeover
|
||||
from module.logger import logger
|
||||
|
||||
@@ -29,7 +30,7 @@ def retry(func):
|
||||
for _ in range(RETRY_TRIES):
|
||||
try:
|
||||
if callable(init):
|
||||
retry_sleep(_)
|
||||
time.sleep(retry_sleep(_))
|
||||
init()
|
||||
return func(self, *args, **kwargs)
|
||||
# Can't handle
|
||||
@@ -62,6 +63,10 @@ def retry(func):
|
||||
if handle_adb_error(e):
|
||||
def init():
|
||||
self.adb_reconnect()
|
||||
elif handle_unknown_host_service(e):
|
||||
def init():
|
||||
self.adb_start_server()
|
||||
self.adb_reconnect()
|
||||
else:
|
||||
break
|
||||
# HermitError: {"code":-1,"msg":"error"}
|
||||
|
||||
@@ -0,0 +1,359 @@
|
||||
import ctypes
|
||||
import os
|
||||
import subprocess
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from functools import wraps
|
||||
|
||||
import cv2
|
||||
import numpy as np
|
||||
|
||||
from module.base.decorator import cached_property
|
||||
from module.device.env import IS_WINDOWS
|
||||
from module.device.method.utils import RETRY_TRIES, get_serial_pair, retry_sleep
|
||||
from module.device.platform.plat import Platform
|
||||
from module.exception import RequestHumanTakeover
|
||||
from module.logger import logger
|
||||
|
||||
|
||||
class LDOpenGLIncompatible(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class LDOpenGLError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
def bytes_to_str(b: bytes) -> str:
|
||||
for encoding in ['utf-8', 'gbk']:
|
||||
try:
|
||||
return b.decode(encoding)
|
||||
except UnicodeDecodeError:
|
||||
pass
|
||||
return str(b)
|
||||
|
||||
|
||||
@dataclass
|
||||
class DataLDPlayerInfo:
|
||||
# Emulator instance index, starting from 0
|
||||
index: int
|
||||
# Instance name
|
||||
name: str
|
||||
# Handle of top window
|
||||
topWnd: int
|
||||
# Handle of bind window
|
||||
bndWnd: int
|
||||
# If instance is running, 1 for True, 0 for False
|
||||
sysboot: int
|
||||
# PID of the instance process, or -1 if instance is not running
|
||||
playerpid: int
|
||||
# PID of the vbox process, or -1 if instance is not running
|
||||
vboxpid: int
|
||||
# Resolution
|
||||
width: int
|
||||
height: int
|
||||
dpi: int
|
||||
|
||||
def __post_init__(self):
|
||||
self.index = int(self.index)
|
||||
self.name = bytes_to_str(self.name)
|
||||
self.topWnd = int(self.topWnd)
|
||||
self.bndWnd = int(self.bndWnd)
|
||||
self.sysboot = int(self.sysboot)
|
||||
self.playerpid = int(self.playerpid)
|
||||
self.vboxpid = int(self.vboxpid)
|
||||
self.width = int(self.width)
|
||||
self.height = int(self.height)
|
||||
self.dpi = int(self.dpi)
|
||||
|
||||
|
||||
class LDConsole:
|
||||
def __init__(self, ld_folder: str):
|
||||
"""
|
||||
Args:
|
||||
ld_folder: Installation path of MuMu12, e.g. E:/ProgramFiles/LDPlayer9
|
||||
which should have a `ldconsole.exe` in it.
|
||||
"""
|
||||
self.ld_console = os.path.abspath(os.path.join(ld_folder, './ldconsole.exe'))
|
||||
|
||||
def subprocess_run(self, cmd, timeout=10):
|
||||
"""
|
||||
Args:
|
||||
cmd (list):
|
||||
timeout (int):
|
||||
|
||||
Returns:
|
||||
bytes:
|
||||
"""
|
||||
cmd = [self.ld_console] + cmd
|
||||
logger.info(f'Execute: {cmd}')
|
||||
|
||||
try:
|
||||
process = subprocess.Popen(cmd, stdout=subprocess.PIPE, shell=False)
|
||||
except FileNotFoundError as e:
|
||||
logger.warning(f'warning when calling {cmd}, {str(e)}')
|
||||
raise LDOpenGLIncompatible(f'ld_folder does not have ldconsole.exe')
|
||||
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
|
||||
|
||||
def list2(self):
|
||||
"""
|
||||
> ldconsole.exe list2
|
||||
0,雷电模拟器,28053900,42935798,1,59776,36816,1280,720,240
|
||||
1,雷电模拟器-1,0,0,0,-1,-1,1280,720,240
|
||||
|
||||
Returns:
|
||||
list[DataLDPlayerInfo]:
|
||||
"""
|
||||
out = []
|
||||
data = self.subprocess_run(['list2'])
|
||||
for row in data.strip().split(b'\n'):
|
||||
row = row.strip()
|
||||
if not row:
|
||||
continue
|
||||
info = row.split(b',')
|
||||
# check parts
|
||||
if len(info) != 10:
|
||||
logger.warning(f'ldplayer info does not have 10 parts: "{row}"')
|
||||
continue
|
||||
# build info
|
||||
try:
|
||||
info = DataLDPlayerInfo(*info)
|
||||
except Exception as e:
|
||||
logger.warning(f'Failed to build ldplayer info from "{row}", {e}')
|
||||
out.append(info)
|
||||
return out
|
||||
|
||||
|
||||
class IScreenShotClass:
|
||||
def __init__(self, ptr):
|
||||
self.ptr = ptr
|
||||
|
||||
# Define in class since ctypes.WINFUNCTYPE is windows only
|
||||
cap_type = ctypes.WINFUNCTYPE(ctypes.c_void_p)
|
||||
release_type = ctypes.WINFUNCTYPE(None)
|
||||
self.class_cap = cap_type(1, "IScreenShotClass_Cap")
|
||||
# Keep reference count
|
||||
# so __del__ won't have an empty IScreenShotClass_Cap
|
||||
self.class_release = release_type(2, "IScreenShotClass_Release")
|
||||
|
||||
def cap(self):
|
||||
return self.class_cap(self.ptr)
|
||||
|
||||
def __del__(self):
|
||||
self.class_release(self.ptr)
|
||||
|
||||
|
||||
def retry(func):
|
||||
@wraps(func)
|
||||
def retry_wrapper(self, *args, **kwargs):
|
||||
"""
|
||||
Args:
|
||||
self (NemuIpcImpl):
|
||||
"""
|
||||
init = None
|
||||
for _ in range(RETRY_TRIES):
|
||||
try:
|
||||
if callable(init):
|
||||
time.sleep(retry_sleep(_))
|
||||
init()
|
||||
return func(self, *args, **kwargs)
|
||||
# Can't handle
|
||||
except RequestHumanTakeover:
|
||||
break
|
||||
# Can't handle
|
||||
except LDOpenGLIncompatible as e:
|
||||
logger.error(e)
|
||||
break
|
||||
# NemuIpcError
|
||||
except LDOpenGLError as e:
|
||||
logger.error(e)
|
||||
|
||||
def init():
|
||||
pass
|
||||
# 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 LDOpenGLImpl:
|
||||
def __init__(self, ld_folder: str, instance_id: int):
|
||||
"""
|
||||
Args:
|
||||
ld_folder: Installation path of MuMu12, e.g. E:/ProgramFiles/LDPlayer9
|
||||
instance_id: Emulator instance ID, starting from 0
|
||||
"""
|
||||
ldopengl_dll = os.path.abspath(os.path.join(ld_folder, './ldopengl64.dll'))
|
||||
logger.info(
|
||||
f'LDOpenGL init, '
|
||||
f'ld_folder={ld_folder}, '
|
||||
f'ldopengl_dll={ldopengl_dll}, '
|
||||
f'instance_id={instance_id}'
|
||||
)
|
||||
# Load dll
|
||||
try:
|
||||
self.lib = ctypes.WinDLL(ldopengl_dll)
|
||||
except OSError as e:
|
||||
logger.error(e)
|
||||
if not os.path.exists(ldopengl_dll):
|
||||
raise LDOpenGLIncompatible(
|
||||
f'ldopengl_dll={ldopengl_dll} does not exist, '
|
||||
f'ldopengl requires LDPlayer >= 9.0.78, please check your version'
|
||||
)
|
||||
else:
|
||||
raise LDOpenGLIncompatible(
|
||||
f'ldopengl_dll={ldopengl_dll} exist, '
|
||||
f'but cannot be loaded'
|
||||
)
|
||||
# Get info after loading DLL, so DLL existence can act as a version check
|
||||
self.console = LDConsole(ld_folder)
|
||||
self.info = self.get_player_info_by_index(instance_id)
|
||||
|
||||
self.lib.CreateScreenShotInstance.restype = ctypes.c_void_p
|
||||
|
||||
# Get screenshot instance
|
||||
instance_ptr = ctypes.c_void_p(self.lib.CreateScreenShotInstance(instance_id, self.info.playerpid))
|
||||
self.screenshot_instance = IScreenShotClass(instance_ptr)
|
||||
|
||||
def get_player_info_by_index(self, instance_id: int):
|
||||
"""
|
||||
Args:
|
||||
instance_id:
|
||||
|
||||
Returns:
|
||||
DataLDPlayerInfo:
|
||||
|
||||
Raises:
|
||||
LDOpenGLError:
|
||||
"""
|
||||
for info in self.console.list2():
|
||||
if info.index == instance_id:
|
||||
logger.info(f'Match LDPlayer instance: {info}')
|
||||
if not info.sysboot:
|
||||
raise LDOpenGLError('Trying to connect LDPlayer instance but emulator is not running')
|
||||
return info
|
||||
raise LDOpenGLError(f'No LDPlayer instance with index {instance_id}')
|
||||
|
||||
@retry
|
||||
def screenshot(self):
|
||||
"""
|
||||
Returns:
|
||||
np.ndarray: Image array in BGR color space
|
||||
Note that image is upside down
|
||||
"""
|
||||
width, height = self.info.width, self.info.height
|
||||
|
||||
img_ptr = self.screenshot_instance.cap()
|
||||
# ValueError: NULL pointer access
|
||||
if img_ptr is None:
|
||||
raise LDOpenGLError('Empty image pointer')
|
||||
|
||||
img = ctypes.cast(img_ptr, ctypes.POINTER(ctypes.c_ubyte * (height * width * 3))).contents
|
||||
|
||||
image = np.ctypeslib.as_array(img).reshape((height, width, 3))
|
||||
return image
|
||||
|
||||
@staticmethod
|
||||
def serial_to_id(serial: str):
|
||||
"""
|
||||
Predict instance ID from serial
|
||||
E.g.
|
||||
"127.0.0.1:5555" -> 0
|
||||
"127.0.0.1:5557" -> 1
|
||||
"emulator-5554" -> 0
|
||||
|
||||
Returns:
|
||||
int: instance_id, or None if failed to predict
|
||||
"""
|
||||
serial, _ = get_serial_pair(serial)
|
||||
if serial is None:
|
||||
return None
|
||||
try:
|
||||
port = int(serial.split(':')[1])
|
||||
except (IndexError, ValueError):
|
||||
return None
|
||||
if 5555 <= port <= 5555 + 32:
|
||||
return int((port - 5555) // 2)
|
||||
return None
|
||||
|
||||
|
||||
class LDOpenGL(Platform):
|
||||
@cached_property
|
||||
def ldopengl(self):
|
||||
"""
|
||||
Initialize a ldopengl implementation
|
||||
"""
|
||||
# Try existing settings first
|
||||
if self.config.EmulatorInfo_path:
|
||||
folder = os.path.abspath(os.path.join(self.config.EmulatorInfo_path, '../'))
|
||||
index = LDOpenGLImpl.serial_to_id(self.serial)
|
||||
if index is not None:
|
||||
try:
|
||||
return LDOpenGLImpl(
|
||||
ld_folder=folder,
|
||||
instance_id=index,
|
||||
)
|
||||
except (LDOpenGLIncompatible, LDOpenGLError) as e:
|
||||
logger.error(e)
|
||||
logger.error('Emulator info incorrect')
|
||||
|
||||
# Search emulator instance
|
||||
# with E:/ProgramFiles/LDPlayer9/dnplayer.exe
|
||||
# installation path is E:/ProgramFiles/LDPlayer9
|
||||
if self.emulator_instance is None:
|
||||
logger.error('Unable to use LDOpenGL because emulator instance not found')
|
||||
raise RequestHumanTakeover
|
||||
try:
|
||||
return LDOpenGLImpl(
|
||||
ld_folder=self.emulator_instance.emulator.abspath('./'),
|
||||
instance_id=self.emulator_instance.LDPlayer_id,
|
||||
)
|
||||
except (LDOpenGLIncompatible, LDOpenGLError) as e:
|
||||
logger.error(e)
|
||||
logger.error('Unable to initialize LDOpenGL')
|
||||
raise RequestHumanTakeover
|
||||
|
||||
def ldopengl_available(self) -> bool:
|
||||
if not IS_WINDOWS:
|
||||
return False
|
||||
if not self.is_ldplayer_bluestacks_family:
|
||||
return False
|
||||
logger.attr('EmulatorInfo_Emulator', self.config.EmulatorInfo_Emulator)
|
||||
if self.config.EmulatorInfo_Emulator not in ['LDPlayer9', 'LDPlayer14']:
|
||||
return False
|
||||
|
||||
try:
|
||||
_ = self.ldopengl
|
||||
except RequestHumanTakeover:
|
||||
return False
|
||||
return True
|
||||
|
||||
def screenshot_ldopengl(self):
|
||||
image = self.ldopengl.screenshot()
|
||||
|
||||
# flip image, also copy image array (no dst=image)
|
||||
if self.orientation == 2:
|
||||
# You may randomly get orientation=2 on ldplayer, but emulators can't be upside-down
|
||||
# If device is upside-down, image is 180 degree rotated (flipped both vertically and horizontally)
|
||||
# plus the different pixel order, we only need to flip it horizontally
|
||||
image = cv2.flip(image, 1)
|
||||
else:
|
||||
# Normal case
|
||||
# Pointer data has different pixel order (positive y-axis upwards)
|
||||
# we need to flip it vertically to the image pixel order (positive y-axis downwards)
|
||||
image = cv2.flip(image, 0)
|
||||
cv2.cvtColor(image, cv2.COLOR_BGR2RGB, dst=image)
|
||||
return image
|
||||
@@ -1,14 +1,16 @@
|
||||
import socket
|
||||
import threading
|
||||
import time
|
||||
from functools import wraps
|
||||
|
||||
from adbutils.errors import AdbError
|
||||
|
||||
from module.base.decorator import cached_property, del_cached_property
|
||||
from module.base.decorator import cached_property, del_cached_property, has_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.device.method.utils import RETRY_TRIES, handle_adb_error, handle_unknown_host_service, retry_sleep
|
||||
from module.exception import RequestHumanTakeover
|
||||
from module.logger import logger
|
||||
|
||||
@@ -24,7 +26,7 @@ def retry(func):
|
||||
for _ in range(RETRY_TRIES):
|
||||
try:
|
||||
if callable(init):
|
||||
retry_sleep(_)
|
||||
time.sleep(retry_sleep(_))
|
||||
init()
|
||||
return func(self, *args, **kwargs)
|
||||
# Can't handle
|
||||
@@ -36,20 +38,25 @@ def retry(func):
|
||||
|
||||
def init():
|
||||
self.adb_reconnect()
|
||||
del_cached_property(self, 'maatouch_builder')
|
||||
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')
|
||||
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')
|
||||
del_cached_property(self, '_maatouch_builder')
|
||||
elif handle_unknown_host_service(e):
|
||||
def init():
|
||||
self.adb_start_server()
|
||||
self.adb_reconnect()
|
||||
del_cached_property(self, '_maatouch_builder')
|
||||
else:
|
||||
break
|
||||
# MaaTouchNotInstalledError: Received "Aborted" from MaaTouch
|
||||
@@ -58,12 +65,12 @@ def retry(func):
|
||||
|
||||
def init():
|
||||
self.maatouch_install()
|
||||
del_cached_property(self, 'maatouch_builder')
|
||||
del_cached_property(self, '_maatouch_builder')
|
||||
except BrokenPipeError as e:
|
||||
logger.error(e)
|
||||
|
||||
def init():
|
||||
del_cached_property(self, 'maatouch_builder')
|
||||
del_cached_property(self, '_maatouch_builder')
|
||||
# Unknown, probably a trucked image
|
||||
except Exception as e:
|
||||
logger.exception(e)
|
||||
@@ -101,20 +108,76 @@ class MaaTouch(Connection):
|
||||
"""
|
||||
max_x: int
|
||||
max_y: int
|
||||
_maatouch_stream = socket.socket
|
||||
_maatouch_stream: socket.socket = None
|
||||
_maatouch_stream_storage = None
|
||||
_maatouch_init_thread = None
|
||||
_maatouch_orientation: int = None
|
||||
|
||||
@cached_property
|
||||
def maatouch_builder(self):
|
||||
@retry
|
||||
def _maatouch_builder(self):
|
||||
self.maatouch_init()
|
||||
return MaatouchBuilder(self)
|
||||
|
||||
@property
|
||||
def maatouch_builder(self):
|
||||
# Wait init thread
|
||||
if self._maatouch_init_thread is not None:
|
||||
self._maatouch_init_thread.join()
|
||||
del self._maatouch_init_thread
|
||||
self._maatouch_init_thread = None
|
||||
|
||||
return self._maatouch_builder
|
||||
|
||||
def early_maatouch_init(self):
|
||||
"""
|
||||
Start a thread to init maatouch connection while the Alas instance just starting to take screenshots
|
||||
This would speed up the first click 0.2 ~ 0.4s.
|
||||
"""
|
||||
if has_cached_property(self, '_maatouch_builder'):
|
||||
return
|
||||
|
||||
def early_maatouch_init_func():
|
||||
_ = self._maatouch_builder
|
||||
|
||||
thread = threading.Thread(target=early_maatouch_init_func, daemon=True)
|
||||
self._maatouch_init_thread = thread
|
||||
thread.start()
|
||||
|
||||
def on_orientation_change_maatouch(self):
|
||||
"""
|
||||
MaaTouch caches devices orientation at its startup
|
||||
A restart is required when orientation changed
|
||||
"""
|
||||
if self._maatouch_orientation is None:
|
||||
return
|
||||
if self.orientation == self._maatouch_orientation:
|
||||
return
|
||||
|
||||
logger.info(f'Orientation changed {self._maatouch_orientation} => {self.orientation}, re-init MaaTouch')
|
||||
del_cached_property(self, '_maatouch_builder')
|
||||
self.early_maatouch_init()
|
||||
|
||||
def maatouch_init(self):
|
||||
logger.hr('MaaTouch init')
|
||||
max_x, max_y = 1280, 720
|
||||
max_contacts = 2
|
||||
max_pressure = 50
|
||||
|
||||
# Try to close existing stream
|
||||
if self._maatouch_stream is not None:
|
||||
try:
|
||||
self._maatouch_stream.close()
|
||||
except Exception as e:
|
||||
logger.error(e)
|
||||
del self._maatouch_stream
|
||||
if self._maatouch_stream_storage is not None:
|
||||
del self._maatouch_stream_storage
|
||||
|
||||
# MaaTouch caches devices orientation at its startup
|
||||
super(MaaTouch, self).get_orientation()
|
||||
self._maatouch_orientation = self.orientation
|
||||
|
||||
# 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'],
|
||||
@@ -215,7 +278,7 @@ class MaaTouch(Connection):
|
||||
points = insert_swipe(p0=p1, p3=p2)
|
||||
builder = self.maatouch_builder
|
||||
|
||||
builder.down(*points[0]).commit()
|
||||
builder.down(*points[0]).commit().wait(10)
|
||||
builder.send()
|
||||
|
||||
for point in points[1:]:
|
||||
@@ -232,7 +295,7 @@ class MaaTouch(Connection):
|
||||
points = insert_swipe(p0=p1, p3=p2, speed=20)
|
||||
builder = self.maatouch_builder
|
||||
|
||||
builder.down(*points[0]).commit()
|
||||
builder.down(*points[0]).commit().wait(10)
|
||||
builder.send()
|
||||
|
||||
for point in points[1:]:
|
||||
@@ -271,3 +334,8 @@ class MaaTouch(Connection):
|
||||
builder.contact = 1
|
||||
builder.up().commit()
|
||||
builder.send()
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
self = MaaTouch('src')
|
||||
self.maatouch_uninstall()
|
||||
@@ -1,7 +1,7 @@
|
||||
import asyncio
|
||||
import json
|
||||
import re
|
||||
import socket
|
||||
import threading
|
||||
import time
|
||||
from functools import wraps
|
||||
from typing import List
|
||||
@@ -10,11 +10,11 @@ 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.decorator import Config, cached_property, del_cached_property, has_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.device.method.utils import RETRY_TRIES, handle_adb_error, handle_unknown_host_service, retry_sleep
|
||||
from module.exception import RequestHumanTakeover, ScriptError
|
||||
from module.logger import logger
|
||||
|
||||
@@ -86,6 +86,8 @@ def insert_swipe(p0, p3, speed=15, min_distance=10):
|
||||
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()
|
||||
if len(points) <= 1:
|
||||
points = [p0, p3]
|
||||
else:
|
||||
points = [p0, p3]
|
||||
|
||||
@@ -302,7 +304,7 @@ def retry(func):
|
||||
for _ in range(RETRY_TRIES):
|
||||
try:
|
||||
if callable(init):
|
||||
retry_sleep(_)
|
||||
time.sleep(retry_sleep(_))
|
||||
init()
|
||||
return func(self, *args, **kwargs)
|
||||
# Can't handle
|
||||
@@ -314,12 +316,18 @@ def retry(func):
|
||||
|
||||
def init():
|
||||
self.adb_reconnect()
|
||||
if self._minitouch_port:
|
||||
self.adb_forward_remove(f'tcp:{self._minitouch_port}')
|
||||
del_cached_property(self, '_minitouch_builder')
|
||||
# Emulator closed
|
||||
except ConnectionAbortedError as e:
|
||||
logger.error(e)
|
||||
|
||||
def init():
|
||||
self.adb_reconnect()
|
||||
if self._minitouch_port:
|
||||
self.adb_forward_remove(f'tcp:{self._minitouch_port}')
|
||||
del_cached_property(self, '_minitouch_builder')
|
||||
# MinitouchNotInstalledError: Received empty data from minitouch
|
||||
except MinitouchNotInstalledError as e:
|
||||
logger.error(e)
|
||||
@@ -328,7 +336,7 @@ def retry(func):
|
||||
self.install_uiautomator2()
|
||||
if self._minitouch_port:
|
||||
self.adb_forward_remove(f'tcp:{self._minitouch_port}')
|
||||
del_cached_property(self, 'minitouch_builder')
|
||||
del_cached_property(self, '_minitouch_builder')
|
||||
# MinitouchOccupiedError: Timeout when connecting to minitouch
|
||||
except MinitouchOccupiedError as e:
|
||||
logger.error(e)
|
||||
@@ -337,19 +345,29 @@ def retry(func):
|
||||
self.restart_atx()
|
||||
if self._minitouch_port:
|
||||
self.adb_forward_remove(f'tcp:{self._minitouch_port}')
|
||||
del_cached_property(self, 'minitouch_builder')
|
||||
del_cached_property(self, '_minitouch_builder')
|
||||
# AdbError
|
||||
except AdbError as e:
|
||||
if handle_adb_error(e):
|
||||
def init():
|
||||
self.adb_reconnect()
|
||||
if self._minitouch_port:
|
||||
self.adb_forward_remove(f'tcp:{self._minitouch_port}')
|
||||
del_cached_property(self, '_minitouch_builder')
|
||||
elif handle_unknown_host_service(e):
|
||||
def init():
|
||||
self.adb_start_server()
|
||||
self.adb_reconnect()
|
||||
if self._minitouch_port:
|
||||
self.adb_forward_remove(f'tcp:{self._minitouch_port}')
|
||||
del_cached_property(self, '_minitouch_builder')
|
||||
else:
|
||||
break
|
||||
except BrokenPipeError as e:
|
||||
logger.error(e)
|
||||
|
||||
def init():
|
||||
del_cached_property(self, 'minitouch_builder')
|
||||
del_cached_property(self, '_minitouch_builder')
|
||||
# Unknown, probably a trucked image
|
||||
except Exception as e:
|
||||
logger.exception(e)
|
||||
@@ -365,23 +383,59 @@ def retry(func):
|
||||
|
||||
class Minitouch(Connection):
|
||||
_minitouch_port: int = 0
|
||||
_minitouch_client: socket.socket
|
||||
_minitouch_client: socket.socket = None
|
||||
_minitouch_pid: int
|
||||
_minitouch_ws: websockets.WebSocketClientProtocol
|
||||
max_x: int
|
||||
max_y: int
|
||||
_minitouch_init_thread = None
|
||||
|
||||
@cached_property
|
||||
def minitouch_builder(self):
|
||||
@retry
|
||||
def _minitouch_builder(self):
|
||||
self.minitouch_init()
|
||||
return CommandBuilder(self)
|
||||
|
||||
@property
|
||||
def minitouch_builder(self):
|
||||
# Wait init thread
|
||||
if self._minitouch_init_thread is not None:
|
||||
self._minitouch_init_thread.join()
|
||||
del self._minitouch_init_thread
|
||||
self._minitouch_init_thread = None
|
||||
|
||||
return self._minitouch_builder
|
||||
|
||||
def early_minitouch_init(self):
|
||||
"""
|
||||
Start a thread to init minitouch connection while the Alas instance just starting to take screenshots
|
||||
This would speed up the first click 0.05s.
|
||||
"""
|
||||
if has_cached_property(self, '_minitouch_builder'):
|
||||
return
|
||||
|
||||
def early_minitouch_init_func():
|
||||
_ = self._minitouch_builder
|
||||
|
||||
thread = threading.Thread(target=early_minitouch_init_func, daemon=True)
|
||||
self._minitouch_init_thread = thread
|
||||
thread.start()
|
||||
|
||||
@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
|
||||
|
||||
# Try to close existing stream
|
||||
if self._minitouch_client is not None:
|
||||
try:
|
||||
self._minitouch_client.close()
|
||||
except Exception as e:
|
||||
logger.error(e)
|
||||
del self._minitouch_client
|
||||
|
||||
self.get_orientation()
|
||||
|
||||
self._minitouch_port = self.adb_forward("localabstract:minitouch")
|
||||
@@ -550,7 +604,7 @@ class Minitouch(Connection):
|
||||
points = insert_swipe(p0=p1, p3=p2)
|
||||
builder = self.minitouch_builder
|
||||
|
||||
builder.down(*points[0]).commit()
|
||||
builder.down(*points[0]).commit().wait(10)
|
||||
builder.send()
|
||||
|
||||
for point in points[1:]:
|
||||
@@ -567,7 +621,7 @@ class Minitouch(Connection):
|
||||
points = insert_swipe(p0=p1, p3=p2, speed=20)
|
||||
builder = self.minitouch_builder
|
||||
|
||||
builder.down(*points[0]).commit()
|
||||
builder.down(*points[0]).commit().wait(10)
|
||||
builder.send()
|
||||
|
||||
for point in points[1:]:
|
||||
|
||||
@@ -0,0 +1,645 @@
|
||||
import ctypes
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
from functools import wraps
|
||||
|
||||
import cv2
|
||||
import numpy as np
|
||||
|
||||
from module.base.decorator import cached_property, del_cached_property, has_cached_property
|
||||
from module.base.timer import Timer
|
||||
from module.base.utils import ensure_time
|
||||
from module.config.deep import deep_get
|
||||
from module.device.env import IS_WINDOWS
|
||||
from module.device.method.minitouch import insert_swipe, random_rectangle_point
|
||||
from module.device.method.pool import JobTimeout, WORKER_POOL
|
||||
from module.device.method.utils import RETRY_TRIES, retry_sleep
|
||||
from module.device.platform.plat import Platform
|
||||
from module.exception import RequestHumanTakeover
|
||||
from module.logger import logger
|
||||
|
||||
|
||||
class NemuIpcIncompatible(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class NemuIpcError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class CaptureStd:
|
||||
"""
|
||||
Capture stdout and stderr from both python and C library
|
||||
https://stackoverflow.com/questions/5081657/how-do-i-prevent-a-c-shared-library-to-print-on-stdout-in-python/17954769
|
||||
|
||||
```
|
||||
with CaptureStd() as capture:
|
||||
# String wasn't printed
|
||||
print('whatever')
|
||||
# But captured in ``capture.stdout``
|
||||
print(f'Got stdout: "{capture.stdout}"')
|
||||
print(f'Got stderr: "{capture.stderr}"')
|
||||
```
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self.stdout = b''
|
||||
self.stderr = b''
|
||||
|
||||
def _redirect_stdout(self, to):
|
||||
sys.stdout.close()
|
||||
os.dup2(to, self.fdout)
|
||||
sys.stdout = os.fdopen(self.fdout, 'w')
|
||||
|
||||
def _redirect_stderr(self, to):
|
||||
sys.stderr.close()
|
||||
os.dup2(to, self.fderr)
|
||||
sys.stderr = os.fdopen(self.fderr, 'w')
|
||||
|
||||
def __enter__(self):
|
||||
self.fdout = sys.stdout.fileno()
|
||||
self.fderr = sys.stderr.fileno()
|
||||
self.reader_out, self.writer_out = os.pipe()
|
||||
self.reader_err, self.writer_err = os.pipe()
|
||||
self.old_stdout = os.dup(self.fdout)
|
||||
self.old_stderr = os.dup(self.fderr)
|
||||
|
||||
file_out = os.fdopen(self.writer_out, 'w')
|
||||
file_err = os.fdopen(self.writer_err, 'w')
|
||||
self._redirect_stdout(to=file_out.fileno())
|
||||
self._redirect_stderr(to=file_err.fileno())
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type, exc_val, exc_tb):
|
||||
self._redirect_stdout(to=self.old_stdout)
|
||||
self._redirect_stderr(to=self.old_stderr)
|
||||
os.close(self.old_stdout)
|
||||
os.close(self.old_stderr)
|
||||
|
||||
self.stdout = self.recvall(self.reader_out)
|
||||
self.stderr = self.recvall(self.reader_err)
|
||||
os.close(self.reader_out)
|
||||
os.close(self.reader_err)
|
||||
|
||||
@staticmethod
|
||||
def recvall(reader, length=1024) -> bytes:
|
||||
fragments = []
|
||||
while 1:
|
||||
chunk = os.read(reader, length)
|
||||
if chunk:
|
||||
fragments.append(chunk)
|
||||
else:
|
||||
break
|
||||
output = b''.join(fragments)
|
||||
return output
|
||||
|
||||
|
||||
class CaptureNemuIpc(CaptureStd):
|
||||
instance = None
|
||||
|
||||
def is_capturing(self):
|
||||
"""
|
||||
Only capture at the topmost wrapper to avoid nested capturing
|
||||
If a capture is ongoing, this instance does nothing
|
||||
"""
|
||||
cls = self.__class__
|
||||
return isinstance(cls.instance, cls) and cls.instance != self
|
||||
|
||||
def __enter__(self):
|
||||
if self.is_capturing():
|
||||
return self
|
||||
|
||||
super().__enter__()
|
||||
CaptureNemuIpc.instance = self
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type, exc_val, exc_tb):
|
||||
if self.is_capturing():
|
||||
return
|
||||
|
||||
CaptureNemuIpc.instance = None
|
||||
super().__exit__(exc_type, exc_val, exc_tb)
|
||||
|
||||
self.check_stdout()
|
||||
self.check_stderr()
|
||||
|
||||
def check_stdout(self):
|
||||
if not self.stdout:
|
||||
return
|
||||
logger.info(f'NemuIpc stdout: {self.stdout}')
|
||||
|
||||
def check_stderr(self):
|
||||
if not self.stderr:
|
||||
return
|
||||
logger.error(f'NemuIpc stderr: {self.stderr}')
|
||||
|
||||
# Calling an old MuMu12 player
|
||||
# Tested on 3.4.0
|
||||
# b'nemu_capture_display rpc error: 1783\r\n'
|
||||
# Tested on 3.7.3
|
||||
# b'nemu_capture_display rpc error: 1745\r\n'
|
||||
if b'error: 1783' in self.stderr or b'error: 1745' in self.stderr:
|
||||
raise NemuIpcIncompatible(
|
||||
f'NemuIpc requires MuMu12 version >= 3.8.13, please check your version')
|
||||
# contact_id incorrect
|
||||
# b'nemu_capture_display cannot find rpc connection\r\n'
|
||||
if b'cannot find rpc connection' in self.stderr:
|
||||
raise NemuIpcError(self.stderr)
|
||||
# Emulator died
|
||||
# b'nemu_capture_display rpc error: 1722\r\n'
|
||||
# MuMuVMMSVC.exe died
|
||||
# b'nemu_capture_display rpc error: 1726\r\n'
|
||||
# No idea how to handle yet
|
||||
if b'error: 1722' in self.stderr or b'error: 1726' in self.stderr:
|
||||
raise NemuIpcError('Emulator instance is probably dead')
|
||||
|
||||
|
||||
def retry(func):
|
||||
@wraps(func)
|
||||
def retry_wrapper(self, *args, **kwargs):
|
||||
"""
|
||||
Args:
|
||||
self (NemuIpcImpl):
|
||||
"""
|
||||
init = None
|
||||
for _ in range(RETRY_TRIES):
|
||||
# Extend timeout on retries
|
||||
if func.__name__ == 'screenshot':
|
||||
timeout = retry_sleep(_)
|
||||
if timeout > 0:
|
||||
kwargs['timeout'] = timeout
|
||||
try:
|
||||
if callable(init):
|
||||
time.sleep(retry_sleep(_))
|
||||
init()
|
||||
return func(self, *args, **kwargs)
|
||||
# Can't handle
|
||||
except RequestHumanTakeover:
|
||||
break
|
||||
# Can't handle
|
||||
except NemuIpcIncompatible as e:
|
||||
logger.error(e)
|
||||
break
|
||||
# Function call timeout
|
||||
except JobTimeout:
|
||||
logger.warning(f'Func {func.__name__}() call timeout, retrying: {_}')
|
||||
|
||||
def init():
|
||||
pass
|
||||
# NemuIpcError
|
||||
except NemuIpcError as e:
|
||||
logger.error(e)
|
||||
|
||||
def init():
|
||||
self.reconnect()
|
||||
# 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 NemuIpcImpl:
|
||||
def __init__(self, nemu_folder: str, instance_id: int, display_id: int = 0):
|
||||
"""
|
||||
Args:
|
||||
nemu_folder: Installation path of MuMu12, e.g. E:/ProgramFiles/MuMuPlayer-12.0
|
||||
instance_id: Emulator instance ID, starting from 0
|
||||
display_id: Always 0 if keep app alive was disabled
|
||||
"""
|
||||
self.nemu_folder: str = nemu_folder
|
||||
self.instance_id: int = instance_id
|
||||
self.display_id: int = display_id
|
||||
|
||||
# try to load dll from various path
|
||||
list_dll = [
|
||||
# MuMuPlayer12
|
||||
os.path.abspath(os.path.join(nemu_folder, './shell/sdk/external_renderer_ipc.dll')),
|
||||
# MuMuPlayer12 5.0
|
||||
os.path.abspath(os.path.join(nemu_folder, './nx_device/12.0/shell/sdk/external_renderer_ipc.dll')),
|
||||
# MuMuPlayer12 6.0
|
||||
os.path.abspath(os.path.join(nemu_folder, './nx_main/sdk/external_renderer_ipc.dll')),
|
||||
]
|
||||
self.lib = None
|
||||
for ipc_dll in list_dll:
|
||||
if not os.path.exists(ipc_dll):
|
||||
continue
|
||||
try:
|
||||
self.lib = ctypes.CDLL(ipc_dll)
|
||||
break
|
||||
except OSError as e:
|
||||
logger.error(e)
|
||||
logger.error(f'ipc_dll={ipc_dll} exists, but cannot be loaded')
|
||||
continue
|
||||
if self.lib is None:
|
||||
# not found
|
||||
raise NemuIpcIncompatible(
|
||||
f'NemuIpc requires MuMu12 version >= 3.8.13, please check your version. '
|
||||
f'None of the following path exists: {list_dll}')
|
||||
# success
|
||||
logger.info(
|
||||
f'NemuIpcImpl init, '
|
||||
f'nemu_folder={nemu_folder}, '
|
||||
f'ipc_dll={ipc_dll}, '
|
||||
f'instance_id={instance_id}, '
|
||||
f'display_id={display_id}'
|
||||
)
|
||||
self.connect_id: int = 0
|
||||
self.width = 0
|
||||
self.height = 0
|
||||
|
||||
def connect(self, on_thread=True):
|
||||
if self.connect_id > 0:
|
||||
return
|
||||
|
||||
if on_thread:
|
||||
connect_id = self.run_func(
|
||||
self.lib.nemu_connect,
|
||||
self.nemu_folder, self.instance_id
|
||||
)
|
||||
else:
|
||||
connect_id = self.lib.nemu_connect(self.nemu_folder, self.instance_id)
|
||||
if connect_id == 0:
|
||||
raise NemuIpcError(
|
||||
'Connection failed, please check if nemu_folder is correct and emulator is running'
|
||||
)
|
||||
|
||||
self.connect_id = connect_id
|
||||
# logger.info(f'NemuIpc connected: {self.connect_id}')
|
||||
|
||||
@retry
|
||||
def connect_with_retry(self, on_thread=True):
|
||||
self.connect(on_thread=on_thread)
|
||||
|
||||
def disconnect(self):
|
||||
if self.connect_id == 0:
|
||||
return
|
||||
|
||||
self.run_func(
|
||||
self.lib.nemu_disconnect,
|
||||
self.connect_id
|
||||
)
|
||||
|
||||
# logger.info(f'NemuIpc disconnected: {self.connect_id}')
|
||||
self.connect_id = 0
|
||||
|
||||
def reconnect(self):
|
||||
self.disconnect()
|
||||
self.connect()
|
||||
|
||||
def __enter__(self):
|
||||
self.connect()
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type, exc_val, exc_tb):
|
||||
self.disconnect()
|
||||
|
||||
@staticmethod
|
||||
def run_func(func, *args, on_thread=True, timeout=0.5):
|
||||
"""
|
||||
Args:
|
||||
func: Sync function to call
|
||||
*args:
|
||||
on_thread: True to run func on a separated thread
|
||||
timeout:
|
||||
|
||||
Raises:
|
||||
JobTimeout: If function call timeout
|
||||
NemuIpcIncompatible:
|
||||
NemuIpcError
|
||||
"""
|
||||
if on_thread:
|
||||
# nemu_ipc may timeout sometimes, so we run it on a separated thread
|
||||
job = WORKER_POOL.start_thread_soon(func, *args)
|
||||
result = job.get_or_kill(timeout)
|
||||
else:
|
||||
result = func(*args)
|
||||
|
||||
err = False
|
||||
if func.__name__ == '_screenshot':
|
||||
pass
|
||||
elif func.__name__ == 'nemu_connect':
|
||||
if result == 0:
|
||||
err = True
|
||||
else:
|
||||
if result > 0:
|
||||
err = True
|
||||
# Get to actual error message printed in std
|
||||
if err:
|
||||
logger.warning(f'Failed to call {func.__name__}, result={result}')
|
||||
with CaptureNemuIpc():
|
||||
func(*args)
|
||||
|
||||
return result
|
||||
|
||||
def get_resolution(self, on_thread=True):
|
||||
"""
|
||||
Get emulator resolution, `self.width` and `self.height` will be set
|
||||
"""
|
||||
if self.connect_id == 0:
|
||||
self.connect()
|
||||
|
||||
width_ptr = ctypes.pointer(ctypes.c_int(0))
|
||||
height_ptr = ctypes.pointer(ctypes.c_int(0))
|
||||
nullptr = ctypes.POINTER(ctypes.c_int)()
|
||||
|
||||
ret = self.run_func(
|
||||
self.lib.nemu_capture_display,
|
||||
self.connect_id, self.display_id, 0, width_ptr, height_ptr, nullptr,
|
||||
on_thread=on_thread
|
||||
)
|
||||
if ret > 0:
|
||||
raise NemuIpcError('nemu_capture_display failed during get_resolution()')
|
||||
self.width = width_ptr.contents.value
|
||||
self.height = height_ptr.contents.value
|
||||
|
||||
def _screenshot(self):
|
||||
if self.connect_id == 0:
|
||||
self.connect(on_thread=False)
|
||||
self.get_resolution(on_thread=False)
|
||||
|
||||
width_ptr = ctypes.pointer(ctypes.c_int(self.width))
|
||||
height_ptr = ctypes.pointer(ctypes.c_int(self.height))
|
||||
length = self.width * self.height * 4
|
||||
pixels_pointer = ctypes.pointer((ctypes.c_ubyte * length)())
|
||||
|
||||
ret = self.lib.nemu_capture_display(
|
||||
self.connect_id, self.display_id, length, width_ptr, height_ptr, pixels_pointer,
|
||||
)
|
||||
if ret > 0:
|
||||
raise NemuIpcError('nemu_capture_display failed during screenshot()')
|
||||
|
||||
# Return pixels_pointer instead of image to avoid passing image through jobs
|
||||
return pixels_pointer
|
||||
|
||||
@retry
|
||||
def screenshot(self, timeout=0.5):
|
||||
"""
|
||||
Args:
|
||||
timeout: Timout in seconds to call nemu_ipc
|
||||
Will be dynamically extended by `@retry`
|
||||
|
||||
Returns:
|
||||
np.ndarray: Image array in RGBA color space
|
||||
Note that image is upside down
|
||||
"""
|
||||
if self.connect_id == 0:
|
||||
self.connect()
|
||||
|
||||
pixels_pointer = self.run_func(self._screenshot, timeout=timeout)
|
||||
|
||||
# image = np.ctypeslib.as_array(pixels_pointer, shape=(self.height, self.width, 4))
|
||||
image = np.ctypeslib.as_array(pixels_pointer.contents).reshape((self.height, self.width, 4))
|
||||
return image
|
||||
|
||||
def convert_xy(self, x, y):
|
||||
"""
|
||||
Convert classic ADB coordinates to Nemu's
|
||||
`self.height` must be updated before calling this method
|
||||
|
||||
Returns:
|
||||
int, int
|
||||
"""
|
||||
x, y = int(x), int(y)
|
||||
x, y = self.height - y, x
|
||||
return x, y
|
||||
|
||||
@retry
|
||||
def down(self, x, y):
|
||||
"""
|
||||
Contact down, continuous contact down will be considered as swipe
|
||||
"""
|
||||
if self.connect_id == 0:
|
||||
self.connect()
|
||||
if self.height == 0:
|
||||
self.get_resolution()
|
||||
|
||||
x, y = self.convert_xy(x, y)
|
||||
|
||||
ret = self.run_func(
|
||||
self.lib.nemu_input_event_touch_down,
|
||||
self.connect_id, self.display_id, x, y
|
||||
)
|
||||
if ret > 0:
|
||||
raise NemuIpcError('nemu_input_event_touch_down failed')
|
||||
|
||||
@retry
|
||||
def up(self):
|
||||
"""
|
||||
Contact up
|
||||
"""
|
||||
if self.connect_id == 0:
|
||||
self.connect()
|
||||
|
||||
ret = self.run_func(
|
||||
self.lib.nemu_input_event_touch_up,
|
||||
self.connect_id, self.display_id
|
||||
)
|
||||
if ret > 0:
|
||||
raise NemuIpcError('nemu_input_event_touch_up failed')
|
||||
|
||||
@staticmethod
|
||||
def serial_to_id(serial: str):
|
||||
"""
|
||||
Predict instance ID from serial
|
||||
E.g.
|
||||
"127.0.0.1:16384" -> 0
|
||||
"127.0.0.1:16416" -> 1
|
||||
Port from 16414 to 16418 -> 1
|
||||
|
||||
Returns:
|
||||
int: instance_id, or None if failed to predict
|
||||
"""
|
||||
try:
|
||||
port = int(serial.split(':')[1])
|
||||
except (IndexError, ValueError):
|
||||
return None
|
||||
index, offset = divmod(port - 16384 + 16, 32)
|
||||
offset -= 16
|
||||
if 0 <= index < 32 and offset in [-2, -1, 0, 1, 2]:
|
||||
return index
|
||||
else:
|
||||
return None
|
||||
|
||||
|
||||
class NemuIpc(Platform):
|
||||
_screenshot_interval = Timer(0.1)
|
||||
|
||||
@cached_property
|
||||
def nemu_ipc(self) -> NemuIpcImpl:
|
||||
"""
|
||||
Initialize a nemu ipc implementation
|
||||
"""
|
||||
# Try existing settings first
|
||||
if self.config.EmulatorInfo_path:
|
||||
if 'MuMuPlayerGlobal' in self.config.EmulatorInfo_path:
|
||||
logger.info(f'nemu_ipc is not available on MuMuPlayerGlobal, {self.config.EmulatorInfo_path}')
|
||||
raise RequestHumanTakeover
|
||||
folder = os.path.abspath(os.path.join(self.config.EmulatorInfo_path, '../../'))
|
||||
index = NemuIpcImpl.serial_to_id(self.serial)
|
||||
if index is not None:
|
||||
try:
|
||||
return NemuIpcImpl(
|
||||
nemu_folder=folder,
|
||||
instance_id=index,
|
||||
display_id=0
|
||||
).__enter__()
|
||||
except (NemuIpcIncompatible, NemuIpcError, JobTimeout) as e:
|
||||
logger.error(e)
|
||||
logger.error('Emulator info incorrect')
|
||||
|
||||
# Search emulator instance
|
||||
# with E:\ProgramFiles\MuMuPlayer-12.0\shell\MuMuPlayer.exe
|
||||
# installation path is E:\ProgramFiles\MuMuPlayer-12.0
|
||||
if self.emulator_instance is None:
|
||||
logger.error('Unable to use NemuIpc because emulator instance not found')
|
||||
raise RequestHumanTakeover
|
||||
if 'MuMuPlayerGlobal' in self.emulator_instance.path:
|
||||
logger.info(f'nemu_ipc is not available on MuMuPlayerGlobal, {self.emulator_instance.path}')
|
||||
raise RequestHumanTakeover
|
||||
try:
|
||||
impl = NemuIpcImpl(
|
||||
nemu_folder=self.emulator_instance.emulator.abspath('../'),
|
||||
instance_id=self.emulator_instance.MuMuPlayer12_id,
|
||||
display_id=0
|
||||
)
|
||||
impl.connect_with_retry()
|
||||
return impl
|
||||
except (NemuIpcIncompatible, NemuIpcError, JobTimeout) as e:
|
||||
logger.error(e)
|
||||
logger.error('Unable to initialize NemuIpc')
|
||||
raise RequestHumanTakeover
|
||||
|
||||
def nemu_ipc_available(self) -> bool:
|
||||
if not IS_WINDOWS:
|
||||
return False
|
||||
if not self.is_mumu_family:
|
||||
return False
|
||||
if self.nemud_player_version == '':
|
||||
# >= 4.0 has no info in getprop
|
||||
# Try initializing nemu_ipc for final check
|
||||
pass
|
||||
else:
|
||||
# Having version, probably MuMu6 or MuMu12 version 3.x
|
||||
if self.nemud_app_keep_alive == '':
|
||||
# Empty property, probably MuMu6 or MuMu12 version < 3.5.6
|
||||
return False
|
||||
try:
|
||||
_ = self.nemu_ipc
|
||||
except RequestHumanTakeover:
|
||||
return False
|
||||
return True
|
||||
|
||||
@staticmethod
|
||||
def check_mumu_app_keep_alive_400(file):
|
||||
"""
|
||||
Check app_keep_alive from emulator config if version >= 4.0
|
||||
|
||||
Args:
|
||||
file: E:/ProgramFiles/MuMuPlayer-12.0/vms/MuMuPlayer-12.0-1/config/customer_config.json
|
||||
|
||||
Returns:
|
||||
bool: If success to read file
|
||||
"""
|
||||
# with E:\ProgramFiles\MuMuPlayer-12.0\shell\MuMuPlayer.exe
|
||||
# config is E:\ProgramFiles\MuMuPlayer-12.0\vms\MuMuPlayer-12.0-1\config\customer_config.json
|
||||
try:
|
||||
with open(file, mode='r', encoding='utf-8') as f:
|
||||
s = f.read()
|
||||
data = json.loads(s)
|
||||
except FileNotFoundError:
|
||||
logger.warning(f'Failed to check check_mumu_app_keep_alive, file {file} not exists')
|
||||
return False
|
||||
value = deep_get(data, keys='customer.app_keptlive', default=None)
|
||||
logger.attr('customer.app_keptlive', value)
|
||||
if str(value).lower() == 'true':
|
||||
# https://mumu.163.com/help/20230802/35047_1102450.html
|
||||
logger.critical('Please turn off "Keep alive in the background" in the settings or MuMuPlayer')
|
||||
logger.critical('请在MuMu模拟器设置内关闭 "后台挂机时保活运行"')
|
||||
raise RequestHumanTakeover
|
||||
return True
|
||||
|
||||
def check_mumu_app_keep_alive(self):
|
||||
if not self.is_mumu_over_version_400:
|
||||
return super().check_mumu_app_keep_alive()
|
||||
|
||||
# Try existing settings first
|
||||
if self.config.EmulatorInfo_path:
|
||||
index = NemuIpcImpl.serial_to_id(self.serial)
|
||||
if index is not None:
|
||||
file = os.path.abspath(os.path.join(
|
||||
self.config.EmulatorInfo_path, f'../../vms/MuMuPlayer-12.0-{index}/configs/customer_config.json'))
|
||||
if self.check_mumu_app_keep_alive_400(file):
|
||||
return True
|
||||
|
||||
# Search emulator instance
|
||||
if self.emulator_instance is None:
|
||||
logger.warning('Failed to check check_mumu_app_keep_alive as emulator_instance is None')
|
||||
return False
|
||||
name = self.emulator_instance.name
|
||||
file = self.emulator_instance.mumu_vms_config('customer_config.json')
|
||||
if self.check_mumu_app_keep_alive_400(file):
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
def nemu_ipc_release(self):
|
||||
if has_cached_property(self, 'nemu_ipc'):
|
||||
self.nemu_ipc.disconnect()
|
||||
del_cached_property(self, 'nemu_ipc')
|
||||
logger.info('nemu_ipc released')
|
||||
|
||||
def screenshot_nemu_ipc(self):
|
||||
image = self.nemu_ipc.screenshot()
|
||||
|
||||
image = cv2.cvtColor(image, cv2.COLOR_BGRA2BGR)
|
||||
cv2.flip(image, 0, dst=image)
|
||||
return image
|
||||
|
||||
def click_nemu_ipc(self, x, y):
|
||||
down = ensure_time((0.010, 0.020))
|
||||
self.nemu_ipc.down(x, y)
|
||||
self.sleep(down)
|
||||
self.nemu_ipc.up()
|
||||
self.sleep(0.050 - down)
|
||||
|
||||
def long_click_nemu_ipc(self, x, y, duration=1.0):
|
||||
self.nemu_ipc.down(x, y)
|
||||
self.sleep(duration)
|
||||
self.nemu_ipc.up()
|
||||
self.sleep(0.050)
|
||||
|
||||
def swipe_nemu_ipc(self, p1, p2):
|
||||
points = insert_swipe(p0=p1, p3=p2)
|
||||
|
||||
for point in points:
|
||||
self.nemu_ipc.down(*point)
|
||||
self.sleep(0.010)
|
||||
|
||||
self.nemu_ipc.up()
|
||||
self.sleep(0.050)
|
||||
|
||||
def drag_nemu_ipc(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)
|
||||
|
||||
for point in points:
|
||||
self.nemu_ipc.down(*point)
|
||||
self.sleep(0.010)
|
||||
|
||||
self.nemu_ipc.down(*p2)
|
||||
self.sleep(0.140)
|
||||
self.nemu_ipc.down(*p2)
|
||||
self.sleep(0.140)
|
||||
|
||||
self.nemu_ipc.up()
|
||||
self.sleep(0.050)
|
||||
@@ -0,0 +1,609 @@
|
||||
import ctypes
|
||||
import subprocess
|
||||
from functools import wraps
|
||||
from threading import Lock, Thread
|
||||
from typing import Generic, TypeVar
|
||||
|
||||
from module.logger import logger
|
||||
|
||||
ResultT = TypeVar("ResultT")
|
||||
|
||||
|
||||
def remove_tb_frames(exc, n: int):
|
||||
"""
|
||||
Args:
|
||||
exc (BaseException):
|
||||
n:
|
||||
|
||||
Returns:
|
||||
BaseException:
|
||||
"""
|
||||
tb = exc.__traceback__
|
||||
for _ in range(n):
|
||||
assert tb is not None
|
||||
tb = tb.tb_next
|
||||
return exc.with_traceback(tb)
|
||||
|
||||
|
||||
class Error:
|
||||
"""Concrete :class:`Outcome` subclass representing a raised exception.
|
||||
|
||||
"""
|
||||
__slots__ = ('error',)
|
||||
|
||||
def __init__(self, error: BaseException):
|
||||
self.error: BaseException = error
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f'Error({self.error!r})'
|
||||
|
||||
def unwrap(self):
|
||||
# Tracebacks show the 'raise' line below out of context, so let's give
|
||||
# this variable a name that makes sense out of context.
|
||||
captured_error = self.error
|
||||
try:
|
||||
raise captured_error
|
||||
finally:
|
||||
# We want to avoid creating a reference cycle here. Python does
|
||||
# collect cycles just fine, so it wouldn't be the end of the world
|
||||
# if we did create a cycle, but the cyclic garbage collector adds
|
||||
# latency to Python programs, and the more cycles you create, the
|
||||
# more often it runs, so it's nicer to avoid creating them in the
|
||||
# first place. For more details see:
|
||||
#
|
||||
# https://github.com/python-trio/trio/issues/1770
|
||||
#
|
||||
# In particular, by deleting this local variables from the 'unwrap'
|
||||
# methods frame, we avoid the 'captured_error' object's
|
||||
# __traceback__ from indirectly referencing 'captured_error'.
|
||||
del captured_error, self
|
||||
|
||||
|
||||
class JobError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class JobTimeout(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class _JobKill(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class Job(Generic[ResultT]):
|
||||
"""
|
||||
A simple queue, copied from queue.Queue()
|
||||
Faster but can only put() once and get() once.
|
||||
"""
|
||||
|
||||
__slots__ = ('worker', 'func', 'args', 'kwargs', 'result', 'put_lock', 'notify_get')
|
||||
|
||||
def __init__(self, worker, func, args, kwargs):
|
||||
# Having attribute "worker" means job is ongoing
|
||||
# Not having attribute "worker" means job is finished or killed
|
||||
self.worker = worker
|
||||
self.func = func
|
||||
self.args = args
|
||||
self.kwargs = kwargs
|
||||
|
||||
# self.result: "Any | Error"
|
||||
self.put_lock = Lock()
|
||||
self.notify_get = Lock()
|
||||
self.notify_get.acquire()
|
||||
|
||||
def __repr__(self):
|
||||
return f'Job({self.func}, {self.args}, {self.kwargs})'
|
||||
|
||||
def get(self) -> ResultT:
|
||||
"""
|
||||
Get job result or job error
|
||||
"""
|
||||
self.notify_get.acquire()
|
||||
|
||||
# Return job result or raise job error
|
||||
# `result` will be set in _handle_job
|
||||
item = self.result
|
||||
if type(item) is Error:
|
||||
return item.unwrap()
|
||||
else:
|
||||
return item
|
||||
|
||||
def get_or_kill(self, timeout) -> ResultT:
|
||||
"""
|
||||
Try to get result within given seconds,
|
||||
if success, return job result or job error
|
||||
if failed, kill job and raise JobTimeout
|
||||
|
||||
Note that JobTimeout may not raises immediately if POOL_SIZE reached
|
||||
"""
|
||||
if self.notify_get.acquire(timeout=timeout):
|
||||
# Return job result or raise job error
|
||||
# `result` will be set in _handle_job
|
||||
result = self.result
|
||||
if type(result) is Error:
|
||||
return result.unwrap()
|
||||
else:
|
||||
return result
|
||||
else:
|
||||
self._kill()
|
||||
raise JobTimeout
|
||||
|
||||
def _kill(self):
|
||||
with self.put_lock:
|
||||
try:
|
||||
worker = self.worker
|
||||
except AttributeError:
|
||||
# Trying to kill a finished job, do nothing
|
||||
return
|
||||
worker.kill()
|
||||
del self.worker
|
||||
|
||||
|
||||
class WorkerThread:
|
||||
def __init__(self, thread_pool, index):
|
||||
"""
|
||||
Args:
|
||||
thread_pool (WorkerPool):
|
||||
index (int): Thread index, starting from 0
|
||||
"""
|
||||
self.job: "Job | None" = None
|
||||
self.thread_pool = thread_pool
|
||||
# This Lock is used in an unconventional way.
|
||||
#
|
||||
# "Unlocked" means we have a pending job that's been assigned to us;
|
||||
# "locked" means that we don't.
|
||||
#
|
||||
# Initially we have no job, so it starts out in locked state.
|
||||
self.worker_lock = Lock()
|
||||
self.worker_lock.acquire()
|
||||
self.default_name = f"Alasio thread {index}"
|
||||
|
||||
self.thread = Thread(target=self._work, name=self.default_name, daemon=True)
|
||||
self.thread.start()
|
||||
|
||||
def __repr__(self):
|
||||
return f'{self.__class__.__name__}({self.default_name})'
|
||||
|
||||
def _handle_job(self) -> None:
|
||||
# Convert to local variable, `self.job` will be another
|
||||
# value if new job is assigned
|
||||
job = self.job
|
||||
del self.job
|
||||
|
||||
# Capture func result
|
||||
try:
|
||||
result = job.func(*job.args, **job.kwargs)
|
||||
except BaseException as exc:
|
||||
exc = remove_tb_frames(exc, 1)
|
||||
result = Error(exc)
|
||||
|
||||
# Check if job killed, must before marking self idle
|
||||
if type(result.error) is _JobKill:
|
||||
return
|
||||
|
||||
# Tell the cache that we're available to be assigned a new
|
||||
# job. We do this *before* calling 'deliver', so that if
|
||||
# 'deliver' triggers a new job, it can be assigned to us
|
||||
# instead of spawning a new thread.
|
||||
self.thread_pool.idle_workers[self] = None
|
||||
self.thread_pool.release_full_lock()
|
||||
|
||||
# Job finished, putin result and notify
|
||||
# logger.info('deliver job')
|
||||
with job.put_lock:
|
||||
job.result = result
|
||||
del job.worker
|
||||
job.notify_get.release()
|
||||
|
||||
def _work(self) -> None:
|
||||
pool = self.thread_pool
|
||||
while True:
|
||||
if self.worker_lock.acquire(timeout=WorkerPool.IDLE_TIMEOUT):
|
||||
# We got a job
|
||||
self._handle_job()
|
||||
else:
|
||||
# Timeout acquiring lock, so we can probably exit. But,
|
||||
# there's a race condition: we might be assigned a job *just*
|
||||
# as we're about to exit. So we have to check.
|
||||
try:
|
||||
del pool.idle_workers[self]
|
||||
except KeyError:
|
||||
# Someone else removed us from the idle worker queue, so
|
||||
# they must be in the process of assigning us a job - loop
|
||||
# around and wait for it.
|
||||
pool.release_full_lock()
|
||||
continue
|
||||
else:
|
||||
# We successfully removed ourselves from the idle
|
||||
# worker queue, so no more jobs are incoming; it's safe to
|
||||
# exit.
|
||||
del pool.all_workers[self]
|
||||
pool.release_full_lock()
|
||||
# logger.info(f'End worker thread: {self.default_name}')
|
||||
return
|
||||
|
||||
def kill(self):
|
||||
"""
|
||||
Yes, it's unsafe to kill a thread, but what else can you do
|
||||
if a single job function get blocked.
|
||||
This method should be protected by `job.put_lock` to prevent
|
||||
race condition with `_handle_job()`.
|
||||
|
||||
Returns:
|
||||
bool: If success to kill the thread
|
||||
"""
|
||||
# Send SystemExit to thread
|
||||
# logger.info(f'kill worker thread: {self.default_name}')
|
||||
thread_id = ctypes.c_long(self.thread.ident)
|
||||
res = ctypes.pythonapi.PyThreadState_SetAsyncExc(
|
||||
thread_id, ctypes.py_object(_JobKill))
|
||||
if res <= 1:
|
||||
self.thread_pool.all_workers.pop(self, None)
|
||||
self.thread_pool.release_full_lock()
|
||||
return True
|
||||
else:
|
||||
try:
|
||||
job = self.job
|
||||
except AttributeError:
|
||||
job = None
|
||||
logger.error(f'Failed to kill thread {self.thread.ident} from job {job}')
|
||||
# Failed to send SystemExit, reset it
|
||||
ctypes.pythonapi.PyThreadState_SetAsyncExc(thread_id, 0)
|
||||
return False
|
||||
|
||||
|
||||
class WorkerPool:
|
||||
"""
|
||||
A thread pool imitating trio.to_thread.start_thread_soon()
|
||||
https://github.com/python-trio/trio/issues/6
|
||||
"""
|
||||
|
||||
# Thread exits after 10s idling.
|
||||
IDLE_TIMEOUT = 1
|
||||
|
||||
def __init__(self, pool_size: int = 8):
|
||||
# Pool has 8 threads at max.
|
||||
# Alasio is for local low-frequency access so default pool size is small
|
||||
self.pool_size = pool_size
|
||||
|
||||
self.idle_workers: "dict[WorkerThread, None]" = {}
|
||||
self.all_workers: "dict[WorkerThread, None]" = {}
|
||||
|
||||
self.notify_worker = Lock()
|
||||
self.notify_worker.acquire()
|
||||
self.notify_pool = Lock()
|
||||
self.notify_pool.acquire()
|
||||
|
||||
self.create_lock = Lock()
|
||||
|
||||
def release_full_lock(self):
|
||||
"""
|
||||
Call this method if worker finished any job, or exited, or get killed.
|
||||
|
||||
When pool full,
|
||||
Pool tells all workers: any worker finishes his job notify me.
|
||||
`self.notify_worker.release()`
|
||||
Then the pool blocks himself.
|
||||
`self.notify_pool.acquire()`
|
||||
The fastest worker, and also the only worker, receives the message,
|
||||
`if self.notify_worker.acquire(blocking=False):`
|
||||
Worker tells the pool, new pool slot is ready, you are ready to go.
|
||||
`self.notify_pool.release()`
|
||||
"""
|
||||
if self.notify_worker.acquire(blocking=False):
|
||||
try:
|
||||
self.notify_pool.release()
|
||||
except RuntimeError:
|
||||
# Race condition when multiple threads trying to get thread worker
|
||||
# They released `notify_worker` but not yet acquire `notify_pool`
|
||||
pass
|
||||
|
||||
def _get_thread_worker(self) -> "WorkerThread":
|
||||
try:
|
||||
worker, _ = self.idle_workers.popitem()
|
||||
# logger.info(f'reuse worker thread: {worker.default_name}')
|
||||
return worker
|
||||
except KeyError:
|
||||
pass
|
||||
|
||||
# Wait if reached max thread
|
||||
# Check without `create_lock` first, otherwise will be 10x slower
|
||||
# if multiple thread trying to get `create_lock`
|
||||
if len(self.all_workers) >= self.pool_size:
|
||||
# See release_full_lock()
|
||||
try:
|
||||
self.notify_worker.release()
|
||||
except RuntimeError:
|
||||
# Race condition when multiple threads trying to get thread worker
|
||||
# It's ok to treat multiple release as one
|
||||
pass
|
||||
while 1:
|
||||
# If any worker finishes within timeout, we can get it
|
||||
# Race condition when all workers just done `release_full_lock` and no one notifies
|
||||
# To handle that, we acquire with timeout and check if there's idle worker
|
||||
self.notify_pool.acquire(timeout=0.01)
|
||||
# Re-acquire `notify_worker` so other workers can
|
||||
# call `release_full_lock` for next `_get_thread_worker`
|
||||
self.notify_worker.acquire(blocking=False)
|
||||
# A worker just idle
|
||||
try:
|
||||
worker, _ = self.idle_workers.popitem()
|
||||
return worker
|
||||
except KeyError:
|
||||
# Race condition when multiple threads trying to get thread worker,
|
||||
# they all pass through full lock check and pop the only idle worker.
|
||||
# Just let the slower ones do full lock check again
|
||||
pass
|
||||
# A thread just existed, pool no longer full
|
||||
if len(self.all_workers) < self.pool_size:
|
||||
break
|
||||
|
||||
# Create thread with lock
|
||||
with self.create_lock:
|
||||
# Wait if reached max thread
|
||||
# Check without `create_lock` first, otherwise will be 10x slower
|
||||
# if multiple thread trying to get `create_lock`
|
||||
if len(self.all_workers) >= self.pool_size:
|
||||
# See release_full_lock()
|
||||
try:
|
||||
self.notify_worker.release()
|
||||
except RuntimeError:
|
||||
# Race condition when multiple threads trying to get thread worker
|
||||
# It's ok to treat multiple release as one
|
||||
pass
|
||||
while 1:
|
||||
# If any worker finishes within timeout, we can get it
|
||||
# Race condition when all workers just done `release_full_lock` and no one notifies
|
||||
# To handle that, we acquire with timeout and check if there's idle worker
|
||||
self.notify_pool.acquire(timeout=0.01)
|
||||
# Re-acquire `notify_worker` so other workers can
|
||||
# call `release_full_lock` for next `_get_thread_worker`
|
||||
self.notify_worker.acquire(blocking=False)
|
||||
# A worker just idle
|
||||
try:
|
||||
worker, _ = self.idle_workers.popitem()
|
||||
return worker
|
||||
except KeyError:
|
||||
# Race condition when multiple threads trying to get thread worker,
|
||||
# they all pass through full lock check and pop the only idle worker.
|
||||
# Just let the slower ones do full lock check again
|
||||
pass
|
||||
# A thread just existed, pool no longer full
|
||||
if len(self.all_workers) < self.pool_size:
|
||||
break
|
||||
else:
|
||||
# A thread just idle while we were waiting for `create_lock`
|
||||
try:
|
||||
worker, _ = self.idle_workers.popitem()
|
||||
# logger.info(f'reuse worker thread: {worker.default_name}')
|
||||
return worker
|
||||
except KeyError:
|
||||
pass
|
||||
# Create thread
|
||||
worker = WorkerThread(self, len(self.all_workers))
|
||||
self.all_workers[worker] = None
|
||||
# logger.info(f'New worker thread: {worker.default_name}')
|
||||
return worker
|
||||
|
||||
def start_thread_soon(self, func, *args, **kwargs):
|
||||
"""
|
||||
Run a function on thread, costs extra ~15us,
|
||||
result can be got from `job` object
|
||||
|
||||
Args:
|
||||
func (Callable[..., ResultT]):
|
||||
*args:
|
||||
**kwargs:
|
||||
|
||||
Returns:
|
||||
Job[ResultT]:
|
||||
|
||||
Examples:
|
||||
job = WORKER_POOL.start_thread_soon(func, *args)
|
||||
result = job.get()
|
||||
"""
|
||||
worker = self._get_thread_worker()
|
||||
job = Job(worker, func, args, kwargs)
|
||||
|
||||
worker.job = job
|
||||
worker.worker_lock.release()
|
||||
return job
|
||||
|
||||
def run_on_thread(self, func):
|
||||
"""
|
||||
Decorate a function to run on thread,
|
||||
result can be got from `job` object
|
||||
|
||||
Args:
|
||||
func (Callable[..., ResultT]):
|
||||
|
||||
Returns:
|
||||
Job[ResultT]:
|
||||
|
||||
Examples:
|
||||
@run_on_thread
|
||||
def function(...):
|
||||
pass
|
||||
job = function(...)
|
||||
result = job.get()
|
||||
"""
|
||||
|
||||
@wraps(func)
|
||||
def thread_wrapper(*args, **kwargs) -> "Job[ResultT]":
|
||||
return self.start_thread_soon(func, *args, **kwargs)
|
||||
|
||||
return thread_wrapper
|
||||
|
||||
@staticmethod
|
||||
def _subprocess_execute(cmd, timeout=10):
|
||||
"""
|
||||
Helper function to run cmd in subprocess
|
||||
|
||||
Args:
|
||||
cmd (list[str]):
|
||||
timeout:
|
||||
|
||||
Returns:
|
||||
bytes:
|
||||
"""
|
||||
logger.info(f'Execute: {cmd}')
|
||||
|
||||
process = subprocess.Popen(cmd, stdout=subprocess.PIPE, shell=False)
|
||||
|
||||
try:
|
||||
stdout, stderr = process.communicate(timeout=timeout)
|
||||
except subprocess.TimeoutExpired:
|
||||
process.kill()
|
||||
stdout, stderr = process.communicate()
|
||||
logger.warning(f'TimeoutExpired when calling {cmd}, stdout={stdout}, stderr={stderr}')
|
||||
return stdout
|
||||
|
||||
def start_cmd_soon(self, cmd, timeout=10):
|
||||
"""
|
||||
Run cmd on subprocess and communicate it on another thread,
|
||||
result can be got from `job` object
|
||||
|
||||
Args:
|
||||
cmd (list[str]):
|
||||
timeout:
|
||||
|
||||
Returns:
|
||||
Job[bytes]:
|
||||
"""
|
||||
worker = self._get_thread_worker()
|
||||
job = Job(worker, self._subprocess_execute, (cmd,), {'timeout': timeout})
|
||||
|
||||
worker.job = job
|
||||
worker.worker_lock.release()
|
||||
return job
|
||||
|
||||
def wait_jobs(self) -> "WaitJobsWrapper":
|
||||
"""
|
||||
Auto wait all jobs finished
|
||||
|
||||
Examples:
|
||||
with WORKER_POOL.wait_jobs() as pool:
|
||||
pool.start_thread_soon(...)
|
||||
"""
|
||||
return WaitJobsWrapper(self)
|
||||
|
||||
def gather_jobs(self) -> "GatherJobsWrapper":
|
||||
"""
|
||||
Auto wait all jobs finished and gather results
|
||||
|
||||
Examples:
|
||||
pool = WORKER_POOL.gather_jobs()
|
||||
with pool:
|
||||
pool.start_thread_soon(...)
|
||||
# Get results
|
||||
print(pool.results)
|
||||
"""
|
||||
return GatherJobsWrapper(self)
|
||||
|
||||
def thread_map(self, func, iterables):
|
||||
"""
|
||||
Alternative to ThreadPoolExecutor.map(func, iterables)
|
||||
|
||||
Args:
|
||||
func (Callable[..., ResultT]):
|
||||
iterables:
|
||||
|
||||
Returns:
|
||||
list[ResultT]:
|
||||
"""
|
||||
jobs = [self.start_thread_soon(func, arg) for arg in iterables]
|
||||
results = [job.get() for job in jobs]
|
||||
return results
|
||||
|
||||
def thread_starmap(self, func, iterables):
|
||||
"""
|
||||
Alternative to multiprocessing.pool.Pool().starmap(func, iterables) but on threads
|
||||
|
||||
Args:
|
||||
func (Callable[..., ResultT]):
|
||||
iterables:
|
||||
|
||||
Returns:
|
||||
list[ResultT]:
|
||||
"""
|
||||
jobs = [self.start_thread_soon(func, *arg) for arg in iterables]
|
||||
results = [job.get() for job in jobs]
|
||||
return results
|
||||
|
||||
def thread_funcmap(self, func_iterables):
|
||||
"""
|
||||
Run a list of functions on threads
|
||||
|
||||
Args:
|
||||
func_iterables (Iterable[Callable[..., ResultT]]):
|
||||
|
||||
Returns:
|
||||
list[ResultT]:
|
||||
"""
|
||||
jobs = [self.start_thread_soon(func) for func in func_iterables]
|
||||
results = [job.get() for job in jobs]
|
||||
return results
|
||||
|
||||
|
||||
class WaitJobsWrapper:
|
||||
"""
|
||||
Wrapper class to wait all jobs
|
||||
"""
|
||||
|
||||
def __init__(self, pool: "WorkerPool"):
|
||||
self.pool: "WorkerPool" = pool
|
||||
self.jobs: "list[Job[ResultT]]" = []
|
||||
|
||||
def get(self):
|
||||
for job in self.jobs:
|
||||
job.get()
|
||||
self.jobs.clear()
|
||||
|
||||
def __enter__(self):
|
||||
self.jobs.clear()
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type, exc_val, exc_tb):
|
||||
self.get()
|
||||
|
||||
def start_thread_soon(self, func, *args, **kwargs):
|
||||
"""
|
||||
Run a function on thread,
|
||||
result can be got from `job` object
|
||||
|
||||
Args:
|
||||
func (Callable[..., ResultT]):
|
||||
*args:
|
||||
**kwargs:
|
||||
|
||||
Returns:
|
||||
Job[ResultT]:
|
||||
"""
|
||||
job = self.pool.start_thread_soon(func, *args, **kwargs)
|
||||
self.jobs.append(job)
|
||||
return job
|
||||
|
||||
|
||||
class GatherJobsWrapper(WaitJobsWrapper):
|
||||
"""
|
||||
Wrapper class to gather all jobs
|
||||
"""
|
||||
|
||||
def __init__(self, pool: "WorkerPool"):
|
||||
super().__init__(pool)
|
||||
self.results: "list[ResultT]" = []
|
||||
|
||||
def get(self):
|
||||
for job in self.jobs:
|
||||
result = job.get()
|
||||
self.results.append(result)
|
||||
self.jobs.clear()
|
||||
|
||||
def __enter__(self):
|
||||
self.jobs.clear()
|
||||
self.results.clear()
|
||||
return self
|
||||
|
||||
|
||||
WORKER_POOL = WorkerPool()
|
||||
@@ -0,0 +1,139 @@
|
||||
from typing import overload
|
||||
|
||||
|
||||
@overload
|
||||
def remove_shell_warning(s: bytes) -> bytes: ...
|
||||
|
||||
|
||||
@overload
|
||||
def remove_shell_warning(s: str) -> str: ...
|
||||
|
||||
|
||||
def remove_shell_warning(s):
|
||||
"""
|
||||
Remove warnings from shell
|
||||
|
||||
1. Warnings in VMOS shell
|
||||
https://github.com/LmeSzinc/AzurLaneAutoScript/issues/1425
|
||||
|
||||
WARNING: linker: [vdso]: unused DT entry: type 0x70000001 arg 0x0\n
|
||||
\x89PNG\r\n\x1a\n\x00\x00\x00\rIH...
|
||||
|
||||
2. This linker thingy might appear multiple times when executing multiple commands
|
||||
|
||||
mek_8q:/dev # getprop | grep gnss
|
||||
WARNING: linker: Warning: "[vdso]" unused DT entry: unknown processor-specific (type 0x70000001 arg 0x0) (ignoring)
|
||||
WARNING: linker: Warning: "[vdso]" unused DT entry: unknown processor-specific (type 0x70000001 arg 0x0) (ignoring)
|
||||
[init.svc.gnss_service]: [running]
|
||||
[init.svc_debug_pid.gnss_service]: [406]
|
||||
[ro.boottime.gnss_service]: [27308752875]
|
||||
|
||||
Args:
|
||||
s (str | bytes): bytes or str
|
||||
|
||||
Returns:
|
||||
str | bytes: Shell output with warnings removed
|
||||
"""
|
||||
if isinstance(s, bytes):
|
||||
while 1:
|
||||
if s.startswith(b'WARNING: linker:'):
|
||||
_, _, s = s.partition(b'\n')
|
||||
else:
|
||||
break
|
||||
elif isinstance(s, str):
|
||||
while 1:
|
||||
if s.startswith('WARNING: linker:'):
|
||||
_, _, s = s.partition('\n')
|
||||
else:
|
||||
break
|
||||
|
||||
return s
|
||||
|
||||
|
||||
@overload
|
||||
def remove_screenshot_warning(s: bytes) -> bytes: ...
|
||||
|
||||
|
||||
@overload
|
||||
def remove_screenshot_warning(s: str) -> str: ...
|
||||
|
||||
|
||||
def remove_screenshot_warning(s):
|
||||
"""
|
||||
Remove warnings when taking screenshot
|
||||
|
||||
1. Errors in waydroid screencap render
|
||||
https://github.com/LmeSzinc/AzurLaneAutoScript/issues/4760
|
||||
|
||||
Failed to create //.cache for shader cache (Read-only file system)---disabling.\n
|
||||
\x89PNG...
|
||||
|
||||
2. Warning when taking screenshot from multiscreen device
|
||||
|
||||
[Warning] Multiple displays were found, but no display id was specified! Defaulting to the first display found,
|
||||
however this default is not guaranteed to be consistent across captures.\n
|
||||
A display id should be specified.\n
|
||||
See "dumpsys SurfaceFlinger --display-id" for valid display IDs.\n
|
||||
\x89PNG...
|
||||
|
||||
3. Another format of multiscreen warning
|
||||
https://github.com/LmeSzinc/AzurLaneAutoScript/issues/5682
|
||||
|
||||
[Warning] Multiple displays were found, but no display id was specified! Defaulting to the first display found,
|
||||
however this default is not guaranteed to be consistent across captures. A display id should be specified.\n
|
||||
A display ID can be specified with the [-d display-id] option.\n
|
||||
See "dumpsys SurfaceFlinger --display-id" for valid display IDs.\n
|
||||
\x89PNG...
|
||||
|
||||
4. Unknown header on VMOS PRO screenshot
|
||||
https://github.com/LmeSzinc/AzurLaneAutoScript/pull/940
|
||||
|
||||
long long=8 fun*=10\n
|
||||
\x89PNG...
|
||||
|
||||
5. Warning from AMD GPU driver when running redroid on minimal linux system (typically a NAS)
|
||||
https://github.com/LmeSzinc/AzurLaneAutoScript/issues/5697
|
||||
|
||||
amdgpu: os_same_file_description couldn't determine if two DRM fds reference the same file description.\n
|
||||
If they do, bad things may happen!\n
|
||||
\x89PNG...
|
||||
|
||||
Args:
|
||||
s (str | bytes): bytes or str
|
||||
|
||||
Returns:
|
||||
str | bytes: Screenshot data with warnings removed
|
||||
"""
|
||||
if isinstance(s, bytes):
|
||||
if s.startswith(b'Failed to create'):
|
||||
_, _, s = s.partition(b'\n')
|
||||
if s.startswith(b'[Warning] Multiple displays'):
|
||||
_, _, s = s.partition(b'\n')
|
||||
if s.startswith(b'A display id') or s.startswith(b'A display ID'):
|
||||
_, _, s = s.partition(b'\n')
|
||||
if s.startswith(b'See "dumpsys'):
|
||||
_, _, s = s.partition(b'\n')
|
||||
if s.startswith(b'long long=8'):
|
||||
_, _, s = s.partition(b'\n')
|
||||
if s.startswith(b'amdgpu:'):
|
||||
_, _, s = s.partition(b'\n')
|
||||
if s.startswith(b'If they do'):
|
||||
_, _, s = s.partition(b'\n')
|
||||
|
||||
elif isinstance(s, str):
|
||||
if s.startswith('Failed to create'):
|
||||
_, _, s = s.partition('\n')
|
||||
if s.startswith('[Warning] Multiple displays'):
|
||||
_, _, s = s.partition('\n')
|
||||
if s.startswith('A display id') or s.startswith('A display ID'):
|
||||
_, _, s = s.partition('\n')
|
||||
if s.startswith('See "dumpsys'):
|
||||
_, _, s = s.partition('\n')
|
||||
if s.startswith('long long=8'):
|
||||
_, _, s = s.partition('\n')
|
||||
if s.startswith('amdgpu:'):
|
||||
_, _, s = s.partition('\n')
|
||||
if s.startswith('If they do'):
|
||||
_, _, s = s.partition('\n')
|
||||
|
||||
return s
|
||||
@@ -65,6 +65,8 @@ class ScrcpyCore(Connection):
|
||||
|
||||
Raises:
|
||||
ScrcpyError:
|
||||
adbutils.AdbTimeout:
|
||||
socket.timeout:
|
||||
"""
|
||||
logger.hr('Scrcpy server start')
|
||||
commands = ScrcpyOptions.command_v120(jar_path=self.config.SCRCPY_FILEPATH_REMOTE)
|
||||
@@ -72,6 +74,7 @@ class ScrcpyCore(Connection):
|
||||
commands,
|
||||
stream=True,
|
||||
)
|
||||
self._scrcpy_server_stream.conn.settimeout(3)
|
||||
|
||||
logger.info('Create server stream')
|
||||
ret = self._scrcpy_server_stream.read(10)
|
||||
@@ -104,6 +107,7 @@ class ScrcpyCore(Connection):
|
||||
self._scrcpy_video_socket = self.adb.create_connection(
|
||||
Network.LOCAL_ABSTRACT, "scrcpy"
|
||||
)
|
||||
self._scrcpy_video_socket.settimeout(3)
|
||||
break
|
||||
except AdbError:
|
||||
sleep(0.1)
|
||||
@@ -115,6 +119,7 @@ class ScrcpyCore(Connection):
|
||||
self._scrcpy_control_socket = self.adb.create_connection(
|
||||
Network.LOCAL_ABSTRACT, "scrcpy"
|
||||
)
|
||||
self._scrcpy_control_socket.settimeout(3)
|
||||
|
||||
logger.info('Fetch device info')
|
||||
device_name = self._scrcpy_video_socket.recv(64).decode("utf-8").rstrip("\x00")
|
||||
@@ -151,23 +156,35 @@ class ScrcpyCore(Connection):
|
||||
# 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_stream_loop_thread is not None:
|
||||
self._scrcpy_stream_loop_thread.join(1)
|
||||
del self._scrcpy_stream_loop_thread
|
||||
self._scrcpy_stream_loop_thread = None
|
||||
|
||||
if self._scrcpy_control_socket is not None:
|
||||
try:
|
||||
self._scrcpy_control_socket.close()
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as e:
|
||||
logger.error(e)
|
||||
del self._scrcpy_control_socket
|
||||
self._scrcpy_control_socket = None
|
||||
|
||||
if self._scrcpy_video_socket is not None:
|
||||
try:
|
||||
self._scrcpy_video_socket.close()
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as e:
|
||||
logger.error(e)
|
||||
del self._scrcpy_video_socket
|
||||
self._scrcpy_video_socket = None
|
||||
|
||||
if self._scrcpy_server_stream is not None:
|
||||
try:
|
||||
self._scrcpy_server_stream.close()
|
||||
except Exception as e:
|
||||
logger.error(e)
|
||||
del self._scrcpy_server_stream
|
||||
self._scrcpy_server_stream = None
|
||||
|
||||
logger.info('Scrcpy server stopped')
|
||||
|
||||
@@ -195,7 +212,8 @@ class ScrcpyCore(Connection):
|
||||
try:
|
||||
raw_h264 = self._scrcpy_video_socket.recv(0x10000)
|
||||
if raw_h264 == b"":
|
||||
raise ScrcpyError("Video stream is disconnected")
|
||||
if self._scrcpy_alive:
|
||||
raise ScrcpyError("_scrcpy_stream_loop_thread: Video stream disconnected")
|
||||
packets = codec.parse(raw_h264)
|
||||
for packet in packets:
|
||||
frames = codec.decode(packet)
|
||||
@@ -212,5 +230,8 @@ class ScrcpyCore(Connection):
|
||||
if self._scrcpy_alive:
|
||||
logger.error(f'_scrcpy_stream_loop_thread: {repr(e)}')
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f'_scrcpy_stream_loop_thread exception: {repr(e)}')
|
||||
raise
|
||||
|
||||
raise ScrcpyError('_scrcpy_stream_loop stopped')
|
||||
|
||||
@@ -1,15 +1,16 @@
|
||||
import socket
|
||||
import time
|
||||
from functools import wraps
|
||||
|
||||
import numpy as np
|
||||
from adbutils.errors import AdbError
|
||||
from adbutils.errors import AdbError, AdbTimeout
|
||||
|
||||
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.device.method.utils import RETRY_TRIES, handle_adb_error, handle_unknown_host_service, retry_sleep
|
||||
from module.exception import RequestHumanTakeover
|
||||
from module.logger import logger
|
||||
|
||||
@@ -19,13 +20,13 @@ def retry(func):
|
||||
def retry_wrapper(self, *args, **kwargs):
|
||||
"""
|
||||
Args:
|
||||
self (Minitouch):
|
||||
self (ScrcpyCore):
|
||||
"""
|
||||
init = None
|
||||
for _ in range(RETRY_TRIES):
|
||||
try:
|
||||
if callable(init):
|
||||
retry_sleep(_)
|
||||
time.sleep(retry_sleep(_))
|
||||
init()
|
||||
return func(self, *args, **kwargs)
|
||||
# Can't handle
|
||||
@@ -47,6 +48,13 @@ def retry(func):
|
||||
except ScrcpyError as e:
|
||||
logger.error(e)
|
||||
|
||||
def init():
|
||||
self.scrcpy_init()
|
||||
# AdbTimeout
|
||||
# socket.timeout
|
||||
except (AdbTimeout, socket.timeout) as e:
|
||||
logger.error(e)
|
||||
|
||||
def init():
|
||||
self.scrcpy_init()
|
||||
# AdbError
|
||||
@@ -54,6 +62,10 @@ def retry(func):
|
||||
if handle_adb_error(e):
|
||||
def init():
|
||||
self.adb_reconnect()
|
||||
elif handle_unknown_host_service(e):
|
||||
def init():
|
||||
self.adb_start_server()
|
||||
self.adb_reconnect()
|
||||
else:
|
||||
break
|
||||
# Unknown, probably a trucked image
|
||||
@@ -85,10 +97,11 @@ class Scrcpy(ScrcpyCore, Uiautomator2):
|
||||
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():
|
||||
thread = self._scrcpy_stream_loop_thread
|
||||
if thread is None or not thread.is_alive():
|
||||
raise ScrcpyError('_scrcpy_stream_loop_thread died')
|
||||
if self._scrcpy_last_frame_time > now:
|
||||
screenshot = self._scrcpy_last_frame.copy()
|
||||
screenshot = self._scrcpy_last_frame
|
||||
return screenshot
|
||||
|
||||
@retry
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import typing as t
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from functools import wraps
|
||||
from json.decoder import JSONDecodeError
|
||||
@@ -9,9 +9,10 @@ from adbutils.errors import AdbError
|
||||
from lxml import etree
|
||||
|
||||
from module.base.utils import *
|
||||
from module.config.server import DICT_PACKAGE_TO_ACTIVITY
|
||||
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.device.method.utils import (ImageTruncated, PackageNotInstalled, RETRY_TRIES, handle_adb_error,
|
||||
handle_unknown_host_service, possible_reasons, retry_sleep)
|
||||
from module.exception import RequestHumanTakeover
|
||||
from module.logger import logger
|
||||
|
||||
@@ -27,7 +28,7 @@ def retry(func):
|
||||
for _ in range(RETRY_TRIES):
|
||||
try:
|
||||
if callable(init):
|
||||
retry_sleep(_)
|
||||
time.sleep(retry_sleep(_))
|
||||
init()
|
||||
return func(self, *args, **kwargs)
|
||||
# Can't handle
|
||||
@@ -51,6 +52,10 @@ def retry(func):
|
||||
if handle_adb_error(e):
|
||||
def init():
|
||||
self.adb_reconnect()
|
||||
elif handle_unknown_host_service(e):
|
||||
def init():
|
||||
self.adb_start_server()
|
||||
self.adb_reconnect()
|
||||
else:
|
||||
break
|
||||
# RuntimeError: USB device 127.0.0.1:5555 is offline
|
||||
@@ -122,7 +127,7 @@ class Uiautomator2(Connection):
|
||||
if image is None:
|
||||
raise ImageTruncated('Empty image after cv2.imdecode')
|
||||
|
||||
image = cv2.cvtColor(image, cv2.COLOR_BGR2RGB)
|
||||
cv2.cvtColor(image, cv2.COLOR_BGR2RGB, dst=image)
|
||||
if image is None:
|
||||
raise ImageTruncated('Empty image after cv2.cvtColor')
|
||||
|
||||
@@ -220,15 +225,147 @@ class Uiautomator2(Connection):
|
||||
return result['package']
|
||||
|
||||
@retry
|
||||
def app_start_uiautomator2(self, package_name=None):
|
||||
def _app_start_u2_monkey(self, package_name=None, allow_failure=False):
|
||||
"""
|
||||
Args:
|
||||
package_name (str):
|
||||
allow_failure (bool):
|
||||
|
||||
Returns:
|
||||
bool: If success to start
|
||||
|
||||
Raises:
|
||||
PackageNotInstalled:
|
||||
"""
|
||||
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)
|
||||
result = self.u2.shell([
|
||||
'monkey', '-p', package_name, '-c',
|
||||
'android.intent.category.LAUNCHER', '--pct-syskeys', '0', '1'
|
||||
])
|
||||
if 'No activities found' in result.output:
|
||||
# ** 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
|
||||
return False
|
||||
else:
|
||||
# Events injected: 1
|
||||
# ## Network stats: elapsed time=4ms (0ms mobile, 0ms wifi, 4ms not connected)
|
||||
return True
|
||||
|
||||
@retry
|
||||
def _app_start_u2_am(self, package_name=None, activity_name=None, allow_failure=False):
|
||||
"""
|
||||
Args:
|
||||
package_name (str):
|
||||
activity_name (str):
|
||||
allow_failure (bool):
|
||||
|
||||
Returns:
|
||||
bool: If success to start
|
||||
|
||||
Raises:
|
||||
PackageNotInstalled:
|
||||
"""
|
||||
if not package_name:
|
||||
package_name = self.package
|
||||
if not activity_name:
|
||||
try:
|
||||
info = self.u2.app_info(package_name)
|
||||
except u2.BaseError as e:
|
||||
if allow_failure:
|
||||
return False
|
||||
# BaseError('package "111" not found')
|
||||
elif 'not found' in str(e):
|
||||
logger.error(e)
|
||||
raise PackageNotInstalled(package_name)
|
||||
# Unknown error
|
||||
else:
|
||||
raise
|
||||
activity_name = info['mainActivity']
|
||||
|
||||
cmd = ['am', 'start', '-a', 'android.intent.action.MAIN', '-c',
|
||||
'android.intent.category.LAUNCHER', '-n', f'{package_name}/{activity_name}']
|
||||
if self.is_local_network_device and self.is_waydroid:
|
||||
cmd += ['--windowingMode', '4']
|
||||
ret = self.u2.shell(cmd)
|
||||
# Invalid activity
|
||||
# Starting: Intent { act=android.intent.action.MAIN cat=[android.intent.category.LAUNCHER] cmp=... }
|
||||
# Error type 3
|
||||
# Error: Activity class {.../...} does not exist.
|
||||
if 'Error: Activity class' in ret.output:
|
||||
if allow_failure:
|
||||
return False
|
||||
else:
|
||||
logger.error(ret)
|
||||
return False
|
||||
# Already running
|
||||
# Warning: Activity not started, intent has been delivered to currently running top-most instance.
|
||||
if 'Warning: Activity not started' in ret.output:
|
||||
logger.info('App activity is already started')
|
||||
return True
|
||||
# Starting: Intent { act=android.intent.action.MAIN cat=[android.intent.category.LAUNCHER] cmp=com.YoStarEN.AzurLane/com.manjuu.azurlane.MainActivity }
|
||||
# java.lang.SecurityException: Permission Denial: starting Intent { act=android.intent.action.MAIN cat=[android.intent.category.LAUNCHER] flg=0x10000000 cmp=com.YoStarEN.AzurLane/com.manjuu.azurlane.MainActivity } from null (pid=5140, uid=2000) not exported from uid 10064
|
||||
# at android.os.Parcel.readException(Parcel.java:1692)
|
||||
# at android.os.Parcel.readException(Parcel.java:1645)
|
||||
# at android.app.ActivityManagerProxy.startActivityAsUser(ActivityManagerNative.java:3152)
|
||||
# at com.android.commands.am.Am.runStart(Am.java:643)
|
||||
# at com.android.commands.am.Am.onRun(Am.java:394)
|
||||
# at com.android.internal.os.BaseCommand.run(BaseCommand.java:51)
|
||||
# at com.android.commands.am.Am.main(Am.java:124)
|
||||
# at com.android.internal.os.RuntimeInit.nativeFinishInit(Native Method)
|
||||
# at com.android.internal.os.RuntimeInit.main(RuntimeInit.java:290)
|
||||
if 'Permission Denial' in ret.output:
|
||||
if allow_failure:
|
||||
return False
|
||||
else:
|
||||
logger.error(ret)
|
||||
logger.error('Permission Denial while starting app, probably because activity invalid')
|
||||
return False
|
||||
# Success
|
||||
# Starting: Intent...
|
||||
return True
|
||||
|
||||
# No @retry decorator since _app_start_adb_am and _app_start_adb_monkey have @retry already
|
||||
# @retry
|
||||
def app_start_uiautomator2(self, package_name=None, activity_name=None, allow_failure=False):
|
||||
"""
|
||||
Args:
|
||||
package_name (str):
|
||||
If None, to get from config
|
||||
activity_name (str):
|
||||
If None, to get from DICT_PACKAGE_TO_ACTIVITY
|
||||
If still None, launch from monkey
|
||||
If monkey failed, fetch activity name and launch from am
|
||||
allow_failure (bool):
|
||||
True for no PackageNotInstalled raising, just return False
|
||||
|
||||
Returns:
|
||||
bool: If success to start
|
||||
|
||||
Raises:
|
||||
PackageNotInstalled:
|
||||
"""
|
||||
if not package_name:
|
||||
package_name = self.package
|
||||
if not activity_name:
|
||||
activity_name = DICT_PACKAGE_TO_ACTIVITY.get(package_name)
|
||||
|
||||
if activity_name:
|
||||
if self._app_start_u2_am(package_name, activity_name, allow_failure):
|
||||
return True
|
||||
if self._app_start_u2_monkey(package_name, allow_failure):
|
||||
return True
|
||||
if self._app_start_u2_am(package_name, activity_name, allow_failure):
|
||||
return True
|
||||
|
||||
logger.error('app_start_uiautomator2: All trials failed')
|
||||
return False
|
||||
|
||||
@retry
|
||||
def app_stop_uiautomator2(self, package_name=None):
|
||||
@@ -238,12 +375,23 @@ class Uiautomator2(Connection):
|
||||
|
||||
@retry
|
||||
def dump_hierarchy_uiautomator2(self) -> etree._Element:
|
||||
content = self.u2.dump_hierarchy(compressed=True)
|
||||
content = self.u2.dump_hierarchy(compressed=False)
|
||||
hierarchy = etree.fromstring(content.encode('utf-8'))
|
||||
return hierarchy
|
||||
|
||||
def uninstall_uiautomator2(self):
|
||||
logger.info('Removing uiautomator2')
|
||||
for file in [
|
||||
'app-uiautomator.apk',
|
||||
'app-uiautomator-test.apk',
|
||||
'minitouch',
|
||||
'minitouch.so',
|
||||
'atx-agent',
|
||||
]:
|
||||
self.adb_shell(["rm", f"/data/local/tmp/{file}"])
|
||||
|
||||
@retry
|
||||
def resolution_uiautomator2(self) -> t.Tuple[int, int]:
|
||||
def resolution_uiautomator2(self, cal_rotation=True) -> t.Tuple[int, int]:
|
||||
"""
|
||||
Faster u2.window_size(), cause that calls `dumpsys display` twice.
|
||||
|
||||
@@ -252,9 +400,10 @@ class Uiautomator2(Connection):
|
||||
"""
|
||||
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
|
||||
if cal_rotation:
|
||||
rotation = self.get_orientation()
|
||||
if (w > h) != (rotation % 2 == 1):
|
||||
w, h = h, w
|
||||
return w, h
|
||||
|
||||
def resolution_check_uiautomator2(self):
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import os
|
||||
import random
|
||||
import re
|
||||
import socket
|
||||
@@ -5,9 +6,12 @@ import time
|
||||
import typing as t
|
||||
|
||||
import uiautomator2 as u2
|
||||
import uiautomator2cache
|
||||
from adbutils import AdbTimeout
|
||||
from lxml import etree
|
||||
|
||||
from module.device.method.remove_warning import remove_shell_warning
|
||||
|
||||
try:
|
||||
# adbutils 0.x
|
||||
from adbutils import _AdbStreamConnection as AdbConnection
|
||||
@@ -18,9 +22,11 @@ except ImportError:
|
||||
# We expect `screencap | nc 192.168.0.1 20298` instead of `screencap '|' nc 192.168.80.1 20298`
|
||||
import adbutils
|
||||
import subprocess
|
||||
|
||||
adbutils._utils.list2cmdline = subprocess.list2cmdline
|
||||
adbutils._device.list2cmdline = subprocess.list2cmdline
|
||||
|
||||
|
||||
# BaseDevice.shell() is missing a check_okay() call before reading output,
|
||||
# resulting in an `OKAY` prefix in output.
|
||||
def shell(self,
|
||||
@@ -40,6 +46,7 @@ except ImportError:
|
||||
output = c.read_until_close()
|
||||
return output.rstrip() if rstrip else output
|
||||
|
||||
|
||||
adbutils._device.BaseDevice.shell = shell
|
||||
|
||||
from module.base.decorator import cached_property
|
||||
@@ -48,6 +55,57 @@ from module.logger import logger
|
||||
RETRY_TRIES = 5
|
||||
RETRY_DELAY = 3
|
||||
|
||||
# Patch uiautomator2 appdir
|
||||
u2.init.appdir = os.path.dirname(uiautomator2cache.__file__)
|
||||
|
||||
# Patch uiautomator2 logger
|
||||
u2_logger = u2.logger
|
||||
u2_logger.debug = logger.info
|
||||
u2_logger.info = logger.info
|
||||
u2_logger.warning = logger.warning
|
||||
u2_logger.error = logger.error
|
||||
u2_logger.critical = logger.critical
|
||||
|
||||
|
||||
def setup_logger(*args, **kwargs):
|
||||
return u2_logger
|
||||
|
||||
|
||||
u2.setup_logger = setup_logger
|
||||
u2.init.setup_logger = setup_logger
|
||||
|
||||
|
||||
# Patch Initer
|
||||
class PatchedIniter(u2.init.Initer):
|
||||
@property
|
||||
def atx_agent_url(self):
|
||||
files = {
|
||||
'armeabi-v7a': 'atx-agent_{v}_linux_armv7.tar.gz',
|
||||
# 'arm64-v8a': 'atx-agent_{v}_linux_armv7.tar.gz',
|
||||
'arm64-v8a': 'atx-agent_{v}_linux_arm64.tar.gz',
|
||||
'armeabi': 'atx-agent_{v}_linux_armv6.tar.gz',
|
||||
'x86': 'atx-agent_{v}_linux_386.tar.gz',
|
||||
'x86_64': 'atx-agent_{v}_linux_386.tar.gz',
|
||||
}
|
||||
name = None
|
||||
for abi in self.abis:
|
||||
name = files.get(abi)
|
||||
if name:
|
||||
break
|
||||
if not name:
|
||||
raise Exception(
|
||||
"arch(%s) need to be supported yet, please report an issue in github"
|
||||
% self.abis)
|
||||
return u2.init.GITHUB_BASEURL + '/atx-agent/releases/download/%s/%s' % (
|
||||
u2.version.__atx_agent_version__, name.format(v=u2.version.__atx_agent_version__))
|
||||
|
||||
@property
|
||||
def minicap_urls(self):
|
||||
return []
|
||||
|
||||
|
||||
u2.init.Initer = PatchedIniter
|
||||
|
||||
|
||||
def is_port_using(port_num):
|
||||
""" if port is using by others, return True. else return False """
|
||||
@@ -130,16 +188,16 @@ class ImageTruncated(Exception):
|
||||
def retry_sleep(trial):
|
||||
# First trial
|
||||
if trial == 0:
|
||||
pass
|
||||
return 0
|
||||
# Failed once, fast retry
|
||||
elif trial == 1:
|
||||
pass
|
||||
return 0
|
||||
# Failed twice
|
||||
elif trial == 2:
|
||||
time.sleep(1)
|
||||
return 1
|
||||
# Failed more
|
||||
else:
|
||||
time.sleep(RETRY_DELAY)
|
||||
return RETRY_DELAY
|
||||
|
||||
|
||||
def handle_adb_error(e):
|
||||
@@ -182,10 +240,9 @@ def handle_adb_error(e):
|
||||
# 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.
|
||||
elif text == 'rest':
|
||||
# AdbError(rest)
|
||||
# Response telling adbd service has reset, client should reconnect
|
||||
logger.error(e)
|
||||
return True
|
||||
else:
|
||||
@@ -199,25 +256,44 @@ def handle_adb_error(e):
|
||||
return False
|
||||
|
||||
|
||||
def handle_unknown_host_service(e):
|
||||
"""
|
||||
Args:
|
||||
e (Exception):
|
||||
|
||||
Returns:
|
||||
bool: If should retry
|
||||
"""
|
||||
text = str(e)
|
||||
if '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:
|
||||
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
|
||||
tuple[Optional[str], Optional[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:
|
||||
if 5555 <= port <= 5555 + 64:
|
||||
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:
|
||||
if 5554 <= port <= 5554 + 64:
|
||||
return f'127.0.0.1:{port + 1}', f'emulator-{port}'
|
||||
except (ValueError, IndexError):
|
||||
pass
|
||||
@@ -225,59 +301,52 @@ def get_serial_pair(serial):
|
||||
return None, None
|
||||
|
||||
|
||||
def remove_prefix(s, prefix):
|
||||
@t.overload
|
||||
def removeprefix(s: str, prefix: str) -> str: ...
|
||||
|
||||
|
||||
@t.overload
|
||||
def removeprefix(s: bytes, prefix: bytes) -> bytes: ...
|
||||
|
||||
|
||||
@t.overload
|
||||
def removesuffix(s: str, suffix: str) -> str: ...
|
||||
|
||||
|
||||
@t.overload
|
||||
def removesuffix(s: bytes, suffix: bytes) -> bytes: ...
|
||||
|
||||
|
||||
def removeprefix(s, prefix):
|
||||
"""
|
||||
Remove prefix of a string or bytes like `string.removeprefix(prefix)`, which is on Python3.9+
|
||||
Backport `string.removeprefix(prefix)`, which is on Python>=3.9
|
||||
|
||||
Args:
|
||||
s (str, bytes):
|
||||
prefix (str, bytes):
|
||||
s (str | bytes):
|
||||
prefix (str | bytes):
|
||||
|
||||
Returns:
|
||||
str, bytes:
|
||||
str | bytes:
|
||||
"""
|
||||
return s[len(prefix):] if s.startswith(prefix) else s
|
||||
if s.startswith(prefix):
|
||||
return s[len(prefix):]
|
||||
return s
|
||||
|
||||
|
||||
def remove_suffix(s, suffix):
|
||||
def removesuffix(s, suffix):
|
||||
"""
|
||||
Remove suffix of a string or bytes like `string.removesuffix(suffix)`, which is on Python3.9+
|
||||
Backport `string.removesuffix(suffix)`, which is on Python>=3.9
|
||||
|
||||
Args:
|
||||
s (str, bytes):
|
||||
suffix (str, bytes):
|
||||
s (str | bytes):
|
||||
suffix (str | bytes):
|
||||
|
||||
Returns:
|
||||
str, bytes:
|
||||
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
|
||||
# s[:-0] is empty string, so we need to check if suffix is empty
|
||||
if suffix and s.endswith(suffix):
|
||||
return s[:-len(suffix)]
|
||||
return s
|
||||
|
||||
|
||||
@@ -323,7 +392,7 @@ class HierarchyButton:
|
||||
if res:
|
||||
return res[0]
|
||||
else:
|
||||
return 'HierarchyButton'
|
||||
return self.xpath
|
||||
|
||||
@cached_property
|
||||
def count(self):
|
||||
@@ -333,15 +402,30 @@ class HierarchyButton:
|
||||
def exist(self):
|
||||
return self.count == 1
|
||||
|
||||
@cached_property
|
||||
def attrib(self):
|
||||
if self.exist:
|
||||
return self.nodes[0].attrib
|
||||
else:
|
||||
return {}
|
||||
|
||||
@cached_property
|
||||
def area(self):
|
||||
if self.exist:
|
||||
bounds = self.nodes[0].attrib.get("bounds")
|
||||
bounds = self.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 size(self):
|
||||
if self.area is not None:
|
||||
lx, ly, rx, ry = self.area
|
||||
return rx - lx, ry - ly
|
||||
else:
|
||||
return None
|
||||
|
||||
@cached_property
|
||||
def button(self):
|
||||
return self.area
|
||||
@@ -352,9 +436,83 @@ class HierarchyButton:
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
"""
|
||||
Element props
|
||||
"""
|
||||
|
||||
def _get_bool_prop(self, prop: str) -> bool:
|
||||
return self.attrib.get(prop, "").lower() == 'true'
|
||||
|
||||
@cached_property
|
||||
def focused(self):
|
||||
if self.exist:
|
||||
return self.nodes[0].attrib.get("focused").lower() == 'true'
|
||||
else:
|
||||
return False
|
||||
def index(self) -> int:
|
||||
try:
|
||||
return int(self.attrib.get("index", 0))
|
||||
except IndexError:
|
||||
return 0
|
||||
|
||||
@cached_property
|
||||
def text(self) -> str:
|
||||
return self.attrib.get("text", "").strip()
|
||||
|
||||
@cached_property
|
||||
def resourceId(self) -> str:
|
||||
return self.attrib.get("resourceId", "").strip()
|
||||
|
||||
@cached_property
|
||||
def package(self) -> str:
|
||||
return self.attrib.get("resourceId", "").strip()
|
||||
|
||||
@cached_property
|
||||
def description(self) -> str:
|
||||
return self.attrib.get("resourceId", "").strip()
|
||||
|
||||
@cached_property
|
||||
def checkable(self) -> bool:
|
||||
return self._get_bool_prop('checkable')
|
||||
|
||||
@cached_property
|
||||
def clickable(self) -> bool:
|
||||
return self._get_bool_prop('clickable')
|
||||
|
||||
@cached_property
|
||||
def enabled(self) -> bool:
|
||||
return self._get_bool_prop('enabled')
|
||||
|
||||
@cached_property
|
||||
def fucusable(self) -> bool:
|
||||
return self._get_bool_prop('fucusable')
|
||||
|
||||
@cached_property
|
||||
def focused(self) -> bool:
|
||||
return self._get_bool_prop('focused')
|
||||
|
||||
@cached_property
|
||||
def scrollable(self) -> bool:
|
||||
return self._get_bool_prop('scrollable')
|
||||
|
||||
@cached_property
|
||||
def longClickable(self) -> bool:
|
||||
return self._get_bool_prop('longClickable')
|
||||
|
||||
@cached_property
|
||||
def password(self) -> bool:
|
||||
return self._get_bool_prop('password')
|
||||
|
||||
@cached_property
|
||||
def selected(self) -> bool:
|
||||
return self._get_bool_prop('selected')
|
||||
|
||||
|
||||
class AreaButton:
|
||||
def __init__(self, area, name='AREA_BUTTON'):
|
||||
self.area = area
|
||||
self.color = ()
|
||||
self.name = name
|
||||
self.button = area
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
def __bool__(self):
|
||||
# Cannot appear
|
||||
return False
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import re
|
||||
import time
|
||||
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.device.method.utils import (PackageNotInstalled, RETRY_TRIES, handle_adb_error, handle_unknown_host_service,
|
||||
retry_sleep)
|
||||
from module.exception import RequestHumanTakeover
|
||||
from module.logger import logger
|
||||
|
||||
@@ -21,7 +22,7 @@ def retry(func):
|
||||
for _ in range(RETRY_TRIES):
|
||||
try:
|
||||
if callable(init):
|
||||
retry_sleep(_)
|
||||
time.sleep(retry_sleep(_))
|
||||
init()
|
||||
return func(self, *args, **kwargs)
|
||||
# Can't handle
|
||||
@@ -38,6 +39,10 @@ def retry(func):
|
||||
if handle_adb_error(e):
|
||||
def init():
|
||||
self.adb_reconnect()
|
||||
elif handle_unknown_host_service(e):
|
||||
def init():
|
||||
self.adb_start_server()
|
||||
self.adb_reconnect()
|
||||
else:
|
||||
break
|
||||
# Package not installed
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
|
||||
from module.base.decorator import cached_property
|
||||
from module.logger import logger
|
||||
|
||||
"""
|
||||
Importing pkg_resources is so slow, like 0.4 ~ 1.0s, just google it you will find it indeed really slow.
|
||||
Since it was some kind of standard library there is no way to modify it or speed it up.
|
||||
So here's a poor but fast implementation of pkg_resources returning the things in need.
|
||||
|
||||
To patch:
|
||||
```
|
||||
# Patch pkg_resources before importing adbutils and uiautomator2
|
||||
from module.device.pkg_resources import get_distribution
|
||||
# Just avoid being removed by import optimization
|
||||
_ = get_distribution
|
||||
```
|
||||
"""
|
||||
# Inject sys.modules, pretend we have pkg_resources imported
|
||||
try:
|
||||
sys.modules['pkg_resources'] = sys.modules['module.device.pkg_resources']
|
||||
except KeyError:
|
||||
logger.error('Patch pkg_resources failed, patch module does not exists')
|
||||
|
||||
|
||||
def removesuffix(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:
|
||||
"""
|
||||
# s[:-0] is empty string, so we need to check if suffix is empty
|
||||
if suffix and s.endswith(suffix):
|
||||
return s[:-len(suffix)]
|
||||
return s
|
||||
|
||||
|
||||
class FakeDistributionObject:
|
||||
def __init__(self, dist, version):
|
||||
self.dist = dist
|
||||
self.version = version
|
||||
|
||||
def __str__(self):
|
||||
return f'{self.__class__.__name__}({self.dist}={self.version})'
|
||||
|
||||
__repr__ = __str__
|
||||
|
||||
|
||||
class PackageCache:
|
||||
@cached_property
|
||||
def site_packages(self):
|
||||
# Just whatever library to locate the `site-packages` directory
|
||||
import requests
|
||||
path = os.path.abspath(os.path.join(requests.__file__, '../../'))
|
||||
return path
|
||||
|
||||
@cached_property
|
||||
def dict_installed_packages(self):
|
||||
"""
|
||||
Returns:
|
||||
dict: Key: str, package name
|
||||
Value: FakeDistributionObject
|
||||
"""
|
||||
dic = {}
|
||||
for file in os.listdir(self.site_packages):
|
||||
# mxnet_cu101-1.6.0.dist-info
|
||||
# adbutils-0.11.0-py3.7.egg-info
|
||||
res = re.match(r'^([a-zA-Z0-9._]+)-([a-zA-Z0-9._]+)-', file)
|
||||
if res:
|
||||
version = removesuffix(res.group(2), '.dist')
|
||||
# version = res.group(2)
|
||||
obj = FakeDistributionObject(
|
||||
dist=res.group(1),
|
||||
version=version,
|
||||
)
|
||||
dic[obj.dist] = obj
|
||||
|
||||
return dic
|
||||
|
||||
|
||||
PACKAGE_CACHE = PackageCache()
|
||||
|
||||
|
||||
def resource_filename(*args):
|
||||
if args == ("adbutils", "binaries"):
|
||||
path = os.path.abspath(os.path.join(PACKAGE_CACHE.site_packages, *args))
|
||||
return path
|
||||
|
||||
|
||||
def get_distribution(dist):
|
||||
"""Return a current distribution object for a Requirement or string"""
|
||||
if dist == 'adbutils':
|
||||
return PACKAGE_CACHE.dict_installed_packages.get(
|
||||
'adbutils',
|
||||
FakeDistributionObject('adbutils', '0.11.0'),
|
||||
)
|
||||
if dist == 'uiautomator2':
|
||||
return PACKAGE_CACHE.dict_installed_packages.get(
|
||||
'uiautomator2',
|
||||
FakeDistributionObject('uiautomator2', '2.16.17'),
|
||||
)
|
||||
|
||||
|
||||
class DistributionNotFound(Exception):
|
||||
pass
|
||||
@@ -36,6 +36,21 @@ def get_serial_pair(serial):
|
||||
return None, None
|
||||
|
||||
|
||||
def remove_duplicated_path(paths):
|
||||
"""
|
||||
Args:
|
||||
paths (list[str]):
|
||||
|
||||
Returns:
|
||||
list[str]:
|
||||
"""
|
||||
paths = sorted(set(paths))
|
||||
dic = {}
|
||||
for path in paths:
|
||||
dic.setdefault(path.lower(), path)
|
||||
return list(dic.values())
|
||||
|
||||
|
||||
@dataclass
|
||||
class EmulatorInstanceBase:
|
||||
# Serial for adb connection
|
||||
@@ -54,7 +69,7 @@ class EmulatorInstanceBase:
|
||||
Returns:
|
||||
str: Emulator type, such as Emulator.NoxPlayer
|
||||
"""
|
||||
return EmulatorBase.path_to_type(self.path)
|
||||
return self.emulator.type
|
||||
|
||||
@cached_property
|
||||
def emulator(self):
|
||||
@@ -83,17 +98,53 @@ class EmulatorInstanceBase:
|
||||
def MuMuPlayer12_id(self):
|
||||
"""
|
||||
Convert MuMu 12 instance name to instance id.
|
||||
Example name: MuMuPlayer-12.0-3
|
||||
Example ID : 3
|
||||
Example names:
|
||||
MuMuPlayer-12.0-3
|
||||
MuMuPlayerGlobal-12.0-0
|
||||
MuMuPlayer-15.0-0
|
||||
YXArkNights-12.0-1
|
||||
|
||||
Returns:
|
||||
int: Instance ID, or None if this is not a MuMu 12 instance
|
||||
"""
|
||||
res = re.search(r'MuMuPlayer-12.0-(\d+)', self.name)
|
||||
res = re.search(r'MuMuPlayer(?:Global)?-12.0-(\d+)', self.name)
|
||||
if res:
|
||||
return int(res.group(1))
|
||||
else:
|
||||
return None
|
||||
res = re.search(r'MuMuPlayer(?:Global)?-15.0-(\d+)', self.name)
|
||||
if res:
|
||||
return int(res.group(1))
|
||||
res = re.search(r'YXArkNights-12.0-(\d+)', self.name)
|
||||
if res:
|
||||
return int(res.group(1))
|
||||
|
||||
return None
|
||||
|
||||
def mumu_vms_config(self, file):
|
||||
"""
|
||||
Args:
|
||||
file (str): Such as customer_config.json
|
||||
|
||||
Returns:
|
||||
str: Absolute filepath to the file
|
||||
"""
|
||||
return self.emulator.abspath(f'../vms/{self.name}/configs/{file}')
|
||||
|
||||
@cached_property
|
||||
def LDPlayer_id(self):
|
||||
"""
|
||||
Convert LDPlayer instance name to instance id.
|
||||
Example names:
|
||||
leidian0
|
||||
leidian1
|
||||
|
||||
Returns:
|
||||
int: Instance ID, or None if this is not a LDPlayer instance
|
||||
"""
|
||||
res = re.search(r'leidian(\d+)', self.name)
|
||||
if res:
|
||||
return int(res.group(1))
|
||||
|
||||
return None
|
||||
|
||||
|
||||
class EmulatorBase:
|
||||
@@ -109,7 +160,8 @@ class EmulatorBase:
|
||||
LDPlayer3 = 'LDPlayer3'
|
||||
LDPlayer4 = 'LDPlayer4'
|
||||
LDPlayer9 = 'LDPlayer9'
|
||||
LDPlayerFamily = [LDPlayer3, LDPlayer4, LDPlayer9]
|
||||
LDPlayer14 = 'LDPlayer14'
|
||||
LDPlayerFamily = [LDPlayer3, LDPlayer4, LDPlayer9, LDPlayer14]
|
||||
MuMuPlayer = 'MuMuPlayer'
|
||||
MuMuPlayerX = 'MuMuPlayerX'
|
||||
MuMuPlayer12 = 'MuMuPlayer12'
|
||||
@@ -201,6 +253,14 @@ class EmulatorBase:
|
||||
|
||||
|
||||
class EmulatorManagerBase:
|
||||
@staticmethod
|
||||
def iter_running_emulator():
|
||||
"""
|
||||
Yields:
|
||||
str: Path to emulator executables, may contains duplicate values
|
||||
"""
|
||||
return
|
||||
|
||||
@cached_property
|
||||
def all_emulators(self) -> t.List[EmulatorBase]:
|
||||
"""
|
||||
|
||||
@@ -8,8 +8,9 @@ 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
|
||||
from module.device.platform.emulator_base import EmulatorBase, EmulatorInstanceBase, EmulatorManagerBase, \
|
||||
remove_duplicated_path
|
||||
from module.device.platform.utils import cached_property, iter_folder, iter_process
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -56,14 +57,6 @@ def abspath(path):
|
||||
|
||||
|
||||
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):
|
||||
"""
|
||||
@@ -78,7 +71,7 @@ class Emulator(EmulatorBase):
|
||||
def path_to_type(cls, path: str) -> str:
|
||||
"""
|
||||
Args:
|
||||
path: Path to .exe file
|
||||
path: Path to .exe file, case insensitive
|
||||
|
||||
Returns:
|
||||
str: Emulator type, such as Emulator.NoxPlayer
|
||||
@@ -86,46 +79,51 @@ class Emulator(EmulatorBase):
|
||||
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':
|
||||
exe = exe.lower()
|
||||
dir1 = dir1.lower()
|
||||
dir2 = dir2.lower()
|
||||
if exe == 'nox.exe':
|
||||
if dir2 == 'nox':
|
||||
return cls.NoxPlayer
|
||||
elif dir2 == 'Nox64':
|
||||
elif dir2 == 'nox64':
|
||||
return cls.NoxPlayer64
|
||||
else:
|
||||
return cls.NoxPlayer
|
||||
if exe == 'Bluestacks.exe':
|
||||
if dir1 in ['BlueStacks', 'BlueStacks_cn']:
|
||||
if exe == 'bluestacks.exe':
|
||||
if dir1 in ['bluestacks', 'bluestacks_cn']:
|
||||
return cls.BlueStacks4
|
||||
elif dir1 in ['BlueStacks_nxt', 'BlueStacks_nxt_cn']:
|
||||
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']:
|
||||
if exe == 'hd-player.exe':
|
||||
if dir1 in ['bluestacks', 'bluestacks_cn']:
|
||||
return cls.BlueStacks4
|
||||
elif dir1 in ['BlueStacks_nxt', 'BlueStacks_nxt_cn']:
|
||||
elif dir1 in ['bluestacks_nxt', 'bluestacks_nxt_cn']:
|
||||
return cls.BlueStacks5
|
||||
else:
|
||||
return cls.BlueStacks5
|
||||
if exe == 'dnplayer.exe':
|
||||
if dir1 == 'LDPlayer':
|
||||
if dir1 == 'ldplayer':
|
||||
return cls.LDPlayer3
|
||||
elif dir1 == 'LDPlayer4':
|
||||
elif dir1 == 'ldplayer4':
|
||||
return cls.LDPlayer4
|
||||
elif dir1 == 'LDPlayer9':
|
||||
elif dir1 == 'ldplayer9':
|
||||
return cls.LDPlayer9
|
||||
elif dir1 == 'ldplayer14':
|
||||
return cls.LDPlayer14
|
||||
else:
|
||||
return cls.LDPlayer3
|
||||
if exe == 'NemuPlayer.exe':
|
||||
if exe == 'nemuplayer.exe':
|
||||
if dir2 == 'nemu':
|
||||
return cls.MuMuPlayer
|
||||
elif dir2 == 'nemu9':
|
||||
return cls.MuMuPlayerX
|
||||
else:
|
||||
return cls.MuMuPlayer
|
||||
if exe == 'MuMuPlayer.exe':
|
||||
if exe in ['mumuplayer.exe', 'mumunxmain.exe']:
|
||||
return cls.MuMuPlayer12
|
||||
if exe == 'MEmu.exe':
|
||||
if exe == 'memu.exe':
|
||||
return cls.MEmuPlayer
|
||||
|
||||
return ''
|
||||
@@ -151,12 +149,41 @@ class Emulator(EmulatorBase):
|
||||
elif 'NemuMultiPlayer.exe' in exe:
|
||||
yield exe.replace('NemuMultiPlayer.exe', 'NemuPlayer.exe')
|
||||
elif 'MuMuMultiPlayer.exe' in exe:
|
||||
yield exe.replace('MuMuMultiPlayer.exe', 'MuMuManager.exe')
|
||||
yield exe.replace('MuMuMultiPlayer.exe', 'MuMuPlayer.exe')
|
||||
elif 'MuMuManager.exe' in exe:
|
||||
yield exe.replace('MuMuManager.exe', 'MuMuPlayer.exe')
|
||||
elif 'MEmuConsole.exe' in exe:
|
||||
yield exe.replace('MEmuConsole.exe', 'MEmu.exe')
|
||||
else:
|
||||
yield exe
|
||||
|
||||
@staticmethod
|
||||
def single_to_console(exe: str):
|
||||
"""
|
||||
Convert a string that might be a single instance executable to its console.
|
||||
|
||||
Args:
|
||||
exe (str): Path to emulator executable
|
||||
|
||||
Returns:
|
||||
str: Path to emulator console
|
||||
"""
|
||||
if 'MuMuPlayer.exe' in exe:
|
||||
return exe.replace('MuMuPlayer.exe', 'MuMuManager.exe')
|
||||
# MuMuPlayer12 5.0
|
||||
elif 'MuMuNxMain.exe' in exe:
|
||||
return exe.replace('MuMuNxMain.exe', 'MuMuManager.exe')
|
||||
elif 'LDPlayer.exe' in exe:
|
||||
return exe.replace('LDPlayer.exe', 'ldconsole.exe')
|
||||
elif 'dnplayer.exe' in exe:
|
||||
return exe.replace('dnplayer.exe', 'ldconsole.exe')
|
||||
elif 'Bluestacks.exe' in exe:
|
||||
return exe.replace('Bluestacks.exe', 'bsconsole.exe')
|
||||
elif 'MEmu.exe' in exe:
|
||||
return exe.replace('MEmu.exe', 'memuc.exe')
|
||||
else:
|
||||
return exe
|
||||
|
||||
@staticmethod
|
||||
def vbox_file_to_serial(file: str) -> str:
|
||||
"""
|
||||
@@ -277,12 +304,23 @@ class Emulator(EmulatorBase):
|
||||
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)
|
||||
name = os.path.basename(folder)
|
||||
if serial:
|
||||
yield EmulatorInstance(
|
||||
serial=serial,
|
||||
name=os.path.basename(folder),
|
||||
name=name,
|
||||
path=self.path,
|
||||
)
|
||||
# Fix for MuMu12 v4.0.4, default instance of which has no forward record in vbox config
|
||||
else:
|
||||
instance = EmulatorInstance(
|
||||
serial=serial,
|
||||
name=name,
|
||||
path=self.path,
|
||||
)
|
||||
if instance.MuMuPlayer12_id:
|
||||
instance.serial = f'127.0.0.1:{16384 + 32 * instance.MuMuPlayer12_id}'
|
||||
yield instance
|
||||
elif self == Emulator.MEmuPlayer:
|
||||
# ./MemuHyperv VMs/{name}/{name}.memu
|
||||
for folder in self.list_folder('./MemuHyperv VMs', is_dir=True):
|
||||
@@ -324,14 +362,18 @@ class EmulatorManager(EmulatorManagerBase):
|
||||
Get recently executed programs in UserAssist
|
||||
https://github.com/forensicmatt/MonitorUserAssist
|
||||
|
||||
Returns:
|
||||
Yields:
|
||||
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)
|
||||
try:
|
||||
with winreg.OpenKey(winreg.HKEY_CURRENT_USER, path) as reg:
|
||||
folders = list_key(reg)
|
||||
except FileNotFoundError:
|
||||
return
|
||||
|
||||
for folder in folders:
|
||||
try:
|
||||
with winreg.OpenKey(winreg.HKEY_CURRENT_USER, f'{path}\\{folder}\\Count') as reg:
|
||||
@@ -358,8 +400,11 @@ class EmulatorManager(EmulatorManagerBase):
|
||||
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)
|
||||
try:
|
||||
with winreg.OpenKey(winreg.HKEY_CURRENT_USER, path) as reg:
|
||||
rows = list_reg(reg)
|
||||
except FileNotFoundError:
|
||||
return
|
||||
|
||||
regex = re.compile(r'(^.*\.exe)\.')
|
||||
for row in rows:
|
||||
@@ -416,33 +461,52 @@ class EmulatorManager(EmulatorManagerBase):
|
||||
'LDPlayer',
|
||||
'LDPlayer4',
|
||||
'LDPlayer9',
|
||||
'LDPlayer14',
|
||||
'leidian',
|
||||
'leidian4',
|
||||
'leidian9',
|
||||
'leidian14',
|
||||
'Nemu',
|
||||
'Nemu9',
|
||||
'MuMuPlayer-12.0'
|
||||
'MuMuPlayer',
|
||||
'MuMuPlayer-12.0',
|
||||
'MuMu Player 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
|
||||
try:
|
||||
with winreg.OpenKey(winreg.HKEY_LOCAL_MACHINE, path) as reg:
|
||||
software_list = list_key(reg)
|
||||
except FileNotFoundError:
|
||||
continue
|
||||
for software in software_list:
|
||||
if software not in known_emulator_registry_name:
|
||||
continue
|
||||
try:
|
||||
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
|
||||
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
|
||||
|
||||
@staticmethod
|
||||
def iter_running_emulator():
|
||||
"""
|
||||
Yields:
|
||||
str: Path to emulator executables, may contains duplicate values
|
||||
"""
|
||||
for pid, cmdline in iter_process():
|
||||
exe = cmdline[0]
|
||||
if Emulator.is_emulator(exe):
|
||||
yield exe
|
||||
|
||||
@cached_property
|
||||
def all_emulators(self) -> t.List[Emulator]:
|
||||
@@ -463,7 +527,8 @@ class EmulatorManager(EmulatorManagerBase):
|
||||
|
||||
# LDPlayer install path
|
||||
for path in [r'SOFTWARE\leidian\ldplayer',
|
||||
r'SOFTWARE\leidian\ldplayer9']:
|
||||
r'SOFTWARE\leidian\ldplayer9',
|
||||
r'SOFTWARE\leidian\ldplayer14']:
|
||||
ld = self.get_install_dir_from_reg(path, 'InstallDir')
|
||||
if ld:
|
||||
ld = abspath(os.path.join(ld, './dnplayer.exe'))
|
||||
@@ -471,7 +536,7 @@ class EmulatorManager(EmulatorManagerBase):
|
||||
exe.add(ld)
|
||||
|
||||
# Uninstall registry
|
||||
for uninstall in self.iter_uninstall_registry():
|
||||
for uninstall in EmulatorManager.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):
|
||||
@@ -485,9 +550,14 @@ class EmulatorManager(EmulatorManagerBase):
|
||||
if Emulator.is_emulator(file) and os.path.exists(file):
|
||||
exe.add(file)
|
||||
|
||||
# Running
|
||||
for file in EmulatorManager.iter_running_emulator():
|
||||
if os.path.exists(file):
|
||||
exe.add(file)
|
||||
|
||||
# De-redundancy
|
||||
exe = [Emulator(path).path for path in exe if Emulator.is_emulator(path)]
|
||||
exe = sorted(set(exe))
|
||||
exe = [Emulator(path) for path in exe]
|
||||
exe = [Emulator(path) for path in remove_duplicated_path(exe)]
|
||||
return exe
|
||||
|
||||
@cached_property
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
from module.device.env import IS_WINDOWS
|
||||
|
||||
if IS_WINDOWS:
|
||||
from module.device.platform.platform_windows import PlatformWindows as Platform
|
||||
else:
|
||||
from module.device.platform.platform_base import PlatformBase as Platform
|
||||
@@ -1,14 +1,14 @@
|
||||
import sys
|
||||
import typing as t
|
||||
|
||||
import yaml
|
||||
from pydantic import BaseModel, SecretStr
|
||||
from pydantic import BaseModel
|
||||
|
||||
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
|
||||
from module.device.connection import Connection
|
||||
from module.device.method.utils import get_serial_pair
|
||||
from module.device.platform.emulator_base import EmulatorInstanceBase, EmulatorManagerBase, remove_duplicated_path
|
||||
from module.logger import logger
|
||||
|
||||
|
||||
class EmulatorInfo(BaseModel):
|
||||
@@ -21,6 +21,29 @@ class EmulatorInfo(BaseModel):
|
||||
# secret: SecretStr = ''
|
||||
|
||||
|
||||
def serial_to_id(serial: str):
|
||||
"""
|
||||
Predict instance ID from serial
|
||||
E.g.
|
||||
"127.0.0.1:16384" -> 0
|
||||
"127.0.0.1:16416" -> 1
|
||||
Port from 16414 to 16418 -> 1
|
||||
|
||||
Returns:
|
||||
int: instance_id, or None if failed to predict
|
||||
"""
|
||||
try:
|
||||
port = int(serial.split(':')[1])
|
||||
except (IndexError, ValueError):
|
||||
return None
|
||||
index, offset = divmod(port - 16384 + 16, 32)
|
||||
offset -= 16
|
||||
if 0 <= index < 32 and offset in [-2, -1, 0, 1, 2]:
|
||||
return index
|
||||
else:
|
||||
return None
|
||||
|
||||
|
||||
class PlatformBase(Connection, EmulatorManagerBase):
|
||||
"""
|
||||
Base interface of a platform, platform can be various operating system or phone clouds.
|
||||
@@ -48,8 +71,20 @@ class PlatformBase(Connection, EmulatorManagerBase):
|
||||
@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', '')
|
||||
if emulator == 'auto':
|
||||
emulator = ''
|
||||
|
||||
def parse_info(value):
|
||||
if isinstance(value, str):
|
||||
value = value.strip().replace('\n', '')
|
||||
if value in ['None', 'False', 'True']:
|
||||
value = ''
|
||||
return value
|
||||
else:
|
||||
return ''
|
||||
|
||||
name = parse_info(self.config.EmulatorInfo_name)
|
||||
path = parse_info(self.config.EmulatorInfo_path)
|
||||
|
||||
return EmulatorInfo(
|
||||
emulator=emulator,
|
||||
@@ -69,8 +104,14 @@ class PlatformBase(Connection, EmulatorManagerBase):
|
||||
path=data.path,
|
||||
name=data.name,
|
||||
)
|
||||
# Redirect emulator-5554 to 127.0.0.1:5555
|
||||
serial = self.serial
|
||||
port_serial, _ = get_serial_pair(self.serial)
|
||||
if port_serial is not None:
|
||||
serial = port_serial
|
||||
|
||||
instance = self.find_emulator_instance(
|
||||
serial=str(self.config.Emulator_Serial).strip(),
|
||||
serial=serial,
|
||||
name=data.name,
|
||||
path=data.path,
|
||||
emulator=data.emulator,
|
||||
@@ -118,7 +159,7 @@ class PlatformBase(Connection, EmulatorManagerBase):
|
||||
# Search by serial
|
||||
select = instances.select(**search_args)
|
||||
if select.count == 0:
|
||||
logger.warning(f'No emulator instance with {search_args}')
|
||||
logger.warning(f'No emulator instance with {search_args}, serial invalid')
|
||||
return None
|
||||
if select.count == 1:
|
||||
instance = select[0]
|
||||
@@ -126,14 +167,41 @@ class PlatformBase(Connection, EmulatorManagerBase):
|
||||
logger.info(f'Found emulator instance: {instance}')
|
||||
return instance
|
||||
|
||||
# Additional fixup for MuMu12
|
||||
# MuMu12 may have 127.0.0.1:7555 in vbox config but user setting serial=127.0.0.1:16xxx
|
||||
# If that happens, we check if serial pairs with instance_id
|
||||
instance_id = serial_to_id(self.serial)
|
||||
if instance_id is not None:
|
||||
select = instances.select(MuMuPlayer12_id=instance_id)
|
||||
# No logs for if select.count == 1:
|
||||
# because this is just a trial
|
||||
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}, type invalid')
|
||||
search_args.pop('type')
|
||||
elif 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:
|
||||
logger.warning(f'No emulator instances with {search_args}, name invalid')
|
||||
search_args.pop('name')
|
||||
elif select.count == 1:
|
||||
instance = select[0]
|
||||
logger.hr('Emulator instance', level=2)
|
||||
logger.info(f'Found emulator instance: {instance}')
|
||||
@@ -144,22 +212,28 @@ class PlatformBase(Connection, EmulatorManagerBase):
|
||||
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:
|
||||
logger.warning(f'No emulator instances with {search_args}, path invalid')
|
||||
search_args.pop('path')
|
||||
elif 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
|
||||
# Still too many instances, search from running emulators
|
||||
running = remove_duplicated_path(list(self.iter_running_emulator()))
|
||||
logger.info('Running emulators')
|
||||
for exe in running:
|
||||
logger.info(exe)
|
||||
if len(running) == 1:
|
||||
logger.info('Only one running emulator')
|
||||
# Same as searching path
|
||||
search_args['path'] = running[0]
|
||||
select = instances.select(**search_args)
|
||||
if select.count == 0:
|
||||
logger.warning(f'No emulator instances with {search_args}')
|
||||
return None
|
||||
if select.count == 1:
|
||||
logger.warning(f'No emulator instances with {search_args}, path invalid')
|
||||
search_args.pop('path')
|
||||
elif select.count == 1:
|
||||
instance = select[0]
|
||||
logger.hr('Emulator instance', level=2)
|
||||
logger.info(f'Found emulator instance: {instance}')
|
||||
@@ -168,9 +242,3 @@ class PlatformBase(Connection, EmulatorManagerBase):
|
||||
# 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)
|
||||
|
||||
@@ -4,15 +4,14 @@ 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.device.platform.platform_base import PlatformBase
|
||||
from module.device.platform.utils import iter_process
|
||||
from module.logger import logger
|
||||
|
||||
import os
|
||||
|
||||
class EmulatorUnknown(Exception):
|
||||
pass
|
||||
@@ -55,7 +54,9 @@ class PlatformWindows(PlatformBase, EmulatorManager):
|
||||
"""
|
||||
command = command.replace(r"\\", "/").replace("\\", "/").replace('"', '"')
|
||||
logger.info(f'Execute: {command}')
|
||||
return subprocess.Popen(command, close_fds=True) # only work on Windows
|
||||
# `close_fds` only work on Windows
|
||||
# `start_new_session` to avoid emulator getting tree-killed when Alas gets killed
|
||||
return subprocess.Popen(command, close_fds=True, start_new_session=True)
|
||||
|
||||
@classmethod
|
||||
def kill_process_by_regex(cls, regex: str) -> int:
|
||||
@@ -70,12 +71,16 @@ class PlatformWindows(PlatformBase, EmulatorManager):
|
||||
"""
|
||||
count = 0
|
||||
|
||||
for proc in psutil.process_iter():
|
||||
cmdline = DataProcessInfo(proc=proc, pid=proc.pid).cmdline
|
||||
for pid, cmdline in iter_process():
|
||||
cmdline = ' '.join(cmdline)
|
||||
if re.search(regex, cmdline):
|
||||
logger.info(f'Kill emulator: {cmdline}')
|
||||
proc.kill()
|
||||
count += 1
|
||||
try:
|
||||
proc = psutil._psplatform.Process(pid)
|
||||
proc.kill()
|
||||
count += 1
|
||||
except (psutil.AccessDenied, psutil.NoSuchProcess, OSError) as e:
|
||||
logger.error(f'Failed to kill process pid={pid}, {e}')
|
||||
|
||||
return count
|
||||
|
||||
@@ -83,7 +88,7 @@ class PlatformWindows(PlatformBase, EmulatorManager):
|
||||
"""
|
||||
Start a emulator without error handling
|
||||
"""
|
||||
exe = instance.emulator.path
|
||||
exe: str = instance.emulator.path
|
||||
if instance == Emulator.MuMuPlayer:
|
||||
# NemuPlayer.exe
|
||||
self.execute(exe)
|
||||
@@ -91,22 +96,29 @@ class PlatformWindows(PlatformBase, EmulatorManager):
|
||||
# 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}')
|
||||
# MuMuManager.exe api -v 0 launch_player
|
||||
# Launch via MuMuManager instead of MuMuPlayer.exe/MuMuNxMain.exe.
|
||||
# MuMuNxMain.exe is a GUI singleton, if two instances get launched at the same time,
|
||||
# the second launch request is handed over to a MuMuNxMain.exe that is still initializing
|
||||
# and gets silently dropped, while MuMuManager queues requests in backend service.
|
||||
if instance.MuMuPlayer12_id is None:
|
||||
logger.warning(f'Cannot get MuMu instance index from name {instance.name}')
|
||||
self.execute(f'"{Emulator.single_to_console(exe)}" api -v {instance.MuMuPlayer12_id} launch_player')
|
||||
elif instance == Emulator.LDPlayerFamily:
|
||||
# ldconsole.exe launch --index 0
|
||||
self.execute(f'"{Emulator.single_to_console(exe)}" launch --index {instance.LDPlayer_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
|
||||
# HD-Player.exe --instance Pie64
|
||||
self.execute(f'"{exe}" --instance {instance.name}')
|
||||
elif instance == Emulator.BlueStacks4:
|
||||
# BlueStacks\Client\Bluestacks.exe -vmname Android_1
|
||||
# Bluestacks.exe -vmname Android_1
|
||||
self.execute(f'"{exe}" -vmname {instance.name}')
|
||||
elif instance == Emulator.LDPlayer9:
|
||||
directory, filename = os.path.split(exe)
|
||||
new_filename = 'ldconsole.exe'
|
||||
exe = os.path.join(directory, new_filename)
|
||||
self.execute(f'"{exe}" launch --index {instance.name.replace("leidian", "")}')
|
||||
elif instance == Emulator.MEmuPlayer:
|
||||
# MEmu.exe MEmu_0
|
||||
self.execute(f'"{exe}" {instance.name}')
|
||||
else:
|
||||
raise EmulatorUnknown(f'Cannot start an unknown emulator instance: {instance}')
|
||||
|
||||
@@ -114,8 +126,7 @@ class PlatformWindows(PlatformBase, EmulatorManager):
|
||||
"""
|
||||
Stop a emulator without error handling
|
||||
"""
|
||||
logger.hr('Emulator stop', level=2)
|
||||
exe = instance.emulator.path
|
||||
exe: str = instance.emulator.path
|
||||
if instance == Emulator.MuMuPlayer:
|
||||
# MuMu6 does not have multi instance, kill one means kill all
|
||||
# Has 4 processes
|
||||
@@ -145,31 +156,35 @@ class PlatformWindows(PlatformBase, EmulatorManager):
|
||||
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
|
||||
# MuMuManager.exe api -v 1 shutdown_player
|
||||
if instance.MuMuPlayer12_id is None:
|
||||
logger.warning(f'Cannot get MuMu instance index from name {instance.name}')
|
||||
self.execute(f'"{Emulator.single_to_console(exe)}" api -v {instance.MuMuPlayer12_id} shutdown_player')
|
||||
elif instance == Emulator.LDPlayerFamily:
|
||||
# ldconsole.exe quit --index 0
|
||||
self.execute(f'"{Emulator.single_to_console(exe)}" quit --index {instance.LDPlayer_id}')
|
||||
elif instance == Emulator.NoxPlayerFamily:
|
||||
# Nox.exe -clone:Nox_1 -quit
|
||||
self.execute(f'"{exe}" -clone:{instance.name} -quit')
|
||||
elif instance == Emulator.BlueStacks5:
|
||||
self.execute(f'taskkill /fi "WINDOWTITLE eq {instance.name}" /IM "HD-Player.exe" /F')
|
||||
elif instance == Emulator.LDPlayer9:
|
||||
directory, filename = os.path.split(exe)
|
||||
new_filename = 'ldconsole.exe'
|
||||
exe = os.path.join(directory, new_filename)
|
||||
self.execute(f'"{exe}" quit --index {instance.name.replace("leidian", "")}')
|
||||
# BlueStack has 2 processes
|
||||
# C:\Program Files\BlueStacks_nxt_cn\HD-Player.exe --instance Pie64
|
||||
# C:\Program Files\BlueStacks_nxt_cn\BstkSVC.exe -Embedding
|
||||
self.kill_process_by_regex(
|
||||
rf'('
|
||||
rf'HD-Player.exe.*"--instance" "{instance.name}"'
|
||||
rf')'
|
||||
)
|
||||
elif instance == Emulator.BlueStacks4:
|
||||
# E:\Program Files (x86)\BluestacksCN\bsconsole.exe quit --name Android
|
||||
self.execute(f'"{Emulator.single_to_console(exe)}" quit --name {instance.name}')
|
||||
elif instance == Emulator.MEmuPlayer:
|
||||
# F:\Program Files\Microvirt\MEmu\memuc.exe stop -n MEmu_0
|
||||
self.execute(f'"{Emulator.single_to_console(exe)}" stop -n {instance.name}')
|
||||
else:
|
||||
raise EmulatorUnknown(f'Cannot stop an unknown emulator instance: {instance}')
|
||||
|
||||
def _emulator_function_wrapper(self, func):
|
||||
def _emulator_function_wrapper(self, func: callable):
|
||||
"""
|
||||
Args:
|
||||
func (callable): _emulator_start or _emulator_stop
|
||||
@@ -230,7 +245,7 @@ class PlatformWindows(PlatformBase, EmulatorManager):
|
||||
logger.info(f'Found azurlane packages: {m}')
|
||||
|
||||
interval = Timer(0.5).start()
|
||||
timeout = Timer(300).start()
|
||||
timeout = Timer(180).start()
|
||||
new_window = 0
|
||||
while 1:
|
||||
interval.wait()
|
||||
@@ -276,7 +291,7 @@ class PlatformWindows(PlatformBase, EmulatorManager):
|
||||
show_ping(pong)
|
||||
|
||||
# Check azuelane package
|
||||
packages = self.list_azurlane_packages(show_log=False)
|
||||
packages = self.list_known_packages(show_log=False)
|
||||
if len(packages):
|
||||
pass
|
||||
else:
|
||||
@@ -306,9 +321,12 @@ class PlatformWindows(PlatformBase, EmulatorManager):
|
||||
return False
|
||||
# Start
|
||||
if self._emulator_function_wrapper(self._emulator_start):
|
||||
# Success
|
||||
self.emulator_start_watch()
|
||||
return True
|
||||
if self.emulator_start_watch():
|
||||
# Success
|
||||
return True
|
||||
else:
|
||||
# Start command was sent but emulator didn't come online, stop and start again
|
||||
continue
|
||||
else:
|
||||
# Failed to start, stop and start again
|
||||
if self._emulator_function_wrapper(self._emulator_stop):
|
||||
@@ -321,9 +339,23 @@ class PlatformWindows(PlatformBase, EmulatorManager):
|
||||
|
||||
def emulator_stop(self):
|
||||
logger.hr('Emulator stop', level=1)
|
||||
return self._emulator_function_wrapper(self._emulator_stop)
|
||||
for _ in range(3):
|
||||
# Stop
|
||||
if self._emulator_function_wrapper(self._emulator_stop):
|
||||
# Success
|
||||
return True
|
||||
else:
|
||||
# Failed to stop, start and stop again
|
||||
if self._emulator_function_wrapper(self._emulator_start):
|
||||
continue
|
||||
else:
|
||||
return False
|
||||
|
||||
logger.error('Failed to stop emulator 3 times, stopped')
|
||||
return False
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
self = PlatformWindows('alas')
|
||||
self.emulator_start()
|
||||
d = self.emulator_instance
|
||||
print(d)
|
||||
|
||||