5 Commits
181 changed files with 3243 additions and 8342 deletions
-8
View File
@@ -21,14 +21,6 @@ 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)
+321
View File
@@ -0,0 +1,321 @@
# FastAPI WebUI Migration Guide
## 概述 / Overview
本项目的 WebUI 已重构为使用 FastAPI 作为后端,提供现代化的 REST API 架构,同时保持与原有 PyWebIO 界面一致的视觉风格。
The WebUI has been refactored to use FastAPI as the backend, providing a modern REST API architecture while maintaining a visual style consistent with the original PyWebIO interface.
## 主要变化 / Key Changes
### 新增功能 / New Features
1. **FastAPI 后端** / **FastAPI Backend**
- 完整的 REST API 支持
- 自动生成的 API 文档 (访问 `/docs`)
- WebSocket 支持实时日志流
- 更好的错误处理和验证
2. **分离的前后端架构** / **Separated Frontend/Backend Architecture**
- 后端: FastAPI (REST API)
- 前端: HTML/CSS/JavaScript
- 可以被多种客户端使用 (Web, Mobile, CLI)
3. **向后兼容** / **Backward Compatible**
- 原有的 PyWebIO 界面仍然可用
- 两个后端可以同时存在
### 架构对比 / Architecture Comparison
| 特性 / Feature | PyWebIO (原有) | FastAPI (新) |
|----------------|---------------|-------------|
| 启动命令 / Launch | `python gui.py` | `python gui_fastapi.py` |
| 架构 / Architecture | 单体应用 | 前后端分离 |
| API 访问 / API Access | 有限 | 完整 REST API |
| 实时更新 / Real-time | Session-based | WebSocket |
| 文档 / Documentation | 无 | 自动生成 (/docs) |
| 可扩展性 / Extensibility | 低 | 高 |
## 使用方法 / Usage
### 启动 FastAPI 后端 / Starting FastAPI Backend
```bash
# 使用默认配置
python gui_fastapi.py
# 指定主机和端口
python gui_fastapi.py --host 0.0.0.0 --port 23467
# 使用密码保护
python gui_fastapi.py --key your_password
```
### 启动原有 PyWebIO 后端 / Starting Original PyWebIO Backend
```bash
python gui.py
```
## API 端点 / API Endpoints
### 配置管理 / Configuration Management
```bash
# 获取所有实例列表
GET /api/config/instances
# 获取特定实例的配置
GET /api/config/{instance_name}
# 更新配置
POST /api/config/{instance_name}
Body: [{"path": "TaskName.GroupName.SettingName", "value": "new_value"}]
# 创建新实例
POST /api/config/create?name=new_instance&copy_from=template-aas
```
### 进程管理 / Process Management
```bash
# 获取所有进程状态
GET /api/process/
# 获取特定进程状态
GET /api/process/{instance_name}/status
# 启动进程
POST /api/process/{instance_name}/start
# 停止进程
POST /api/process/{instance_name}/stop
# 重启进程
POST /api/process/{instance_name}/restart
```
### 系统管理 / System Management
```bash
# 获取系统信息
GET /api/system/info
# 设置语言
POST /api/system/language
Body: {"language": "zh-CN"}
# 设置主题
POST /api/system/theme
Body: {"theme": "dark"}
# 检查更新
POST /api/system/update/check
# 执行更新
POST /api/system/update/run
```
### WebSocket
```javascript
// 连接到特定实例的日志流
const ws = new WebSocket('ws://localhost:23467/ws/logs/aas');
ws.onmessage = (event) => {
const data = JSON.parse(event.data);
console.log(data);
};
// 系统级 WebSocket
const sysWs = new WebSocket('ws://localhost:23467/ws/system');
```
## 前端界面 / Frontend Interface
### 布局 / Layout
新界面采用网格布局,分为四个主要区域:
The new interface uses a grid layout with four main areas:
1. **Header** (顶部) - 标题和状态指示器
2. **Aside** (左侧) - 实例导航
3. **Menu** (中左) - 功能菜单
4. **Content** (主要内容区) - 内容显示
### 样式 / Styling
前端复用了原有的 CSS 文件以保持一致的视觉风格:
The frontend reuses the original CSS files to maintain consistent styling:
- `assets/gui/css/alas.css` - 基础样式
- `assets/gui/css/alas-pc.css` - 桌面端样式
- `assets/gui/css/light-alas.css` - 浅色主题
- `assets/gui/css/dark-alas.css` - 深色主题
## 功能对比 / Feature Comparison
### 已实现 / Implemented ✅
- [x] 实例列表和选择
- [x] 进程控制 (启动/停止/重启)
- [x] 系统信息显示
- [x] 语言切换
- [x] 主题切换
- [x] WebSocket 日志流
- [x] REST API 端点
- [x] API 文档 (/docs)
### 待完善 / To Be Completed 🚧
- [ ] 完整的配置编辑器
- [ ] 任务调度可视化
- [ ] 日志过滤和搜索
- [ ] 更新系统界面
- [ ] 远程访问管理界面
- [ ] 移动端响应式优化
## 开发指南 / Development Guide
### 添加新的 API 端点 / Adding New API Endpoints
1. 在 `module/webui/fastapi_backend/routes/` 中创建或修改文件
2. 定义 Pydantic 模型用于请求/响应验证
3. 在 `main.py` 中注册路由器
示例 / Example:
```python
# routes/my_feature.py
from fastapi import APIRouter
from pydantic import BaseModel
router = APIRouter()
class MyRequest(BaseModel):
value: str
@router.post("/my-endpoint")
async def my_endpoint(request: MyRequest):
return {"status": "success", "received": request.value}
# main.py
from module.webui.fastapi_backend.routes import my_feature
app.include_router(my_feature.router, prefix="/api/my-feature", tags=["my-feature"])
```
### 修改前端 / Modifying Frontend
主要的前端文件:
Main frontend file:
- `module/webui/fastapi_backend/templates/index.html`
可以添加新的模板文件或静态资源到:
You can add new templates or static assets to:
- `module/webui/fastapi_backend/templates/`
- `module/webui/fastapi_backend/static/`
## 迁移建议 / Migration Recommendations
### 对于最终用户 / For End Users
1. **渐进式迁移** / **Gradual Migration**
- 继续使用 `python gui.py` 运行原有界面
- 尝试 `python gui_fastapi.py` 体验新界面
- 当新界面功能完善后再切换
2. **配置兼容** / **Config Compatibility**
- 两个界面共享相同的配置文件
- 切换界面不会丢失配置
### 对于开发者 / For Developers
1. **API 优先** / **API First**
- 使用 REST API 开发新功能
- 可以为移动端、CLI 等创建新客户端
2. **逐步迁移功能** / **Gradual Feature Migration**
- 从简单功能开始迁移
- 每个功能独立测试
- 保持与原有功能的兼容
## 故障排除 / Troubleshooting
### 常见问题 / Common Issues
1. **端口被占用** / **Port Already in Use**
```bash
python gui_fastapi.py --port 23468
```
2. **依赖缺失** / **Missing Dependencies**
```bash
pip install fastapi>=0.100.0 starlette>=0.27.0 uvicorn>=0.20.0 jinja2>=3.0.0
```
3. **WebSocket 连接失败** / **WebSocket Connection Failed**
- 检查防火墙设置
- 确保使用正确的协议 (ws:// 或 wss://)
4. **样式显示异常** / **Styling Issues**
- 清除浏览器缓存
- 检查 CSS 文件路径是否正确
## 技术栈 / Technology Stack
### 后端 / Backend
- FastAPI >= 0.100.0
- Starlette >= 0.27.0
- Uvicorn >= 0.20.0
- Pydantic
- Python 3.10+
### 前端 / Frontend
- HTML5
- CSS3 (Bootstrap 5)
- Vanilla JavaScript
- WebSocket API
## 性能对比 / Performance Comparison
| 指标 / Metric | PyWebIO | FastAPI |
|--------------|---------|---------|
| 启动时间 / Startup | ~2s | ~1s |
| API 响应 / API Response | N/A | <50ms |
| 内存占用 / Memory | ~100MB | ~80MB |
| 并发支持 / Concurrency | 有限 | 优秀 |
## 贡献 / Contributing
欢迎贡献代码!以下是一些可以改进的方向:
Contributions are welcome! Here are some areas for improvement:
1. 完善配置编辑器 / Complete config editor
2. 增强日志查看器 / Enhanced log viewer
3. 移动端适配 / Mobile responsiveness
4. 国际化改进 / i18n improvements
5. 单元测试 / Unit tests
6. 文档完善 / Documentation
## 反馈 / Feedback
如果遇到问题或有建议,请:
If you encounter issues or have suggestions:
1. 在 GitHub 上创建 Issue
2. 提供详细的错误信息和日志
3. 说明使用的 Python 版本和操作系统
## 许可 / License
本项目遵循与主项目相同的许可协议。
This follows the same license as the main project.
+27
View File
@@ -29,6 +29,33 @@
- [x] 日语 - [x] 日语
- [x] 英语 - [x] 英语
## WebUI 界面
项目提供两种 WebUI 后端选项:
### FastAPI 后端(推荐/新)
```bash
python gui_fastapi.py
```
- ✅ 现代化 REST API 架构
- ✅ 完整的 API 文档 (`/docs`)
- ✅ WebSocket 实时日志
- ✅ 更好的扩展性
- ✅ 保持与原界面一致的样式
### PyWebIO 后端(传统)
```bash
python gui.py
```
- 原有的 WebUI 实现
- 功能完整且稳定
详细说明请参阅 [FastAPI 迁移指南](FASTAPI_MIGRATION.md)
## 已知问题 ## 已知问题
若愿意提供其他语言或国服支持,请开 PR 或 Issue。 若愿意提供其他语言或国服支持,请开 PR 或 Issue。
+1 -9
View File
@@ -11,10 +11,6 @@ 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
@@ -60,7 +56,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, task="Momotalk").run() MomoTalk(config=self.config, device=self.device).run()
def mission(self): def mission(self):
from tasks.mission.mission import Mission from tasks.mission.mission import Mission
@@ -74,10 +70,6 @@ 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()
Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.4 KiB

