5 Commits
181 changed files with 3243 additions and 8342 deletions
-8
View File
@@ -21,14 +21,6 @@ test.py
test/
note.md
MCE/config.json
resources/
locales/
*.pak
*.dll
*.dat
v8_context_snapshot.bin
snapshot_blob.bin
vk_swiftshader_icd.json
# Created by .ignore support plugin (hsz.mobi)
+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] 英语
## 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。
+1 -9
View File
@@ -11,10 +11,6 @@ class ArisuAutoSweeper(AzurLaneAutoScript):
from tasks.login.login import Login
Login(self.config, device=self.device).app_start()
def stop(self):
from tasks.login.login import Login
Login(self.config, device=self.device).app_stop()
def goto_main(self):
from tasks.login.login import Login
from tasks.base.ui import UI
@@ -60,7 +56,7 @@ class ArisuAutoSweeper(AzurLaneAutoScript):
def momotalk(self):
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):
from tasks.mission.mission import Mission
@@ -74,10 +70,6 @@ class ArisuAutoSweeper(AzurLaneAutoScript):
from tasks.item.data_update import DataUpdate
DataUpdate(config=self.config, device=self.device).run()
def benchmark(self):
from module.daemon.benchmark import run_benchmark
run_benchmark(config=self.config)
if __name__ == '__main__':
aas = ArisuAutoSweeper('aas')
aas.loop()
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;
}
.anim-rotate {
animation: rotate-keyframes 0.4s linear infinite
}
@keyframes rotate-keyframes {
100% {
transform: rotate(360deg)
}
}
#pywebio-scope-contents {
margin-top: 0;
overflow-y: auto;
@@ -418,13 +408,13 @@ pre.rich-traceback-code {
#pywebio-scope-dashboard > i {
flex-grow: 1;
align-self: flex-end;
width: 9.5rem;
width: 10rem;
}
[id^="pywebio-scope-dashboard-row-"] {
display: flex;
flex-grow: 1;
width: 9.5rem;
width: 10rem;
}
.dashboard-icon {
@@ -446,26 +436,16 @@ pre.rich-traceback-code {
overflow-wrap: break-word;
}
*[style*="--dashboard-bold--"] {
font-size: 1rem;
font-weight: bold;
color: #7a77bb;
overflow-wrap: break-word;
}
[id^="pywebio-scope-dashboard-row-"] p {
margin-bottom: 0;
}
[id^="pywebio-scope-dashboard-value-"] {
display: flex;
align-items: baseline;
align-items: flex-end;
height: 1.5rem;
}
[id^="pywebio-scope-arg_stored-stored-value-"] p {
margin-bottom: 0;
}
#pywebio-scope-log {
line-height: 1.2;
@@ -570,19 +550,19 @@ pre.rich-traceback-code {
padding-right: .25rem;
}
*[style*="--loading-grow--"] > div {
*[style*="--loading-grow--"] {
width: 1.5rem;
height: 1.5rem;
}
*[style*="--loading-border--"] > div {
*[style*="--loading-border--"] {
width: 1.5rem;
height: 1.5rem;
border: .2em solid currentColor;
border-right-color: transparent;
}
*[style*="--loading-border-fill--"] > div {
*[style*="--loading-border-fill--"] {
width: 1.5rem;
height: 1.5rem;
border: .2em solid currentColor;
+3 -3
View File
@@ -63,18 +63,18 @@ select {
background-image: url("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAMgAAADICAYAAACtWK6eAAAAAXNSR0IArs4c6QAADI1JREFUeF7tnVeTZlUVht9V5Y03lvdapVXeWMUfQL3BC2+8Qa0SkOyQMzPEGUKVoog5Y0TFnANmEQUVVBQjZhBMmAOCAdNrLd1Y37TTPeusc87X55z17tvZ65y9nrWfb+9ePd1t0BABEdiUgImNCIjA5gQkiHaHCGxBQIJoe4iABNEeEIEcAZ0gOW6KKkJAghQptNLMEZAgOW6KKkJAghQptNLMEZAgOW6KKkJAghQptNLMEZAgOW6KKkJAghQptNLMEZAgOW6KKkJAghQptNLMEZAgOW6KKkJAghQptNLMEZAgOW6KKkJAghQptNLMEZAgOW6KKkJAghQptNLMEZAgOW6KKkJAghQptNLMEZAgOW6KKkJAghQptNLMEZAgOW6KKkJAghQptNLMEZAgOW6KKkJAghQptNLMEZAgOW6KKkJAghQptNLMEZAgOW6KKkJAghQptNLMEZAgOW6KKkJAghQptNLMEZAgOW6KKkJAghQptNLMEZAgOW6KKkJAghQptNLMEZAgOW6KKkJAghQptNLMEZAgOW6KKkJAghQptNLMEZAgOW6KKkJAghQptNLMEZAgOW6KKkJAghQptNLMEZAgOW6KKkJAghQptNLMEZAgOW6KKkJAghQptNLMEZAgOW6KKkJAghQptNLMEZAgOW6KKkJAghQptNLMEZAgOW6KKkJAghQptNLMEZAgOW6KKkJAghQptNLMEZAgOW6KKkJAghQptNLMEZisICQfD+AhAB4M4HYA15rZPbk0FTV3AiQfBOBRAA4EcAOAW9axHyYpCMlPAzhoQ1FvBrDHzK6be7G1/m4ESO4EsAvAQ1ci7wTwVDO7vtvTus2enCAkbwPwiC3SOMTM3tUtTc2eKwGS7wTw5C3Wf4CZfXus/CYlCMnLAewOJHuomTk4jQUTIPkOAIfsJ8W7ADxuLEmmJsitAA4I1vwwM3OAGgskQPLtAA4NprbbzK4Izu00bWqC/BnAAztkIEk6wJrL1I5yeFrXmNnBY+Q3NUHuAPDwjok+xcz800ZjAQRIvg3AYR1TudLMTusYE5o+NUHeC+CJoZXvPelwM3OwGjMmkJTDMx7tQ3JqgjwSwCfb9z+6llqSdCU2ofkk3+obPbGk683ssYm4UMikBPEVk/SWXrZDdYSZOWiNGREg+RYAhyeXfLCZXZOM3W/Y5ARpknhrL9uhOtLMHLjGDAj0kIPeAjazd4+Z5iQFkSRjlnw6zyb5ZgBHJFa0Fjl8XZMVpEniffBsh+ooM/MCaEyQAMk3ATgysbR/tZPjPYnYziGTFqRJ4i2/bIfqaDPzQmhMiEAPOf7Z5PBu51rG5AWRJGvZB2t7Cck3Ajgq8cK1yzH5K9YqRJLeAsx2qI4xMy+MxjYSIHk1gKMTS/hHOznel4jtFTKLE+T+DEl6KzDboZIkvbZKv+Aecvzd/0+Wma1djlmdIANJcqyZ+aeYxhoJkHwDgGMSr3Q5vJX7/kTsICGzOkFWJPHWYLZD5T9k4wXTWAMBkq8HcGziVX9rcnwgETtYyCwF8exJeosw26GSJINtoc0f1EOO+9q1alvlmOUVa7UcPSXZYWb+6aYxAgGSr/MfiU082uXwa9Vo/32ky5pme4KsXLe8ZZjtUB1nZl5IjQEJ9JDjr02ODw64nF6Pmr0g7bolSXptg+GCSV4FYEfiiX9p16rJyDH7K9aG65b317MdquPNzAur0YMAydcCOC7xCJfDr1UfSsSOGrKIE2TluuWtxGyH6gQz8wJrJAj0kMN/zNrl+HDitaOHLEqQdt2SJKNvm71fQPI1AI5PvPZP7Vo1STkWdcXacN3yvnu2Q3WimXnBNQIESL4awAmBqRunuBx+cnwkEbu2kMWdICvXLW8xZjtUJ5mZF15jCwI95Li3yfHRqQNerCDtuiVJRtqBJF8F4MTE42cjx2KvWBuuW95yzHaoTjYz3wgaKwRIvhLASQko/svH/Vr1sUTstoQs+gRZuW556zHboZIkw8jxx/YF+WzkKHGCDCTJKWbmn5qlB8lXADg5AcHl8JPj44nYbQ0pcYKsSOKtyGyH6lQz8w1ScvSQ4+4mxyfmCK6UIO0Ld29JZjtUJSUheSWAUxIb/A/tWjVLOUpdsTZ84d5HktPMzDdMiUHy5QBOTSTrcvi16tpE7GRCyp0gK9ctb1FmO1Snm5lvnEWPHnL8vsnhv0Z21qOsIO26JUk22b4kXwYg8xvTf9euVbOXo+wVa8N1y/v52Q7VGWbmG2lRg+RLAZyeSMrl8GvVYv6OZOkTZOW65a3LbIfqTDPzDbWI0UOO3zY5PrUIEC0JCdJAkCwvCcmXADgjscF/065Vi5JDV6wNO4GktzKzHaqzzMw32CwHyRcDODOxeJfDr1X+p7sXN3SC/L8k3tLMdqjONjPfaLMaPeT4dZNj1L9Vvp0wJcg+6JMsIwnJFwE4K7EJFy+Hrlhb7AqS3uLMdqh2mplvvEkPki8EcHZikb9qJ8cNidhZhegE2VoSb3VmO1STlqSHHL9sX5AvXg6dIIHPMpJ9JNllZv4pPalB8gUAdiYW5XL4F+SfScTOMkQnSKBsJL31me1QnWNmviEnMXrI8Ysmx2cnkciaFiFBgqBJegs026GahCQknw9gVzDl1Wk/b9eqUnLoitVxp/SU5Fwz8w26LYPk8wCck3i5y+HXqs8lYmcfohOkYwlJeks026E6z8x8o6519JDjribHjWtd8IReJkESxZiTJCSfC+DcRJo/a9eqsnLoipXYNfeHkPTvH2Q7VOebmW/cUQfJ5wA4L/ESl8OvVTclYhcVohOkRzlJeqs026G6wMx8A48yesjx0ybH50dZ2MweKkF6FmyKkpB8NoDzE6n9pF2rJEeDJ0ESu2hjCElvnWY7VBeamW/oQQbJKwBckHiYy+HXqi8kYhcbIkEGKi1Jb6FmO1SDSNJDjh+3k0NybNgPEmQgQfwxPSXZbWb+6Z8aJJ8F4MJE8I+aHF9MxC4+RIIMXGKS3lLNdqj2mJlv9E6D5OUAdncK+u9kl8OvVTcnYkuESJARykzSW6vZDlUnSXrIcWc7OSTHFntAgowgSLtu9ZHkIjPzU2HLQfKZAPbsb94+/t3l8JPjS4nYUiESZMRyk/RWa7ZDdbGZuQD7HCSfAeCixPLvaHJ8ORFbLkSCjFzyMSTpIccP27VKcgTrLkGCoPpMI+nfl8h2qC4xMz8t/jNIXgbg4sR6XA6/Vt2SiC0bIkHWVHqS3oLt3KFqy7vUzC7rIcftTY6vrCndxbxGgqyxlD0l8V+tc1Biube1a5XkSMCTIAlofUJI+vcr9tuh6vOOlViXw69VXx3oeeUeI0G2oeQkvTW7aYdqoCX9oMnxtYGeV/IxEmSbyj6yJN9v1yrJ0bO+EqQnwD7hJP37GP/rUPV51kqsy+HXqq8P9LzSj5Eg21x+kt6y9dbtEON77eSQHEPQBCBBBgLZ5zEDSfLdJsc3+qxFsXsTkCAT2REkLwHw9ORyXA6/Vn0zGa+wTQhIkAltDZKXAnhaxyV9p50ckqMjuMh0CRKhtMY5HSXxVu6TzOzWNS6x1KskyATLHfylC3cDeIyZfWuCKSxmSRJkoqUkeRWAHZss714AB0qO8YsnQcZnnH4DyUcDuBrAwwA8AMA9APyXuT3BzO5LP1iBYQISJIxKEysSkCAVq66cwwQkSBiVJlYkIEEqVl05hwlIkDAqTaxIQIJUrLpyDhOQIGFUmliRgASpWHXlHCYgQcKoNLEiAQlSserKOUxAgoRRaWJFAhKkYtWVc5iABAmj0sSKBCRIxaor5zABCRJGpYkVCUiQilVXzmECEiSMShMrEpAgFauunMMEJEgYlSZWJCBBKlZdOYcJSJAwKk2sSECCVKy6cg4TkCBhVJpYkYAEqVh15RwmIEHCqDSxIgEJUrHqyjlMQIKEUWliRQISpGLVlXOYgAQJo9LEigQkSMWqK+cwAQkSRqWJFQlIkIpVV85hAhIkjEoTKxKQIBWrrpzDBCRIGJUmViQgQSpWXTmHCUiQMCpNrEhAglSsunIOE5AgYVSaWJGABKlYdeUcJiBBwqg0sSIBCVKx6so5TECChFFpYkUCEqRi1ZVzmIAECaPSxIoEJEjFqivnMAEJEkaliRUJSJCKVVfOYQISJIxKEysSkCAVq66cwwQkSBiVJlYkIEEqVl05hwlIkDAqTaxIQIJUrLpyDhOQIGFUmliRgASpWHXlHCYgQcKoNLEiAQlSserKOUxAgoRRaWJFAhKkYtWVc5iABAmj0sSKBCRIxaor5zCBfwOoUQv2LE3rzAAAAABJRU5ErkJggg==");
}
select.state {
.state > select {
border-bottom: 0;
background-image: none;
pointer-events: none;
}
select.state-bold {
.state-bold > select {
font-weight: bold;
color: #7a77bb;
}
select.state-light {
.state-light > select {
color: #777777;
}
+3 -3
View File
@@ -64,18 +64,18 @@ select {
background-image: url("data:image/png;base64,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");
}
select.state {
.state > select {
border-bottom: 0;
background-image: none;
pointer-events: none;
}
select.state-bold {
.state-bold > select {
font-weight: bold;
color: #7a77bb;
}
select.state-light {
.state-light > select {
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","alas2"]' specified "alas" "alas2" configs
Run: null
# SSL support
# Only effective when both parameters below are set
# --ssl-key. Path to SSL key file
# [Default] null (no SSL)
WebuiSSLKey: null
# --ssl-cert. Path to SSL cert file
# [Default] null (no SSL)
WebuiSSLCert: null
# To update app.asar
# [In most cases] true
AppAsarUpdate: true
-8
View File
@@ -157,14 +157,6 @@ Deploy:
# '["alas"]' specified "alas" config
# '["alas","alas2"]' specified "alas" "alas2" configs
Run: null
# SSL support
# Only effective when both parameters below are set
# --ssl-key. Path to SSL key file
# [Default] null (no SSL)
WebuiSSLKey: null
# --ssl-cert. Path to SSL cert file
# [Default] null (no SSL)
WebuiSSLCert: null
# To update app.asar
# [In most cases] true
AppAsarUpdate: true
-8
View File
@@ -181,14 +181,6 @@
"Schoolhouse": {
"Stage": 0,
"Count": 2
},
"Road": {
"Stage": 0,
"Count": 0
},
"Mansion": {
"Stage": 0,
"Count": 0
}
},
"Scrimmage": {
+19 -21
View File
@@ -1,10 +1,10 @@
import os
import time
from typing import Iterable
import typing as t
from deploy.Windows.config import DeployConfig
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):
@@ -19,45 +19,43 @@ class AlasManager(DeployConfig):
def self_pid(self):
return os.getpid()
def list_process(self) -> "list[tuple[int, list[str]]]":
def list_process(self) -> t.List[DataProcessInfo]:
logger.info('List process')
process_data = list(iter_process())
logger.info(f'Found {len(process_data)} processes')
return process_data
process = list(iter_process())
logger.info(f'Found {len(process)} processes')
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:
names (str, list[str]): process name, such as 'alas.exe'
in_alas (bool): If the output process must in Alas
Yields:
pid:
DataProcessInfo:
"""
if not isinstance(names, list):
names = [names]
try:
for pid, cmdline in self.list_process():
if pid == self.self_pid:
continue
exe = cmdline[0]
name = os.path.basename(exe)
if not (name and name in names):
continue
for proc in self.list_process():
if not (proc.name and proc.name in names):
continue
if proc.pid == self.self_pid:
continue
if in_alas:
exe = exe.replace(r"\\", "/").replace("\\", "/")
cmdline = proc.cmdline.replace(r"\\", "/").replace("\\", "/")
for folder in self.alas_folder:
if folder in exe:
yield pid
if folder in cmdline:
yield proc
else:
yield pid
yield proc
except Exception as e:
logger.info(str(e))
return False
def kill_process(self, pid: int):
self.execute(f'taskkill /f /t /pid {pid}', allow_failure=True, output=False)
def kill_process(self, process: DataProcessInfo):
self.execute(f'taskkill /f /t /pid {process.pid}', allow_failure=True, output=False)
def alas_kill(self):
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 os
import subprocess
import sys
from typing import Optional, Union
from deploy.Windows.logger import logger
@@ -57,8 +56,6 @@ class ConfigModel:
# Webui
WebuiHost: str = "0.0.0.0"
WebuiPort: int = 23467
WebuiSSLKey: Optional[str] = None
WebuiSSLCert: Optional[str] = None
Language: str = "en-US"
Theme: str = "default"
DpiScaling: bool = True
@@ -80,45 +77,12 @@ class DeployConfig(ConfigModel):
self.config_template = {}
self.read()
self.set_repo()
self.write()
self.show_config()
def show_config(self):
logger.hr("Show deploy config", 1)
for k, v in self.config.items():
if k in ("Password", "SSHUser"):
continue
if self.config_template.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()`
"""
def set_repo(self):
# Bypass webui.config.DeployConfig.__setattr__()
# Don't write these into deploy.yaml
if self.Repository == 'cn':
@@ -126,6 +90,29 @@ class DeployConfig(ConfigModel):
if self.Repository == 'global':
super().__setattr__('Repository', 'https://github.com/TheFunny/ArisuAutoSweeper')
def show_config(self):
logger.hr("Show deploy config", 1)
for k, v in self.config.items():
if k in ("Password", "SSHUser"):
continue
if self.config_template[k] == v:
continue
logger.info(f"{k}: {v}")
logger.info(f"Rest of the configs are the same as default")
def read(self):
self.config = poor_yaml_read(DEPLOY_TEMPLATE)
self.config_template = copy.deepcopy(self.config)
self.config.update(poor_yaml_read(self.file))
for key, value in self.config.items():
if hasattr(self, key):
super().__setattr__(key, value)
def write(self):
poor_yaml_write(self.config, self.file)
def filepath(self, path):
"""
Args:
@@ -157,7 +144,7 @@ class DeployConfig(ConfigModel):
if os.path.exists(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'
@cached_property
@@ -166,21 +153,12 @@ class DeployConfig(ConfigModel):
if os.path.exists(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'
@cached_property
def python(self) -> str:
# No need to read PythonExecutable
# since you run this code with python, current python is the python
# exe = self.filepath(self.PythonExecutable)
# if os.path.exists(exe):
# return exe
current = sys.executable.replace("\\", "/")
# logger.warning(f'PythonExecutable: {exe} does not exist, use current python instead: {current}')
return current
return self.filepath(self.PythonExecutable)
@cached_property
def requirements_file(self) -> str:
+1 -6
View File
@@ -2,7 +2,6 @@ import asyncio
import filecmp
import os
import shutil
import sys
import typing as t
from dataclasses import dataclass
@@ -10,8 +9,7 @@ from deploy.Windows.alas import AlasManager
from deploy.Windows.logger import logger
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
@@ -142,13 +140,10 @@ class EmulatorManager(AlasManager):
for adb in replace:
logger.info(f'Replacing {adb}')
bak = self.adb_path_to_backup(adb, new_backup=True)
try:
logger.info(f'{adb} -----> {bak}')
shutil.move(adb, bak)
logger.info(f'{self.adb} -----> {adb}')
shutil.copy(self.adb, adb)
except OSError as e:
logger.warning(f'Failed to replace {adb}, {e}')
def adb_recover(self):
"""
+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
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')
to:
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:
self.push_url(url)
to:
for url in []:
self.push_url(url)
3. Fix atx_agent_url so ARM Mac can have correct ATX installed
```
@property
def atx_agent_url(self):
files = {
'armeabi-v7a': 'atx-agent_{v}_linux_armv7.tar.gz',
'arm64-v8a': 'atx-agent_{v}_linux_armv7.tar.gz',
'armeabi': 'atx-agent_{v}_linux_armv6.tar.gz',
'x86': 'atx-agent_{v}_linux_386.tar.gz',
'x86_64': 'atx-agent_{v}_linux_386.tar.gz',
}
```
where
'arm64-v8a': 'atx-agent_{v}_linux_armv7.tar.gz',
to
'arm64-v8a': 'atx-agent_{v}_linux_arm64.tar.gz',
"""
init_file = './toolkit/Lib/site-packages/uiautomator2/init.py'
cache_dir = './toolkit/Lib/site-packages/uiautomator2cache/cache'
@@ -109,17 +92,6 @@ def patch_uiautomator2():
else:
logger.info(f'{init_file} minicap_urls no need to patch')
# Patch atx_agent_url
res = re.search(r"'arm64-v8a': 'atx-agent_\{v}_linux_armv7.tar.gz'", content)
if res:
content = re.sub(r"'arm64-v8a': 'atx-agent_\{v}_linux_armv7.tar.gz'",
"'arm64-v8a': 'atx-agent_{v}_linux_arm64.tar.gz'",
content)
modified = True
logger.info(f'{init_file} atx_agent_url patched')
else:
logger.info(f'{init_file} atx_agent_url no need to patch')
# Patch appdir
if os.path.exists(cache_dir):
res = re.search(r'appdir ?=(.*)\n', content)
+4 -10
View File
@@ -5,7 +5,7 @@ from dataclasses import dataclass
from urllib.parse import urlparse
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
@@ -46,15 +46,9 @@ class PipManager(DeployConfig):
return f'"{self.python}" -m pip'
@cached_property
def python_site_packages(self) -> str:
import site
paths = site.getsitepackages()
# site-packages should be site-packages folder
for path in paths:
if path.endswith('site-packages'):
return path
# Otherwise pick first
return paths[0]
def python_site_packages(self):
return os.path.abspath(os.path.join(self.python, '../Lib/site-packages')) \
.replace(r"\\", "/").replace("\\", "/")
@cached_property
def set_installed_dependency(self) -> t.Set[DataDependency]:
-8
View File
@@ -157,14 +157,6 @@ Deploy:
# '["alas"]' specified "alas" config
# '["alas","alas2"]' specified "alas" "alas2" configs
Run: null
# SSL support
# Only effective when both parameters below are set
# --ssl-key. Path to SSL key file
# [Default] null (no SSL)
WebuiSSLKey: null
# --ssl-cert. Path to SSL cert file
# [Default] null (no SSL)
WebuiSSLCert: null
# To update app.asar
# [In most cases] true
AppAsarUpdate: true
+49 -56
View File
@@ -1,9 +1,8 @@
import os
import re
from dataclasses import dataclass
from typing import Callable, Generic, Iterable, TypeVar
from deploy.Windows.atomic import atomic_read_text, atomic_write
T = TypeVar("T")
DEPLOY_CONFIG = './config/deploy.yaml'
@@ -65,10 +64,13 @@ def poor_yaml_read(file):
Returns:
dict:
"""
content = atomic_read_text(file)
if not os.path.exists(file):
return {}
data = {}
regex = re.compile(r'^(.*?):(.*?)$')
for line in content.splitlines():
with open(file, 'r', encoding='utf-8') as f:
for line in f.readlines():
line = line.strip('\n\r\t ').replace('\\', '/')
if line.startswith('#'):
continue
@@ -96,8 +98,8 @@ def poor_yaml_write(data, file, template_file=DEPLOY_TEMPLATE):
file (str):
template_file (str):
"""
text = atomic_read_text(template_file)
text = text.replace('\\', '/')
with open(template_file, 'r', encoding='utf-8') as f:
text = f.read().replace('\\', '/')
for key, value in data.items():
if value is None:
@@ -108,15 +110,38 @@ def poor_yaml_write(data, file, template_file=DEPLOY_TEMPLATE):
value = "false"
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]]]":
"""
Yields:
int: pid
list[str]: cmdline, and it's guaranteed to have at least one element
"""
@dataclass
class DataProcessInfo:
proc: object # psutil.Process or psutil._pswindows.Process
pid: int
@cached_property
def name(self):
name = self.proc.name()
return name
@cached_property
def cmdline(self):
try:
cmdline = self.proc.cmdline()
except:
# psutil.AccessDenied
cmdline = []
cmdline = ' '.join(cmdline).replace(r'\\', '/').replace('\\', '/')
return cmdline
def __str__(self):
# Don't print `proc`, it will take some time to get process properties
return f'DataProcessInfo(name="{self.name}", pid={self.pid}, cmdline="{self.cmdline}")'
__repr__ = __str__
def iter_process() -> Iterable[DataProcessInfo]:
try:
import psutil
except ModuleNotFoundError:
@@ -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
# to bypass the call of psutil.Process.is_running().
# This only costs about 0.017s.
# If you do psutil.process_iter(['pid', 'cmdline']) it will take over 1s
import psutil._psutil_windows as cetx
for pid in psutil.pids():
# 0 and 4 are always represented in taskmgr and process-hacker
if pid == 0 or pid == 4:
continue
try:
# This would be fast on psutil<=5.9.8 taking overall time 0.027s
# but taking 0.39s on psutil>=6.0.0
cmdline = cetx.proc_cmdline(pid, use_peb=True)
except (psutil.AccessDenied, psutil.NoSuchProcess, OSError):
# psutil.AccessDenied
# NoSuchProcess: process no longer exists (pid=xxx)
# ProcessLookupError: [Errno 3] assume no such process (originated from psutil_pid_is_running -> 0)
# OSError: [WinError 87] 参数错误。: '(originated from ReadProcessMemory)'
continue
# Validate cmdline
if not cmdline:
continue
try:
exe = cmdline[0]
except IndexError:
continue
# \??\C:\Windows\system32\conhost.exe
if exe.startswith(r'\??'):
continue
yield pid, cmdline
else:
# No optimizations yet
for pid in psutil.pids():
proc = psutil._psplatform.Process(pid)
try:
cmdline = proc.cmdline()
except (psutil.AccessDenied, psutil.NoSuchProcess, OSError):
continue
# Validate cmdline
if not cmdline:
continue
try:
cmdline[0]
except IndexError:
continue
yield pid, cmdline
yield DataProcessInfo(
proc=proc,
pid=proc.pid,
)
else:
# This will cost about 0.45s, even `attr` is given.
for proc in psutil.process_iter():
yield DataProcessInfo(
proc=proc,
pid=proc.pid,
)
-2
View File
@@ -13,8 +13,6 @@ from deploy.Windows.pip import PipManager
class Installer(GitManager, PipManager, AdbManager, AppManager, AlasManager):
def install(self):
from deploy.Windows.atomic import atomic_failure_cleanup
atomic_failure_cleanup('./config')
try:
self.git_install()
self.alas_kill()
+18 -116
View File
@@ -3,61 +3,20 @@ import re
import typing as t
from dataclasses import dataclass
import cv2
import numpy as np
from tqdm import tqdm
from module.base.code_generator import CodeGenerator
from module.base.utils import SelectedGrids, area_center, area_limit, area_pad, corner2area, get_bbox, get_color, \
image_size, load_image
from module.base.utils import SelectedGrids, area_limit, area_pad, get_bbox, get_color, image_size, load_image
from module.config.config_manual import ManualConfig as AzurLaneConfig
from module.config.deep import deep_get, deep_iter, deep_set
from module.config.server import VALID_LANG
from module.config.utils import iter_folder
from module.config.utils import deep_get, deep_iter, deep_set, iter_folder
from module.logger import logger
SHARE_SERVER = 'share'
ASSET_SERVER = [SHARE_SERVER] + VALID_LANG
def parse_grid(image):
"""
Args:
image:
Returns:
dict: Key: Grid position (x, y)
Value: Area on image
"""
image = cv2.inRange(image, (127, 127, 127), (255, 255, 255))
contours, _ = cv2.findContours(image, cv2.RETR_TREE, cv2.CHAIN_APPROX_SIMPLE)
dic_rect = {}
for corners in contours:
area = corner2area(corners.reshape(4, 2)) + (0, 0, 1, 1)
center = area_center(area)
dic_rect[center] = area
# for k, v in dic_rect.items():
# print(k, v)
dic_grid = {}
prev_y = -100
grid_y = -1
stack_center = []
for center in sorted(dic_rect.keys(), key=lambda x: x[1]):
if center[1] > prev_y + 3:
for x, c in enumerate(sorted(stack_center, key=lambda x: x[0])):
dic_grid[(x, grid_y)] = tuple(dic_rect[c].astype(int))
grid_y += 1
stack_center = []
stack_center.append(center)
prev_y = center[1]
for x, c in enumerate(sorted(stack_center, key=lambda x: x[0])):
dic_grid[(x, grid_y)] = tuple(dic_rect[c].astype(int))
# for k, v in dic_grid.items():
# print(k, v)
return dic_grid
class AssetsImage:
REGEX_ASSETS = re.compile(
f'^{AzurLaneConfig.ASSETS_FOLDER}/'
@@ -65,7 +24,7 @@ class AssetsImage:
f'(?P<module>[a-zA-Z0-9_/]+?)/'
f'(?P<assets>\w+)'
f'(?P<frame>\.\d+)?'
f'(?P<attr>\.AREA|\.SEARCH|\.COLOR|\.BUTTON|\.GRID)?'
f'(?P<attr>\.AREA|\.SEARCH|\.COLOR|\.BUTTON)?'
f'\.png$'
)
@@ -87,7 +46,6 @@ class AssetsImage:
self.assets = ''
self.frame = 1
self.attr = ''
self.posi = None
if res:
self.valid = True
@@ -108,7 +66,6 @@ class AssetsImage:
self.bbox: t.Tuple = ()
self.mean: t.Tuple = ()
self.grids = {}
def parse(self):
image = load_image(self.file)
@@ -122,10 +79,6 @@ class AssetsImage:
mean = tuple(np.rint(mean).astype(int))
self.bbox = bbox
self.mean = mean
if self.attr == 'GRID':
self.grids = parse_grid(image)
return bbox, mean
def __str__(self):
@@ -134,42 +87,15 @@ class AssetsImage:
else:
return f'AssetsImage(file={self.file}, valid={self.valid})'
@property
def is_GRID(self):
return self.attr == 'GRID'
@property
def is_base(self):
return self.attr == ''
def iter_grids(self):
frame = 0
for posi, rect in self.grids.items():
frame += 1
image = AssetsImage(self.file)
image.attr = ''
image.bbox = rect
image.mean = self.mean
image.frame = frame
image.posi = posi
yield image
def iter_images():
for server in ASSET_SERVER:
for path, folders, files in os.walk(os.path.join(AzurLaneConfig.ASSETS_FOLDER, server)):
for file in files:
if not file.startswith('.'):
file = os.path.join(path, file).replace('\\', '/')
yield AssetsImage(file)
def iter_grids(images):
for image in images:
for grid in image.iter_grids():
yield grid
@dataclass
class DataAssets:
module: str
@@ -177,17 +103,11 @@ class DataAssets:
server: str
frame: int
file: str = ''
posi = None
area: t.Tuple[int, int, int, int] = ()
search: t.Tuple[int, int, int, int] = ()
color: t.Tuple[int, int, int] = ()
button: t.Tuple[int, int, int, int] = ()
has_raw_area = False
has_raw_search = False
has_raw_color = False
has_raw_button = False
@staticmethod
def area_to_search(area):
area = area_pad(area, pad=-20)
@@ -200,6 +120,7 @@ class DataAssets:
Product DataAssets from AssetsImage with attr=""
"""
data = cls(module=image.module, assets=image.assets, server=image.server, frame=image.frame, file=image.file)
data.load_image(image)
return data
def load_image(self, image: AssetsImage):
@@ -208,21 +129,14 @@ class DataAssets:
self.area = image.bbox
self.color = image.mean
self.button = image.bbox
self.posi = image.posi
elif image.attr == 'AREA':
self.area = image.bbox
self.has_raw_area = True
elif image.attr == 'SEARCH':
self.search = image.bbox
self.has_raw_search = True
elif image.attr == 'COLOR':
self.color = image.mean
self.has_raw_color = True
elif image.attr == 'BUTTON':
self.button = image.bbox
self.has_raw_button = True
elif image.attr == 'GRID':
pass
else:
logger.warning(f'Trying to load an image with unknown attribute: {image}')
@@ -237,16 +151,11 @@ def iter_assets():
for image in tqdm(images):
image.parse()
images += list(iter_grids(images))
# Validate images
images = SelectedGrids(images).select(valid=True)
images.create_index('module', 'assets', 'server', 'frame', 'attr')
for image in images.filter(lambda x: bool(x.attr)):
image: AssetsImage = image
if image.is_GRID:
pass
else:
if not images.indexed_select(image.module, image.assets, image.server, image.frame, ''):
logger.warning(f'Attribute assets has no parent assets: {image.file}')
image.valid = False
@@ -261,34 +170,31 @@ def iter_assets():
# Convert to DataAssets
data = {}
for image in images:
if image.is_base:
if image.attr == '':
row = DataAssets.product(image)
row.load_image(image)
deep_set(data, keys=[image.module, image.assets, image.server, image.frame], value=row)
# Load attribute images
for image in images:
if not image.is_base:
if image.attr != '':
row = deep_get(data, keys=[image.module, image.assets, image.server, image.frame])
row.load_image(image)
# Set `search`
for path, frames in deep_iter(data, depth=3):
# print(path, frames)
print(path, frames)
# If `search` attribute is set in the first frame, apply to all
first = frames[1]
if first.search:
for frame in frames.values():
# Generate `search` from `area`
if not frame.has_raw_search:
frame.search = DataAssets.area_to_search(frame.area)
# If an attribute is set in the first frame, apply to all
first: DataAssets = frames[1]
for frame in frames.values():
# frame: DataAssets = frame
if not frame.has_raw_area and first.has_raw_area:
frame.area = first.area
if not frame.has_raw_search and first.has_raw_search:
frame.search = first.search
if not frame.has_raw_color and first.has_raw_color:
frame.color = first.color
if not frame.has_raw_button and first.has_raw_button:
frame.button = first.button
else:
for frame in frames.values():
if frame.search:
# Follow frame specific `search`
pass
else:
# Generate `search` from `area`
frame.search = DataAssets.area_to_search(frame.area)
return data
@@ -334,8 +240,6 @@ def generate_code():
gen.ObjectAttr(key='search', value=frame.search)
gen.ObjectAttr(key='color', value=frame.color)
gen.ObjectAttr(key='button', value=frame.button)
if frame.posi is not None:
gen.ObjectAttr(key='posi', value=frame.posi)
elif len(frames) == 1:
frame = frames[0]
with gen.ObjectAttr(key=server, value=gen.Object(object_class='Button')):
@@ -344,8 +248,6 @@ def generate_code():
gen.ObjectAttr(key='search', value=frame.search)
gen.ObjectAttr(key='color', value=frame.color)
gen.ObjectAttr(key='button', value=frame.button)
if frame.posi is not None:
gen.ObjectAttr(key='posi', value=frame.posi)
else:
gen.ObjectAttr(key=server, value=None)
gen.write(os.path.join(output, f'assets_{module.replace("/", "_")}.py'))
+4 -4
View File
@@ -2,8 +2,8 @@ import os
from datetime import datetime
from PIL import Image
from pynput import keyboard
from pynput import keyboard
from module.config.config import AzurLaneConfig
from module.config.utils import alas_instance
from module.device.connection import Connection, ConnectionAttr
@@ -50,12 +50,12 @@ def handle_sensitive_info(image):
_ = EmptyConnection()
name = input(
'输入aas配置文件名称,或者模拟器serial,或者模拟器端口号: (默认输入 "aas"):\n'
'例如:"aas", "127.0.0.1:16384", "7555"\n'
'输入alas配置文件名称,或者模拟器serial,或者模拟器端口号: (默认输入 "alas"):\n'
'例如:"alas", "127.0.0.1:16384", "7555"\n'
)
name = name.strip().strip('"').strip()
if not name:
name = 'aas'
name = 'alas'
if name.isdigit():
name = f'127.0.0.1:{name}'
if name in alas_instance():
-19
View File
@@ -40,12 +40,6 @@ def func(ev: threading.Event):
parser.add_argument(
"--electron", action="store_true", help="Runs by electron client."
)
parser.add_argument(
"--ssl-key", dest="ssl_key", type=str, help="SSL key file path for HTTPS support"
)
parser.add_argument(
"--ssl-cert", type=str, help="SSL certificate file path for HTTPS support"
)
parser.add_argument(
"--run",
nargs="+",
@@ -56,15 +50,11 @@ def func(ev: threading.Event):
host = args.host or State.deploy_config.WebuiHost or "0.0.0.0"
port = args.port or int(State.deploy_config.WebuiPort) or 23467
ssl_key = args.ssl_key or State.deploy_config.WebuiSSLKey
ssl_cert = args.ssl_cert or State.deploy_config.WebuiSSLCert
ssl = ssl_key is not None and ssl_cert is not None
State.electron = args.electron
logger.hr("Launcher config")
logger.attr("Host", host)
logger.attr("Port", port)
logger.attr("SSL", ssl)
logger.attr("Electron", args.electron)
logger.attr("Reload", ev is not None)
@@ -74,15 +64,6 @@ def func(ev: threading.Event):
from module.logger.logger import console_hdlr
logger.removeHandler(console_hdlr)
if ssl_cert is None and ssl_key is not None:
logger.error("SSL key provided without certificate. Please provide both SSL key and certificate.")
elif ssl_key is None and ssl_cert is not None:
logger.error("SSL certificate provided without key. Please provide both SSL key and certificate.")
if ssl:
uvicorn.run("module.webui.app:app", host=host, port=port, factory=True, ssl_keyfile=ssl_key,
ssl_certfile=ssl_cert)
else:
uvicorn.run("module.webui.app:app", host=host, port=port, factory=True)
+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()
def run_set(modify: t.List[str]) -> t.Dict[str, str]:
def run_set(modify=t.List[str]) -> t.Dict[str, str]:
data = {}
for kv in modify:
if "=" in kv:
+43 -74
View File
@@ -1,5 +1,5 @@
import platform
import subprocess
import os
import re
import threading
import time
from datetime import datetime, timedelta
@@ -7,14 +7,16 @@ from datetime import datetime, timedelta
import inflection
from cached_property import cached_property
from MCE.custom_widgets.ctkmessagebox import CTkMessagebox
from module.base.decorator import del_cached_property
from module.config.config import AzurLaneConfig, TaskEnd
from module.config.deep import deep_get, deep_set
from module.config.utils import deep_get, deep_set
from module.exception import *
from module.logger import logger, save_error_log
from module.logger import logger
from module.notify import handle_notify
from MCE.custom_widgets.ctkmessagebox import CTkMessagebox
import subprocess
import platform
class AzurLaneAutoScript:
stop_event: threading.Event = None
@@ -64,22 +66,9 @@ class AzurLaneAutoScript:
logger.exception(e)
exit(1)
def restart(self):
raise NotImplemented
def start(self):
raise NotImplemented
def stop(self):
raise NotImplemented
def goto_main(self):
raise NotImplemented
def run(self, command):
try:
self.device.screenshot()
self.device.screenshot_tracking.clear()
self.__getattribute__(command)()
return True
except TaskEnd:
@@ -87,68 +76,61 @@ class AzurLaneAutoScript:
except GameNotRunningError as e:
logger.warning(e)
self.config.task_call('Restart')
return False
return True
except (GameStuckError, GameTooManyClickError) as e:
logger.error(e)
self.save_error_log()
logger.warning(f'Game stuck, {self.device.package} will be restarted in 10 seconds')
logger.warning('If you are playing by hand, please stop AAS')
logger.warning('If you are playing by hand, please stop Alas')
self.config.task_call('Restart')
self.device.sleep(10)
return False
except GameBugError as e:
logger.warning(e)
self.save_error_log()
logger.warning('An error has occurred in 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')
self.config.task_call('Restart')
self.device.sleep(10)
return False
except GamePageUnknownError:
# logger.info('Game server may be under maintenance or network may be broken, check server status now')
logger.info('Game server may be under maintenance or network may be broken, check server status now')
self.checker.check_now()
if self.checker.is_available():
logger.critical('Game page unknown')
self.save_error_log()
handle_notify(
self.config.Error_OnePushConfig,
title=f"AAS <{self.config_name}> crashed",
title=f"Alas <{self.config_name}> crashed",
content=f"<{self.config_name}> GamePageUnknownError",
)
exit(1)
else:
self.checker.wait_until_available()
return False
except HandledError as e:
logger.error(e)
return False
except ScriptError as e:
logger.exception(e)
self.error_postprocess()
logger.critical(e)
logger.critical('This is likely to be a mistake of developers, but sometimes just random issues')
self.save_error_log()
handle_notify(
self.config.Error_OnePushConfig,
title=f"AAS <{self.config_name}> crashed",
title=f"Alas <{self.config_name}> crashed",
content=f"<{self.config_name}> ScriptError",
)
exit(1)
except RequestHumanTakeover:
logger.critical('Request human takeover')
self.error_postprocess()
handle_notify(
self.config.Error_OnePushConfig,
title=f"AAS <{self.config_name}> crashed",
title=f"Alas <{self.config_name}> crashed",
content=f"<{self.config_name}> RequestHumanTakeover",
)
exit(1)
except Exception as e:
logger.exception(e)
self.error_postprocess()
self.save_error_log()
handle_notify(
self.config.Error_OnePushConfig,
title=f"AAS <{self.config_name}> crashed",
title=f"Alas <{self.config_name}> crashed",
content=f"<{self.config_name}> Exception occured",
)
exit(1)
@@ -158,13 +140,29 @@ class AzurLaneAutoScript:
Save last 60 screenshots in ./log/error/<timestamp>
Save logs to ./log/error/<timestamp>/log.txt
"""
save_error_log(config=self.config, device=self.device)
def error_postprocess(self):
"""
Do something when error occurred
"""
pass
from module.base.utils import save_image
from module.handler.sensitive_info import (handle_sensitive_image, handle_sensitive_logs)
if self.config.Error_SaveError:
if not os.path.exists('./log/error'):
os.mkdir('./log/error')
folder = f'./log/error/{int(time.time() * 1000)}'
logger.warning(f'Saving error: {folder}')
os.mkdir(folder)
for data in self.device.screenshot_deque:
image_time = datetime.strftime(data['time'], '%Y-%m-%d_%H-%M-%S-%f')
image = handle_sensitive_image(data['image'])
save_image(image, f'{folder}/{image_time}.png')
with open(logger.log_file, 'r', encoding='utf-8') as f:
lines = f.readlines()
start = 0
for index, line in enumerate(lines):
line = line.strip(' \r\t\n')
if re.match('^═{15,}$', line):
start = index
lines = lines[start - 2:]
lines = handle_sensitive_logs(lines)
with open(f'{folder}/log.txt', 'w', encoding='utf-8') as f:
f.writelines(lines)
def wait_until(self, future):
"""
@@ -212,16 +210,13 @@ class AzurLaneAutoScript:
method = self.config.Optimization_WhenTaskQueueEmpty
if method == 'close_game':
logger.info('Close game during wait')
self.run('stop')
self.device.app_stop()
release_resources()
self.device.release_during_wait()
if not self.wait_until(task.next_run):
del_cached_property(self, 'config')
continue
if task.command != 'Restart':
self.config.task_call('Restart')
del_cached_property(self, 'config')
continue
self.run('start')
elif method == 'goto_main':
logger.info('Goto main page during wait')
self.run('goto_main')
@@ -237,31 +232,6 @@ class AzurLaneAutoScript:
if not self.wait_until(task.next_run):
del_cached_property(self, 'config')
continue
elif method == 'close_emulator':
logger.info('Close emulator during wait')
self.run('stop')
release_resources()
self.device.release_during_wait()
# 关闭模拟器
try:
self.device.emulator_stop()
logger.info('Emulator stopped successfully')
except Exception as e:
logger.warning(f'Failed to stop emulator: {e}')
if not self.wait_until(task.next_run):
del_cached_property(self, 'config')
del_cached_property(self, 'device')
continue
if task.command == 'Restart':
del_cached_property(self, 'config')
del_cached_property(self, 'device')
continue
# 重新启动模拟器
if task.command != 'Restart':
self.config.task_call('Restart')
del_cached_property(self, 'config')
del_cached_property(self, 'device')
continue
elif method == 'exit_aas':
if abs(task.next_run - datetime.now()) >= timedelta(minutes=2): # ensure tactical challenge is fully ran
self.config.Optimization_WhenTaskQueueEmpty = 'goto_main'
@@ -323,7 +293,7 @@ class AzurLaneAutoScript:
if self.stop_event is not None:
if self.stop_event.is_set():
logger.info("Update event detected")
logger.info(f"AAS [{self.config_name}] exited.")
logger.info(f"Alas [{self.config_name}] exited.")
break
# Check game server maintenance
self.checker.wait_until_available()
@@ -339,7 +309,6 @@ class AzurLaneAutoScript:
task = self.get_next_task()
# Init device and change server
_ = self.device
self.device.config = self.config
# Skip first restart
if self.is_first_task and task == 'Restart':
logger.info('Skip task `Restart` at scheduler start')
@@ -369,7 +338,7 @@ class AzurLaneAutoScript:
logger.critical('Request human takeover')
handle_notify(
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.",
)
exit(1)
+18 -239
View File
@@ -4,9 +4,7 @@ from module.base.timer import Timer
from module.base.utils import *
from module.config.config import AzurLaneConfig
from module.device.device import Device
from module.device.method.utils import HierarchyButton
from module.logger import logger
from module.webui.setting import cached_class_property
class ModuleBase:
@@ -28,8 +26,6 @@ class ModuleBase:
"""
if isinstance(config, AzurLaneConfig):
self.config = config
if task is not None:
self.config.init_task(task)
elif isinstance(config, str):
self.config = AzurLaneConfig(config, task=task)
else:
@@ -49,111 +45,6 @@ class ModuleBase:
self.interval_timer = {}
@cached_class_property
def worker(self):
"""
A thread pool to run things at background
Examples:
```
def func(image):
logger.info('Update thread start')
with self.config.multi_set():
self.dungeon_get_simuni_point(image)
self.dungeon_update_stamina(image)
ModuleBase.worker.submit(func, self.device.image)
```
"""
logger.hr('Creating worker')
from concurrent.futures import ThreadPoolExecutor
pool = ThreadPoolExecutor(1)
return pool
def loop(self, skip_first=True, timeout=None):
"""
A syntactic sugar to start a state loop
Args:
skip_first (bool): Usually to be True to reuse the previous screenshot
timeout (int | float | Timer): Seconds of timeout or a Timer object
Yields:
np.ndarray: screenshot
Examples:
# state machine that handle clicking until destination
for _ in self.loop():
if self.appear(...):
break
if self.appear_then_click(...):
continue
Examples:
# state machine with timeout
for _ in self.loop(timeout=2):
if self.appear(...):
logger.info('Wait success')
break
else:
logger.warning('Wait timeout')
"""
if timeout is not None:
if isinstance(timeout, Timer):
timeout.reset()
else:
timeout = Timer.from_seconds(timeout).start()
while 1:
if timeout is not None:
if timeout.reached():
return
if skip_first:
skip_first = False
else:
self.device.screenshot()
try:
yield self.device.image
except AttributeError:
self.device.screenshot()
yield self.device.image
def loop_hierarchy(self, skip_first=True):
"""
A syntactic sugar to start a hierarchy state loop
Args:
skip_first (bool): Usually to be True to reuse the previous hierarchy
Yields:
etree._Element: hierarchy
"""
while 1:
if skip_first:
skip_first = False
else:
self.device.dump_hierarchy()
yield self.device.hierarchy
def loop_screenshot_hierarchy(self, skip_first=True):
"""
A syntactic sugar to start a state loop that takes screenshots and dump hierarchy
Args:
skip_first (bool): Usually to be True to reuse the previous screenshot
Yields:
tuple[np.ndarray, etree._Element]: screenshot, hierarchy
"""
while 1:
if skip_first:
skip_first = False
else:
self.device.screenshot()
self.device.dump_hierarchy()
yield self.device.image, self.device.hierarchy
def match_template(self, button, interval=0, similarity=0.85):
"""
Args:
@@ -184,36 +75,6 @@ class ModuleBase:
return appear
def match_template_luma(self, button, interval=0, similarity=0.85):
"""
Args:
button (ButtonWrapper):
interval (int, float): interval between two active events.
similarity (int, float): 0 to 1.
Returns:
bool:
Examples:
Image detection:
```
self.device.screenshot()
self.appear(Button(area=(...), color=(...), button=(...))
self.appear(Template(file='...')
```
"""
self.device.stuck_record_add(button)
if interval and not self.interval_is_reached(button, interval=interval):
return False
appear = button.match_template_luma(self.device.image, similarity=similarity)
if appear and interval:
self.interval_reset(button, interval=interval)
return appear
def match_color(self, button, interval=0, threshold=10):
"""
Args:
@@ -259,56 +120,9 @@ class ModuleBase:
return appear
def xpath(self, xpath) -> HierarchyButton:
if isinstance(xpath, str):
return HierarchyButton(self.device.hierarchy, xpath)
else:
return xpath
def xpath_appear(self, xpath: str, interval=0):
button = self.xpath(xpath)
self.device.stuck_record_add(button)
if interval and not self.interval_is_reached(button, interval=interval):
return False
appear = bool(button)
if appear and interval:
self.interval_reset(button, interval=interval)
return appear
def appear(self, button, interval=0, similarity=0.85):
"""
Args:
button (Button, ButtonWrapper, HierarchyButton, str):
interval (int, float): interval between two active events.
Returns:
bool:
Examples:
Template match:
```
self.device.screenshot()
self.appear(POPUP_CONFIRM)
```
Hierarchy detection (detect elements with xpath):
```
self.device.dump_hierarchy()
self.appear('//*[@resource-id="..."]')
```
"""
if isinstance(button, (HierarchyButton, str)):
return self.xpath_appear(button, interval=interval)
else:
return self.match_template(button, interval=interval, similarity=similarity)
appear = match_template
def appear_then_click(self, button, interval=5, similarity=0.85):
button = self.xpath(button)
appear = self.appear(button, interval=interval, similarity=similarity)
if appear:
self.device.click(button)
@@ -396,7 +210,7 @@ class ModuleBase:
Returns:
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]
if points.shape[0] < encourage ** 2:
# Not having enough pixels to match
@@ -405,26 +219,7 @@ class ModuleBase:
point = fit_points(points, mod=image_size(image), encourage=encourage)
point = ensure_int(point + area[:2])
button_area = area_offset((-encourage, -encourage, encourage, encourage), offset=point)
return ClickButton(area=button_area, name=name)
def get_interval_timer(self, button, interval=5, renew=False) -> Timer:
if hasattr(button, 'name'):
name = button.name
elif callable(button):
name = button.__name__
else:
name = str(button)
try:
timer = self.interval_timer[name]
if renew and timer.limit != interval:
timer = Timer(interval)
self.interval_timer[name] = timer
return timer
except KeyError:
timer = Timer(interval)
self.interval_timer[name] = timer
return timer
return ClickButton(button=button_area, name=name)
def interval_reset(self, button, interval=5):
if isinstance(button, (list, tuple)):
@@ -433,7 +228,10 @@ class ModuleBase:
return
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):
if isinstance(button, (list, tuple)):
@@ -442,10 +240,19 @@ class ModuleBase:
return
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):
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 = ''
@@ -475,31 +282,3 @@ class ModuleBase:
"""
server_.set_lang(lang)
logger.attr('Lang', self.config.LANG)
def screenshot_tracking_add(self):
"""
Add a tracking image, image will be saved
"""
if not self.config.Error_SaveError:
return
logger.info('screenshot_tracking_add')
data = self.device.screenshot_deque[-1]
image = data['image']
now = data['time']
def image_encode(im, ti):
import io
from module.handler.sensitive_info import handle_sensitive_image
output = io.BytesIO()
im = handle_sensitive_image(im)
Image.fromarray(im, mode='RGB').save(output, format='png')
output.seek(0)
self.device.screenshot_tracking.append({
'time': ti,
'image': output
})
ModuleBase.worker.submit(image_encode, image, now)
+10 -120
View File
@@ -6,7 +6,7 @@ from module.exception import ScriptError
class Button(Resource):
def __init__(self, file, area, search, color, button, posi=None):
def __init__(self, file, area, search, color, button):
"""
Args:
file: Filepath to an assets
@@ -20,7 +20,6 @@ class Button(Resource):
self.search: t.Tuple[int, int, int, int] = search
self.color: t.Tuple[int, int, int] = color
self._button: t.Tuple[int, int, int, int] = button
self.posi: t.Optional[t.Tuple[int, int]] = posi
self.resource_add(self.file)
self._button_offset: t.Tuple[int, int] = (0, 0)
@@ -29,60 +28,18 @@ class Button(Resource):
def button(self):
return area_offset(self._button, self._button_offset)
def load_offset(self, offset):
"""
Args:
offset (Button | ButtonWrapper | tuple[int, int]):
"""
if isinstance(offset, ButtonWrapper):
offset = offset.matched_button._button_offset
elif isinstance(offset, Button):
offset = offset._button_offset
self._button_offset = offset
def load_search(self, search):
"""
Args:
search (Button | ButtonWrapper | tuple[int, int]):
"""
if isinstance(search, ButtonWrapper):
search = search.search
elif isinstance(search, Button):
search = search.search
self.search = search
def load_offset(self, button):
self._button_offset = button._button_offset
def clear_offset(self):
self._button_offset = (0, 0)
def is_offset_in(self, x=0, y=0):
"""
Args:
x:
y:
Returns:
bool: If _button_offset is in (-x, -y, x, y)
"""
if x:
if self._button_offset[0] < -x or self._button_offset[0] > x:
return False
if y:
if self._button_offset[1] < -y or self._button_offset[1] > y:
return False
return True
@cached_property
def image(self):
return load_image(self.file, self.area)
@cached_property
def image_luma(self):
return rgb2luma(self.image)
def resource_release(self):
del_cached_property(self, 'image')
del_cached_property(self, 'image_luma')
self.clear_offset()
def __str__(self):
@@ -135,29 +92,6 @@ class Button(Resource):
image = crop(image, self.search, copy=False)
res = cv2.matchTemplate(self.image, image, cv2.TM_CCOEFF_NORMED)
_, sim, _, point = cv2.minMaxLoc(res)
self._button_offset = np.array(point) + self.search[:2] - self.area[:2]
return sim > similarity
def match_template_luma(self, image, similarity=0.85, direct_match=False) -> bool:
"""
Detects assets by template matching.
To Some buttons, its location may not be static, `_button_offset` will be set.
Args:
image: Screenshot.
similarity (float): 0-1.
direct_match: True to ignore `self.search`
Returns:
bool.
"""
if not direct_match:
image = crop(image, self.search, copy=False)
image = rgb2luma(image)
res = cv2.matchTemplate(self.image_luma, image, cv2.TM_CCOEFF_NORMED)
_, sim, _, point = cv2.minMaxLoc(res)
self._button_offset = np.array(point) + self.search[:2] - self.area[:2]
return sim > similarity
@@ -200,7 +134,7 @@ class Button(Resource):
Returns:
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:
return False
@@ -274,13 +208,6 @@ class ButtonWrapper(Resource):
return True
return False
def match_template_luma(self, image, similarity=0.85, direct_match=False) -> bool:
for assets in self.buttons:
if assets.match_template_luma(image, similarity=similarity, direct_match=direct_match):
self._matched_button = assets
return True
return False
def match_multi_template(self, image, similarity=0.85, threshold=5, direct_match=False):
"""
Detects assets by template matching, return multiple results
@@ -357,8 +284,10 @@ class ButtonWrapper(Resource):
Load offset from another button.
Args:
button (Button | ButtonWrapper | tuple[int, int]):
button (Button, ButtonWrapper):
"""
if isinstance(button, ButtonWrapper):
button = button.matched_button
for b in self.iter_buttons():
b.load_offset(button)
@@ -366,55 +295,16 @@ class ButtonWrapper(Resource):
for b in self.iter_buttons():
b.clear_offset()
def is_offset_in(self, x=0, y=0):
"""
Args:
x:
y:
Returns:
bool: If _button_offset is in (-x, -y, x, y)
"""
return self.matched_button.is_offset_in(x=x, y=y)
def load_search(self, search):
def load_search(self, area):
"""
Set `search` attribute.
Note that this method is irreversible.
Args:
search (Button | ButtonWrapper | tuple[int, int, int, int]):
area:
"""
for b in self.iter_buttons():
b.load_search(search)
def set_search_offset(self, offset):
"""
Compatible with Alas’ `offset` attribute
In ALAS:
if self.appear(BUTTON, offset=(20, 20)):
pass
In SRC:
BUTTON.set_search_offset((20, 20))
if self.appear(BUTTON):
pass
Note that `search` attribute will be set, and it's irreversible.
Args:
offset (tuple): (x, y) or (left, up, right, bottom)
"""
if len(offset) == 2:
left, up, right, bottom = -offset[0], -offset[1], offset[0], offset[1]
else:
left, up, right, bottom = offset
for b in self.iter_buttons():
upper_left_x, upper_left_y, bottom_right_x, bottom_right_y = b.area
b.search = (
upper_left_x + left,
upper_left_y + up,
bottom_right_x + right,
bottom_right_y + bottom,
)
b.search = area
class ClickButton:
+9 -55
View File
@@ -33,33 +33,23 @@ class TabWrapper:
self.suffix += suffix
class VariableWrapper:
def __init__(self, name):
self.name = name
def __repr__(self):
return str(self.name)
__str__ = __repr__
class CodeGenerator:
def __init__(self):
self.tab_count = 0
self.lines = []
def generate(self) -> t.Iterable[str]:
yield ''
def add(self, line, comment=False, newline=True):
self.lines.append(self._line_with_tabs(line, comment=comment, newline=newline))
def generate(self) -> t.Iterable[str]:
return ''.join(self.lines)
def print(self):
lines = self.generate()
lines = ''.join(self.lines)
print(lines)
def write(self, file: str = None):
lines = self.generate()
lines = ''.join(self.lines)
with open(file, 'w', encoding='utf-8', newline='') as f:
f.write(lines)
@@ -87,10 +77,7 @@ class CodeGenerator:
return TabWrapper(self)
def Empty(self):
self.lines.append('\n')
def Pass(self):
self.add('pass')
self.add('')
def Import(self, text, empty=2):
for line in text.strip().split('\n'):
@@ -99,9 +86,6 @@ class CodeGenerator:
for _ in range(empty):
self.Empty()
def Variable(self, name):
return VariableWrapper(name)
def Value(self, key=None, value=None, type_=None, **kwargs):
if key is not None:
if type_ is not None:
@@ -133,7 +117,7 @@ class CodeGenerator:
if key is not None:
return TabWrapper(self, prefix=str(key) + ' = [', suffix=']')
else:
return TabWrapper(self, prefix='[', suffix=']')
return TabWrapper(self, prefix='[', suffix=']', newline=False)
def ListItem(self, value):
if isinstance(value, TabWrapper):
@@ -147,7 +131,7 @@ class CodeGenerator:
if key is not None:
return TabWrapper(self, prefix=str(key) + ' = {', suffix='}')
else:
return TabWrapper(self, prefix='{', suffix='}')
return TabWrapper(self, prefix='{', suffix='}', newline=False)
def DictItem(self, key=None, value=None):
if isinstance(value, TabWrapper):
@@ -163,7 +147,7 @@ class CodeGenerator:
if key is not None:
return TabWrapper(self, prefix=f'{key} = {object_class}(', suffix=')')
else:
return TabWrapper(self, prefix=f'{object_class}(', suffix=')')
return TabWrapper(self, prefix=f'{object_class}(', suffix=')', newline=False)
def ObjectAttr(self, key=None, value=None):
if isinstance(value, TabWrapper):
@@ -195,33 +179,3 @@ Value = generator.Value
Comment = generator.Comment
Dict = generator.Dict
DictItem = generator.DictItem
class MarkdownGenerator:
def __init__(self, column: t.List[str]):
self.rows = [column]
def add_row(self, row):
self.rows.append([str(ele) for ele in row])
def product_line(self, row, max_width):
row = [ele.ljust(width) for ele, width in zip(row, max_width)]
row = ' | '.join(row)
row = '| ' + row + ' |'
return row
def generate(self) -> t.List[str]:
import numpy as np
width = np.array([
[len(ele) for ele in row] for row in self.rows
])
max_width = np.max(width, axis=0)
dash = ['-' * width for width in max_width]
rows = [
self.product_line(self.rows[0], max_width),
self.product_line(dash, max_width),
] + [
self.product_line(row, max_width) for row in self.rows[1:]
]
return rows
-12
View File
@@ -123,18 +123,6 @@ def has_cached_property(obj, name):
return name in obj.__dict__
def set_cached_property(obj, name, value):
"""
Set a cached property.
Args:
obj:
name (str):
value:
"""
obj.__dict__[name] = value
def function_drop(rate=0.5, default=None):
"""
Drop function calls to simulate random emulator stuck, for testing purpose.
-6
View File
@@ -78,11 +78,5 @@ def release_resources(next_task=''):
# logger.info(f'Release {obj}')
obj.resource_release()
# If no task, check in-game text language again at next run
# cause user may change it
if not next_task:
from tasks.base.main_page import MainPage
MainPage._lang_checked = False
# Useless in most cases, but just call it
# gc.collect()
-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 time import sleep, time
def timer(function):
"""
Decorator to time a function, for debug only
"""
@wraps(function)
def function_timer(*args, **kwargs):
start = time()
t0 = time.time()
result = function(*args, **kwargs)
cost = time() - start
print(f'{function.__name__}: {cost:.10f} s')
t1 = time.time()
print('%s: %s s' % (function.__name__, str(round(t1 - t0, 10))))
return result
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:
def __init__(self, limit, count=0):
"""
Dual timer for time count and access count.
Access count can provide robustness on slow devices where screenshot time cost > timer.limit
Args:
limit (int | float): Timer limit
count (int): Timer access count. Default to 0.
limit (int, float): Timer limit
count (int): Timer reach confirm count. Default to 0.
When using a structure like this, must set a count.
Otherwise it goes wrong, if screenshot time cost greater than limit.
if self.appear(MAIN_CHECK):
if confirm_timer.reached():
pass
else:
confirm_timer.reset()
Also, It's a good idea to set `count`, to make alas run more stable on slow computers.
Expected speed is 0.35 second / screenshot.
"""
self.limit = limit
self.count = count
self._start = 0.
self._access = 0
@classmethod
def from_seconds(cls, limit, speed=0.5):
"""
Create timer from given seconds
Args:
limit (int | float):
speed (int | float): Approximate screenshot time cost
if time cost > 0.5s, device is considered slow
"""
count = int(limit / speed)
return cls(limit, count=count)
self._current = 0
self._reach_count = count
def start(self):
"""
Start current timer.
If timer not started, reached() always return True. So we can have fast first try on:
interval = Timer(2)
while 1:
if interval.reached():
pass
"""
if self._start <= 0:
self._start = time()
self._access = 0
if not self.started():
self._current = time.time()
self._reach_count = 0
return self
def started(self):
"""
Returns:
bool:
"""
return self._start > 0
return bool(self._current)
def current_time(self):
def current(self):
"""
Returns:
float:
float
"""
if self._start > 0:
diff = time() - self._start
if diff < 0:
diff = 0.
return diff
if self.started():
return time.time() - self._current
else:
return 0.
def current_count(self):
"""
Returns:
int:
"""
return self._access
def set(self, current=None, count=None, speed=0.5):
"""
Set internal state directly
Args:
current (int, float):
count (int):
speed (int, float):
"""
if current is not None:
if count is not None:
# set both
self._start = time() - current
self._access = count
else:
# set current only, calculate count
count = int(current / speed)
self._start = time() - current
self._access = count
else:
if count is not None:
# set count only
self._access = count
else:
# nothing to set
pass
return self
def add_count(self):
self._access += 1
return self
def set_current(self, current, count=0):
self._current = time.time() - current
self._reach_count = count
def reached(self):
"""
Returns:
bool:
bool
"""
# each reached() call is consider as an access
self._access += 1
if self._start > 0:
return self._access > self.count and time() - self._start > self.limit
else:
# not started, return True for fast first try
return True
self._reach_count += 1
return time.time() - self._current > self.limit and self._reach_count > self.count
def reset(self):
"""
Reset the timer as if it just started
"""
self._start = time()
self._access = 0
self._current = time.time()
self._reach_count = 0
return self
def clear(self):
"""
Reset the timer as if it never started
"""
self._start = 0.
self._access = self.count
self._current = 0
self._reach_count = self.count
return self
def reached_and_reset(self):
@@ -180,16 +149,15 @@ class Timer:
"""
Wait until timer reached.
"""
diff = self._start + self.limit - time()
diff = self._current + self.limit - time.time()
if diff > 0:
sleep(diff)
time.sleep(diff)
def show(self):
from module.logger import logger
logger.info(str(self))
def __str__(self):
# Timer(limit=2.351/3, count=4/6)
return f'Timer(limit={round(self.current_time(), 3)}/{self.limit}, count={self._access}/{self.count})'
return f'Timer(limit={round(self.current(), 3)}/{self.limit}, count={self._reach_count}/{self.count})'
__repr__ = __str__
+63 -364
View File
@@ -1,4 +1,3 @@
import random
import re
import cv2
@@ -9,9 +8,8 @@ REGEX_NODE = re.compile(r'(-?[A-Za-z]+)(-?\d+)')
def random_normal_distribution_int(a, b, n=3):
"""
Generate a normal distribution int within the interval.
Use the average value of several random numbers to simulate normal distribution.
"""Generate a normal distribution int within the interval. Use the average value of several random numbers to
simulate normal distribution.
Args:
a (int): The minimum of the interval.
@@ -21,13 +19,9 @@ def random_normal_distribution_int(a, b, n=3):
Returns:
int
"""
a = round(a)
b = round(b)
if a < b:
total = 0
for _ in range(n):
total += random.randint(a, b)
return round(total / n)
output = np.mean(np.random.randint(a, b, size=n))
return int(output.round())
else:
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):
"""
Limit point in an area.
@@ -517,22 +497,6 @@ def location2node(location):
return col2name(x) + str(y)
def xywh2xyxy(area):
"""
Convert (x, y, width, height) to (x1, y1, x2, y2)
"""
x, y, w, h = area
return x, y, x + w, y + h
def xyxy2xywh(area):
"""
Convert (x1, y1, x2, y2) to (x, y, width, height)
"""
x1, y1, x2, y2 = area
return min(x1, x2), min(y1, y2), abs(x2 - x1), abs(y2 - y1)
def load_image(file, area=None):
"""
Load an image like pillow and drop alpha channel.
@@ -544,17 +508,13 @@ def load_image(file, area=None):
Returns:
np.ndarray:
"""
# always remember to close Image object
with Image.open(file) as f:
image = Image.open(file)
if area is not None:
f = f.crop(area)
image = np.array(f)
channel = image_channel(image)
if channel == 4:
image = cv2.cvtColor(image, cv2.COLOR_RGBA2RGB)
image = image.crop(area)
image = np.array(image)
channel = image.shape[2] if len(image.shape) > 2 else 1
if channel > 3:
image = image[:, :, :3].copy()
return image
@@ -571,19 +531,6 @@ def save_image(image, file):
Image.fromarray(image).save(file)
def copy_image(src):
"""
Equivalent to image.copy() but a little bit faster
Time cost to copy a 1280*720*3 image:
image.copy() 0.743ms
copy_image(image) 0.639ms
"""
dst = np.empty_like(src)
cv2.copyTo(src, None, dst)
return dst
def crop(image, area, copy=True):
"""
Crop image like pillow, when using opencv / numpy.
@@ -597,71 +544,15 @@ def crop(image, area, copy=True):
Returns:
np.ndarray:
"""
# map(round, area)
x1, y1, x2, y2 = area
x1 = round(x1)
y1 = round(y1)
x2 = round(x2)
y2 = round(y2)
# h, w = image.shape[:2]
shape = image.shape
h = shape[0]
w = shape[1]
# top, bottom, left, right
# border = np.maximum((0 - y1, y2 - h, 0 - x1, x2 - w), 0)
overflow = False
if y1 >= 0:
top = 0
if y1 >= h:
overflow = True
else:
top = -y1
if y2 > h:
bottom = y2 - h
else:
bottom = 0
if y2 <= 0:
overflow = True
if x1 >= 0:
left = 0
if x1 >= w:
overflow = True
else:
left = -x1
if x2 > w:
right = x2 - w
else:
right = 0
if x2 <= 0:
overflow = True
# If overflowed, return empty image
if overflow:
if len(shape) == 2:
size = (y2 - y1, x2 - x1)
else:
size = (y2 - y1, x2 - x1, shape[2])
return np.zeros(size, dtype=image.dtype)
# x1, y1, x2, y2 = np.maximum((x1, y1, x2, y2), 0)
if x1 < 0:
x1 = 0
if y1 < 0:
y1 = 0
if x2 < 0:
x2 = 0
if y2 < 0:
y2 = 0
# crop image
x1, y1, x2, y2 = map(int, map(round, area))
h, w = image.shape[:2]
border = np.maximum((0 - y1, y2 - h, 0 - x1, x2 - w), 0)
x1, y1, x2, y2 = np.maximum((x1, y1, x2, y2), 0)
image = image[y1:y2, x1:x2]
# if border
if top or bottom or left or right:
if len(shape) == 2:
value = 0
else:
value = tuple(0 for _ in range(image.shape[2]))
return cv2.copyMakeBorder(image, top, bottom, left, right, borderType=cv2.BORDER_CONSTANT, value=value)
elif copy:
return copy_image(image)
else:
if sum(border) > 0:
image = cv2.copyMakeBorder(image, *border, borderType=cv2.BORDER_CONSTANT, value=(0, 0, 0))
if copy:
image = image.copy()
return image
@@ -720,29 +611,17 @@ def image_paste(image, background, origin):
def rgb2gray(image):
"""
gray = ( MAX(r, g, b) + MIN(r, g, b)) / 2
Args:
image (np.ndarray): Shape (height, width, channel)
Returns:
np.ndarray: Shape (height, width)
"""
# r, g, b = cv2.split(image)
# return cv2.add(
# cv2.multiply(cv2.max(cv2.max(r, g), b), 0.5),
# cv2.multiply(cv2.min(cv2.min(r, g), b), 0.5)
# )
r, g, b = cv2.split(image)
maximum = cv2.max(r, g)
cv2.min(r, g, dst=r)
cv2.max(maximum, b, dst=maximum)
cv2.min(r, b, dst=r)
# minimum = r
cv2.convertScaleAbs(maximum, alpha=0.5, dst=maximum)
cv2.convertScaleAbs(r, alpha=0.5, dst=r)
cv2.add(maximum, r, dst=maximum)
return maximum
return cv2.add(
cv2.multiply(cv2.max(cv2.max(r, g), b), 0.5),
cv2.multiply(cv2.min(cv2.min(r, g), b), 0.5)
)
def rgb2hsv(image):
@@ -757,7 +636,7 @@ def rgb2hsv(image):
np.ndarray: Hue (0~360), Saturation (0~100), Value (0~100).
"""
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
@@ -805,133 +684,40 @@ def get_color(image, area):
return color[:3]
class ImageNotSupported(Exception):
"""
Raised if we can't perform image calculation on this image
"""
pass
def get_bbox(image, threshold=0):
"""
Get outbound box of the content in image
A opencv implementation of the getbbox() in pillow
A numpy implementation of the getbbox() in pillow.
Args:
image (np.ndarray):
threshold (int):
color > threshold will be considered as content
color <= threshold will be considered background
image (np.ndarray): Screenshot.
threshold (int): Color <= threshold will be considered black
Returns:
tuple[int, int, int, int]: area
Raises:
ImageNotSupported: if failed to get bbox
tuple: (upper_left_x, upper_left_y, bottom_right_x, bottom_right_y)
"""
channel = image_channel(image)
# convert to grayscale
if channel == 3:
# RGB
mask = cv2.cvtColor(image, cv2.COLOR_RGB2GRAY)
cv2.threshold(mask, threshold, 255, cv2.THRESH_BINARY, dst=mask)
elif channel == 0:
# grayscale
_, mask = cv2.threshold(image, threshold, 255, cv2.THRESH_BINARY)
elif channel == 4:
# RGBA
mask = cv2.cvtColor(image, cv2.COLOR_RGBA2GRAY)
cv2.threshold(mask, threshold, 255, cv2.THRESH_BINARY, dst=mask)
else:
raise ImageNotSupported(f'shape={image.shape}')
# find bbox
contours, _ = cv2.findContours(mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
min_y, min_x = mask.shape
max_x = 0
max_y = 0
# all black
if not contours:
raise ImageNotSupported(f'Cannot get bbox from a pure black image')
for contour in contours:
# x, y, w, h
x1, y1, x2, y2 = cv2.boundingRect(contour)
x2 += x1
y2 += y1
if x1 < min_x:
min_x = x1
if y1 < min_y:
min_y = y1
if x2 > max_x:
max_x = x2
if y2 > max_y:
max_y = y2
if min_x < max_x and min_y < max_y:
return min_x, min_y, max_x, max_y
else:
# This shouldn't happen
raise ImageNotSupported(f'Empty bbox {(min_x, min_y, max_x, max_y)}')
if image_channel(image) == 3:
image = np.max(image, axis=2)
x = np.where(np.max(image, axis=0) > threshold)[0]
y = np.where(np.max(image, axis=1) > threshold)[0]
return x[0], y[0], x[-1] + 1, y[-1] + 1
def get_bbox_reversed(image, threshold=255):
def get_bbox_reversed(image, threshold=0):
"""
Get outbound box of the content in image
A opencv implementation of the getbbox() in pillow
Similar to `get_bbox` but for black contents on white background.
Args:
image (np.ndarray):
threshold (int):
color < threshold will be considered as content
color >= threshold will be considered background
image (np.ndarray): Screenshot.
threshold (int): Color >= threshold will be considered white
Returns:
tuple[int, int, int, int]: area
Raises:
ImageNotSupported: if failed to get bbox
tuple: (upper_left_x, upper_left_y, bottom_right_x, bottom_right_y)
"""
channel = image_channel(image)
# convert to grayscale
if channel == 3:
# RGB
mask = cv2.cvtColor(image, cv2.COLOR_RGB2GRAY)
cv2.threshold(mask, 0, threshold, cv2.THRESH_BINARY, dst=mask)
elif channel == 0:
# grayscale
mask = cv2.threshold(image, 0, threshold, cv2.THRESH_BINARY)
elif channel == 4:
# RGBA
mask = cv2.cvtColor(image, cv2.COLOR_RGBA2GRAY)
cv2.threshold(mask, 0, threshold, cv2.THRESH_BINARY, dst=mask)
else:
raise ImageNotSupported(f'shape={image.shape}')
# find bbox
contours, _ = cv2.findContours(mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
min_y, min_x = mask.shape
max_x = 0
max_y = 0
# all black
if not contours:
raise ImageNotSupported(f'Cannot get bbox from a pure black image')
for contour in contours:
# x, y, w, h
x1, y1, x2, y2 = cv2.boundingRect(contour)
x2 += x1
y2 += y1
if x1 < min_x:
min_x = x1
if y1 < min_y:
min_y = y1
if x2 > max_x:
max_x = x2
if y2 > max_y:
max_y = y2
if min_x < max_x and min_y < max_y:
return min_x, min_y, max_x, max_y
else:
# This shouldn't happen
raise ImageNotSupported(f'Empty bbox {(min_x, min_y, max_x, max_y)}')
if image_channel(image) == 3:
image = np.min(image, axis=2)
x = np.where(np.min(image, axis=0) < threshold)[0]
y = np.where(np.min(image, axis=1) < threshold)[0]
return x[0], y[0], x[-1] + 1, y[-1] + 1
def color_similarity(color1, color2):
@@ -943,35 +729,13 @@ def color_similarity(color1, color2):
Returns:
int:
"""
# print(color1, color2)
# diff = np.array(color1).astype(int) - np.array(color2).astype(int)
# diff = np.max(np.maximum(diff, 0)) - np.min(np.minimum(diff, 0))
diff_r = color1[0] - color2[0]
diff_g = color1[1] - color2[1]
diff_b = color1[2] - color2[2]
max_positive = 0
max_negative = 0
if diff_r > max_positive:
max_positive = diff_r
elif diff_r < max_negative:
max_negative = diff_r
if diff_g > max_positive:
max_positive = diff_g
elif diff_g < max_negative:
max_negative = diff_g
if diff_b > max_positive:
max_positive = diff_b
elif diff_b < max_negative:
max_negative = diff_b
diff = max_positive - max_negative
diff = np.array(color1).astype(int) - np.array(color2).astype(int)
diff = np.max(np.maximum(diff, 0)) - np.min(np.minimum(diff, 0))
return diff
def color_similar(color1, color2, threshold=10):
"""
Consider two colors are similar, if tolerance lesser or equal threshold.
"""Consider two colors are similar, if tolerance lesser or equal threshold.
Tolerance = Max(Positive(difference_rgb)) + Max(- Negative(difference_rgb))
The same as the tolerance in Photoshop.
@@ -984,28 +748,8 @@ def color_similar(color1, color2, threshold=10):
bool: True if two colors are similar.
"""
# print(color1, color2)
# diff = np.array(color1).astype(int) - np.array(color2).astype(int)
# diff = np.max(np.maximum(diff, 0)) - np.min(np.minimum(diff, 0))
diff_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
diff = np.array(color1).astype(int) - np.array(color2).astype(int)
diff = np.max(np.maximum(diff, 0)) - np.min(np.minimum(diff, 0))
return diff <= threshold
@@ -1033,24 +777,11 @@ def color_similarity_2d(image, color):
Returns:
np.ndarray: uint8
"""
# r, g, b = cv2.split(cv2.subtract(image, (*color, 0)))
# positive = cv2.max(cv2.max(r, g), b)
# r, g, b = cv2.split(cv2.subtract((*color, 0), image))
# negative = cv2.max(cv2.max(r, g), b)
# return cv2.subtract(255, cv2.add(positive, negative))
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
r, g, b = cv2.split(cv2.subtract(image, (*color, 0)))
positive = cv2.max(cv2.max(r, g), b)
r, g, b = cv2.split(cv2.subtract((*color, 0), image))
negative = cv2.max(cv2.max(r, g), b)
return cv2.subtract(255, cv2.add(positive, negative))
def extract_letters(image, letter=(255, 255, 255), threshold=128):
@@ -1064,25 +795,11 @@ def extract_letters(image, letter=(255, 255, 255), threshold=128):
Returns:
np.ndarray: Shape (height, width)
"""
# r, g, b = cv2.split(cv2.subtract(image, (*letter, 0)))
# positive = cv2.max(cv2.max(r, g), b)
# r, g, b = cv2.split(cv2.subtract((*letter, 0), image))
# negative = cv2.max(cv2.max(r, g), b)
# return cv2.multiply(cv2.add(positive, negative), 255.0 / threshold)
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
r, g, b = cv2.split(cv2.subtract(image, (*letter, 0)))
positive = cv2.max(cv2.max(r, g), b)
r, g, b = cv2.split(cv2.subtract((*letter, 0), image))
negative = cv2.max(cv2.max(r, g), b)
return cv2.multiply(cv2.add(positive, negative), 255.0 / threshold)
def extract_white_letters(image, threshold=128):
@@ -1096,23 +813,10 @@ def extract_white_letters(image, threshold=128):
Returns:
np.ndarray: Shape (height, width)
"""
# minimum = cv2.min(cv2.min(r, g), b)
# maximum = cv2.max(cv2.max(r, g), b)
# return cv2.multiply(cv2.add(maximum, cv2.subtract(maximum, minimum)), 255.0 / threshold)
r, g, b = cv2.split(cv2.subtract((255, 255, 255, 0), image))
maximum = cv2.max(r, g)
cv2.min(r, g, dst=r)
cv2.max(maximum, b, dst=maximum)
cv2.min(r, b, dst=r)
# minimum = r
cv2.convertScaleAbs(maximum, alpha=0.5, dst=maximum)
cv2.convertScaleAbs(r, alpha=0.5, dst=r)
cv2.subtract(maximum, r, dst=r)
cv2.add(maximum, r, dst=maximum)
if threshold != 255:
cv2.convertScaleAbs(maximum, alpha=255.0 / threshold, dst=maximum)
return maximum
minimum = cv2.min(cv2.min(r, g), b)
maximum = cv2.max(cv2.max(r, g), b)
return cv2.multiply(cv2.add(maximum, cv2.subtract(maximum, minimum)), 255.0 / threshold)
def color_mapping(image, max_multiply=2):
@@ -1131,9 +835,7 @@ def color_mapping(image, max_multiply=2):
low, high = np.min(image), np.max(image)
multiply = min(255 / (high - low), max_multiply)
add = (255 - multiply * (low + high)) / 2
# image = cv2.add(cv2.multiply(image, multiply), add)
cv2.multiply(image, multiply, dst=image)
cv2.add(image, add, dst=image)
image = cv2.add(cv2.multiply(image, multiply), add)
image[image > 255] = 255
image[image < 0] = 0
return image.astype(np.uint8)
@@ -1193,7 +895,7 @@ def color_bar_percentage(image, area, prev_color, reverse=False, starter=0, thre
Returns:
float: 0 to 1.
"""
image = crop(image, area, copy=False)
image = crop(image, area)
image = image[:, ::-1, :] if reverse else image
length = image.shape[1]
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
if not prev_row.size:
return prev_index / length
# Look back 5px to get average color
left = max(prev_index - 5, 0)
mask = np.where(bar[:, left:prev_index + 1] > 255 - threshold)
prev_color = np.mean(image[:, left:prev_index + 1][mask], axis=0)
prev_color = np.mean(image[:, prev_index], axis=0)
return 0.
+3 -52
View File
@@ -41,10 +41,9 @@
"aScreenCap_nc",
"DroidCast",
"DroidCast_raw",
"scrcpy",
"nemu_ipc",
"ldopengl"
]
"scrcpy"
],
"display": "hide"
},
"ControlMethod": {
"type": "select",
@@ -75,7 +74,6 @@
"LDPlayer3",
"LDPlayer4",
"LDPlayer9",
"LDPlayer14",
"MuMuPlayer",
"MuMuPlayerX",
"MuMuPlayer12",
@@ -132,7 +130,6 @@
"stay_there",
"goto_main",
"close_game",
"close_emulator",
"exit_aas",
"exit_emulator",
"exit_aas_emulator",
@@ -827,52 +824,6 @@
"type": "input",
"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": {
+2 -13
View File
@@ -26,7 +26,7 @@ Emulator:
option: [ auto, jp, en, zht ]
ScreenshotMethod:
value: auto
option: [ auto, ADB, ADB_nc, uiautomator2, aScreenCap, aScreenCap_nc, DroidCast, DroidCast_raw, scrcpy, nemu_ipc, ldopengl, ]
option: [ auto, ADB, ADB_nc, uiautomator2, aScreenCap, aScreenCap_nc, DroidCast, DroidCast_raw, scrcpy ]
ControlMethod:
value: MaaTouch
option: [ minitouch, MaaTouch ]
@@ -45,7 +45,6 @@ EmulatorInfo:
LDPlayer3,
LDPlayer4,
LDPlayer9,
LDPlayer14,
MuMuPlayer,
MuMuPlayerX,
MuMuPlayer12,
@@ -72,7 +71,7 @@ Optimization:
CombatScreenshotInterval: 1.0
WhenTaskQueueEmpty:
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 ====================
@@ -169,16 +168,6 @@ Schoolhouse:
value: 0
option: [ 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10 ]
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:
OnError:
-3
View File
@@ -4,9 +4,6 @@
# ==================== Alas ====================
Alas:
Emulator:
ScreenshotMethod: scrcpy
Cafe:
Scheduler:
Enable: true
-1
View File
@@ -61,7 +61,6 @@ Dashboard:
HoursAgo:
DaysAgo:
LongTimeAgo:
EtaDays:
AddAlas:
PopupTitle:
+1
View File
@@ -7,6 +7,7 @@
Alas:
Emulator:
ScreenshotMethod: scrcpy
ControlMethod: MaaTouch
Optimization:
ScreenshotInterval: 0.2
-2
View File
@@ -56,8 +56,6 @@ Farm:
- Highway
- DesertRailroad
- Schoolhouse
- Road
- Mansion
Scrimmage:
- Scheduler
- 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()
+23 -35
View File
@@ -1,17 +1,18 @@
import copy
import datetime
import operator
import threading
from datetime import datetime, timedelta
import pywebio
from module.base.decorator import cached_property, del_cached_property
from module.base.filter import Filter
from module.config.config_generated import GeneratedConfig
from module.config.config_manual import ManualConfig
from module.config.config_updater import ConfigUpdater, ensure_time, get_server_next_update, nearest_future
from module.config.deep import deep_get, deep_set
from module.config.config_updater import ConfigUpdater
from module.config.stored.classes import iter_attribute
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.exception import RequestHumanTakeover, ScriptError
from module.logger import logger
@@ -105,12 +106,7 @@ class AzurLaneConfig(ConfigUpdater, ManualConfig, GeneratedConfig, ConfigWatcher
logger.info("Using template config, which is read only")
self.auto_update = False
self.task = name_to_function("template")
self.init_task(task)
def init_task(self, task=None):
if self.is_template_config:
return
else:
self.load()
if task is None:
# Bind `Alas` by default which includes emulator settings.
@@ -133,17 +129,13 @@ class AzurLaneConfig(ConfigUpdater, ManualConfig, GeneratedConfig, ConfigWatcher
"""
Args:
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):
func = func.command
# func_list: ["Alas", <task>, *func_list]
if func_list is None:
func_list = []
if func not in func_list:
func_list.insert(0, func)
if "Alas" not in func_list:
func_list.insert(0, "Alas")
func_list.append(func)
logger.info(f"Bind task {func_list}")
# Bind arguments
@@ -180,10 +172,6 @@ class AzurLaneConfig(ConfigUpdater, ManualConfig, GeneratedConfig, ConfigWatcher
self.data, keys="Alas.Optimization.CloseGameDuringWait", default=False
)
@property
def is_actual_task(self):
return self.task.command.lower() not in ['alas', 'template']
@cached_property
def stored(self) -> StoredGenerated:
stored = StoredGenerated()
@@ -291,7 +279,6 @@ class AzurLaneConfig(ConfigUpdater, ManualConfig, GeneratedConfig, ConfigWatcher
deep_set(self.data, keys=f"{task}.Scheduler.NextRun", value=now)
limit_next_run(['BattlePass'], limit=now + timedelta(days=31, seconds=-1))
limit_next_run(['Weekly'], limit=now + timedelta(days=7, seconds=-1))
limit_next_run(self.args.keys(), limit=now + timedelta(hours=24, seconds=-1))
def override(self, **kwargs):
@@ -498,18 +485,19 @@ class AzurLaneConfig(ConfigUpdater, ManualConfig, GeneratedConfig, ConfigWatcher
def is_task_enabled(self, task):
return bool(self.cross_get(keys=[task, 'Scheduler', 'Enable'], default=False))
# def update_daily_quests(self):
# """
# Raises:
# TaskEnd: Call task `DailyQuest` and stop current task
# """
# with self.multi_set():
# if self.stored.DailyActivity.is_expired():
# logger.info('DailyActivity expired')
# self.stored.DailyActivity.clear()
# if self.stored.DailyQuest.is_expired():
# logger.info('DailyQuest expired')
# self.stored.DailyQuest.clear()
def update_daily_quests(self):
"""
Raises:
TaskEnd: Call task `DailyQuest` and stop current task
"""
if self.stored.DailyActivity.is_expired():
logger.info('DailyActivity expired, call task to update')
self.task_call('DailyQuest')
self.task_stop()
if self.stored.DailyQuest.is_expired():
logger.info('DailyQuest expired, call task to update')
self.task_call('DailyQuest')
self.task_stop()
@property
def DEVICE_SCREENSHOT_METHOD(self):
+3 -11
View File
@@ -19,12 +19,12 @@ class GeneratedConfig:
Emulator_Serial = 'auto'
Emulator_PackageName = 'auto' # auto, JP-Official, OVERSEA-TWHKMO, OVERSEA-Korea, OVERSEA-Asia, OVERSEA-America, OVERSEA-Global
Emulator_GameLanguage = 'auto' # auto, jp, en, zht
Emulator_ScreenshotMethod = 'auto' # auto, ADB, ADB_nc, uiautomator2, aScreenCap, aScreenCap_nc, DroidCast, DroidCast_raw, scrcpy, nemu_ipc, ldopengl
Emulator_ScreenshotMethod = 'auto' # auto, ADB, ADB_nc, uiautomator2, aScreenCap, aScreenCap_nc, DroidCast, DroidCast_raw, scrcpy
Emulator_ControlMethod = 'MaaTouch' # minitouch, MaaTouch
Emulator_AdbRestart = False
# 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_path = None
@@ -37,7 +37,7 @@ class GeneratedConfig:
# Group `Optimization`
Optimization_ScreenshotInterval = 0.3
Optimization_CombatScreenshotInterval = 1.0
Optimization_WhenTaskQueueEmpty = 'goto_main' # stay_there, goto_main, close_game, 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`
Cafe_Reward = True
@@ -98,14 +98,6 @@ class GeneratedConfig:
Schoolhouse_Stage = 0 # 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10
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`
Scrimmage_OnError = 'skip' # stop, skip
+6 -66
View File
@@ -1,11 +1,9 @@
import typing as t
from copy import deepcopy
from cached_property import cached_property
from deploy.Windows.utils import DEPLOY_TEMPLATE, poor_yaml_read, poor_yaml_write
from module.base.timer import timer
from module.config.deep import deep_default, deep_get, deep_iter, deep_set
from module.config.server import VALID_SERVER
from module.config.utils import *
@@ -454,19 +452,20 @@ class ConfigUpdater:
dict:
"""
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'])
typ = data['type']
display = data.get('display')
if is_template or value is None or value == '' \
or typ in type_lock or (display == 'hide' and typ not in type_stored):
if (is_template or value is None or value == ''
or typ in ['lock', 'state'] or (display == 'hide' and typ != 'stored')):
value = data['value']
value = parse_value(value, data=data)
deep_set(new, keys=keys, value=value)
for path, _ in deep_iter(self.args, depth=3):
deep_load(path)
# if not is_template:
# new = self.config_redirect(old, new)
@@ -570,65 +569,6 @@ class ConfigUpdater:
# set_daily('Use_Consumables_1_time', 'achievable')
# return data
def save_callback(self, key: str, value: t.Any) -> t.Iterable[t.Tuple[str, t.Any]]:
"""
Args:
key: Key path in config json, such as "Main.Emotion.Fleet1Value"
value: Value set by user, such as "98"
Yields:
str: Key path to set config json, such as "Main.Emotion.Fleet1Record"
any: Value to set, such as "2020-01-01 00:00:00"
"""
# if key.startswith('Dungeon.Dungeon') or key.startswith('Dungeon.DungeonDaily'):
# from tasks.dungeon.keywords.dungeon import DungeonList
# from module.exception import ScriptError
# try:
# dungeon = DungeonList.find(value)
# except ScriptError:
# return
# if key.endswith('Name'):
# if dungeon.is_Calyx_Golden:
# yield 'Dungeon.Dungeon.NameAtDoubleCalyx', value
# yield 'Dungeon.DungeonDaily.CalyxGolden', value
# elif dungeon.is_Calyx_Crimson:
# yield 'Dungeon.Dungeon.NameAtDoubleCalyx', value
# yield 'Dungeon.DungeonDaily.CalyxCrimson', value
# elif dungeon.is_Stagnant_Shadow:
# yield 'Dungeon.DungeonDaily.StagnantShadow', value
# elif dungeon.is_Cavern_of_Corrosion:
# yield 'Dungeon.Dungeon.NameAtDoubleRelic', value
# yield 'Dungeon.DungeonDaily.CavernOfCorrosion', value
# elif key.endswith('NameAtDoubleCalyx'):
# if dungeon.is_Calyx_Golden:
# yield 'Dungeon.DungeonDaily.CalyxGolden', value
# elif dungeon.is_Calyx_Crimson:
# yield 'Dungeon.DungeonDaily.CalyxCrimson', value
# elif key.endswith('NameAtDoubleRelic'):
# yield 'Dungeon.DungeonDaily.CavernOfCorrosion', value
# elif key.endswith('CavernOfCorrosion'):
# yield 'Dungeon.Dungeon.NameAtDoubleRelic', value
yield '', None
def iter_hidden_args(self, data) -> t.Iterator[str]:
"""
Args:
data (dict): config
Yields:
str: Arg path that should be hidden
"""
# if deep_get(data, 'Rogue.RoguePath.PresetResonanceFilter') != 'custom':
# yield 'Rogue.RoguePath.CustomResonanceFilter'
yield ''
def get_hidden_args(self, data) -> t.Set[str]:
"""
Return a set of hidden args
"""
out = list(self.iter_hidden_args(data))
return set(out)
def read_file(self, config_name, is_template=False):
"""
Read and update config file.

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