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
|
||
|
|
02b8de6d18
|
||
|
|
7a321eae48
|
||
|
|
ad19268e5a
|
||
|
|
8bc170e170
|
||
|
|
1d618d1624
|
||
|
|
9eb3bde649
|
||
|
|
62ef4c733c
|
||
|
|
27dbd31434
|
||
|
|
74ef1a7b4d
|
||
|
|
43282354cd
|
||
|
|
f92d92aec0
|
||
|
|
3a00c47125
|
||
|
|
bca3395cb9
|
||
|
|
766cb78377
|
||
|
|
84acb67061
|
||
|
|
9c07251669
|
||
|
|
19272d6cbe
|
||
|
|
f268cfeda8
|
||
|
|
37b5d4e4d2
|
||
|
|
29bd71ccc8
|
||
|
|
d5f4891d4e
|
||
|
|
9a500b9191
|
||
|
|
180fa6aa7d
|
||
|
|
4325118562
|
@@ -21,6 +21,14 @@ test.py
|
|||||||
test/
|
test/
|
||||||
note.md
|
note.md
|
||||||
MCE/config.json
|
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)
|
# Created by .ignore support plugin (hsz.mobi)
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,10 @@ class ArisuAutoSweeper(AzurLaneAutoScript):
|
|||||||
from tasks.login.login import Login
|
from tasks.login.login import Login
|
||||||
Login(self.config, device=self.device).app_start()
|
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):
|
def goto_main(self):
|
||||||
from tasks.login.login import Login
|
from tasks.login.login import Login
|
||||||
from tasks.base.ui import UI
|
from tasks.base.ui import UI
|
||||||
@@ -56,7 +60,7 @@ class ArisuAutoSweeper(AzurLaneAutoScript):
|
|||||||
|
|
||||||
def momotalk(self):
|
def momotalk(self):
|
||||||
from tasks.momotalk.momotalk import MomoTalk
|
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):
|
def mission(self):
|
||||||
from tasks.mission.mission import Mission
|
from tasks.mission.mission import Mission
|
||||||
@@ -70,6 +74,10 @@ class ArisuAutoSweeper(AzurLaneAutoScript):
|
|||||||
from tasks.item.data_update import DataUpdate
|
from tasks.item.data_update import DataUpdate
|
||||||
DataUpdate(config=self.config, device=self.device).run()
|
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__':
|
if __name__ == '__main__':
|
||||||
aas = ArisuAutoSweeper('aas')
|
aas = ArisuAutoSweeper('aas')
|
||||||
aas.loop()
|
aas.loop()
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 7.1 KiB After Width: | Height: | Size: 6.2 KiB |
|
Before Width: | Height: | Size: 5.5 KiB After Width: | Height: | Size: 4.6 KiB |
|
Before Width: | Height: | Size: 5.0 KiB After Width: | Height: | Size: 5.7 KiB |
|
Before Width: | Height: | Size: 5.0 KiB After Width: | Height: | Size: 5.6 KiB |
|
Before Width: | Height: | Size: 5.6 KiB After Width: | Height: | Size: 4.5 KiB |
|
Before Width: | Height: | Size: 7.1 KiB |
|
Before Width: | Height: | Size: 7.4 KiB |
|
Before Width: | Height: | Size: 9.9 KiB After Width: | Height: | Size: 9.4 KiB |
|
Before Width: | Height: | Size: 7.8 KiB After Width: | Height: | Size: 6.8 KiB |
|
Before Width: | Height: | Size: 6.6 KiB After Width: | Height: | Size: 6.7 KiB |
@@ -315,6 +315,16 @@ pre.rich-traceback-code {
|
|||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.anim-rotate {
|
||||||
|
animation: rotate-keyframes 0.4s linear infinite
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes rotate-keyframes {
|
||||||
|
100% {
|
||||||
|
transform: rotate(360deg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#pywebio-scope-contents {
|
#pywebio-scope-contents {
|
||||||
margin-top: 0;
|
margin-top: 0;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
@@ -377,6 +387,7 @@ pre.rich-traceback-code {
|
|||||||
#pywebio-scope-scheduler-bar,
|
#pywebio-scope-scheduler-bar,
|
||||||
#pywebio-scope-log-bar,
|
#pywebio-scope-log-bar,
|
||||||
#pywebio-scope-log,
|
#pywebio-scope-log,
|
||||||
|
#pywebio-scope-daemon-log-bar,
|
||||||
#pywebio-scope-daemon-overview #pywebio-scope-groups {
|
#pywebio-scope-daemon-overview #pywebio-scope-groups {
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
margin: 0.3125rem;
|
margin: 0.3125rem;
|
||||||
@@ -407,13 +418,13 @@ pre.rich-traceback-code {
|
|||||||
#pywebio-scope-dashboard > i {
|
#pywebio-scope-dashboard > i {
|
||||||
flex-grow: 1;
|
flex-grow: 1;
|
||||||
align-self: flex-end;
|
align-self: flex-end;
|
||||||
width: 10rem;
|
width: 9.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
[id^="pywebio-scope-dashboard-row-"] {
|
[id^="pywebio-scope-dashboard-row-"] {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-grow: 1;
|
flex-grow: 1;
|
||||||
width: 10rem;
|
width: 9.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dashboard-icon {
|
.dashboard-icon {
|
||||||
@@ -435,16 +446,26 @@ pre.rich-traceback-code {
|
|||||||
overflow-wrap: break-word;
|
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 {
|
[id^="pywebio-scope-dashboard-row-"] p {
|
||||||
margin-bottom: 0;
|
margin-bottom: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
[id^="pywebio-scope-dashboard-value-"] {
|
[id^="pywebio-scope-dashboard-value-"] {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: flex-end;
|
align-items: baseline;
|
||||||
height: 1.5rem;
|
height: 1.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[id^="pywebio-scope-arg_stored-stored-value-"] p {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
#pywebio-scope-log {
|
#pywebio-scope-log {
|
||||||
line-height: 1.2;
|
line-height: 1.2;
|
||||||
@@ -549,19 +570,19 @@ pre.rich-traceback-code {
|
|||||||
padding-right: .25rem;
|
padding-right: .25rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
*[style*="--loading-grow--"] {
|
*[style*="--loading-grow--"] > div {
|
||||||
width: 1.5rem;
|
width: 1.5rem;
|
||||||
height: 1.5rem;
|
height: 1.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
*[style*="--loading-border--"] {
|
*[style*="--loading-border--"] > div {
|
||||||
width: 1.5rem;
|
width: 1.5rem;
|
||||||
height: 1.5rem;
|
height: 1.5rem;
|
||||||
border: .2em solid currentColor;
|
border: .2em solid currentColor;
|
||||||
border-right-color: transparent;
|
border-right-color: transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
*[style*="--loading-border-fill--"] {
|
*[style*="--loading-border-fill--"] > div {
|
||||||
width: 1.5rem;
|
width: 1.5rem;
|
||||||
height: 1.5rem;
|
height: 1.5rem;
|
||||||
border: .2em solid currentColor;
|
border: .2em solid currentColor;
|
||||||
|
|||||||
@@ -63,18 +63,18 @@ select {
|
|||||||
background-image: url("data:image/png;base64,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");
|
background-image: url("data:image/png;base64,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");
|
||||||
}
|
}
|
||||||
|
|
||||||
.state > select {
|
select.state {
|
||||||
border-bottom: 0;
|
border-bottom: 0;
|
||||||
background-image: none;
|
background-image: none;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.state-bold > select {
|
select.state-bold {
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
color: #7a77bb;
|
color: #7a77bb;
|
||||||
}
|
}
|
||||||
|
|
||||||
.state-light > select {
|
select.state-light {
|
||||||
color: #777777;
|
color: #777777;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -133,12 +133,13 @@ pre.rich-traceback-code {
|
|||||||
color: #c9d1d9;
|
color: #c9d1d9;
|
||||||
}
|
}
|
||||||
|
|
||||||
#pywebio-scope-scheduler-bar,
|
|
||||||
#pywebio-scope-log-bar,
|
|
||||||
#pywebio-scope-log,
|
|
||||||
#pywebio-scope-running,
|
#pywebio-scope-running,
|
||||||
#pywebio-scope-pending,
|
#pywebio-scope-pending,
|
||||||
#pywebio-scope-waiting,
|
#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 {
|
#pywebio-scope-daemon-overview #pywebio-scope-groups {
|
||||||
background-color: #2f3136;
|
background-color: #2f3136;
|
||||||
border: 1px solid #21262d;
|
border: 1px solid #21262d;
|
||||||
|
|||||||
@@ -64,18 +64,18 @@ select {
|
|||||||
background-image: url("data:image/png;base64,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");
|
background-image: url("data:image/png;base64,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");
|
||||||
}
|
}
|
||||||
|
|
||||||
.state > select {
|
select.state {
|
||||||
border-bottom: 0;
|
border-bottom: 0;
|
||||||
background-image: none;
|
background-image: none;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.state-bold > select {
|
select.state-bold {
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
color: #7a77bb;
|
color: #7a77bb;
|
||||||
}
|
}
|
||||||
|
|
||||||
.state-light > select {
|
select.state-light {
|
||||||
color: #777777;
|
color: #777777;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -133,12 +133,13 @@ pre.rich-traceback-code {
|
|||||||
border: 1px solid lightgrey;
|
border: 1px solid lightgrey;
|
||||||
}
|
}
|
||||||
|
|
||||||
#pywebio-scope-scheduler-bar,
|
|
||||||
#pywebio-scope-log-bar,
|
|
||||||
#pywebio-scope-log,
|
|
||||||
#pywebio-scope-running,
|
#pywebio-scope-running,
|
||||||
#pywebio-scope-pending,
|
#pywebio-scope-pending,
|
||||||
#pywebio-scope-waiting,
|
#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 {
|
#pywebio-scope-daemon-overview #pywebio-scope-groups {
|
||||||
background-color: white;
|
background-color: white;
|
||||||
border: 1px solid lightgrey;
|
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.8 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 |
|
Before Width: | Height: | Size: 5.6 KiB After Width: | Height: | Size: 5.1 KiB |
|
Before Width: | Height: | Size: 5.0 KiB After Width: | Height: | Size: 5.7 KiB |
|
Before Width: | Height: | Size: 5.0 KiB After Width: | Height: | Size: 5.6 KiB |
|
Before Width: | Height: | Size: 5.8 KiB After Width: | Height: | Size: 4.8 KiB |
|
Before Width: | Height: | Size: 7.2 KiB |
|
Before Width: | Height: | Size: 7.6 KiB |
|
Before Width: | Height: | Size: 10 KiB After Width: | Height: | Size: 9.7 KiB |
|
Before Width: | Height: | Size: 5.3 KiB After Width: | Height: | Size: 5.2 KiB |
|
Before Width: | Height: | Size: 7.0 KiB After Width: | Height: | Size: 6.7 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: 8.5 KiB After Width: | Height: | Size: 7.1 KiB |
|
Before Width: | Height: | Size: 6.1 KiB After Width: | Height: | Size: 5.8 KiB |
|
Before Width: | Height: | Size: 7.0 KiB After Width: | Height: | Size: 7.2 KiB |
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 6.6 KiB After Width: | Height: | Size: 6.0 KiB |
|
Before Width: | Height: | Size: 6.2 KiB After Width: | Height: | Size: 4.7 KiB |
|
Before Width: | Height: | Size: 5.0 KiB After Width: | Height: | Size: 5.7 KiB |
|
Before Width: | Height: | Size: 5.0 KiB After Width: | Height: | Size: 5.6 KiB |
|
Before Width: | Height: | Size: 6.2 KiB After Width: | Height: | Size: 4.6 KiB |
|
Before Width: | Height: | Size: 8.6 KiB |
|
Before Width: | Height: | Size: 8.9 KiB |
|
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 10 KiB |
|
Before Width: | Height: | Size: 8.6 KiB After Width: | Height: | Size: 7.5 KiB |
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 10 KiB After Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 10 KiB After Width: | Height: | Size: 7.8 KiB |
|
Before Width: | Height: | Size: 7.2 KiB After Width: | Height: | Size: 6.7 KiB |
@@ -157,6 +157,14 @@ Deploy:
|
|||||||
# '["alas"]' specified "alas" config
|
# '["alas"]' specified "alas" config
|
||||||
# '["alas","alas2"]' specified "alas" "alas2" configs
|
# '["alas","alas2"]' specified "alas" "alas2" configs
|
||||||
Run: null
|
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
|
# To update app.asar
|
||||||
# [In most cases] true
|
# [In most cases] true
|
||||||
AppAsarUpdate: true
|
AppAsarUpdate: true
|
||||||
|
|||||||
@@ -157,6 +157,14 @@ Deploy:
|
|||||||
# '["alas"]' specified "alas" config
|
# '["alas"]' specified "alas" config
|
||||||
# '["alas","alas2"]' specified "alas" "alas2" configs
|
# '["alas","alas2"]' specified "alas" "alas2" configs
|
||||||
Run: null
|
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
|
# To update app.asar
|
||||||
# [In most cases] true
|
# [In most cases] true
|
||||||
AppAsarUpdate: true
|
AppAsarUpdate: true
|
||||||
|
|||||||
@@ -250,11 +250,8 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"Momotalk": {
|
"Momotalk": {
|
||||||
"Scheduler": {
|
"Momotalk": {
|
||||||
"Enable": false,
|
"Enable": true
|
||||||
"NextRun": "2020-01-01 00:00:00",
|
|
||||||
"Command": "Momotalk",
|
|
||||||
"ServerUpdate": "04:00"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,10 +1,10 @@
|
|||||||
import os
|
import os
|
||||||
import time
|
import time
|
||||||
import typing as t
|
from typing import Iterable
|
||||||
|
|
||||||
from deploy.Windows.config import DeployConfig
|
from deploy.Windows.config import DeployConfig
|
||||||
from deploy.Windows.logger import Progress, logger
|
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):
|
class AlasManager(DeployConfig):
|
||||||
@@ -19,43 +19,45 @@ class AlasManager(DeployConfig):
|
|||||||
def self_pid(self):
|
def self_pid(self):
|
||||||
return os.getpid()
|
return os.getpid()
|
||||||
|
|
||||||
def list_process(self) -> t.List[DataProcessInfo]:
|
def list_process(self) -> "list[tuple[int, list[str]]]":
|
||||||
logger.info('List process')
|
logger.info('List process')
|
||||||
process = list(iter_process())
|
process_data = list(iter_process())
|
||||||
logger.info(f'Found {len(process)} processes')
|
logger.info(f'Found {len(process_data)} processes')
|
||||||
return process
|
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:
|
Args:
|
||||||
names (str, list[str]): process name, such as 'alas.exe'
|
names (str, list[str]): process name, such as 'alas.exe'
|
||||||
in_alas (bool): If the output process must in Alas
|
in_alas (bool): If the output process must in Alas
|
||||||
|
|
||||||
Yields:
|
Yields:
|
||||||
DataProcessInfo:
|
pid:
|
||||||
"""
|
"""
|
||||||
if not isinstance(names, list):
|
if not isinstance(names, list):
|
||||||
names = [names]
|
names = [names]
|
||||||
try:
|
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:
|
if in_alas:
|
||||||
cmdline = proc.cmdline.replace(r"\\", "/").replace("\\", "/")
|
exe = exe.replace(r"\\", "/").replace("\\", "/")
|
||||||
for folder in self.alas_folder:
|
for folder in self.alas_folder:
|
||||||
if folder in cmdline:
|
if folder in exe:
|
||||||
yield proc
|
yield pid
|
||||||
else:
|
else:
|
||||||
yield proc
|
yield pid
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.info(str(e))
|
logger.info(str(e))
|
||||||
return False
|
return False
|
||||||
|
|
||||||
def kill_process(self, process: DataProcessInfo):
|
def kill_process(self, pid: int):
|
||||||
self.execute(f'taskkill /f /t /pid {process.pid}', allow_failure=True, output=False)
|
self.execute(f'taskkill /f /t /pid {pid}', allow_failure=True, output=False)
|
||||||
|
|
||||||
def alas_kill(self):
|
def alas_kill(self):
|
||||||
for _ in range(10):
|
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 copy
|
||||||
import os
|
import os
|
||||||
import subprocess
|
import subprocess
|
||||||
|
import sys
|
||||||
from typing import Optional, Union
|
from typing import Optional, Union
|
||||||
|
|
||||||
from deploy.Windows.logger import logger
|
from deploy.Windows.logger import logger
|
||||||
@@ -56,6 +57,8 @@ class ConfigModel:
|
|||||||
# Webui
|
# Webui
|
||||||
WebuiHost: str = "0.0.0.0"
|
WebuiHost: str = "0.0.0.0"
|
||||||
WebuiPort: int = 23467
|
WebuiPort: int = 23467
|
||||||
|
WebuiSSLKey: Optional[str] = None
|
||||||
|
WebuiSSLCert: Optional[str] = None
|
||||||
Language: str = "en-US"
|
Language: str = "en-US"
|
||||||
Theme: str = "default"
|
Theme: str = "default"
|
||||||
DpiScaling: bool = True
|
DpiScaling: bool = True
|
||||||
@@ -77,42 +80,52 @@ class DeployConfig(ConfigModel):
|
|||||||
self.config_template = {}
|
self.config_template = {}
|
||||||
self.read()
|
self.read()
|
||||||
|
|
||||||
self.set_repo()
|
|
||||||
|
|
||||||
self.write()
|
self.write()
|
||||||
self.show_config()
|
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):
|
def show_config(self):
|
||||||
logger.hr("Show deploy config", 1)
|
logger.hr("Show deploy config", 1)
|
||||||
for k, v in self.config.items():
|
for k, v in self.config.items():
|
||||||
if k in ("Password", "SSHUser"):
|
if k in ("Password", "SSHUser"):
|
||||||
continue
|
continue
|
||||||
if self.config_template[k] == v:
|
if self.config_template.get(k) == v:
|
||||||
continue
|
continue
|
||||||
logger.info(f"{k}: {v}")
|
logger.info(f"{k}: {v}")
|
||||||
|
|
||||||
logger.info(f"Rest of the configs are the same as default")
|
logger.info(f"Rest of the configs are the same as default")
|
||||||
|
|
||||||
def read(self):
|
def read(self):
|
||||||
|
"""
|
||||||
|
Read and update deploy config, copy `self.configs` to properties.
|
||||||
|
"""
|
||||||
self.config = poor_yaml_read(DEPLOY_TEMPLATE)
|
self.config = poor_yaml_read(DEPLOY_TEMPLATE)
|
||||||
self.config_template = copy.deepcopy(self.config)
|
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():
|
for key, value in self.config.items():
|
||||||
if hasattr(self, key):
|
if hasattr(self, key):
|
||||||
super().__setattr__(key, value)
|
super().__setattr__(key, value)
|
||||||
|
|
||||||
|
self.config_redirect()
|
||||||
|
|
||||||
|
if self.config != origin:
|
||||||
|
self.write()
|
||||||
|
|
||||||
def write(self):
|
def write(self):
|
||||||
poor_yaml_write(self.config, self.file)
|
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):
|
def filepath(self, path):
|
||||||
"""
|
"""
|
||||||
Args:
|
Args:
|
||||||
@@ -144,7 +157,7 @@ class DeployConfig(ConfigModel):
|
|||||||
if os.path.exists(exe):
|
if os.path.exists(exe):
|
||||||
return 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'
|
return 'adb'
|
||||||
|
|
||||||
@cached_property
|
@cached_property
|
||||||
@@ -153,12 +166,21 @@ class DeployConfig(ConfigModel):
|
|||||||
if os.path.exists(exe):
|
if os.path.exists(exe):
|
||||||
return 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'
|
return 'git'
|
||||||
|
|
||||||
@cached_property
|
@cached_property
|
||||||
def python(self) -> str:
|
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
|
@cached_property
|
||||||
def requirements_file(self) -> str:
|
def requirements_file(self) -> str:
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import asyncio
|
|||||||
import filecmp
|
import filecmp
|
||||||
import os
|
import os
|
||||||
import shutil
|
import shutil
|
||||||
|
import sys
|
||||||
import typing as t
|
import typing as t
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
|
|
||||||
@@ -9,7 +10,8 @@ from deploy.Windows.alas import AlasManager
|
|||||||
from deploy.Windows.logger import logger
|
from deploy.Windows.logger import logger
|
||||||
from deploy.Windows.utils import cached_property
|
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
|
@dataclass
|
||||||
@@ -140,10 +142,13 @@ class EmulatorManager(AlasManager):
|
|||||||
for adb in replace:
|
for adb in replace:
|
||||||
logger.info(f'Replacing {adb}')
|
logger.info(f'Replacing {adb}')
|
||||||
bak = self.adb_path_to_backup(adb, new_backup=True)
|
bak = self.adb_path_to_backup(adb, new_backup=True)
|
||||||
logger.info(f'{adb} -----> {bak}')
|
try:
|
||||||
shutil.move(adb, bak)
|
logger.info(f'{adb} -----> {bak}')
|
||||||
logger.info(f'{self.adb} -----> {adb}')
|
shutil.move(adb, bak)
|
||||||
shutil.copy(self.adb, adb)
|
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):
|
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
|
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.
|
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')
|
appdir = os.path.join(os.path.expanduser('~'), '.uiautomator2')
|
||||||
to:
|
to:
|
||||||
appdir = os.path.join(__file__, '../../uiautomator2cache')
|
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:
|
for url in self.minicap_urls:
|
||||||
self.push_url(url)
|
self.push_url(url)
|
||||||
to:
|
to:
|
||||||
for url in []:
|
for url in []:
|
||||||
self.push_url(url)
|
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'
|
init_file = './toolkit/Lib/site-packages/uiautomator2/init.py'
|
||||||
cache_dir = './toolkit/Lib/site-packages/uiautomator2cache/cache'
|
cache_dir = './toolkit/Lib/site-packages/uiautomator2cache/cache'
|
||||||
@@ -92,6 +109,17 @@ def patch_uiautomator2():
|
|||||||
else:
|
else:
|
||||||
logger.info(f'{init_file} minicap_urls no need to patch')
|
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
|
# Patch appdir
|
||||||
if os.path.exists(cache_dir):
|
if os.path.exists(cache_dir):
|
||||||
res = re.search(r'appdir ?=(.*)\n', content)
|
res = re.search(r'appdir ?=(.*)\n', content)
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ from dataclasses import dataclass
|
|||||||
from urllib.parse import urlparse
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
from deploy.Windows.config import DeployConfig
|
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
|
from deploy.Windows.utils import cached_property
|
||||||
|
|
||||||
|
|
||||||
@@ -46,9 +46,15 @@ class PipManager(DeployConfig):
|
|||||||
return f'"{self.python}" -m pip'
|
return f'"{self.python}" -m pip'
|
||||||
|
|
||||||
@cached_property
|
@cached_property
|
||||||
def python_site_packages(self):
|
def python_site_packages(self) -> str:
|
||||||
return os.path.abspath(os.path.join(self.python, '../Lib/site-packages')) \
|
import site
|
||||||
.replace(r"\\", "/").replace("\\", "/")
|
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
|
@cached_property
|
||||||
def set_installed_dependency(self) -> t.Set[DataDependency]:
|
def set_installed_dependency(self) -> t.Set[DataDependency]:
|
||||||
|
|||||||
@@ -157,6 +157,14 @@ Deploy:
|
|||||||
# '["alas"]' specified "alas" config
|
# '["alas"]' specified "alas" config
|
||||||
# '["alas","alas2"]' specified "alas" "alas2" configs
|
# '["alas","alas2"]' specified "alas" "alas2" configs
|
||||||
Run: null
|
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
|
# To update app.asar
|
||||||
# [In most cases] true
|
# [In most cases] true
|
||||||
AppAsarUpdate: true
|
AppAsarUpdate: true
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
from dataclasses import dataclass
|
|
||||||
from typing import Callable, Generic, Iterable, TypeVar
|
from typing import Callable, Generic, Iterable, TypeVar
|
||||||
|
|
||||||
|
from deploy.Windows.atomic import atomic_read_text, atomic_write
|
||||||
|
|
||||||
T = TypeVar("T")
|
T = TypeVar("T")
|
||||||
|
|
||||||
DEPLOY_CONFIG = './config/deploy.yaml'
|
DEPLOY_CONFIG = './config/deploy.yaml'
|
||||||
@@ -64,29 +65,26 @@ def poor_yaml_read(file):
|
|||||||
Returns:
|
Returns:
|
||||||
dict:
|
dict:
|
||||||
"""
|
"""
|
||||||
if not os.path.exists(file):
|
content = atomic_read_text(file)
|
||||||
return {}
|
|
||||||
|
|
||||||
data = {}
|
data = {}
|
||||||
regex = re.compile(r'^(.*?):(.*?)$')
|
regex = re.compile(r'^(.*?):(.*?)$')
|
||||||
with open(file, 'r', encoding='utf-8') as f:
|
for line in content.splitlines():
|
||||||
for line in f.readlines():
|
line = line.strip('\n\r\t ').replace('\\', '/')
|
||||||
line = line.strip('\n\r\t ').replace('\\', '/')
|
if line.startswith('#'):
|
||||||
if line.startswith('#'):
|
continue
|
||||||
continue
|
result = re.match(regex, line)
|
||||||
result = re.match(regex, line)
|
if result:
|
||||||
if result:
|
k, v = result.group(1), result.group(2).strip('\n\r\t\' ')
|
||||||
k, v = result.group(1), result.group(2).strip('\n\r\t\' ')
|
if v:
|
||||||
if v:
|
if v.lower() == 'null':
|
||||||
if v.lower() == 'null':
|
v = None
|
||||||
v = None
|
elif v.lower() == 'false':
|
||||||
elif v.lower() == 'false':
|
v = False
|
||||||
v = False
|
elif v.lower() == 'true':
|
||||||
elif v.lower() == 'true':
|
v = True
|
||||||
v = True
|
elif v.isdigit():
|
||||||
elif v.isdigit():
|
v = int(v)
|
||||||
v = int(v)
|
data[k] = v
|
||||||
data[k] = v
|
|
||||||
|
|
||||||
return data
|
return data
|
||||||
|
|
||||||
@@ -98,8 +96,8 @@ def poor_yaml_write(data, file, template_file=DEPLOY_TEMPLATE):
|
|||||||
file (str):
|
file (str):
|
||||||
template_file (str):
|
template_file (str):
|
||||||
"""
|
"""
|
||||||
with open(template_file, 'r', encoding='utf-8') as f:
|
text = atomic_read_text(template_file)
|
||||||
text = f.read().replace('\\', '/')
|
text = text.replace('\\', '/')
|
||||||
|
|
||||||
for key, value in data.items():
|
for key, value in data.items():
|
||||||
if value is None:
|
if value is None:
|
||||||
@@ -110,38 +108,15 @@ def poor_yaml_write(data, file, template_file=DEPLOY_TEMPLATE):
|
|||||||
value = "false"
|
value = "false"
|
||||||
text = re.sub(f'{key}:.*?\n', f'{key}: {value}\n', text)
|
text = re.sub(f'{key}:.*?\n', f'{key}: {value}\n', text)
|
||||||
|
|
||||||
with open(file, 'w', encoding='utf-8', newline='') as f:
|
atomic_write(file, text)
|
||||||
f.write(text)
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
def iter_process() -> "Iterable[tuple[int, list[str]]]":
|
||||||
class DataProcessInfo:
|
"""
|
||||||
proc: object # psutil.Process or psutil._pswindows.Process
|
Yields:
|
||||||
pid: int
|
int: pid
|
||||||
|
list[str]: cmdline, and it's guaranteed to have at least one element
|
||||||
@cached_property
|
"""
|
||||||
def name(self):
|
|
||||||
name = self.proc.name()
|
|
||||||
return name
|
|
||||||
|
|
||||||
@cached_property
|
|
||||||
def cmdline(self):
|
|
||||||
try:
|
|
||||||
cmdline = self.proc.cmdline()
|
|
||||||
except:
|
|
||||||
# psutil.AccessDenied
|
|
||||||
cmdline = []
|
|
||||||
cmdline = ' '.join(cmdline).replace(r'\\', '/').replace('\\', '/')
|
|
||||||
return cmdline
|
|
||||||
|
|
||||||
def __str__(self):
|
|
||||||
# Don't print `proc`, it will take some time to get process properties
|
|
||||||
return f'DataProcessInfo(name="{self.name}", pid={self.pid}, cmdline="{self.cmdline}")'
|
|
||||||
|
|
||||||
__repr__ = __str__
|
|
||||||
|
|
||||||
|
|
||||||
def iter_process() -> Iterable[DataProcessInfo]:
|
|
||||||
try:
|
try:
|
||||||
import psutil
|
import psutil
|
||||||
except ModuleNotFoundError:
|
except ModuleNotFoundError:
|
||||||
@@ -151,16 +126,48 @@ def iter_process() -> Iterable[DataProcessInfo]:
|
|||||||
# Since this is a one-time-usage, we access psutil._psplatform.Process directly
|
# Since this is a one-time-usage, we access psutil._psplatform.Process directly
|
||||||
# to bypass the call of psutil.Process.is_running().
|
# to bypass the call of psutil.Process.is_running().
|
||||||
# This only costs about 0.017s.
|
# 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():
|
for pid in psutil.pids():
|
||||||
proc = psutil._psplatform.Process(pid)
|
proc = psutil._psplatform.Process(pid)
|
||||||
yield DataProcessInfo(
|
try:
|
||||||
proc=proc,
|
cmdline = proc.cmdline()
|
||||||
pid=proc.pid,
|
except (psutil.AccessDenied, psutil.NoSuchProcess, OSError):
|
||||||
)
|
continue
|
||||||
else:
|
|
||||||
# This will cost about 0.45s, even `attr` is given.
|
# Validate cmdline
|
||||||
for proc in psutil.process_iter():
|
if not cmdline:
|
||||||
yield DataProcessInfo(
|
continue
|
||||||
proc=proc,
|
try:
|
||||||
pid=proc.pid,
|
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):
|
class Installer(GitManager, PipManager, AdbManager, AppManager, AlasManager):
|
||||||
def install(self):
|
def install(self):
|
||||||
|
from deploy.Windows.atomic import atomic_failure_cleanup
|
||||||
|
atomic_failure_cleanup('./config')
|
||||||
try:
|
try:
|
||||||
self.git_install()
|
self.git_install()
|
||||||
self.alas_kill()
|
self.alas_kill()
|
||||||
|
|||||||
@@ -3,20 +3,61 @@ import re
|
|||||||
import typing as t
|
import typing as t
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
import cv2
|
||||||
import numpy as np
|
import numpy as np
|
||||||
from tqdm import tqdm
|
from tqdm import tqdm
|
||||||
|
|
||||||
from module.base.code_generator import CodeGenerator
|
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.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.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
|
from module.logger import logger
|
||||||
|
|
||||||
SHARE_SERVER = 'share'
|
SHARE_SERVER = 'share'
|
||||||
ASSET_SERVER = [SHARE_SERVER] + VALID_LANG
|
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:
|
class AssetsImage:
|
||||||
REGEX_ASSETS = re.compile(
|
REGEX_ASSETS = re.compile(
|
||||||
f'^{AzurLaneConfig.ASSETS_FOLDER}/'
|
f'^{AzurLaneConfig.ASSETS_FOLDER}/'
|
||||||
@@ -24,7 +65,7 @@ class AssetsImage:
|
|||||||
f'(?P<module>[a-zA-Z0-9_/]+?)/'
|
f'(?P<module>[a-zA-Z0-9_/]+?)/'
|
||||||
f'(?P<assets>\w+)'
|
f'(?P<assets>\w+)'
|
||||||
f'(?P<frame>\.\d+)?'
|
f'(?P<frame>\.\d+)?'
|
||||||
f'(?P<attr>\.AREA|\.SEARCH|\.COLOR|\.BUTTON)?'
|
f'(?P<attr>\.AREA|\.SEARCH|\.COLOR|\.BUTTON|\.GRID)?'
|
||||||
f'\.png$'
|
f'\.png$'
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -46,6 +87,7 @@ class AssetsImage:
|
|||||||
self.assets = ''
|
self.assets = ''
|
||||||
self.frame = 1
|
self.frame = 1
|
||||||
self.attr = ''
|
self.attr = ''
|
||||||
|
self.posi = None
|
||||||
|
|
||||||
if res:
|
if res:
|
||||||
self.valid = True
|
self.valid = True
|
||||||
@@ -66,6 +108,7 @@ class AssetsImage:
|
|||||||
|
|
||||||
self.bbox: t.Tuple = ()
|
self.bbox: t.Tuple = ()
|
||||||
self.mean: t.Tuple = ()
|
self.mean: t.Tuple = ()
|
||||||
|
self.grids = {}
|
||||||
|
|
||||||
def parse(self):
|
def parse(self):
|
||||||
image = load_image(self.file)
|
image = load_image(self.file)
|
||||||
@@ -79,6 +122,10 @@ class AssetsImage:
|
|||||||
mean = tuple(np.rint(mean).astype(int))
|
mean = tuple(np.rint(mean).astype(int))
|
||||||
self.bbox = bbox
|
self.bbox = bbox
|
||||||
self.mean = mean
|
self.mean = mean
|
||||||
|
|
||||||
|
if self.attr == 'GRID':
|
||||||
|
self.grids = parse_grid(image)
|
||||||
|
|
||||||
return bbox, mean
|
return bbox, mean
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
@@ -87,13 +134,40 @@ class AssetsImage:
|
|||||||
else:
|
else:
|
||||||
return f'AssetsImage(file={self.file}, valid={self.valid})'
|
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():
|
def iter_images():
|
||||||
for server in ASSET_SERVER:
|
for server in ASSET_SERVER:
|
||||||
for path, folders, files in os.walk(os.path.join(AzurLaneConfig.ASSETS_FOLDER, server)):
|
for path, folders, files in os.walk(os.path.join(AzurLaneConfig.ASSETS_FOLDER, server)):
|
||||||
for file in files:
|
for file in files:
|
||||||
file = os.path.join(path, file).replace('\\', '/')
|
if not file.startswith('.'):
|
||||||
yield AssetsImage(file)
|
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
|
@dataclass
|
||||||
@@ -103,11 +177,17 @@ class DataAssets:
|
|||||||
server: str
|
server: str
|
||||||
frame: int
|
frame: int
|
||||||
file: str = ''
|
file: str = ''
|
||||||
|
posi = None
|
||||||
area: t.Tuple[int, int, int, int] = ()
|
area: t.Tuple[int, int, int, int] = ()
|
||||||
search: t.Tuple[int, int, int, int] = ()
|
search: t.Tuple[int, int, int, int] = ()
|
||||||
color: t.Tuple[int, int, int] = ()
|
color: t.Tuple[int, int, int] = ()
|
||||||
button: t.Tuple[int, 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
|
@staticmethod
|
||||||
def area_to_search(area):
|
def area_to_search(area):
|
||||||
area = area_pad(area, pad=-20)
|
area = area_pad(area, pad=-20)
|
||||||
@@ -120,7 +200,6 @@ class DataAssets:
|
|||||||
Product DataAssets from AssetsImage with attr=""
|
Product DataAssets from AssetsImage with attr=""
|
||||||
"""
|
"""
|
||||||
data = cls(module=image.module, assets=image.assets, server=image.server, frame=image.frame, file=image.file)
|
data = cls(module=image.module, assets=image.assets, server=image.server, frame=image.frame, file=image.file)
|
||||||
data.load_image(image)
|
|
||||||
return data
|
return data
|
||||||
|
|
||||||
def load_image(self, image: AssetsImage):
|
def load_image(self, image: AssetsImage):
|
||||||
@@ -129,14 +208,21 @@ class DataAssets:
|
|||||||
self.area = image.bbox
|
self.area = image.bbox
|
||||||
self.color = image.mean
|
self.color = image.mean
|
||||||
self.button = image.bbox
|
self.button = image.bbox
|
||||||
|
self.posi = image.posi
|
||||||
elif image.attr == 'AREA':
|
elif image.attr == 'AREA':
|
||||||
self.area = image.bbox
|
self.area = image.bbox
|
||||||
|
self.has_raw_area = True
|
||||||
elif image.attr == 'SEARCH':
|
elif image.attr == 'SEARCH':
|
||||||
self.search = image.bbox
|
self.search = image.bbox
|
||||||
|
self.has_raw_search = True
|
||||||
elif image.attr == 'COLOR':
|
elif image.attr == 'COLOR':
|
||||||
self.color = image.mean
|
self.color = image.mean
|
||||||
|
self.has_raw_color = True
|
||||||
elif image.attr == 'BUTTON':
|
elif image.attr == 'BUTTON':
|
||||||
self.button = image.bbox
|
self.button = image.bbox
|
||||||
|
self.has_raw_button = True
|
||||||
|
elif image.attr == 'GRID':
|
||||||
|
pass
|
||||||
else:
|
else:
|
||||||
logger.warning(f'Trying to load an image with unknown attribute: {image}')
|
logger.warning(f'Trying to load an image with unknown attribute: {image}')
|
||||||
|
|
||||||
@@ -151,17 +237,22 @@ def iter_assets():
|
|||||||
for image in tqdm(images):
|
for image in tqdm(images):
|
||||||
image.parse()
|
image.parse()
|
||||||
|
|
||||||
|
images += list(iter_grids(images))
|
||||||
|
|
||||||
# Validate images
|
# Validate images
|
||||||
images = SelectedGrids(images).select(valid=True)
|
images = SelectedGrids(images).select(valid=True)
|
||||||
images.create_index('module', 'assets', 'server', 'frame', 'attr')
|
images.create_index('module', 'assets', 'server', 'frame', 'attr')
|
||||||
for image in images.filter(lambda x: bool(x.attr)):
|
for image in images.filter(lambda x: bool(x.attr)):
|
||||||
image: AssetsImage = image
|
image: AssetsImage = image
|
||||||
if not images.indexed_select(image.module, image.assets, image.server, image.frame, ''):
|
if image.is_GRID:
|
||||||
logger.warning(f'Attribute assets has no parent assets: {image.file}')
|
pass
|
||||||
image.valid = False
|
else:
|
||||||
if not images.indexed_select(image.module, image.assets, image.server, 1, ''):
|
if not images.indexed_select(image.module, image.assets, image.server, image.frame, ''):
|
||||||
logger.warning(f'Attribute assets has no first frame: {image.file}')
|
logger.warning(f'Attribute assets has no parent assets: {image.file}')
|
||||||
image.valid = False
|
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:
|
if image.attr == 'SEARCH' and image.frame > 1:
|
||||||
logger.warning(f'Attribute SEARCH with frame > 1 is not allowed: {image.file}')
|
logger.warning(f'Attribute SEARCH with frame > 1 is not allowed: {image.file}')
|
||||||
image.valid = False
|
image.valid = False
|
||||||
@@ -170,31 +261,34 @@ def iter_assets():
|
|||||||
# Convert to DataAssets
|
# Convert to DataAssets
|
||||||
data = {}
|
data = {}
|
||||||
for image in images:
|
for image in images:
|
||||||
if image.attr == '':
|
if image.is_base:
|
||||||
row = DataAssets.product(image)
|
row = DataAssets.product(image)
|
||||||
row.load_image(image)
|
row.load_image(image)
|
||||||
deep_set(data, keys=[image.module, image.assets, image.server, image.frame], value=row)
|
deep_set(data, keys=[image.module, image.assets, image.server, image.frame], value=row)
|
||||||
# Load attribute images
|
# Load attribute images
|
||||||
for image in 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 = deep_get(data, keys=[image.module, image.assets, image.server, image.frame])
|
||||||
row.load_image(image)
|
row.load_image(image)
|
||||||
# Set `search`
|
# Set `search`
|
||||||
for path, frames in deep_iter(data, depth=3):
|
for path, frames in deep_iter(data, depth=3):
|
||||||
print(path, frames)
|
# print(path, frames)
|
||||||
# If `search` attribute is set in the first frame, apply to all
|
for frame in frames.values():
|
||||||
first = frames[1]
|
# Generate `search` from `area`
|
||||||
if first.search:
|
if not frame.has_raw_search:
|
||||||
for frame in frames.values():
|
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
|
frame.search = first.search
|
||||||
else:
|
if not frame.has_raw_color and first.has_raw_color:
|
||||||
for frame in frames.values():
|
frame.color = first.color
|
||||||
if frame.search:
|
if not frame.has_raw_button and first.has_raw_button:
|
||||||
# Follow frame specific `search`
|
frame.button = first.button
|
||||||
pass
|
|
||||||
else:
|
|
||||||
# Generate `search` from `area`
|
|
||||||
frame.search = DataAssets.area_to_search(frame.area)
|
|
||||||
|
|
||||||
return data
|
return data
|
||||||
|
|
||||||
@@ -240,6 +334,8 @@ def generate_code():
|
|||||||
gen.ObjectAttr(key='search', value=frame.search)
|
gen.ObjectAttr(key='search', value=frame.search)
|
||||||
gen.ObjectAttr(key='color', value=frame.color)
|
gen.ObjectAttr(key='color', value=frame.color)
|
||||||
gen.ObjectAttr(key='button', value=frame.button)
|
gen.ObjectAttr(key='button', value=frame.button)
|
||||||
|
if frame.posi is not None:
|
||||||
|
gen.ObjectAttr(key='posi', value=frame.posi)
|
||||||
elif len(frames) == 1:
|
elif len(frames) == 1:
|
||||||
frame = frames[0]
|
frame = frames[0]
|
||||||
with gen.ObjectAttr(key=server, value=gen.Object(object_class='Button')):
|
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='search', value=frame.search)
|
||||||
gen.ObjectAttr(key='color', value=frame.color)
|
gen.ObjectAttr(key='color', value=frame.color)
|
||||||
gen.ObjectAttr(key='button', value=frame.button)
|
gen.ObjectAttr(key='button', value=frame.button)
|
||||||
|
if frame.posi is not None:
|
||||||
|
gen.ObjectAttr(key='posi', value=frame.posi)
|
||||||
else:
|
else:
|
||||||
gen.ObjectAttr(key=server, value=None)
|
gen.ObjectAttr(key=server, value=None)
|
||||||
gen.write(os.path.join(output, f'assets_{module.replace("/", "_")}.py'))
|
gen.write(os.path.join(output, f'assets_{module.replace("/", "_")}.py'))
|
||||||
|
|||||||
@@ -2,8 +2,8 @@ import os
|
|||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
from PIL import Image
|
from PIL import Image
|
||||||
|
|
||||||
from pynput import keyboard
|
from pynput import keyboard
|
||||||
|
|
||||||
from module.config.config import AzurLaneConfig
|
from module.config.config import AzurLaneConfig
|
||||||
from module.config.utils import alas_instance
|
from module.config.utils import alas_instance
|
||||||
from module.device.connection import Connection, ConnectionAttr
|
from module.device.connection import Connection, ConnectionAttr
|
||||||
@@ -50,12 +50,12 @@ def handle_sensitive_info(image):
|
|||||||
|
|
||||||
_ = EmptyConnection()
|
_ = EmptyConnection()
|
||||||
name = input(
|
name = input(
|
||||||
'输入alas配置文件名称,或者模拟器serial,或者模拟器端口号: (默认输入 "alas"):\n'
|
'输入aas配置文件名称,或者模拟器serial,或者模拟器端口号: (默认输入 "aas"):\n'
|
||||||
'例如:"alas", "127.0.0.1:16384", "7555"\n'
|
'例如:"aas", "127.0.0.1:16384", "7555"\n'
|
||||||
)
|
)
|
||||||
name = name.strip().strip('"').strip()
|
name = name.strip().strip('"').strip()
|
||||||
if not name:
|
if not name:
|
||||||
name = 'alas'
|
name = 'aas'
|
||||||
if name.isdigit():
|
if name.isdigit():
|
||||||
name = f'127.0.0.1:{name}'
|
name = f'127.0.0.1:{name}'
|
||||||
if name in alas_instance():
|
if name in alas_instance():
|
||||||
|
|||||||
@@ -40,6 +40,12 @@ def func(ev: threading.Event):
|
|||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--electron", action="store_true", help="Runs by electron client."
|
"--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(
|
parser.add_argument(
|
||||||
"--run",
|
"--run",
|
||||||
nargs="+",
|
nargs="+",
|
||||||
@@ -50,11 +56,15 @@ def func(ev: threading.Event):
|
|||||||
|
|
||||||
host = args.host or State.deploy_config.WebuiHost or "0.0.0.0"
|
host = args.host or State.deploy_config.WebuiHost or "0.0.0.0"
|
||||||
port = args.port or int(State.deploy_config.WebuiPort) or 23467
|
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
|
State.electron = args.electron
|
||||||
|
|
||||||
logger.hr("Launcher config")
|
logger.hr("Launcher config")
|
||||||
logger.attr("Host", host)
|
logger.attr("Host", host)
|
||||||
logger.attr("Port", port)
|
logger.attr("Port", port)
|
||||||
|
logger.attr("SSL", ssl)
|
||||||
logger.attr("Electron", args.electron)
|
logger.attr("Electron", args.electron)
|
||||||
logger.attr("Reload", ev is not None)
|
logger.attr("Reload", ev is not None)
|
||||||
|
|
||||||
@@ -64,7 +74,16 @@ def func(ev: threading.Event):
|
|||||||
from module.logger.logger import console_hdlr
|
from module.logger.logger import console_hdlr
|
||||||
logger.removeHandler(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__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ def run_print_test():
|
|||||||
run()
|
run()
|
||||||
|
|
||||||
|
|
||||||
def run_set(modify=t.List[str]) -> t.Dict[str, str]:
|
def run_set(modify: t.List[str]) -> t.Dict[str, str]:
|
||||||
data = {}
|
data = {}
|
||||||
for kv in modify:
|
for kv in modify:
|
||||||
if "=" in kv:
|
if "=" in kv:
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import os
|
import platform
|
||||||
import re
|
import subprocess
|
||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
@@ -7,16 +7,14 @@ from datetime import datetime, timedelta
|
|||||||
import inflection
|
import inflection
|
||||||
from cached_property import cached_property
|
from cached_property import cached_property
|
||||||
|
|
||||||
|
from MCE.custom_widgets.ctkmessagebox import CTkMessagebox
|
||||||
from module.base.decorator import del_cached_property
|
from module.base.decorator import del_cached_property
|
||||||
from module.config.config import AzurLaneConfig, TaskEnd
|
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.exception import *
|
||||||
from module.logger import logger
|
from module.logger import logger, save_error_log
|
||||||
from module.notify import handle_notify
|
from module.notify import handle_notify
|
||||||
|
|
||||||
from MCE.custom_widgets.ctkmessagebox import CTkMessagebox
|
|
||||||
import subprocess
|
|
||||||
import platform
|
|
||||||
|
|
||||||
class AzurLaneAutoScript:
|
class AzurLaneAutoScript:
|
||||||
stop_event: threading.Event = None
|
stop_event: threading.Event = None
|
||||||
@@ -66,9 +64,22 @@ class AzurLaneAutoScript:
|
|||||||
logger.exception(e)
|
logger.exception(e)
|
||||||
exit(1)
|
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):
|
def run(self, command):
|
||||||
try:
|
try:
|
||||||
self.device.screenshot()
|
self.device.screenshot()
|
||||||
|
self.device.screenshot_tracking.clear()
|
||||||
self.__getattribute__(command)()
|
self.__getattribute__(command)()
|
||||||
return True
|
return True
|
||||||
except TaskEnd:
|
except TaskEnd:
|
||||||
@@ -76,61 +87,68 @@ class AzurLaneAutoScript:
|
|||||||
except GameNotRunningError as e:
|
except GameNotRunningError as e:
|
||||||
logger.warning(e)
|
logger.warning(e)
|
||||||
self.config.task_call('Restart')
|
self.config.task_call('Restart')
|
||||||
return True
|
return False
|
||||||
except (GameStuckError, GameTooManyClickError) as e:
|
except (GameStuckError, GameTooManyClickError) as e:
|
||||||
logger.error(e)
|
logger.error(e)
|
||||||
self.save_error_log()
|
self.save_error_log()
|
||||||
logger.warning(f'Game stuck, {self.device.package} will be restarted in 10 seconds')
|
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.config.task_call('Restart')
|
||||||
self.device.sleep(10)
|
self.device.sleep(10)
|
||||||
return False
|
return False
|
||||||
except GameBugError as e:
|
except GameBugError as e:
|
||||||
logger.warning(e)
|
logger.warning(e)
|
||||||
self.save_error_log()
|
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')
|
logger.warning(f'Restarting {self.device.package} to fix it')
|
||||||
self.config.task_call('Restart')
|
self.config.task_call('Restart')
|
||||||
self.device.sleep(10)
|
self.device.sleep(10)
|
||||||
return False
|
return False
|
||||||
except GamePageUnknownError:
|
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()
|
self.checker.check_now()
|
||||||
if self.checker.is_available():
|
if self.checker.is_available():
|
||||||
logger.critical('Game page unknown')
|
logger.critical('Game page unknown')
|
||||||
self.save_error_log()
|
self.save_error_log()
|
||||||
handle_notify(
|
handle_notify(
|
||||||
self.config.Error_OnePushConfig,
|
self.config.Error_OnePushConfig,
|
||||||
title=f"Alas <{self.config_name}> crashed",
|
title=f"AAS <{self.config_name}> crashed",
|
||||||
content=f"<{self.config_name}> GamePageUnknownError",
|
content=f"<{self.config_name}> GamePageUnknownError",
|
||||||
)
|
)
|
||||||
exit(1)
|
exit(1)
|
||||||
else:
|
else:
|
||||||
self.checker.wait_until_available()
|
self.checker.wait_until_available()
|
||||||
return False
|
return False
|
||||||
|
except HandledError as e:
|
||||||
|
logger.error(e)
|
||||||
|
return False
|
||||||
except ScriptError as e:
|
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')
|
logger.critical('This is likely to be a mistake of developers, but sometimes just random issues')
|
||||||
|
self.save_error_log()
|
||||||
handle_notify(
|
handle_notify(
|
||||||
self.config.Error_OnePushConfig,
|
self.config.Error_OnePushConfig,
|
||||||
title=f"Alas <{self.config_name}> crashed",
|
title=f"AAS <{self.config_name}> crashed",
|
||||||
content=f"<{self.config_name}> ScriptError",
|
content=f"<{self.config_name}> ScriptError",
|
||||||
)
|
)
|
||||||
exit(1)
|
exit(1)
|
||||||
except RequestHumanTakeover:
|
except RequestHumanTakeover:
|
||||||
logger.critical('Request human takeover')
|
logger.critical('Request human takeover')
|
||||||
|
self.error_postprocess()
|
||||||
handle_notify(
|
handle_notify(
|
||||||
self.config.Error_OnePushConfig,
|
self.config.Error_OnePushConfig,
|
||||||
title=f"Alas <{self.config_name}> crashed",
|
title=f"AAS <{self.config_name}> crashed",
|
||||||
content=f"<{self.config_name}> RequestHumanTakeover",
|
content=f"<{self.config_name}> RequestHumanTakeover",
|
||||||
)
|
)
|
||||||
exit(1)
|
exit(1)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.exception(e)
|
logger.exception(e)
|
||||||
|
self.error_postprocess()
|
||||||
self.save_error_log()
|
self.save_error_log()
|
||||||
handle_notify(
|
handle_notify(
|
||||||
self.config.Error_OnePushConfig,
|
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",
|
content=f"<{self.config_name}> Exception occured",
|
||||||
)
|
)
|
||||||
exit(1)
|
exit(1)
|
||||||
@@ -140,29 +158,13 @@ class AzurLaneAutoScript:
|
|||||||
Save last 60 screenshots in ./log/error/<timestamp>
|
Save last 60 screenshots in ./log/error/<timestamp>
|
||||||
Save logs to ./log/error/<timestamp>/log.txt
|
Save logs to ./log/error/<timestamp>/log.txt
|
||||||
"""
|
"""
|
||||||
from module.base.utils import save_image
|
save_error_log(config=self.config, device=self.device)
|
||||||
from module.handler.sensitive_info import (handle_sensitive_image, handle_sensitive_logs)
|
|
||||||
if self.config.Error_SaveError:
|
def error_postprocess(self):
|
||||||
if not os.path.exists('./log/error'):
|
"""
|
||||||
os.mkdir('./log/error')
|
Do something when error occurred
|
||||||
folder = f'./log/error/{int(time.time() * 1000)}'
|
"""
|
||||||
logger.warning(f'Saving error: {folder}')
|
pass
|
||||||
os.mkdir(folder)
|
|
||||||
for data in self.device.screenshot_deque:
|
|
||||||
image_time = datetime.strftime(data['time'], '%Y-%m-%d_%H-%M-%S-%f')
|
|
||||||
image = handle_sensitive_image(data['image'])
|
|
||||||
save_image(image, f'{folder}/{image_time}.png')
|
|
||||||
with open(logger.log_file, 'r', encoding='utf-8') as f:
|
|
||||||
lines = f.readlines()
|
|
||||||
start = 0
|
|
||||||
for index, line in enumerate(lines):
|
|
||||||
line = line.strip(' \r\t\n')
|
|
||||||
if re.match('^═{15,}$', line):
|
|
||||||
start = index
|
|
||||||
lines = lines[start - 2:]
|
|
||||||
lines = handle_sensitive_logs(lines)
|
|
||||||
with open(f'{folder}/log.txt', 'w', encoding='utf-8') as f:
|
|
||||||
f.writelines(lines)
|
|
||||||
|
|
||||||
def wait_until(self, future):
|
def wait_until(self, future):
|
||||||
"""
|
"""
|
||||||
@@ -210,13 +212,16 @@ class AzurLaneAutoScript:
|
|||||||
method = self.config.Optimization_WhenTaskQueueEmpty
|
method = self.config.Optimization_WhenTaskQueueEmpty
|
||||||
if method == 'close_game':
|
if method == 'close_game':
|
||||||
logger.info('Close game during wait')
|
logger.info('Close game during wait')
|
||||||
self.device.app_stop()
|
self.run('stop')
|
||||||
release_resources()
|
release_resources()
|
||||||
self.device.release_during_wait()
|
self.device.release_during_wait()
|
||||||
if not self.wait_until(task.next_run):
|
if not self.wait_until(task.next_run):
|
||||||
del_cached_property(self, 'config')
|
del_cached_property(self, 'config')
|
||||||
continue
|
continue
|
||||||
self.run('start')
|
if task.command != 'Restart':
|
||||||
|
self.config.task_call('Restart')
|
||||||
|
del_cached_property(self, 'config')
|
||||||
|
continue
|
||||||
elif method == 'goto_main':
|
elif method == 'goto_main':
|
||||||
logger.info('Goto main page during wait')
|
logger.info('Goto main page during wait')
|
||||||
self.run('goto_main')
|
self.run('goto_main')
|
||||||
@@ -232,6 +237,31 @@ class AzurLaneAutoScript:
|
|||||||
if not self.wait_until(task.next_run):
|
if not self.wait_until(task.next_run):
|
||||||
del_cached_property(self, 'config')
|
del_cached_property(self, 'config')
|
||||||
continue
|
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':
|
elif method == 'exit_aas':
|
||||||
if abs(task.next_run - datetime.now()) >= timedelta(minutes=2): # ensure tactical challenge is fully ran
|
if abs(task.next_run - datetime.now()) >= timedelta(minutes=2): # ensure tactical challenge is fully ran
|
||||||
self.config.Optimization_WhenTaskQueueEmpty = 'goto_main'
|
self.config.Optimization_WhenTaskQueueEmpty = 'goto_main'
|
||||||
@@ -293,7 +323,7 @@ class AzurLaneAutoScript:
|
|||||||
if self.stop_event is not None:
|
if self.stop_event is not None:
|
||||||
if self.stop_event.is_set():
|
if self.stop_event.is_set():
|
||||||
logger.info("Update event detected")
|
logger.info("Update event detected")
|
||||||
logger.info(f"Alas [{self.config_name}] exited.")
|
logger.info(f"AAS [{self.config_name}] exited.")
|
||||||
break
|
break
|
||||||
# Check game server maintenance
|
# Check game server maintenance
|
||||||
self.checker.wait_until_available()
|
self.checker.wait_until_available()
|
||||||
@@ -309,6 +339,7 @@ class AzurLaneAutoScript:
|
|||||||
task = self.get_next_task()
|
task = self.get_next_task()
|
||||||
# Init device and change server
|
# Init device and change server
|
||||||
_ = self.device
|
_ = self.device
|
||||||
|
self.device.config = self.config
|
||||||
# Skip first restart
|
# Skip first restart
|
||||||
if self.is_first_task and task == 'Restart':
|
if self.is_first_task and task == 'Restart':
|
||||||
logger.info('Skip task `Restart` at scheduler start')
|
logger.info('Skip task `Restart` at scheduler start')
|
||||||
@@ -338,7 +369,7 @@ class AzurLaneAutoScript:
|
|||||||
logger.critical('Request human takeover')
|
logger.critical('Request human takeover')
|
||||||
handle_notify(
|
handle_notify(
|
||||||
self.config.Error_OnePushConfig,
|
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.",
|
content=f"<{self.config_name}> RequestHumanTakeover\nTask `{task}` failed 3 or more times.",
|
||||||
)
|
)
|
||||||
exit(1)
|
exit(1)
|
||||||
|
|||||||
@@ -4,7 +4,9 @@ from module.base.timer import Timer
|
|||||||
from module.base.utils import *
|
from module.base.utils import *
|
||||||
from module.config.config import AzurLaneConfig
|
from module.config.config import AzurLaneConfig
|
||||||
from module.device.device import Device
|
from module.device.device import Device
|
||||||
|
from module.device.method.utils import HierarchyButton
|
||||||
from module.logger import logger
|
from module.logger import logger
|
||||||
|
from module.webui.setting import cached_class_property
|
||||||
|
|
||||||
|
|
||||||
class ModuleBase:
|
class ModuleBase:
|
||||||
@@ -26,6 +28,8 @@ class ModuleBase:
|
|||||||
"""
|
"""
|
||||||
if isinstance(config, AzurLaneConfig):
|
if isinstance(config, AzurLaneConfig):
|
||||||
self.config = config
|
self.config = config
|
||||||
|
if task is not None:
|
||||||
|
self.config.init_task(task)
|
||||||
elif isinstance(config, str):
|
elif isinstance(config, str):
|
||||||
self.config = AzurLaneConfig(config, task=task)
|
self.config = AzurLaneConfig(config, task=task)
|
||||||
else:
|
else:
|
||||||
@@ -45,6 +49,111 @@ class ModuleBase:
|
|||||||
|
|
||||||
self.interval_timer = {}
|
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):
|
def match_template(self, button, interval=0, similarity=0.85):
|
||||||
"""
|
"""
|
||||||
Args:
|
Args:
|
||||||
@@ -75,6 +184,36 @@ class ModuleBase:
|
|||||||
|
|
||||||
return appear
|
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):
|
def match_color(self, button, interval=0, threshold=10):
|
||||||
"""
|
"""
|
||||||
Args:
|
Args:
|
||||||
@@ -120,9 +259,56 @@ class ModuleBase:
|
|||||||
|
|
||||||
return appear
|
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):
|
def appear_then_click(self, button, interval=5, similarity=0.85):
|
||||||
|
button = self.xpath(button)
|
||||||
appear = self.appear(button, interval=interval, similarity=similarity)
|
appear = self.appear(button, interval=interval, similarity=similarity)
|
||||||
if appear:
|
if appear:
|
||||||
self.device.click(button)
|
self.device.click(button)
|
||||||
@@ -210,7 +396,7 @@ class ModuleBase:
|
|||||||
Returns:
|
Returns:
|
||||||
Button: Or None if nothing matched.
|
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]
|
points = np.array(np.where(image > color_threshold)).T[:, ::-1]
|
||||||
if points.shape[0] < encourage ** 2:
|
if points.shape[0] < encourage ** 2:
|
||||||
# Not having enough pixels to match
|
# Not having enough pixels to match
|
||||||
@@ -219,7 +405,26 @@ class ModuleBase:
|
|||||||
point = fit_points(points, mod=image_size(image), encourage=encourage)
|
point = fit_points(points, mod=image_size(image), encourage=encourage)
|
||||||
point = ensure_int(point + area[:2])
|
point = ensure_int(point + area[:2])
|
||||||
button_area = area_offset((-encourage, -encourage, encourage, encourage), offset=point)
|
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):
|
def interval_reset(self, button, interval=5):
|
||||||
if isinstance(button, (list, tuple)):
|
if isinstance(button, (list, tuple)):
|
||||||
@@ -228,10 +433,7 @@ class ModuleBase:
|
|||||||
return
|
return
|
||||||
|
|
||||||
if button is not None:
|
if button is not None:
|
||||||
if button.name in self.interval_timer:
|
self.get_interval_timer(button, interval=interval).reset()
|
||||||
self.interval_timer[button.name].reset()
|
|
||||||
else:
|
|
||||||
self.interval_timer[button.name] = Timer(interval).reset()
|
|
||||||
|
|
||||||
def interval_clear(self, button, interval=5):
|
def interval_clear(self, button, interval=5):
|
||||||
if isinstance(button, (list, tuple)):
|
if isinstance(button, (list, tuple)):
|
||||||
@@ -240,19 +442,10 @@ class ModuleBase:
|
|||||||
return
|
return
|
||||||
|
|
||||||
if button is not None:
|
if button is not None:
|
||||||
if button.name in self.interval_timer:
|
self.get_interval_timer(button, interval=interval).clear()
|
||||||
self.interval_timer[button.name].clear()
|
|
||||||
else:
|
|
||||||
self.interval_timer[button.name] = Timer(interval).clear()
|
|
||||||
|
|
||||||
def interval_is_reached(self, button, interval=5):
|
def interval_is_reached(self, button, interval=5):
|
||||||
if button.name in self.interval_timer:
|
return self.get_interval_timer(button, interval=interval, renew=True).reached()
|
||||||
if self.interval_timer[button.name].limit != interval:
|
|
||||||
self.interval_timer[button.name] = Timer(interval)
|
|
||||||
else:
|
|
||||||
self.interval_timer[button.name] = Timer(interval)
|
|
||||||
|
|
||||||
return self.interval_timer[button.name].reached()
|
|
||||||
|
|
||||||
_image_file = ''
|
_image_file = ''
|
||||||
|
|
||||||
@@ -282,3 +475,31 @@ class ModuleBase:
|
|||||||
"""
|
"""
|
||||||
server_.set_lang(lang)
|
server_.set_lang(lang)
|
||||||
logger.attr('Lang', self.config.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):
|
class Button(Resource):
|
||||||
def __init__(self, file, area, search, color, button):
|
def __init__(self, file, area, search, color, button, posi=None):
|
||||||
"""
|
"""
|
||||||
Args:
|
Args:
|
||||||
file: Filepath to an assets
|
file: Filepath to an assets
|
||||||
@@ -20,6 +20,7 @@ class Button(Resource):
|
|||||||
self.search: t.Tuple[int, int, int, int] = search
|
self.search: t.Tuple[int, int, int, int] = search
|
||||||
self.color: t.Tuple[int, int, int] = color
|
self.color: t.Tuple[int, int, int] = color
|
||||||
self._button: t.Tuple[int, int, int, int] = button
|
self._button: t.Tuple[int, int, int, int] = button
|
||||||
|
self.posi: t.Optional[t.Tuple[int, int]] = posi
|
||||||
|
|
||||||
self.resource_add(self.file)
|
self.resource_add(self.file)
|
||||||
self._button_offset: t.Tuple[int, int] = (0, 0)
|
self._button_offset: t.Tuple[int, int] = (0, 0)
|
||||||
@@ -28,18 +29,60 @@ class Button(Resource):
|
|||||||
def button(self):
|
def button(self):
|
||||||
return area_offset(self._button, self._button_offset)
|
return area_offset(self._button, self._button_offset)
|
||||||
|
|
||||||
def load_offset(self, button):
|
def load_offset(self, offset):
|
||||||
self._button_offset = button._button_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):
|
def clear_offset(self):
|
||||||
self._button_offset = (0, 0)
|
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
|
@cached_property
|
||||||
def image(self):
|
def image(self):
|
||||||
return load_image(self.file, self.area)
|
return load_image(self.file, self.area)
|
||||||
|
|
||||||
|
@cached_property
|
||||||
|
def image_luma(self):
|
||||||
|
return rgb2luma(self.image)
|
||||||
|
|
||||||
|
|
||||||
def resource_release(self):
|
def resource_release(self):
|
||||||
del_cached_property(self, 'image')
|
del_cached_property(self, 'image')
|
||||||
|
del_cached_property(self, 'image_luma')
|
||||||
self.clear_offset()
|
self.clear_offset()
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
@@ -92,6 +135,29 @@ class Button(Resource):
|
|||||||
image = crop(image, self.search, copy=False)
|
image = crop(image, self.search, copy=False)
|
||||||
res = cv2.matchTemplate(self.image, image, cv2.TM_CCOEFF_NORMED)
|
res = cv2.matchTemplate(self.image, image, cv2.TM_CCOEFF_NORMED)
|
||||||
_, sim, _, point = cv2.minMaxLoc(res)
|
_, 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]
|
self._button_offset = np.array(point) + self.search[:2] - self.area[:2]
|
||||||
return sim > similarity
|
return sim > similarity
|
||||||
@@ -134,7 +200,7 @@ class Button(Resource):
|
|||||||
Returns:
|
Returns:
|
||||||
bool.
|
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:
|
if not matched:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
@@ -208,6 +274,13 @@ class ButtonWrapper(Resource):
|
|||||||
return True
|
return True
|
||||||
return False
|
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):
|
def match_multi_template(self, image, similarity=0.85, threshold=5, direct_match=False):
|
||||||
"""
|
"""
|
||||||
Detects assets by template matching, return multiple results
|
Detects assets by template matching, return multiple results
|
||||||
@@ -284,10 +357,8 @@ class ButtonWrapper(Resource):
|
|||||||
Load offset from another button.
|
Load offset from another button.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
button (Button, ButtonWrapper):
|
button (Button | ButtonWrapper | tuple[int, int]):
|
||||||
"""
|
"""
|
||||||
if isinstance(button, ButtonWrapper):
|
|
||||||
button = button.matched_button
|
|
||||||
for b in self.iter_buttons():
|
for b in self.iter_buttons():
|
||||||
b.load_offset(button)
|
b.load_offset(button)
|
||||||
|
|
||||||
@@ -295,16 +366,55 @@ class ButtonWrapper(Resource):
|
|||||||
for b in self.iter_buttons():
|
for b in self.iter_buttons():
|
||||||
b.clear_offset()
|
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.
|
Set `search` attribute.
|
||||||
Note that this method is irreversible.
|
Note that this method is irreversible.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
area:
|
search (Button | ButtonWrapper | tuple[int, int, int, int]):
|
||||||
"""
|
"""
|
||||||
for b in self.iter_buttons():
|
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:
|
class ClickButton:
|
||||||
|
|||||||
@@ -33,23 +33,33 @@ class TabWrapper:
|
|||||||
self.suffix += suffix
|
self.suffix += suffix
|
||||||
|
|
||||||
|
|
||||||
|
class VariableWrapper:
|
||||||
|
def __init__(self, name):
|
||||||
|
self.name = name
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return str(self.name)
|
||||||
|
|
||||||
|
__str__ = __repr__
|
||||||
|
|
||||||
|
|
||||||
class CodeGenerator:
|
class CodeGenerator:
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self.tab_count = 0
|
self.tab_count = 0
|
||||||
self.lines = []
|
self.lines = []
|
||||||
|
|
||||||
def generate(self) -> t.Iterable[str]:
|
|
||||||
yield ''
|
|
||||||
|
|
||||||
def add(self, line, comment=False, newline=True):
|
def add(self, line, comment=False, newline=True):
|
||||||
self.lines.append(self._line_with_tabs(line, comment=comment, newline=newline))
|
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):
|
def print(self):
|
||||||
lines = ''.join(self.lines)
|
lines = self.generate()
|
||||||
print(lines)
|
print(lines)
|
||||||
|
|
||||||
def write(self, file: str = None):
|
def write(self, file: str = None):
|
||||||
lines = ''.join(self.lines)
|
lines = self.generate()
|
||||||
with open(file, 'w', encoding='utf-8', newline='') as f:
|
with open(file, 'w', encoding='utf-8', newline='') as f:
|
||||||
f.write(lines)
|
f.write(lines)
|
||||||
|
|
||||||
@@ -77,7 +87,10 @@ class CodeGenerator:
|
|||||||
return TabWrapper(self)
|
return TabWrapper(self)
|
||||||
|
|
||||||
def Empty(self):
|
def Empty(self):
|
||||||
self.add('')
|
self.lines.append('\n')
|
||||||
|
|
||||||
|
def Pass(self):
|
||||||
|
self.add('pass')
|
||||||
|
|
||||||
def Import(self, text, empty=2):
|
def Import(self, text, empty=2):
|
||||||
for line in text.strip().split('\n'):
|
for line in text.strip().split('\n'):
|
||||||
@@ -86,6 +99,9 @@ class CodeGenerator:
|
|||||||
for _ in range(empty):
|
for _ in range(empty):
|
||||||
self.Empty()
|
self.Empty()
|
||||||
|
|
||||||
|
def Variable(self, name):
|
||||||
|
return VariableWrapper(name)
|
||||||
|
|
||||||
def Value(self, key=None, value=None, type_=None, **kwargs):
|
def Value(self, key=None, value=None, type_=None, **kwargs):
|
||||||
if key is not None:
|
if key is not None:
|
||||||
if type_ is not None:
|
if type_ is not None:
|
||||||
@@ -117,7 +133,7 @@ class CodeGenerator:
|
|||||||
if key is not None:
|
if key is not None:
|
||||||
return TabWrapper(self, prefix=str(key) + ' = [', suffix=']')
|
return TabWrapper(self, prefix=str(key) + ' = [', suffix=']')
|
||||||
else:
|
else:
|
||||||
return TabWrapper(self, prefix='[', suffix=']', newline=False)
|
return TabWrapper(self, prefix='[', suffix=']')
|
||||||
|
|
||||||
def ListItem(self, value):
|
def ListItem(self, value):
|
||||||
if isinstance(value, TabWrapper):
|
if isinstance(value, TabWrapper):
|
||||||
@@ -131,7 +147,7 @@ class CodeGenerator:
|
|||||||
if key is not None:
|
if key is not None:
|
||||||
return TabWrapper(self, prefix=str(key) + ' = {', suffix='}')
|
return TabWrapper(self, prefix=str(key) + ' = {', suffix='}')
|
||||||
else:
|
else:
|
||||||
return TabWrapper(self, prefix='{', suffix='}', newline=False)
|
return TabWrapper(self, prefix='{', suffix='}')
|
||||||
|
|
||||||
def DictItem(self, key=None, value=None):
|
def DictItem(self, key=None, value=None):
|
||||||
if isinstance(value, TabWrapper):
|
if isinstance(value, TabWrapper):
|
||||||
@@ -147,7 +163,7 @@ class CodeGenerator:
|
|||||||
if key is not None:
|
if key is not None:
|
||||||
return TabWrapper(self, prefix=f'{key} = {object_class}(', suffix=')')
|
return TabWrapper(self, prefix=f'{key} = {object_class}(', suffix=')')
|
||||||
else:
|
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):
|
def ObjectAttr(self, key=None, value=None):
|
||||||
if isinstance(value, TabWrapper):
|
if isinstance(value, TabWrapper):
|
||||||
@@ -179,3 +195,33 @@ Value = generator.Value
|
|||||||
Comment = generator.Comment
|
Comment = generator.Comment
|
||||||
Dict = generator.Dict
|
Dict = generator.Dict
|
||||||
DictItem = generator.DictItem
|
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__
|
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):
|
def function_drop(rate=0.5, default=None):
|
||||||
"""
|
"""
|
||||||
Drop function calls to simulate random emulator stuck, for testing purpose.
|
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}')
|
# logger.info(f'Release {obj}')
|
||||||
obj.resource_release()
|
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
|
# Useless in most cases, but just call it
|
||||||
# gc.collect()
|
# 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
|
||||||
from datetime import datetime, timedelta
|
|
||||||
from functools import wraps
|
from functools import wraps
|
||||||
|
from time import sleep, time
|
||||||
|
|
||||||
|
|
||||||
def timer(function):
|
def timer(function):
|
||||||
|
"""
|
||||||
|
Decorator to time a function, for debug only
|
||||||
|
"""
|
||||||
|
|
||||||
@wraps(function)
|
@wraps(function)
|
||||||
def function_timer(*args, **kwargs):
|
def function_timer(*args, **kwargs):
|
||||||
t0 = time.time()
|
start = time()
|
||||||
|
|
||||||
result = function(*args, **kwargs)
|
result = function(*args, **kwargs)
|
||||||
t1 = time.time()
|
cost = time() - start
|
||||||
print('%s: %s s' % (function.__name__, str(round(t1 - t0, 10))))
|
print(f'{function.__name__}: {cost:.10f} s')
|
||||||
return result
|
return result
|
||||||
|
|
||||||
return function_timer
|
return function_timer
|
||||||
|
|
||||||
|
|
||||||
def future_time(string):
|
def now():
|
||||||
"""
|
"""
|
||||||
Args:
|
Get datatime now without timezone
|
||||||
string (str): Such as 14:59.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
datetime.datetime: Time with given hour, minute in the future.
|
|
||||||
"""
|
"""
|
||||||
hour, minute = [int(x) for x in string.split(':')]
|
return datetime.now().replace(microsecond=0)
|
||||||
future = datetime.now().replace(hour=hour, minute=minute, second=0, microsecond=0)
|
|
||||||
future = future + timedelta(days=1) if future < datetime.now() else future
|
|
||||||
return future
|
|
||||||
|
|
||||||
|
|
||||||
def past_time(string):
|
def nowtz():
|
||||||
"""
|
"""
|
||||||
Args:
|
Get datatime now with timezone
|
||||||
string (str): Such as 14:59.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
datetime.datetime: Time with given hour, minute in the past.
|
|
||||||
"""
|
"""
|
||||||
hour, minute = [int(x) for x in string.split(':')]
|
return datetime.now().replace(microsecond=0).astimezone()
|
||||||
past = datetime.now().replace(hour=hour, minute=minute, second=0, microsecond=0)
|
|
||||||
past = past - timedelta(days=1) if past > datetime.now() else past
|
|
||||||
return past
|
|
||||||
|
|
||||||
|
|
||||||
def future_time_range(string):
|
|
||||||
"""
|
|
||||||
Args:
|
|
||||||
string (str): Such as 23:30-06:30.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
tuple(datetime.datetime): (time start, time end).
|
|
||||||
"""
|
|
||||||
start, end = [future_time(s) for s in string.split('-')]
|
|
||||||
if start > end:
|
|
||||||
start = start - timedelta(days=1)
|
|
||||||
return start, end
|
|
||||||
|
|
||||||
|
|
||||||
def time_range_active(time_range):
|
|
||||||
"""
|
|
||||||
Args:
|
|
||||||
time_range(tuple(datetime.datetime)): (time start, time end).
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
bool:
|
|
||||||
"""
|
|
||||||
return time_range[0] < datetime.now() < time_range[1]
|
|
||||||
|
|
||||||
|
|
||||||
class Timer:
|
class Timer:
|
||||||
def __init__(self, limit, count=0):
|
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:
|
Args:
|
||||||
limit (int, float): Timer limit
|
limit (int | float): Timer limit
|
||||||
count (int): Timer reach confirm count. Default to 0.
|
count (int): Timer access count. Default to 0.
|
||||||
When using a structure like this, must set a count.
|
|
||||||
Otherwise it goes wrong, if screenshot time cost greater than limit.
|
|
||||||
|
|
||||||
if self.appear(MAIN_CHECK):
|
|
||||||
if confirm_timer.reached():
|
|
||||||
pass
|
|
||||||
else:
|
|
||||||
confirm_timer.reset()
|
|
||||||
|
|
||||||
Also, It's a good idea to set `count`, to make alas run more stable on slow computers.
|
|
||||||
Expected speed is 0.35 second / screenshot.
|
|
||||||
"""
|
"""
|
||||||
self.limit = limit
|
self.limit = limit
|
||||||
self.count = count
|
self.count = count
|
||||||
self._current = 0
|
self._start = 0.
|
||||||
self._reach_count = count
|
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):
|
def start(self):
|
||||||
if not self.started():
|
"""
|
||||||
self._current = time.time()
|
Start current timer.
|
||||||
self._reach_count = 0
|
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
|
return self
|
||||||
|
|
||||||
def started(self):
|
def started(self):
|
||||||
return bool(self._current)
|
|
||||||
|
|
||||||
def current(self):
|
|
||||||
"""
|
"""
|
||||||
Returns:
|
Returns:
|
||||||
float
|
bool:
|
||||||
"""
|
"""
|
||||||
if self.started():
|
return self._start > 0
|
||||||
return time.time() - self._current
|
|
||||||
|
def current_time(self):
|
||||||
|
"""
|
||||||
|
Returns:
|
||||||
|
float:
|
||||||
|
"""
|
||||||
|
if self._start > 0:
|
||||||
|
diff = time() - self._start
|
||||||
|
if diff < 0:
|
||||||
|
diff = 0.
|
||||||
|
return diff
|
||||||
else:
|
else:
|
||||||
return 0.
|
return 0.
|
||||||
|
|
||||||
def set_current(self, current, count=0):
|
def current_count(self):
|
||||||
self._current = time.time() - current
|
"""
|
||||||
self._reach_count = count
|
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):
|
def reached(self):
|
||||||
"""
|
"""
|
||||||
Returns:
|
Returns:
|
||||||
bool
|
bool:
|
||||||
"""
|
"""
|
||||||
self._reach_count += 1
|
# each reached() call is consider as an access
|
||||||
return time.time() - self._current > self.limit and self._reach_count > self.count
|
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):
|
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
|
return self
|
||||||
|
|
||||||
def clear(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
|
return self
|
||||||
|
|
||||||
def reached_and_reset(self):
|
def reached_and_reset(self):
|
||||||
@@ -149,15 +180,16 @@ class Timer:
|
|||||||
"""
|
"""
|
||||||
Wait until timer reached.
|
Wait until timer reached.
|
||||||
"""
|
"""
|
||||||
diff = self._current + self.limit - time.time()
|
diff = self._start + self.limit - time()
|
||||||
if diff > 0:
|
if diff > 0:
|
||||||
time.sleep(diff)
|
sleep(diff)
|
||||||
|
|
||||||
def show(self):
|
def show(self):
|
||||||
from module.logger import logger
|
from module.logger import logger
|
||||||
logger.info(str(self))
|
logger.info(str(self))
|
||||||
|
|
||||||
def __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__
|
__repr__ = __str__
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import random
|
||||||
import re
|
import re
|
||||||
|
|
||||||
import cv2
|
import cv2
|
||||||
@@ -8,8 +9,9 @@ REGEX_NODE = re.compile(r'(-?[A-Za-z]+)(-?\d+)')
|
|||||||
|
|
||||||
|
|
||||||
def random_normal_distribution_int(a, b, n=3):
|
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:
|
Args:
|
||||||
a (int): The minimum of the interval.
|
a (int): The minimum of the interval.
|
||||||
@@ -19,9 +21,13 @@ def random_normal_distribution_int(a, b, n=3):
|
|||||||
Returns:
|
Returns:
|
||||||
int
|
int
|
||||||
"""
|
"""
|
||||||
|
a = round(a)
|
||||||
|
b = round(b)
|
||||||
if a < b:
|
if a < b:
|
||||||
output = np.mean(np.random.randint(a, b, size=n))
|
total = 0
|
||||||
return int(output.round())
|
for _ in range(n):
|
||||||
|
total += random.randint(a, b)
|
||||||
|
return round(total / n)
|
||||||
else:
|
else:
|
||||||
return b
|
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):
|
def point_limit(point, area):
|
||||||
"""
|
"""
|
||||||
Limit point in an area.
|
Limit point in an area.
|
||||||
@@ -497,6 +517,22 @@ def location2node(location):
|
|||||||
return col2name(x) + str(y)
|
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):
|
def load_image(file, area=None):
|
||||||
"""
|
"""
|
||||||
Load an image like pillow and drop alpha channel.
|
Load an image like pillow and drop alpha channel.
|
||||||
@@ -508,13 +544,17 @@ def load_image(file, area=None):
|
|||||||
Returns:
|
Returns:
|
||||||
np.ndarray:
|
np.ndarray:
|
||||||
"""
|
"""
|
||||||
image = Image.open(file)
|
# always remember to close Image object
|
||||||
if area is not None:
|
with Image.open(file) as f:
|
||||||
image = image.crop(area)
|
if area is not None:
|
||||||
image = np.array(image)
|
f = f.crop(area)
|
||||||
channel = image.shape[2] if len(image.shape) > 2 else 1
|
|
||||||
if channel > 3:
|
image = np.array(f)
|
||||||
image = image[:, :, :3].copy()
|
|
||||||
|
channel = image_channel(image)
|
||||||
|
if channel == 4:
|
||||||
|
image = cv2.cvtColor(image, cv2.COLOR_RGBA2RGB)
|
||||||
|
|
||||||
return image
|
return image
|
||||||
|
|
||||||
|
|
||||||
@@ -531,6 +571,19 @@ def save_image(image, file):
|
|||||||
Image.fromarray(image).save(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):
|
def crop(image, area, copy=True):
|
||||||
"""
|
"""
|
||||||
Crop image like pillow, when using opencv / numpy.
|
Crop image like pillow, when using opencv / numpy.
|
||||||
@@ -544,16 +597,72 @@ def crop(image, area, copy=True):
|
|||||||
Returns:
|
Returns:
|
||||||
np.ndarray:
|
np.ndarray:
|
||||||
"""
|
"""
|
||||||
x1, y1, x2, y2 = map(int, map(round, area))
|
# map(round, area)
|
||||||
h, w = image.shape[:2]
|
x1, y1, x2, y2 = area
|
||||||
border = np.maximum((0 - y1, y2 - h, 0 - x1, x2 - w), 0)
|
x1 = round(x1)
|
||||||
x1, y1, x2, y2 = np.maximum((x1, y1, x2, y2), 0)
|
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]
|
image = image[y1:y2, x1:x2]
|
||||||
if sum(border) > 0:
|
# if border
|
||||||
image = cv2.copyMakeBorder(image, *border, borderType=cv2.BORDER_CONSTANT, value=(0, 0, 0))
|
if top or bottom or left or right:
|
||||||
if copy:
|
if len(shape) == 2:
|
||||||
image = image.copy()
|
value = 0
|
||||||
return image
|
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):
|
def resize(image, size):
|
||||||
@@ -611,17 +720,29 @@ def image_paste(image, background, origin):
|
|||||||
|
|
||||||
def rgb2gray(image):
|
def rgb2gray(image):
|
||||||
"""
|
"""
|
||||||
|
gray = ( MAX(r, g, b) + MIN(r, g, b)) / 2
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
image (np.ndarray): Shape (height, width, channel)
|
image (np.ndarray): Shape (height, width, channel)
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
np.ndarray: Shape (height, width)
|
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)
|
r, g, b = cv2.split(image)
|
||||||
return cv2.add(
|
maximum = cv2.max(r, g)
|
||||||
cv2.multiply(cv2.max(cv2.max(r, g), b), 0.5),
|
cv2.min(r, g, dst=r)
|
||||||
cv2.multiply(cv2.min(cv2.min(r, g), b), 0.5)
|
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):
|
def rgb2hsv(image):
|
||||||
@@ -636,7 +757,7 @@ def rgb2hsv(image):
|
|||||||
np.ndarray: Hue (0~360), Saturation (0~100), Value (0~100).
|
np.ndarray: Hue (0~360), Saturation (0~100), Value (0~100).
|
||||||
"""
|
"""
|
||||||
image = cv2.cvtColor(image, cv2.COLOR_RGB2HSV).astype(float)
|
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
|
return image
|
||||||
|
|
||||||
|
|
||||||
@@ -684,40 +805,133 @@ def get_color(image, area):
|
|||||||
return color[:3]
|
return color[:3]
|
||||||
|
|
||||||
|
|
||||||
|
class ImageNotSupported(Exception):
|
||||||
|
"""
|
||||||
|
Raised if we can't perform image calculation on this image
|
||||||
|
"""
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
def get_bbox(image, threshold=0):
|
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:
|
Args:
|
||||||
image (np.ndarray): Screenshot.
|
image (np.ndarray):
|
||||||
threshold (int): Color <= threshold will be considered black
|
threshold (int):
|
||||||
|
color > threshold will be considered as content
|
||||||
|
color <= threshold will be considered background
|
||||||
|
|
||||||
Returns:
|
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:
|
channel = image_channel(image)
|
||||||
image = np.max(image, axis=2)
|
# convert to grayscale
|
||||||
x = np.where(np.max(image, axis=0) > threshold)[0]
|
if channel == 3:
|
||||||
y = np.where(np.max(image, axis=1) > threshold)[0]
|
# RGB
|
||||||
return x[0], y[0], x[-1] + 1, y[-1] + 1
|
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:
|
Args:
|
||||||
image (np.ndarray): Screenshot.
|
image (np.ndarray):
|
||||||
threshold (int): Color >= threshold will be considered white
|
threshold (int):
|
||||||
|
color < threshold will be considered as content
|
||||||
|
color >= threshold will be considered background
|
||||||
|
|
||||||
Returns:
|
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:
|
channel = image_channel(image)
|
||||||
image = np.min(image, axis=2)
|
# convert to grayscale
|
||||||
x = np.where(np.min(image, axis=0) < threshold)[0]
|
if channel == 3:
|
||||||
y = np.where(np.min(image, axis=1) < threshold)[0]
|
# RGB
|
||||||
return x[0], y[0], x[-1] + 1, y[-1] + 1
|
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):
|
def color_similarity(color1, color2):
|
||||||
@@ -729,13 +943,35 @@ def color_similarity(color1, color2):
|
|||||||
Returns:
|
Returns:
|
||||||
int:
|
int:
|
||||||
"""
|
"""
|
||||||
diff = np.array(color1).astype(int) - np.array(color2).astype(int)
|
# print(color1, color2)
|
||||||
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
|
return diff
|
||||||
|
|
||||||
|
|
||||||
def color_similar(color1, color2, threshold=10):
|
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))
|
Tolerance = Max(Positive(difference_rgb)) + Max(- Negative(difference_rgb))
|
||||||
The same as the tolerance in Photoshop.
|
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.
|
bool: True if two colors are similar.
|
||||||
"""
|
"""
|
||||||
# print(color1, color2)
|
# print(color1, color2)
|
||||||
diff = np.array(color1).astype(int) - np.array(color2).astype(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))
|
# 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
|
return diff <= threshold
|
||||||
|
|
||||||
|
|
||||||
@@ -777,11 +1033,24 @@ def color_similarity_2d(image, color):
|
|||||||
Returns:
|
Returns:
|
||||||
np.ndarray: uint8
|
np.ndarray: uint8
|
||||||
"""
|
"""
|
||||||
r, g, b = cv2.split(cv2.subtract(image, (*color, 0)))
|
# r, g, b = cv2.split(cv2.subtract(image, (*color, 0)))
|
||||||
positive = cv2.max(cv2.max(r, g), b)
|
# positive = cv2.max(cv2.max(r, g), b)
|
||||||
r, g, b = cv2.split(cv2.subtract((*color, 0), image))
|
# r, g, b = cv2.split(cv2.subtract((*color, 0), image))
|
||||||
negative = cv2.max(cv2.max(r, g), b)
|
# negative = cv2.max(cv2.max(r, g), b)
|
||||||
return cv2.subtract(255, cv2.add(positive, negative))
|
# 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):
|
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:
|
Returns:
|
||||||
np.ndarray: Shape (height, width)
|
np.ndarray: Shape (height, width)
|
||||||
"""
|
"""
|
||||||
r, g, b = cv2.split(cv2.subtract(image, (*letter, 0)))
|
# r, g, b = cv2.split(cv2.subtract(image, (*letter, 0)))
|
||||||
positive = cv2.max(cv2.max(r, g), b)
|
# positive = cv2.max(cv2.max(r, g), b)
|
||||||
r, g, b = cv2.split(cv2.subtract((*letter, 0), image))
|
# r, g, b = cv2.split(cv2.subtract((*letter, 0), image))
|
||||||
negative = cv2.max(cv2.max(r, g), b)
|
# negative = cv2.max(cv2.max(r, g), b)
|
||||||
return cv2.multiply(cv2.add(positive, negative), 255.0 / threshold)
|
# 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):
|
def extract_white_letters(image, threshold=128):
|
||||||
@@ -813,10 +1096,23 @@ def extract_white_letters(image, threshold=128):
|
|||||||
Returns:
|
Returns:
|
||||||
np.ndarray: Shape (height, width)
|
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))
|
r, g, b = cv2.split(cv2.subtract((255, 255, 255, 0), image))
|
||||||
minimum = cv2.min(cv2.min(r, g), b)
|
maximum = cv2.max(r, g)
|
||||||
maximum = cv2.max(cv2.max(r, g), b)
|
cv2.min(r, g, dst=r)
|
||||||
return cv2.multiply(cv2.add(maximum, cv2.subtract(maximum, minimum)), 255.0 / threshold)
|
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):
|
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)
|
low, high = np.min(image), np.max(image)
|
||||||
multiply = min(255 / (high - low), max_multiply)
|
multiply = min(255 / (high - low), max_multiply)
|
||||||
add = (255 - multiply * (low + high)) / 2
|
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 > 255] = 255
|
||||||
image[image < 0] = 0
|
image[image < 0] = 0
|
||||||
return image.astype(np.uint8)
|
return image.astype(np.uint8)
|
||||||
@@ -895,7 +1193,7 @@ def color_bar_percentage(image, area, prev_color, reverse=False, starter=0, thre
|
|||||||
Returns:
|
Returns:
|
||||||
float: 0 to 1.
|
float: 0 to 1.
|
||||||
"""
|
"""
|
||||||
image = crop(image, area)
|
image = crop(image, area, copy=False)
|
||||||
image = image[:, ::-1, :] if reverse else image
|
image = image[:, ::-1, :] if reverse else image
|
||||||
length = image.shape[1]
|
length = image.shape[1]
|
||||||
prev_index = starter
|
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
|
prev_row = bar[:, prev_index] > 255 - threshold
|
||||||
if not prev_row.size:
|
if not prev_row.size:
|
||||||
return prev_index / length
|
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.
|
return 0.
|
||||||
|
|||||||
@@ -41,9 +41,10 @@
|
|||||||
"aScreenCap_nc",
|
"aScreenCap_nc",
|
||||||
"DroidCast",
|
"DroidCast",
|
||||||
"DroidCast_raw",
|
"DroidCast_raw",
|
||||||
"scrcpy"
|
"scrcpy",
|
||||||
],
|
"nemu_ipc",
|
||||||
"display": "hide"
|
"ldopengl"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
"ControlMethod": {
|
"ControlMethod": {
|
||||||
"type": "select",
|
"type": "select",
|
||||||
@@ -74,6 +75,7 @@
|
|||||||
"LDPlayer3",
|
"LDPlayer3",
|
||||||
"LDPlayer4",
|
"LDPlayer4",
|
||||||
"LDPlayer9",
|
"LDPlayer9",
|
||||||
|
"LDPlayer14",
|
||||||
"MuMuPlayer",
|
"MuMuPlayer",
|
||||||
"MuMuPlayerX",
|
"MuMuPlayerX",
|
||||||
"MuMuPlayer12",
|
"MuMuPlayer12",
|
||||||
@@ -130,6 +132,7 @@
|
|||||||
"stay_there",
|
"stay_there",
|
||||||
"goto_main",
|
"goto_main",
|
||||||
"close_game",
|
"close_game",
|
||||||
|
"close_emulator",
|
||||||
"exit_aas",
|
"exit_aas",
|
||||||
"exit_emulator",
|
"exit_emulator",
|
||||||
"exit_aas_emulator",
|
"exit_aas_emulator",
|
||||||
@@ -770,7 +773,8 @@
|
|||||||
6,
|
6,
|
||||||
7,
|
7,
|
||||||
8,
|
8,
|
||||||
9
|
9,
|
||||||
|
10
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"Count": {
|
"Count": {
|
||||||
@@ -792,7 +796,8 @@
|
|||||||
6,
|
6,
|
||||||
7,
|
7,
|
||||||
8,
|
8,
|
||||||
9
|
9,
|
||||||
|
10
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"Count": {
|
"Count": {
|
||||||
@@ -814,7 +819,8 @@
|
|||||||
6,
|
6,
|
||||||
7,
|
7,
|
||||||
8,
|
8,
|
||||||
9
|
9,
|
||||||
|
10
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"Count": {
|
"Count": {
|
||||||
@@ -1059,28 +1065,10 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"Momotalk": {
|
"Momotalk": {
|
||||||
"Scheduler": {
|
"Momotalk": {
|
||||||
"Enable": {
|
"Enable": {
|
||||||
"type": "checkbox",
|
"type": "checkbox",
|
||||||
"value": false,
|
"value": true,
|
||||||
"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",
|
|
||||||
"display": "hide"
|
"display": "hide"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ Emulator:
|
|||||||
option: [ auto, jp, en, zht ]
|
option: [ auto, jp, en, zht ]
|
||||||
ScreenshotMethod:
|
ScreenshotMethod:
|
||||||
value: auto
|
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:
|
ControlMethod:
|
||||||
value: MaaTouch
|
value: MaaTouch
|
||||||
option: [ minitouch, MaaTouch ]
|
option: [ minitouch, MaaTouch ]
|
||||||
@@ -45,6 +45,7 @@ EmulatorInfo:
|
|||||||
LDPlayer3,
|
LDPlayer3,
|
||||||
LDPlayer4,
|
LDPlayer4,
|
||||||
LDPlayer9,
|
LDPlayer9,
|
||||||
|
LDPlayer14,
|
||||||
MuMuPlayer,
|
MuMuPlayer,
|
||||||
MuMuPlayerX,
|
MuMuPlayerX,
|
||||||
MuMuPlayer12,
|
MuMuPlayer12,
|
||||||
@@ -71,7 +72,7 @@ Optimization:
|
|||||||
CombatScreenshotInterval: 1.0
|
CombatScreenshotInterval: 1.0
|
||||||
WhenTaskQueueEmpty:
|
WhenTaskQueueEmpty:
|
||||||
value: goto_main
|
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 ====================
|
# ==================== Daily ====================
|
||||||
|
|
||||||
@@ -156,17 +157,17 @@ Bounty:
|
|||||||
Highway:
|
Highway:
|
||||||
Stage:
|
Stage:
|
||||||
value: 0
|
value: 0
|
||||||
option: [ 0, 1, 2, 3, 4, 5, 6, 7, 8, 9 ]
|
option: [ 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10 ]
|
||||||
Count: 2
|
Count: 2
|
||||||
DesertRailroad:
|
DesertRailroad:
|
||||||
Stage:
|
Stage:
|
||||||
value: 0
|
value: 0
|
||||||
option: [ 0, 1, 2, 3, 4, 5, 6, 7, 8, 9 ]
|
option: [ 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10 ]
|
||||||
Count: 2
|
Count: 2
|
||||||
Schoolhouse:
|
Schoolhouse:
|
||||||
Stage:
|
Stage:
|
||||||
value: 0
|
value: 0
|
||||||
option: [ 0, 1, 2, 3, 4, 5, 6, 7, 8, 9 ]
|
option: [ 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10 ]
|
||||||
Count: 2
|
Count: 2
|
||||||
|
|
||||||
Scrimmage:
|
Scrimmage:
|
||||||
@@ -267,3 +268,10 @@ ItemStorage:
|
|||||||
stored: StoredTacticalChallengeTicket
|
stored: StoredTacticalChallengeTicket
|
||||||
order: 6
|
order: 6
|
||||||
color: "#7ac8e5"
|
color: "#7ac8e5"
|
||||||
|
|
||||||
|
# ==================== Momotalk ====================
|
||||||
|
|
||||||
|
Momotalk:
|
||||||
|
Enable:
|
||||||
|
value: true
|
||||||
|
display: hide
|
||||||
@@ -4,6 +4,9 @@
|
|||||||
|
|
||||||
# ==================== Alas ====================
|
# ==================== Alas ====================
|
||||||
|
|
||||||
|
Alas:
|
||||||
|
Emulator:
|
||||||
|
ScreenshotMethod: scrcpy
|
||||||
Cafe:
|
Cafe:
|
||||||
Scheduler:
|
Scheduler:
|
||||||
Enable: true
|
Enable: true
|
||||||
|
|||||||
@@ -61,6 +61,7 @@ Dashboard:
|
|||||||
HoursAgo:
|
HoursAgo:
|
||||||
DaysAgo:
|
DaysAgo:
|
||||||
LongTimeAgo:
|
LongTimeAgo:
|
||||||
|
EtaDays:
|
||||||
|
|
||||||
AddAlas:
|
AddAlas:
|
||||||
PopupTitle:
|
PopupTitle:
|
||||||
|
|||||||
@@ -33,7 +33,13 @@
|
|||||||
"tasks": [
|
"tasks": [
|
||||||
"Circle",
|
"Circle",
|
||||||
"Task",
|
"Task",
|
||||||
"Mail",
|
"Mail"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Tool": {
|
||||||
|
"menu": "collapse",
|
||||||
|
"page": "tool",
|
||||||
|
"tasks": [
|
||||||
"Momotalk"
|
"Momotalk"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,6 @@
|
|||||||
|
|
||||||
Alas:
|
Alas:
|
||||||
Emulator:
|
Emulator:
|
||||||
ScreenshotMethod: scrcpy
|
|
||||||
ControlMethod: MaaTouch
|
ControlMethod: MaaTouch
|
||||||
Optimization:
|
Optimization:
|
||||||
ScreenshotInterval: 0.2
|
ScreenshotInterval: 0.2
|
||||||
|
|||||||
@@ -80,5 +80,12 @@ Reward:
|
|||||||
- Scheduler
|
- Scheduler
|
||||||
Mail:
|
Mail:
|
||||||
- Scheduler
|
- Scheduler
|
||||||
|
|
||||||
|
# ==================== Tool ====================
|
||||||
|
|
||||||
|
Tool:
|
||||||
|
menu: 'collapse'
|
||||||
|
page: 'tool'
|
||||||
|
tasks:
|
||||||
Momotalk:
|
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 copy
|
||||||
import datetime
|
|
||||||
import operator
|
import operator
|
||||||
import threading
|
import threading
|
||||||
|
from datetime import datetime, timedelta
|
||||||
import pywebio
|
|
||||||
|
|
||||||
from module.base.decorator import cached_property, del_cached_property
|
from module.base.decorator import cached_property, del_cached_property
|
||||||
from module.base.filter import Filter
|
from module.base.filter import Filter
|
||||||
from module.config.config_generated import GeneratedConfig
|
from module.config.config_generated import GeneratedConfig
|
||||||
from module.config.config_manual import ManualConfig
|
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.classes import iter_attribute
|
||||||
from module.config.stored.stored_generated import StoredGenerated
|
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.config.watcher import ConfigWatcher
|
||||||
from module.exception import RequestHumanTakeover, ScriptError
|
from module.exception import RequestHumanTakeover, ScriptError
|
||||||
from module.logger import logger
|
from module.logger import logger
|
||||||
@@ -106,17 +105,22 @@ class AzurLaneConfig(ConfigUpdater, ManualConfig, GeneratedConfig, ConfigWatcher
|
|||||||
logger.info("Using template config, which is read only")
|
logger.info("Using template config, which is read only")
|
||||||
self.auto_update = False
|
self.auto_update = False
|
||||||
self.task = name_to_function("template")
|
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:
|
else:
|
||||||
self.load()
|
# Bind a specific task for debug purpose.
|
||||||
if task is None:
|
task = name_to_function(task)
|
||||||
# Bind `Alas` by default which includes emulator settings.
|
self.bind(task)
|
||||||
task = name_to_function("Alas")
|
self.task = task
|
||||||
else:
|
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):
|
def load(self):
|
||||||
self.data = self.read_file(self.config_name)
|
self.data = self.read_file(self.config_name)
|
||||||
@@ -129,13 +133,17 @@ class AzurLaneConfig(ConfigUpdater, ManualConfig, GeneratedConfig, ConfigWatcher
|
|||||||
"""
|
"""
|
||||||
Args:
|
Args:
|
||||||
func (str, Function): Function to run
|
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):
|
if isinstance(func, Function):
|
||||||
func = func.command
|
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}")
|
logger.info(f"Bind task {func_list}")
|
||||||
|
|
||||||
# Bind arguments
|
# Bind arguments
|
||||||
@@ -172,6 +180,10 @@ class AzurLaneConfig(ConfigUpdater, ManualConfig, GeneratedConfig, ConfigWatcher
|
|||||||
self.data, keys="Alas.Optimization.CloseGameDuringWait", default=False
|
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
|
@cached_property
|
||||||
def stored(self) -> StoredGenerated:
|
def stored(self) -> StoredGenerated:
|
||||||
stored = StoredGenerated()
|
stored = StoredGenerated()
|
||||||
@@ -279,6 +291,7 @@ class AzurLaneConfig(ConfigUpdater, ManualConfig, GeneratedConfig, ConfigWatcher
|
|||||||
deep_set(self.data, keys=f"{task}.Scheduler.NextRun", value=now)
|
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(['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))
|
limit_next_run(self.args.keys(), limit=now + timedelta(hours=24, seconds=-1))
|
||||||
|
|
||||||
def override(self, **kwargs):
|
def override(self, **kwargs):
|
||||||
@@ -485,19 +498,18 @@ class AzurLaneConfig(ConfigUpdater, ManualConfig, GeneratedConfig, ConfigWatcher
|
|||||||
def is_task_enabled(self, task):
|
def is_task_enabled(self, task):
|
||||||
return bool(self.cross_get(keys=[task, 'Scheduler', 'Enable'], default=False))
|
return bool(self.cross_get(keys=[task, 'Scheduler', 'Enable'], default=False))
|
||||||
|
|
||||||
def update_daily_quests(self):
|
# def update_daily_quests(self):
|
||||||
"""
|
# """
|
||||||
Raises:
|
# Raises:
|
||||||
TaskEnd: Call task `DailyQuest` and stop current task
|
# TaskEnd: Call task `DailyQuest` and stop current task
|
||||||
"""
|
# """
|
||||||
if self.stored.DailyActivity.is_expired():
|
# with self.multi_set():
|
||||||
logger.info('DailyActivity expired, call task to update')
|
# if self.stored.DailyActivity.is_expired():
|
||||||
self.task_call('DailyQuest')
|
# logger.info('DailyActivity expired')
|
||||||
self.task_stop()
|
# self.stored.DailyActivity.clear()
|
||||||
if self.stored.DailyQuest.is_expired():
|
# if self.stored.DailyQuest.is_expired():
|
||||||
logger.info('DailyQuest expired, call task to update')
|
# logger.info('DailyQuest expired')
|
||||||
self.task_call('DailyQuest')
|
# self.stored.DailyQuest.clear()
|
||||||
self.task_stop()
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def DEVICE_SCREENSHOT_METHOD(self):
|
def DEVICE_SCREENSHOT_METHOD(self):
|
||||||
|
|||||||
@@ -19,12 +19,12 @@ class GeneratedConfig:
|
|||||||
Emulator_Serial = 'auto'
|
Emulator_Serial = 'auto'
|
||||||
Emulator_PackageName = 'auto' # auto, JP-Official, OVERSEA-TWHKMO, OVERSEA-Korea, OVERSEA-Asia, OVERSEA-America, OVERSEA-Global
|
Emulator_PackageName = 'auto' # auto, JP-Official, OVERSEA-TWHKMO, OVERSEA-Korea, OVERSEA-Asia, OVERSEA-America, OVERSEA-Global
|
||||||
Emulator_GameLanguage = 'auto' # auto, jp, en, zht
|
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_ControlMethod = 'MaaTouch' # minitouch, MaaTouch
|
||||||
Emulator_AdbRestart = False
|
Emulator_AdbRestart = False
|
||||||
|
|
||||||
# Group `EmulatorInfo`
|
# 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_name = None
|
||||||
EmulatorInfo_path = None
|
EmulatorInfo_path = None
|
||||||
|
|
||||||
@@ -37,7 +37,7 @@ class GeneratedConfig:
|
|||||||
# Group `Optimization`
|
# Group `Optimization`
|
||||||
Optimization_ScreenshotInterval = 0.3
|
Optimization_ScreenshotInterval = 0.3
|
||||||
Optimization_CombatScreenshotInterval = 1.0
|
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`
|
# Group `Cafe`
|
||||||
Cafe_Reward = True
|
Cafe_Reward = True
|
||||||
@@ -87,15 +87,15 @@ class GeneratedConfig:
|
|||||||
Bounty_OnError = 'skip' # stop, skip
|
Bounty_OnError = 'skip' # stop, skip
|
||||||
|
|
||||||
# Group `Highway`
|
# Group `Highway`
|
||||||
Highway_Stage = 0 # 0, 1, 2, 3, 4, 5, 6, 7, 8, 9
|
Highway_Stage = 0 # 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10
|
||||||
Highway_Count = 2
|
Highway_Count = 2
|
||||||
|
|
||||||
# Group `DesertRailroad`
|
# Group `DesertRailroad`
|
||||||
DesertRailroad_Stage = 0 # 0, 1, 2, 3, 4, 5, 6, 7, 8, 9
|
DesertRailroad_Stage = 0 # 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10
|
||||||
DesertRailroad_Count = 2
|
DesertRailroad_Count = 2
|
||||||
|
|
||||||
# Group `Schoolhouse`
|
# Group `Schoolhouse`
|
||||||
Schoolhouse_Stage = 0 # 0, 1, 2, 3, 4, 5, 6, 7, 8, 9
|
Schoolhouse_Stage = 0 # 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10
|
||||||
Schoolhouse_Count = 2
|
Schoolhouse_Count = 2
|
||||||
|
|
||||||
# Group `Scrimmage`
|
# Group `Scrimmage`
|
||||||
@@ -166,3 +166,6 @@ class GeneratedConfig:
|
|||||||
ItemStorage_BountyTicket = {}
|
ItemStorage_BountyTicket = {}
|
||||||
ItemStorage_ScrimmageTicket = {}
|
ItemStorage_ScrimmageTicket = {}
|
||||||
ItemStorage_TacticalChallengeTicket = {}
|
ItemStorage_TacticalChallengeTicket = {}
|
||||||
|
|
||||||
|
# Group `Momotalk`
|
||||||
|
Momotalk_Enable = True
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
|
import typing as t
|
||||||
from copy import deepcopy
|
from copy import deepcopy
|
||||||
|
|
||||||
from cached_property import cached_property
|
from cached_property import cached_property
|
||||||
|
|
||||||
from deploy.Windows.utils import DEPLOY_TEMPLATE, poor_yaml_read, poor_yaml_write
|
from deploy.Windows.utils import DEPLOY_TEMPLATE, poor_yaml_read, poor_yaml_write
|
||||||
from module.base.timer import timer
|
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.server import VALID_SERVER
|
||||||
from module.config.utils import *
|
from module.config.utils import *
|
||||||
|
|
||||||
@@ -452,20 +454,19 @@ class ConfigUpdater:
|
|||||||
dict:
|
dict:
|
||||||
"""
|
"""
|
||||||
new = {}
|
new = {}
|
||||||
|
type_lock = {'lock', 'state'}
|
||||||
|
type_stored = {'stored', 'dict'}
|
||||||
|
|
||||||
def deep_load(keys):
|
for keys, data in deep_iter(self.args, depth=3):
|
||||||
data = deep_get(self.args, keys=keys, default={})
|
|
||||||
value = deep_get(old, keys=keys, default=data['value'])
|
value = deep_get(old, keys=keys, default=data['value'])
|
||||||
typ = data['type']
|
typ = data['type']
|
||||||
display = data.get('display')
|
display = data.get('display')
|
||||||
if (is_template or value is None or value == ''
|
if is_template or value is None or value == '' \
|
||||||
or typ in ['lock', 'state'] or (display == 'hide' and typ != 'stored')):
|
or typ in type_lock or (display == 'hide' and typ not in type_stored):
|
||||||
value = data['value']
|
value = data['value']
|
||||||
value = parse_value(value, data=data)
|
value = parse_value(value, data=data)
|
||||||
deep_set(new, keys=keys, value=value)
|
deep_set(new, keys=keys, value=value)
|
||||||
|
|
||||||
for path, _ in deep_iter(self.args, depth=3):
|
|
||||||
deep_load(path)
|
|
||||||
|
|
||||||
# if not is_template:
|
# if not is_template:
|
||||||
# new = self.config_redirect(old, new)
|
# new = self.config_redirect(old, new)
|
||||||
@@ -569,6 +570,65 @@ class ConfigUpdater:
|
|||||||
# set_daily('Use_Consumables_1_time', 'achievable')
|
# set_daily('Use_Consumables_1_time', 'achievable')
|
||||||
# return data
|
# 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):
|
def read_file(self, config_name, is_template=False):
|
||||||
"""
|
"""
|
||||||
Read and update config file.
|
Read and update config file.
|
||||||
|
|||||||