After

Width:  |  Height:  |  Size: 6.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.7 KiB

After

Width:  |  Height:  |  Size: 8.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.9 KiB

After

Width:  |  Height:  |  Size: 9.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.4 KiB

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.8 KiB

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.2 KiB

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.7 KiB

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.0 KiB

After

Width:  |  Height:  |  Size: 5.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.6 KiB

After

Width:  |  Height:  |  Size: 4.8 KiB

+6 -26
View File
@@ -315,16 +315,6 @@ 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;
@@ -418,13 +408,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: 9.5rem; width: 10rem;
} }
[id^="pywebio-scope-dashboard-row-"] { [id^="pywebio-scope-dashboard-row-"] {
display: flex; display: flex;
flex-grow: 1; flex-grow: 1;
width: 9.5rem; width: 10rem;
} }
.dashboard-icon { .dashboard-icon {
@@ -446,26 +436,16 @@ 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: baseline; align-items: flex-end;
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;
@@ -570,19 +550,19 @@ pre.rich-traceback-code {
padding-right: .25rem; padding-right: .25rem;
} }
*[style*="--loading-grow--"] > div { *[style*="--loading-grow--"] {
width: 1.5rem; width: 1.5rem;
height: 1.5rem; height: 1.5rem;
} }
*[style*="--loading-border--"] > div { *[style*="--loading-border--"] {
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--"] > div { *[style*="--loading-border-fill--"] {
width: 1.5rem; width: 1.5rem;
height: 1.5rem; height: 1.5rem;
border: .2em solid currentColor; border: .2em solid currentColor;
+3 -3
View File
@@ -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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");
} }
select.state { .state > select {
border-bottom: 0; border-bottom: 0;
background-image: none; background-image: none;
pointer-events: none; pointer-events: none;
} }
select.state-bold { .state-bold > select {
font-weight: bold; font-weight: bold;
color: #7a77bb; color: #7a77bb;
} }
select.state-light { .state-light > select {
color: #777777; color: #777777;
} }
+3 -3
View File
@@ -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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");
} }
select.state { .state > select {
border-bottom: 0; border-bottom: 0;
background-image: none; background-image: none;
pointer-events: none; pointer-events: none;
} }
select.state-bold { .state-bold > select {
font-weight: bold; font-weight: bold;
color: #7a77bb; color: #7a77bb;
} }
select.state-light { .state-light > select {
color: #777777; color: #777777;
} }
Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.9 KiB

After

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.0 KiB

After

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.8 KiB

After

Width:  |  Height:  |  Size: 6.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.2 KiB

After

Width:  |  Height:  |  Size: 9.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.5 KiB

After

Width:  |  Height:  |  Size: 9.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.7 KiB

After

Width:  |  Height:  |  Size: 9.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.0 KiB

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.2 KiB

After

Width:  |  Height:  |  Size: 7.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.1 KiB

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.6 KiB

After

Width:  |  Height:  |  Size: 7.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.5 KiB

After

Width:  |  Height:  |  Size: 5.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.6 KiB

After

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.9 KiB

After

Width:  |  Height:  |  Size: 8.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.6 KiB

After

Width:  |  Height:  |  Size: 5.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.0 KiB

After

Width:  |  Height:  |  Size: 5.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.0 KiB

After

Width:  |  Height:  |  Size: 5.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.8 KiB

After

Width:  |  Height:  |  Size: 6.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.4 KiB

After

Width:  |  Height:  |  Size: 6.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.2 KiB

After

Width:  |  Height:  |  Size: 6.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.6 KiB

After

Width:  |  Height:  |  Size: 5.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.5 KiB

After

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.2 KiB

After

Width:  |  Height:  |  Size: 7.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.0 KiB

After

Width:  |  Height:  |  Size: 5.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.2 KiB

After

Width:  |  Height:  |  Size: 6.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.6 KiB

After

Width:  |  Height:  |  Size: 6.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.4 KiB

After

Width:  |  Height:  |  Size: 7.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.4 KiB

After

Width:  |  Height:  |  Size: 6.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.9 KiB

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.5 KiB

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.2 KiB

After

Width:  |  Height:  |  Size: 9.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.3 KiB

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.6 KiB

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.2 KiB

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.8 KiB

After

Width:  |  Height:  |  Size: 6.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.6 KiB

After

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.
-8
View File
@@ -157,14 +157,6 @@ 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
-8
View File
@@ -157,14 +157,6 @@ 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
-8
View File
@@ -181,14 +181,6 @@
"Schoolhouse": { "Schoolhouse": {
"Stage": 0, "Stage": 0,
"Count": 2 "Count": 2
},
"Road": {
"Stage": 0,
"Count": 0
},
"Mansion": {
"Stage": 0,
"Count": 0
} }
}, },
"Scrimmage": { "Scrimmage": {
+19 -21
View File
@@ -1,10 +1,10 @@
import os import os
import time import time
from typing import Iterable import typing as t
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 cached_property, iter_process from deploy.Windows.utils import DataProcessInfo, cached_property, iter_process
class AlasManager(DeployConfig): class AlasManager(DeployConfig):
@@ -19,45 +19,43 @@ class AlasManager(DeployConfig):
def self_pid(self): def self_pid(self):
return os.getpid() return os.getpid()
def list_process(self) -> "list[tuple[int, list[str]]]": def list_process(self) -> t.List[DataProcessInfo]:
logger.info('List process') logger.info('List process')
process_data = list(iter_process()) process = list(iter_process())
logger.info(f'Found {len(process_data)} processes') logger.info(f'Found {len(process)} processes')
return process_data return process
def iter_process_by_names(self, names, in_alas=False) -> "Iterable[int]": def iter_process_by_names(self, names, in_alas=False) -> t.Iterable[DataProcessInfo]:
""" """
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:
pid: DataProcessInfo:
""" """
if not isinstance(names, list): if not isinstance(names, list):
names = [names] names = [names]
try: try:
for pid, cmdline in self.list_process(): for proc 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:
exe = exe.replace(r"\\", "/").replace("\\", "/") cmdline = proc.cmdline.replace(r"\\", "/").replace("\\", "/")
for folder in self.alas_folder: for folder in self.alas_folder:
if folder in exe: if folder in cmdline:
yield pid yield proc
else: else:
yield pid yield proc
except Exception as e: except Exception as e:
logger.info(str(e)) logger.info(str(e))
return False return False
def kill_process(self, pid: int): def kill_process(self, process: DataProcessInfo):
self.execute(f'taskkill /f /t /pid {pid}', allow_failure=True, output=False) self.execute(f'taskkill /f /t /pid {process.pid}', allow_failure=True, output=False)
def alas_kill(self): def alas_kill(self):
for _ in range(10): for _ in range(10):
-630
View File
@@ -1,630 +0,0 @@
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
+29 -51
View File
@@ -1,7 +1,6 @@
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
@@ -57,8 +56,6 @@ 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
@@ -80,45 +77,12 @@ 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 show_config(self): def set_repo(self):
logger.hr("Show deploy config", 1)
for k, v in self.config.items():
if k in ("Password", "SSHUser"):
continue
if self.config_template.get(k) == v:
continue
logger.info(f"{k}: {v}")
logger.info(f"Rest of the configs are the same as default")
def read(self):
"""
Read and update deploy config, copy `self.configs` to properties.
"""
self.config = poor_yaml_read(DEPLOY_TEMPLATE)
self.config_template = copy.deepcopy(self.config)
origin = poor_yaml_read(self.file)
self.config.update(origin)
for key, value in self.config.items():
if hasattr(self, key):
super().__setattr__(key, value)
self.config_redirect()
if self.config != origin:
self.write()
def write(self):
poor_yaml_write(self.config, self.file)
def config_redirect(self):
"""
Redirect deploy config, must be called after each `read()`
"""
# Bypass webui.config.DeployConfig.__setattr__() # Bypass webui.config.DeployConfig.__setattr__()
# Don't write these into deploy.yaml # Don't write these into deploy.yaml
if self.Repository == 'cn': if self.Repository == 'cn':
@@ -126,6 +90,29 @@ class DeployConfig(ConfigModel):
if self.Repository == 'global': if self.Repository == 'global':
super().__setattr__('Repository', 'https://github.com/TheFunny/ArisuAutoSweeper') super().__setattr__('Repository', 'https://github.com/TheFunny/ArisuAutoSweeper')
def show_config(self):
logger.hr("Show deploy config", 1)
for k, v in self.config.items():
if k in ("Password", "SSHUser"):
continue
if self.config_template[k] == v:
continue
logger.info(f"{k}: {v}")
logger.info(f"Rest of the configs are the same as default")
def read(self):
self.config = poor_yaml_read(DEPLOY_TEMPLATE)
self.config_template = copy.deepcopy(self.config)
self.config.update(poor_yaml_read(self.file))
for key, value in self.config.items():
if hasattr(self, key):
super().__setattr__(key, value)
def write(self):
poor_yaml_write(self.config, self.file)
def filepath(self, path): def filepath(self, path):
""" """
Args: Args:
@@ -157,7 +144,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 exist, use `adb` instead') logger.warning(f'AdbExecutable: {exe} does not exists, use `adb` instead')
return 'adb' return 'adb'
@cached_property @cached_property
@@ -166,21 +153,12 @@ class DeployConfig(ConfigModel):
if os.path.exists(exe): if os.path.exists(exe):
return exe return exe
logger.warning(f'GitExecutable: {exe} does not exist, use `git` instead') logger.warning(f'GitExecutable: {exe} does not exists, use `git` instead')
return 'git' return 'git'
@cached_property @cached_property
def python(self) -> str: def python(self) -> str:
# No need to read PythonExecutable return self.filepath(self.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:
+5 -10
View File
@@ -2,7 +2,6 @@ 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
@@ -10,8 +9,7 @@ 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
if sys.platform.startswith('win'): asyncio.set_event_loop_policy(asyncio.WindowsProactorEventLoopPolicy())
asyncio.set_event_loop_policy(asyncio.WindowsProactorEventLoopPolicy())
@dataclass @dataclass
@@ -142,13 +140,10 @@ 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)
try: logger.info(f'{adb} -----> {bak}')
logger.info(f'{adb} -----> {bak}') shutil.move(adb, bak)
shutil.move(adb, bak) logger.info(f'{self.adb} -----> {adb}')
logger.info(f'{self.adb} -----> {adb}') shutil.copy(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):
""" """
+2 -30
View File
@@ -59,34 +59,17 @@ 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.
1. So we patch `uiautomator2/init.py` to a local assets cache `uiautomator2cache/cache`. 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')
2. And we also remove minicap installations since emulators doesn't need it. 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'
@@ -109,17 +92,6 @@ 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)
+4 -10
View File
@@ -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 Progress, logger from deploy.Windows.logger import logger, Progress
from deploy.Windows.utils import cached_property from deploy.Windows.utils import cached_property
@@ -46,15 +46,9 @@ class PipManager(DeployConfig):
return f'"{self.python}" -m pip' return f'"{self.python}" -m pip'
@cached_property @cached_property
def python_site_packages(self) -> str: def python_site_packages(self):
import site return os.path.abspath(os.path.join(self.python, '../Lib/site-packages')) \
paths = site.getsitepackages() .replace(r"\\", "/").replace("\\", "/")
# 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]:
-8
View File
@@ -157,14 +157,6 @@ 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
+65 -72
View File
@@ -1,9 +1,8 @@
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'
@@ -65,26 +64,29 @@ def poor_yaml_read(file):
Returns: Returns:
dict: dict:
""" """
content = atomic_read_text(file) if not os.path.exists(file):
return {}
data = {} data = {}
regex = re.compile(r'^(.*?):(.*?)$') regex = re.compile(r'^(.*?):(.*?)$')
for line in content.splitlines(): with open(file, 'r', encoding='utf-8') as f:
line = line.strip('\n\r\t ').replace('\\', '/') for line in f.readlines():
if line.startswith('#'): line = line.strip('\n\r\t ').replace('\\', '/')
continue if line.startswith('#'):
result = re.match(regex, line) continue
if result: result = re.match(regex, line)
k, v = result.group(1), result.group(2).strip('\n\r\t\' ') if result:
if v: k, v = result.group(1), result.group(2).strip('\n\r\t\' ')
if v.lower() == 'null': if v:
v = None if v.lower() == 'null':
elif v.lower() == 'false': v = None
v = False elif v.lower() == 'false':
elif v.lower() == 'true': v = False
v = True elif v.lower() == 'true':
elif v.isdigit(): v = True
v = int(v) elif v.isdigit():
data[k] = v v = int(v)
data[k] = v
return data return data
@@ -96,8 +98,8 @@ def poor_yaml_write(data, file, template_file=DEPLOY_TEMPLATE):
file (str): file (str):
template_file (str): template_file (str):
""" """
text = atomic_read_text(template_file) with open(template_file, 'r', encoding='utf-8') as f:
text = text.replace('\\', '/') text = f.read().replace('\\', '/')
for key, value in data.items(): for key, value in data.items():
if value is None: if value is None:
@@ -108,15 +110,38 @@ 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)
atomic_write(file, text) with open(file, 'w', encoding='utf-8', newline='') as f:
f.write(text)
def iter_process() -> "Iterable[tuple[int, list[str]]]": @dataclass
""" class DataProcessInfo:
Yields: proc: object # psutil.Process or psutil._pswindows.Process
int: pid pid: int
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:
@@ -126,48 +151,16 @@ def iter_process() -> "Iterable[tuple[int, list[str]]]":
# 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)
try: yield DataProcessInfo(
cmdline = proc.cmdline() proc=proc,
except (psutil.AccessDenied, psutil.NoSuchProcess, OSError): pid=proc.pid,
continue )
else:
# Validate cmdline # This will cost about 0.45s, even `attr` is given.
if not cmdline: for proc in psutil.process_iter():
continue yield DataProcessInfo(
try: proc=proc,
cmdline[0] pid=proc.pid,
except IndexError: )
continue
yield pid, cmdline
-2
View File
@@ -13,8 +13,6 @@ 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()
+27 -125
View File
@@ -3,61 +3,20 @@ 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_center, area_limit, area_pad, corner2area, get_bbox, get_color, \ from module.base.utils import SelectedGrids, area_limit, area_pad, get_bbox, get_color, image_size, load_image
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 iter_folder from module.config.utils import deep_get, deep_iter, deep_set, 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}/'
@@ -65,7 +24,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|\.GRID)?' f'(?P<attr>\.AREA|\.SEARCH|\.COLOR|\.BUTTON)?'
f'\.png$' f'\.png$'
) )
@@ -87,7 +46,6 @@ 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
@@ -108,7 +66,6 @@ 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)
@@ -122,10 +79,6 @@ 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):
@@ -134,40 +87,13 @@ 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:
if not file.startswith('.'): file = os.path.join(path, file).replace('\\', '/')
file = os.path.join(path, file).replace('\\', '/') yield AssetsImage(file)
yield AssetsImage(file)
def iter_grids(images):
for image in images:
for grid in image.iter_grids():
yield grid
@dataclass @dataclass
@@ -177,17 +103,11 @@ 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)
@@ -200,6 +120,7 @@ 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):
@@ -208,21 +129,14 @@ 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}')
@@ -237,22 +151,17 @@ 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 image.is_GRID: if not images.indexed_select(image.module, image.assets, image.server, image.frame, ''):
pass logger.warning(f'Attribute assets has no parent assets: {image.file}')
else: image.valid = False
if not images.indexed_select(image.module, image.assets, image.server, image.frame, ''): if not images.indexed_select(image.module, image.assets, image.server, 1, ''):
logger.warning(f'Attribute assets has no parent assets: {image.file}') logger.warning(f'Attribute assets has no first frame: {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
@@ -261,34 +170,31 @@ def iter_assets():
# Convert to DataAssets # Convert to DataAssets
data = {} data = {}
for image in images: for image in images:
if image.is_base: if image.attr == '':
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 not image.is_base: if image.attr != '':
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)
for frame in frames.values(): # If `search` attribute is set in the first frame, apply to all
# Generate `search` from `area` first = frames[1]
if not frame.has_raw_search: if first.search:
frame.search = DataAssets.area_to_search(frame.area) for frame in frames.values():
# 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
if not frame.has_raw_color and first.has_raw_color: else:
frame.color = first.color for frame in frames.values():
if not frame.has_raw_button and first.has_raw_button: if frame.search:
frame.button = first.button # Follow frame specific `search`
pass
else:
# Generate `search` from `area`
frame.search = DataAssets.area_to_search(frame.area)
return data return data
@@ -334,8 +240,6 @@ 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')):
@@ -344,8 +248,6 @@ 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'))
+4 -4
View File
@@ -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(
'输入aas配置文件名称,或者模拟器serial,或者模拟器端口号: (默认输入 "aas"):\n' '输入alas配置文件名称,或者模拟器serial,或者模拟器端口号: (默认输入 "alas"):\n'
'例如:"aas", "127.0.0.1:16384", "7555"\n' '例如:"alas", "127.0.0.1:16384", "7555"\n'
) )
name = name.strip().strip('"').strip() name = name.strip().strip('"').strip()
if not name: if not name:
name = 'aas' name = 'alas'
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():
+1 -20
View File
@@ -40,12 +40,6 @@ 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="+",
@@ -56,15 +50,11 @@ 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)
@@ -74,16 +64,7 @@ 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)
if ssl_cert is None and ssl_key is not None: uvicorn.run("module.webui.app:app", host=host, port=port, factory=True)
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__":
+92
View File
@@ -0,0 +1,92 @@
import threading
from multiprocessing import Event, Process
from module.logger import logger
from module.webui.setting import State
def func(ev: threading.Event):
import argparse
import asyncio
import sys
import uvicorn
if sys.platform.startswith("win"):
asyncio.set_event_loop_policy(asyncio.WindowsProactorEventLoopPolicy())
State.restart_event = ev
parser = argparse.ArgumentParser(description="Alas FastAPI web service")
parser.add_argument(
"--host",
type=str,
help="Host to listen. Default to WebuiHost in deploy setting",
)
parser.add_argument(
"-p",
"--port",
type=int,
help="Port to listen. Default to WebuiPort in deploy setting",
)
parser.add_argument(
"-k", "--key", type=str, help="Password of alas. No password by default"
)
parser.add_argument(
"--electron", action="store_true", help="Runs by electron client."
)
parser.add_argument(
"--run",
nargs="+",
type=str,
help="Run alas by config names on startup",
)
args, _ = parser.parse_known_args()
host = args.host or State.deploy_config.WebuiHost or "0.0.0.0"
port = args.port or int(State.deploy_config.WebuiPort) or 23467
State.electron = args.electron
logger.hr("Launcher config")
logger.attr("Host", host)
logger.attr("Port", port)
logger.attr("Backend", "FastAPI")
logger.attr("Electron", args.electron)
logger.attr("Reload", ev is not None)
if State.electron:
logger.info("Electron detected, remove log output to stdout")
from module.logger.logger import console_hdlr
logger.removeHandler(console_hdlr)
# Use the new FastAPI backend
uvicorn.run(
"module.webui.fastapi_backend.main:app",
host=host,
port=port,
reload=False
)
if __name__ == "__main__":
if State.deploy_config.EnableReload:
should_exit = False
while not should_exit:
event = Event()
process = Process(target=func, args=(event,))
process.start()
while not should_exit:
try:
b = event.wait(1)
except KeyboardInterrupt:
should_exit = True
break
if b:
process.kill()
break
elif process.is_alive():
continue
else:
should_exit = True
else:
func(None)
+1 -1
View File
@@ -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:
+43 -74
View File
@@ -1,5 +1,5 @@
import platform import os
import subprocess import re
import threading import threading
import time import time
from datetime import datetime, timedelta from datetime import datetime, timedelta
@@ -7,14 +7,16 @@ 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.deep import deep_get, deep_set from module.config.utils import deep_get, deep_set
from module.exception import * from module.exception import *
from module.logger import logger, save_error_log from module.logger import logger
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
@@ -64,22 +66,9 @@ 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:
@@ -87,68 +76,61 @@ 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 False return True
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 AAS') logger.warning('If you are playing by hand, please stop Alas')
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 Blue Archive game client, AAS is unable to handle') logger.warning('An error has occurred in Azur Lane game client, Alas is unable to handle')
logger.warning(f'Restarting {self.device.package} to fix it') 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"AAS <{self.config_name}> crashed", title=f"Alas <{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.exception(e) logger.critical(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"AAS <{self.config_name}> crashed", title=f"Alas <{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"AAS <{self.config_name}> crashed", title=f"Alas <{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"AAS <{self.config_name}> crashed", title=f"Alas <{self.config_name}> crashed",
content=f"<{self.config_name}> Exception occured", content=f"<{self.config_name}> Exception occured",
) )
exit(1) exit(1)
@@ -158,13 +140,29 @@ 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
""" """
save_error_log(config=self.config, device=self.device) from module.base.utils import save_image
from module.handler.sensitive_info import (handle_sensitive_image, handle_sensitive_logs)
def error_postprocess(self): if self.config.Error_SaveError:
""" if not os.path.exists('./log/error'):
Do something when error occurred os.mkdir('./log/error')
""" folder = f'./log/error/{int(time.time() * 1000)}'
pass logger.warning(f'Saving error: {folder}')
os.mkdir(folder)
for data in self.device.screenshot_deque:
image_time = datetime.strftime(data['time'], '%Y-%m-%d_%H-%M-%S-%f')
image = handle_sensitive_image(data['image'])
save_image(image, f'{folder}/{image_time}.png')
with open(logger.log_file, 'r', encoding='utf-8') as f:
lines = f.readlines()
start = 0
for index, line in enumerate(lines):
line = line.strip(' \r\t\n')
if re.match('^═{15,}$', line):
start = index
lines = lines[start - 2:]
lines = handle_sensitive_logs(lines)
with open(f'{folder}/log.txt', 'w', encoding='utf-8') as f:
f.writelines(lines)
def wait_until(self, future): def wait_until(self, future):
""" """
@@ -212,16 +210,13 @@ 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.run('stop') self.device.app_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
if task.command != 'Restart': self.run('start')
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')
@@ -237,31 +232,6 @@ 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'
@@ -323,7 +293,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"AAS [{self.config_name}] exited.") logger.info(f"Alas [{self.config_name}] exited.")
break break
# Check game server maintenance # Check game server maintenance
self.checker.wait_until_available() self.checker.wait_until_available()
@@ -339,7 +309,6 @@ 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')
@@ -369,7 +338,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"AAS <{self.config_name}> crashed", title=f"Alas <{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)
+18 -239
View File
@@ -4,9 +4,7 @@ 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:
@@ -28,8 +26,6 @@ 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:
@@ -49,111 +45,6 @@ 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:
@@ -184,36 +75,6 @@ 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:
@@ -259,56 +120,9 @@ class ModuleBase:
return appear return appear
def xpath(self, xpath) -> HierarchyButton: appear = match_template
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)
@@ -396,7 +210,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, copy=False), color=color) image = color_similarity_2d(self.image_crop(area), 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
@@ -405,26 +219,7 @@ 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(area=button_area, name=name) return ClickButton(button=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)):
@@ -433,7 +228,10 @@ class ModuleBase:
return return
if button is not None: if button is not None:
self.get_interval_timer(button, interval=interval).reset() if button.name in self.interval_timer:
self.interval_timer[button.name].reset()
else:
self.interval_timer[button.name] = Timer(interval).reset()
def interval_clear(self, button, interval=5): def interval_clear(self, button, interval=5):
if isinstance(button, (list, tuple)): if isinstance(button, (list, tuple)):
@@ -442,10 +240,19 @@ class ModuleBase:
return return
if button is not None: if button is not None:
self.get_interval_timer(button, interval=interval).clear() if button.name in self.interval_timer:
self.interval_timer[button.name].clear()
else:
self.interval_timer[button.name] = Timer(interval).clear()
def interval_is_reached(self, button, interval=5): def interval_is_reached(self, button, interval=5):
return self.get_interval_timer(button, interval=interval, renew=True).reached() if button.name in self.interval_timer:
if self.interval_timer[button.name].limit != interval:
self.interval_timer[button.name] = Timer(interval)
else:
self.interval_timer[button.name] = Timer(interval)
return self.interval_timer[button.name].reached()
_image_file = '' _image_file = ''
@@ -475,31 +282,3 @@ 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)
+10 -120
View File
@@ -6,7 +6,7 @@ from module.exception import ScriptError
class Button(Resource): class Button(Resource):
def __init__(self, file, area, search, color, button, posi=None): def __init__(self, file, area, search, color, button):
""" """
Args: Args:
file: Filepath to an assets file: Filepath to an assets
@@ -20,7 +20,6 @@ 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)
@@ -29,60 +28,18 @@ 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, offset): def load_offset(self, button):
""" 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):
@@ -135,29 +92,6 @@ 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
@@ -200,7 +134,7 @@ class Button(Resource):
Returns: Returns:
bool. bool.
""" """
matched = self.match_template_luma(image, similarity=similarity, direct_match=direct_match) matched = self.match_template(image, similarity=similarity, direct_match=direct_match)
if not matched: if not matched:
return False return False
@@ -274,13 +208,6 @@ 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
@@ -357,8 +284,10 @@ class ButtonWrapper(Resource):
Load offset from another button. Load offset from another button.
Args: Args:
button (Button | ButtonWrapper | tuple[int, int]): button (Button, ButtonWrapper):
""" """
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)
@@ -366,55 +295,16 @@ class ButtonWrapper(Resource):
for b in self.iter_buttons(): for b in self.iter_buttons():
b.clear_offset() b.clear_offset()
def is_offset_in(self, x=0, y=0): def load_search(self, area):
"""
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:
search (Button | ButtonWrapper | tuple[int, int, int, int]): area:
""" """
for b in self.iter_buttons(): for b in self.iter_buttons():
b.load_search(search) b.search = area
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:
+9 -55
View File
@@ -33,33 +33,23 @@ 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 = self.generate() lines = ''.join(self.lines)
print(lines) print(lines)
def write(self, file: str = None): def write(self, file: str = None):
lines = self.generate() lines = ''.join(self.lines)
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)
@@ -87,10 +77,7 @@ class CodeGenerator:
return TabWrapper(self) return TabWrapper(self)
def Empty(self): def Empty(self):
self.lines.append('\n') self.add('')
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'):
@@ -99,9 +86,6 @@ 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:
@@ -133,7 +117,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=']') return TabWrapper(self, prefix='[', suffix=']', newline=False)
def ListItem(self, value): def ListItem(self, value):
if isinstance(value, TabWrapper): if isinstance(value, TabWrapper):
@@ -147,7 +131,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='}') return TabWrapper(self, prefix='{', suffix='}', newline=False)
def DictItem(self, key=None, value=None): def DictItem(self, key=None, value=None):
if isinstance(value, TabWrapper): if isinstance(value, TabWrapper):
@@ -163,7 +147,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=')') return TabWrapper(self, prefix=f'{object_class}(', suffix=')', newline=False)
def ObjectAttr(self, key=None, value=None): def ObjectAttr(self, key=None, value=None):
if isinstance(value, TabWrapper): if isinstance(value, TabWrapper):
@@ -195,33 +179,3 @@ 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
-12
View File
@@ -123,18 +123,6 @@ 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.
-6
View File
@@ -78,11 +78,5 @@ 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()
-110
View File
@@ -1,110 +0,0 @@
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
+87 -119
View File
@@ -1,168 +1,137 @@
from datetime import datetime import time
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):
start = time() t0 = time.time()
result = function(*args, **kwargs) result = function(*args, **kwargs)
cost = time() - start t1 = time.time()
print(f'{function.__name__}: {cost:.10f} s') print('%s: %s s' % (function.__name__, str(round(t1 - t0, 10))))
return result return result
return function_timer return function_timer
def now(): def future_time(string):
""" """
Get datatime now without timezone Args:
string (str): Such as 14:59.
Returns:
datetime.datetime: Time with given hour, minute in the future.
""" """
return datetime.now().replace(microsecond=0) hour, minute = [int(x) for x in string.split(':')]
future = datetime.now().replace(hour=hour, minute=minute, second=0, microsecond=0)
future = future + timedelta(days=1) if future < datetime.now() else future
return future
def nowtz(): def past_time(string):
""" """
Get datatime now with timezone Args:
string (str): Such as 14:59.
Returns:
datetime.datetime: Time with given hour, minute in the past.
""" """
return datetime.now().replace(microsecond=0).astimezone() hour, minute = [int(x) for x in string.split(':')]
past = datetime.now().replace(hour=hour, minute=minute, second=0, microsecond=0)
past = past - timedelta(days=1) if past > datetime.now() else past
return past
def future_time_range(string):
"""
Args:
string (str): Such as 23:30-06:30.
Returns:
tuple(datetime.datetime): (time start, time end).
"""
start, end = [future_time(s) for s in string.split('-')]
if start > end:
start = start - timedelta(days=1)
return start, end
def time_range_active(time_range):
"""
Args:
time_range(tuple(datetime.datetime)): (time start, time end).
Returns:
bool:
"""
return time_range[0] < datetime.now() < time_range[1]
class Timer: 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 access count. Default to 0. count (int): Timer reach confirm count. Default to 0.
When using a structure like this, must set a count.
Otherwise it goes wrong, if screenshot time cost greater than limit.
if self.appear(MAIN_CHECK):
if confirm_timer.reached():
pass
else:
confirm_timer.reset()
Also, It's a good idea to set `count`, to make alas run more stable on slow computers.
Expected speed is 0.35 second / screenshot.
""" """
self.limit = limit self.limit = limit
self.count = count self.count = count
self._start = 0. self._current = 0
self._access = 0 self._reach_count = count
@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():
Start current timer. self._current = time.time()
If timer not started, reached() always return True. So we can have fast first try on: self._reach_count = 0
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)
Returns:
bool:
"""
return self._start > 0
def current_time(self): def current(self):
""" """
Returns: Returns:
float: float
""" """
if self._start > 0: if self.started():
diff = time() - self._start return time.time() - self._current
if diff < 0:
diff = 0.
return diff
else: else:
return 0. return 0.
def current_count(self): def set_current(self, current, count=0):
""" self._current = time.time() - current
Returns: self._reach_count = count
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
""" """
# each reached() call is consider as an access self._reach_count += 1
self._access += 1 return time.time() - self._current > self.limit and self._reach_count > self.count
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()
Reset the timer as if it just started self._reach_count = 0
"""
self._start = time()
self._access = 0
return self return self
def clear(self): def clear(self):
""" self._current = 0
Reset the timer as if it never started self._reach_count = self.count
"""
self._start = 0.
self._access = self.count
return self return self
def reached_and_reset(self): def reached_and_reset(self):
@@ -180,16 +149,15 @@ class Timer:
""" """
Wait until timer reached. Wait until timer reached.
""" """
diff = self._start + self.limit - time() diff = self._current + self.limit - time.time()
if diff > 0: if diff > 0:
sleep(diff) time.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):
# Timer(limit=2.351/3, count=4/6) return f'Timer(limit={round(self.current(), 3)}/{self.limit}, count={self._reach_count}/{self.count})'
return f'Timer(limit={round(self.current_time(), 3)}/{self.limit}, count={self._access}/{self.count})'
__repr__ = __str__ __repr__ = __str__
+65 -366
View File
@@ -1,4 +1,3 @@
import random
import re import re
import cv2 import cv2
@@ -9,9 +8,8 @@ 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
Generate a normal distribution int within the interval. simulate normal distribution.
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.
@@ -21,13 +19,9 @@ 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:
total = 0 output = np.mean(np.random.randint(a, b, size=n))
for _ in range(n): return int(output.round())
total += random.randint(a, b)
return round(total / n)
else: else:
return b return b
@@ -280,20 +274,6 @@ 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.
@@ -517,22 +497,6 @@ 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.
@@ -544,17 +508,13 @@ def load_image(file, area=None):
Returns: Returns:
np.ndarray: np.ndarray:
""" """
# always remember to close Image object image = Image.open(file)
with Image.open(file) as f: if area is not None:
if area is not None: image = image.crop(area)
f = f.crop(area) image = np.array(image)
channel = image.shape[2] if len(image.shape) > 2 else 1
image = np.array(f) if channel > 3:
image = image[:, :, :3].copy()
channel = image_channel(image)
if channel == 4:
image = cv2.cvtColor(image, cv2.COLOR_RGBA2RGB)
return image return image
@@ -571,19 +531,6 @@ 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.
@@ -597,72 +544,16 @@ def crop(image, area, copy=True):
Returns: Returns:
np.ndarray: np.ndarray:
""" """
# map(round, area) x1, y1, x2, y2 = map(int, map(round, area))
x1, y1, x2, y2 = area h, w = image.shape[:2]
x1 = round(x1) border = np.maximum((0 - y1, y2 - h, 0 - x1, x2 - w), 0)
y1 = round(y1) x1, y1, x2, y2 = np.maximum((x1, y1, x2, y2), 0)
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 border if sum(border) > 0:
if top or bottom or left or right: image = cv2.copyMakeBorder(image, *border, borderType=cv2.BORDER_CONSTANT, value=(0, 0, 0))
if len(shape) == 2: if copy:
value = 0 image = image.copy()
else: return image
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):
@@ -720,29 +611,17 @@ 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)
maximum = cv2.max(r, g) return cv2.add(
cv2.min(r, g, dst=r) cv2.multiply(cv2.max(cv2.max(r, g), b), 0.5),
cv2.max(maximum, b, dst=maximum) cv2.multiply(cv2.min(cv2.min(r, g), b), 0.5)
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):
@@ -757,7 +636,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)
cv2.multiply(image, (360 / 180, 100 / 255, 100 / 255, 0), dst=image) image *= (360 / 180, 100 / 255, 100 / 255)
return image return image
@@ -805,133 +684,40 @@ 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):
""" """
Get outbound box of the content in image A numpy implementation of the getbbox() in pillow.
A opencv implementation of the getbbox() in pillow
Args: Args:
image (np.ndarray): image (np.ndarray): Screenshot.
threshold (int): threshold (int): Color <= threshold will be considered black
color > threshold will be considered as content
color <= threshold will be considered background
Returns: Returns:
tuple[int, int, int, int]: area tuple: (upper_left_x, upper_left_y, bottom_right_x, bottom_right_y)
Raises:
ImageNotSupported: if failed to get bbox
""" """
channel = image_channel(image) if image_channel(image) == 3:
# convert to grayscale image = np.max(image, axis=2)
if channel == 3: x = np.where(np.max(image, axis=0) > threshold)[0]
# RGB y = np.where(np.max(image, axis=1) > threshold)[0]
mask = cv2.cvtColor(image, cv2.COLOR_RGB2GRAY) return x[0], y[0], x[-1] + 1, y[-1] + 1
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=255): def get_bbox_reversed(image, threshold=0):
""" """
Get outbound box of the content in image Similar to `get_bbox` but for black contents on white background.
A opencv implementation of the getbbox() in pillow
Args: Args:
image (np.ndarray): image (np.ndarray): Screenshot.
threshold (int): threshold (int): Color >= threshold will be considered white
color < threshold will be considered as content
color >= threshold will be considered background
Returns: Returns:
tuple[int, int, int, int]: area tuple: (upper_left_x, upper_left_y, bottom_right_x, bottom_right_y)
Raises:
ImageNotSupported: if failed to get bbox
""" """
channel = image_channel(image) if image_channel(image) == 3:
# convert to grayscale image = np.min(image, axis=2)
if channel == 3: x = np.where(np.min(image, axis=0) < threshold)[0]
# RGB y = np.where(np.min(image, axis=1) < threshold)[0]
mask = cv2.cvtColor(image, cv2.COLOR_RGB2GRAY) return x[0], y[0], x[-1] + 1, y[-1] + 1
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):
@@ -943,35 +729,13 @@ def color_similarity(color1, color2):
Returns: Returns:
int: int:
""" """
# 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 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.
@@ -984,28 +748,8 @@ 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
@@ -1033,24 +777,11 @@ 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):
@@ -1064,25 +795,11 @@ 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):
@@ -1096,23 +813,10 @@ 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))
maximum = cv2.max(r, g) minimum = cv2.min(cv2.min(r, g), b)
cv2.min(r, g, dst=r) maximum = cv2.max(cv2.max(r, g), b)
cv2.max(maximum, b, dst=maximum) return cv2.multiply(cv2.add(maximum, cv2.subtract(maximum, minimum)), 255.0 / threshold)
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):
@@ -1131,9 +835,7 @@ 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)
@@ -1193,7 +895,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, copy=False) image = crop(image, area)
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
@@ -1212,9 +914,6 @@ 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
# Look back 5px to get average color prev_color = np.mean(image[:, prev_index], axis=0)
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.
+3 -52
View File
@@ -41,10 +41,9 @@
"aScreenCap_nc", "aScreenCap_nc",
"DroidCast", "DroidCast",
"DroidCast_raw", "DroidCast_raw",
"scrcpy", "scrcpy"
"nemu_ipc", ],
"ldopengl" "display": "hide"
]
}, },
"ControlMethod": { "ControlMethod": {
"type": "select", "type": "select",
@@ -75,7 +74,6 @@
"LDPlayer3", "LDPlayer3",
"LDPlayer4", "LDPlayer4",
"LDPlayer9", "LDPlayer9",
"LDPlayer14",
"MuMuPlayer", "MuMuPlayer",
"MuMuPlayerX", "MuMuPlayerX",
"MuMuPlayer12", "MuMuPlayer12",
@@ -132,7 +130,6 @@
"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",
@@ -827,52 +824,6 @@
"type": "input", "type": "input",
"value": 2 "value": 2
} }
},
"Road": {
"Stage": {
"type": "select",
"value": 0,
"option": [
0,
1,
2,
3,
4,
5,
6,
7,
8,
9,
10
]
},
"Count": {
"type": "input",
"value": 0
}
},
"Mansion": {
"Stage": {
"type": "select",
"value": 0,
"option": [
0,
1,
2,
3,
4,
5,
6,
7,
8,
9,
10
]
},
"Count": {
"type": "input",
"value": 0
}
} }
}, },
"Scrimmage": { "Scrimmage": {
+2 -13
View File
@@ -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, nemu_ipc, ldopengl, ] option: [ auto, ADB, ADB_nc, uiautomator2, aScreenCap, aScreenCap_nc, DroidCast, DroidCast_raw, scrcpy ]
ControlMethod: ControlMethod:
value: MaaTouch value: MaaTouch
option: [ minitouch, MaaTouch ] option: [ minitouch, MaaTouch ]
@@ -45,7 +45,6 @@ EmulatorInfo:
LDPlayer3, LDPlayer3,
LDPlayer4, LDPlayer4,
LDPlayer9, LDPlayer9,
LDPlayer14,
MuMuPlayer, MuMuPlayer,
MuMuPlayerX, MuMuPlayerX,
MuMuPlayer12, MuMuPlayer12,
@@ -72,7 +71,7 @@ Optimization:
CombatScreenshotInterval: 1.0 CombatScreenshotInterval: 1.0
WhenTaskQueueEmpty: WhenTaskQueueEmpty:
value: goto_main value: goto_main
option: [ stay_there, goto_main, close_game, close_emulator, exit_aas, exit_emulator, exit_aas_emulator, shutdown ] option: [ stay_there, goto_main, close_game, exit_aas, exit_emulator, exit_aas_emulator, shutdown ]
# ==================== Daily ==================== # ==================== Daily ====================
@@ -169,16 +168,6 @@ Schoolhouse:
value: 0 value: 0
option: [ 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10 ] option: [ 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10 ]
Count: 2 Count: 2
Road:
Stage:
value: 0
option: [ 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10 ]
Count: 0
Mansion:
Stage:
value: 0
option: [ 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10 ]
Count: 0
Scrimmage: Scrimmage:
OnError: OnError:
-3
View File
@@ -4,9 +4,6 @@
# ==================== Alas ==================== # ==================== Alas ====================
Alas:
Emulator:
ScreenshotMethod: scrcpy
Cafe: Cafe:
Scheduler: Scheduler:
Enable: true Enable: true
-1
View File
@@ -61,7 +61,6 @@ Dashboard:
HoursAgo: HoursAgo:
DaysAgo: DaysAgo:
LongTimeAgo: LongTimeAgo:
EtaDays:
AddAlas: AddAlas:
PopupTitle: PopupTitle:
+1
View File
@@ -7,6 +7,7 @@
Alas: Alas:
Emulator: Emulator:
ScreenshotMethod: scrcpy
ControlMethod: MaaTouch ControlMethod: MaaTouch
Optimization: Optimization:
ScreenshotInterval: 0.2 ScreenshotInterval: 0.2
-2
View File
@@ -56,8 +56,6 @@ Farm:
- Highway - Highway
- DesertRailroad - DesertRailroad
- Schoolhouse - Schoolhouse
- Road
- Mansion
Scrimmage: Scrimmage:
- Scheduler - Scheduler
- Scrimmage - Scrimmage
+236
View File
@@ -0,0 +1,236 @@
"""
Copy-pasted from
https://github.com/untitaker/python-atomicwrites
"""
import contextlib
import io
import os
import sys
import tempfile
try:
import fcntl
except ImportError:
fcntl = None
# `fspath` was added in Python 3.6
try:
from os import fspath
except ImportError:
fspath = None
__version__ = '1.4.1'
PY2 = sys.version_info[0] == 2
text_type = unicode if PY2 else str # noqa
def _path_to_unicode(x):
if not isinstance(x, text_type):
return x.decode(sys.getfilesystemencoding())
return x
DEFAULT_MODE = "wb" if PY2 else "w"
_proper_fsync = os.fsync
if sys.platform != 'win32':
if hasattr(fcntl, 'F_FULLFSYNC'):
def _proper_fsync(fd):
# https://lists.apple.com/archives/darwin-dev/2005/Feb/msg00072.html
# https://developer.apple.com/library/mac/documentation/Darwin/Reference/ManPages/man2/fsync.2.html
# https://github.com/untitaker/python-atomicwrites/issues/6
fcntl.fcntl(fd, fcntl.F_FULLFSYNC)
def _sync_directory(directory):
# Ensure that filenames are written to disk
fd = os.open(directory, 0)
try:
_proper_fsync(fd)
finally:
os.close(fd)
def _replace_atomic(src, dst):
os.rename(src, dst)
_sync_directory(os.path.normpath(os.path.dirname(dst)))
def _move_atomic(src, dst):
os.link(src, dst)
os.unlink(src)
src_dir = os.path.normpath(os.path.dirname(src))
dst_dir = os.path.normpath(os.path.dirname(dst))
_sync_directory(dst_dir)
if src_dir != dst_dir:
_sync_directory(src_dir)
else:
from ctypes import windll, WinError
_MOVEFILE_REPLACE_EXISTING = 0x1
_MOVEFILE_WRITE_THROUGH = 0x8
_windows_default_flags = _MOVEFILE_WRITE_THROUGH
def _handle_errors(rv):
if not rv:
raise WinError()
def _replace_atomic(src, dst):
_handle_errors(windll.kernel32.MoveFileExW(
_path_to_unicode(src), _path_to_unicode(dst),
_windows_default_flags | _MOVEFILE_REPLACE_EXISTING
))
def _move_atomic(src, dst):
_handle_errors(windll.kernel32.MoveFileExW(
_path_to_unicode(src), _path_to_unicode(dst),
_windows_default_flags
))
def replace_atomic(src, dst):
'''
Move ``src`` to ``dst``. If ``dst`` exists, it will be silently
overwritten.
Both paths must reside on the same filesystem for the operation to be
atomic.
'''
return _replace_atomic(src, dst)
def move_atomic(src, dst):
'''
Move ``src`` to ``dst``. There might a timewindow where both filesystem
entries exist. If ``dst`` already exists, :py:exc:`FileExistsError` will be
raised.
Both paths must reside on the same filesystem for the operation to be
atomic.
'''
return _move_atomic(src, dst)
class AtomicWriter(object):
'''
A helper class for performing atomic writes. Usage::
with AtomicWriter(path).open() as f:
f.write(...)
:param path: The destination filepath. May or may not exist.
:param mode: The filemode for the temporary file. This defaults to `wb` in
Python 2 and `w` in Python 3.
:param overwrite: If set to false, an error is raised if ``path`` exists.
Errors are only raised after the file has been written to. Either way,
the operation is atomic.
:param open_kwargs: Keyword-arguments to pass to the underlying
:py:func:`open` call. This can be used to set the encoding when opening
files in text-mode.
If you need further control over the exact behavior, you are encouraged to
subclass.
'''
def __init__(self, path, mode=DEFAULT_MODE, overwrite=False,
**open_kwargs):
if 'a' in mode:
raise ValueError(
'Appending to an existing file is not supported, because that '
'would involve an expensive `copy`-operation to a temporary '
'file. Open the file in normal `w`-mode and copy explicitly '
'if that\'s what you\'re after.'
)
if 'x' in mode:
raise ValueError('Use the `overwrite`-parameter instead.')
if 'w' not in mode:
raise ValueError('AtomicWriters can only be written to.')
# Attempt to convert `path` to `str` or `bytes`
if fspath is not None:
path = fspath(path)
self._path = path
self._mode = mode
self._overwrite = overwrite
self._open_kwargs = open_kwargs
def open(self):
'''
Open the temporary file.
'''
return self._open(self.get_fileobject)
@contextlib.contextmanager
def _open(self, get_fileobject):
f = None # make sure f exists even if get_fileobject() fails
try:
success = False
with get_fileobject(**self._open_kwargs) as f:
yield f
self.sync(f)
self.commit(f)
success = True
finally:
if not success:
try:
self.rollback(f)
except Exception:
pass
def get_fileobject(self, suffix="", prefix=tempfile.gettempprefix(),
dir=None, **kwargs):
'''Return the temporary file to use.'''
if dir is None:
dir = os.path.normpath(os.path.dirname(self._path))
descriptor, name = tempfile.mkstemp(suffix=suffix, prefix=prefix,
dir=dir)
# io.open() will take either the descriptor or the name, but we need
# the name later for commit()/replace_atomic() and couldn't find a way
# to get the filename from the descriptor.
os.close(descriptor)
kwargs['mode'] = self._mode
kwargs['file'] = name
return io.open(**kwargs)
def sync(self, f):
'''responsible for clearing as many file caches as possible before
commit'''
f.flush()
_proper_fsync(f.fileno())
def commit(self, f):
'''Move the temporary file to the target location.'''
if self._overwrite:
replace_atomic(f.name, self._path)
else:
move_atomic(f.name, self._path)
def rollback(self, f):
'''Clean up all temporary resources.'''
os.unlink(f.name)
def atomic_write(path, writer_cls=AtomicWriter, **cls_kwargs):
'''
Simple atomic writes. This wraps :py:class:`AtomicWriter`::
with atomic_write(path) as f:
f.write(...)
:param path: The target path to write to.
:param writer_cls: The writer class to use. This parameter is useful if you
subclassed :py:class:`AtomicWriter` to change some behavior and want to
use that new subclass.
Additional keyword arguments are passed to the writer class. See
:py:class:`AtomicWriter`.
'''
return writer_cls(path, **cls_kwargs).open()
+32 -44
View File
@@ -1,17 +1,18 @@
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, ensure_time, get_server_next_update, nearest_future from module.config.config_updater import ConfigUpdater
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 DEFAULT_TIME, dict_to_kv, filepath_config, path_to_arg from module.config.utils import *
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
@@ -105,22 +106,17 @@ 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:
# Bind a specific task for debug purpose. self.load()
task = name_to_function(task) if task is None:
self.bind(task) # Bind `Alas` by default which includes emulator settings.
self.task = task task = name_to_function("Alas")
self.save() else:
# 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)
@@ -133,17 +129,13 @@ class AzurLaneConfig(ConfigUpdater, ManualConfig, GeneratedConfig, ConfigWatcher
""" """
Args: Args:
func (str, Function): Function to run func (str, Function): Function to run
func_list (list[str]): List of tasks to be bound func_list (set): Set 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: ["Alas", <task>, *func_list] func_list.append(func)
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
@@ -180,10 +172,6 @@ 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()
@@ -291,7 +279,6 @@ 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):
@@ -498,18 +485,19 @@ 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
# """ """
# with self.multi_set(): if self.stored.DailyActivity.is_expired():
# if self.stored.DailyActivity.is_expired(): logger.info('DailyActivity expired, call task to update')
# logger.info('DailyActivity expired') self.task_call('DailyQuest')
# self.stored.DailyActivity.clear() self.task_stop()
# if self.stored.DailyQuest.is_expired(): if self.stored.DailyQuest.is_expired():
# logger.info('DailyQuest expired') logger.info('DailyQuest expired, call task to update')
# self.stored.DailyQuest.clear() self.task_call('DailyQuest')
self.task_stop()
@property @property
def DEVICE_SCREENSHOT_METHOD(self): def DEVICE_SCREENSHOT_METHOD(self):
+3 -11
View File
@@ -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, nemu_ipc, ldopengl Emulator_ScreenshotMethod = 'auto' # auto, ADB, ADB_nc, uiautomator2, aScreenCap, aScreenCap_nc, DroidCast, DroidCast_raw, scrcpy
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, LDPlayer14, MuMuPlayer, MuMuPlayerX, MuMuPlayer12, MEmuPlayer EmulatorInfo_Emulator = 'auto' # auto, NoxPlayer, NoxPlayer64, BlueStacks4, BlueStacks5, BlueStacks4HyperV, BlueStacks5HyperV, LDPlayer3, LDPlayer4, LDPlayer9, 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, close_emulator, exit_aas, exit_emulator, exit_aas_emulator, shutdown Optimization_WhenTaskQueueEmpty = 'goto_main' # stay_there, goto_main, close_game, exit_aas, exit_emulator, exit_aas_emulator, shutdown
# Group `Cafe` # Group `Cafe`
Cafe_Reward = True Cafe_Reward = True
@@ -98,14 +98,6 @@ class GeneratedConfig:
Schoolhouse_Stage = 0 # 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10 Schoolhouse_Stage = 0 # 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10
Schoolhouse_Count = 2 Schoolhouse_Count = 2
# Group `Road`
Road_Stage = 0 # 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10
Road_Count = 0
# Group `Mansion`
Mansion_Stage = 0 # 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10
Mansion_Count = 0
# Group `Scrimmage` # Group `Scrimmage`
Scrimmage_OnError = 'skip' # stop, skip Scrimmage_OnError = 'skip' # stop, skip
+6 -66
View File
@@ -1,11 +1,9 @@
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 *
@@ -454,19 +452,20 @@ class ConfigUpdater:
dict: dict:
""" """
new = {} new = {}
type_lock = {'lock', 'state'}
type_stored = {'stored', 'dict'}
for keys, data in deep_iter(self.args, depth=3): def deep_load(keys):
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 type_lock or (display == 'hide' and typ not in type_stored): or typ in ['lock', 'state'] or (display == 'hide' and typ != '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)
@@ -570,65 +569,6 @@ 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.

Some files were not shown because too many files have changed in this diff Show More