Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f5ee209726 | |||
| 466dadf1c9 | |||
| b4745214f2 | |||
| 910307967b | |||
| 042c50536c | |||
| 19febeaf3f | |||
| 47f3320c3d |
150
README.md
150
README.md
@@ -1,9 +1,10 @@
|
|||||||
# ⚡ Mira Firmware Loader
|
# ⚡ Mira Firmware Loader
|
||||||
|
|
||||||
Công cụ desktop dùng để **scan, phát hiện và flash firmware hàng loạt** cho các thiết bị OpenWrt trong mạng LAN.
|
Công cụ desktop dùng để **scan, phát hiện và flash firmware hàng loạt** cho các thiết bị OpenWrt trong mạng LAN.
|
||||||
|
Hỗ trợ nạp **thủ công** (chọn thiết bị → flash) và **tự động hóa** (scan → phát hiện → flash → retry).
|
||||||
|
|
||||||
> **Tech stack:** Python 3.9+ · PyQt6 · Paramiko/SCP · Scapy · Requests · PyInstaller
|
> **Tech stack:** Python 3.9+ · PyQt6 · Paramiko/SCP · Scapy · Requests · PyInstaller
|
||||||
> **Phiên bản:** `1.1.2`
|
> **Phiên bản:** `1.2.0`
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -11,7 +12,7 @@ Công cụ desktop dùng để **scan, phát hiện và flash firmware hàng lo
|
|||||||
|
|
||||||
```text
|
```text
|
||||||
Mira_Firmware_Loader/
|
Mira_Firmware_Loader/
|
||||||
├── main.py # UI chính (PyQt6)
|
├── main.py # UI chính (PyQt6) — App + AutoFlashWindow
|
||||||
├── version.txt # Số phiên bản ứng dụng
|
├── version.txt # Số phiên bản ứng dụng
|
||||||
├── requirements.txt # Danh sách dependencies
|
├── requirements.txt # Danh sách dependencies
|
||||||
├── core/
|
├── core/
|
||||||
@@ -22,17 +23,19 @@ Mira_Firmware_Loader/
|
|||||||
│ ├── ssh_new_flash.py # Luồng SSH cho chế độ Nạp Mới (Telnet → set passwd → SSH)
|
│ ├── ssh_new_flash.py # Luồng SSH cho chế độ Nạp Mới (Telnet → set passwd → SSH)
|
||||||
│ ├── ssh_update_flash.py # Luồng SSH cho chế độ Update (SSH trực tiếp)
|
│ ├── ssh_update_flash.py # Luồng SSH cho chế độ Update (SSH trực tiếp)
|
||||||
│ ├── flash_new_worker.py # NewFlashThread — QThread điều phối Nạp Mới FW
|
│ ├── flash_new_worker.py # NewFlashThread — QThread điều phối Nạp Mới FW
|
||||||
│ └── flash_update_worker.py # UpdateFlashThread — QThread điều phối Update FW
|
│ ├── flash_update_worker.py # UpdateFlashThread — QThread điều phối Update FW
|
||||||
|
│ └── auto_flash_worker.py # AutoFlashWorker — QThread tự động scan → flash → retry
|
||||||
├── ui/
|
├── ui/
|
||||||
│ ├── components.py # Custom Qt Widgets (CollapsibleGroupBox)
|
│ ├── components.py # Custom Qt Widgets (CollapsibleGroupBox)
|
||||||
│ └── styles.py # Stylesheet toàn ứng dụng
|
│ └── styles.py # Stylesheet toàn ứng dụng (STYLE + AUTO_STYLE)
|
||||||
├── utils/
|
├── utils/
|
||||||
│ ├── network.py # Helper IP / network (get_local_ip, get_default_network)
|
│ ├── network.py # Helper IP / network (get_local_ip, get_default_network)
|
||||||
│ └── system.py # Lấy thông tin máy, resource path cho PyInstaller
|
│ └── system.py # Lấy thông tin máy, resource path cho PyInstaller
|
||||||
├── docs/
|
├── docs/
|
||||||
│ ├── api_flash_docs.md # Tài liệu kỹ thuật LuCI API flash
|
│ ├── api_flash_docs.md # Tài liệu kỹ thuật LuCI API flash
|
||||||
│ ├── load_fw_ssh_docs.md # Tài liệu kỹ thuật SSH flash (cả 2 luồng)
|
│ ├── load_fw_ssh_docs.md # Tài liệu kỹ thuật SSH flash (cả 2 luồng)
|
||||||
│ └── scanner_docs.md # Tài liệu kỹ thuật scanner
|
│ ├── scanner_docs.md # Tài liệu kỹ thuật scanner
|
||||||
|
│ └── auto_flash_docs.md # Tài liệu kỹ thuật tự động hóa nạp FW
|
||||||
├── run.sh # Script khởi chạy (macOS/Linux)
|
├── run.sh # Script khởi chạy (macOS/Linux)
|
||||||
├── run.bat # Script khởi chạy (Windows)
|
├── run.bat # Script khởi chạy (Windows)
|
||||||
└── build_windows.bat # Script đóng gói thành .exe (Windows, PyInstaller)
|
└── build_windows.bat # Script đóng gói thành .exe (Windows, PyInstaller)
|
||||||
@@ -89,31 +92,33 @@ Output: `dist\Mira_Firmware_Loader.exe` — không cần cài Python trên máy
|
|||||||
│ Method: [ API (LuCI) | SSH (paramiko) ] │
|
│ Method: [ API (LuCI) | SSH (paramiko) ] │
|
||||||
│ Concurrent devices: [SpinBox] │
|
│ Concurrent devices: [SpinBox] │
|
||||||
│ [ ⚡ FLASH SELECTED DEVICES ] │
|
│ [ ⚡ FLASH SELECTED DEVICES ] │
|
||||||
|
│ ───────────────────────────────────────────────────── │
|
||||||
|
│ [ 🤖 Tự động hóa nạp FW ] │
|
||||||
└────────────────────────┬─────────────────────────────────┘
|
└────────────────────────┬─────────────────────────────────┘
|
||||||
│
|
│
|
||||||
┌──────────────┼──────────────┐
|
┌─────────────────┼─────────────────┐
|
||||||
│ │
|
│ │ │
|
||||||
┌──────▼──────┐ ┌────────▼────────────────┐
|
┌──────▼──────┐ ┌───────▼──────────┐ ┌───▼──────────────────┐
|
||||||
│ scanner.py │ │ Flash Workers │
|
│ scanner.py │ │ Flash Workers │ │ AutoFlashWorker │
|
||||||
│ │ │ │
|
│ │ │ (thủ công) │ │ (tự động hóa) │
|
||||||
│ 1. Ping │ │ NewFlashThread │
|
│ 1. Ping │ │ │ │ │
|
||||||
│ Sweep │ │ ├─ method=api │
|
│ Sweep │ │ NewFlashThread │ │ Phase 1: Scan LAN │
|
||||||
│ 2. arp -a │ │ │ └── api_flash.py │
|
│ 2. arp -a │ │ ├─ api_flash │ │ (tối đa 15 lần) │
|
||||||
│ 3. Scapy │ │ └─ method=ssh │
|
│ 3. Scapy │ │ └─ ssh_new_flash│ │ Phase 2: Flash │
|
||||||
│ ARP │ │ └── ssh_new_flash │
|
│ ARP │ │ │ │ (auto-retry x3) │
|
||||||
└─────────────┘ │ │
|
└─────────────┘ │ UpdateFlash │ │ ├─ api_flash │
|
||||||
│ UpdateFlashThread │
|
│ Thread │ │ └─ ssh_new_flash │
|
||||||
│ └─ ssh_update_flash.py │
|
│ └─ ssh_update │ └──────────────────────┘
|
||||||
└─────────────────────────┘
|
└──────────────────┘
|
||||||
│
|
│
|
||||||
┌──────────▼──────────┐
|
┌──────────▼──────────┐
|
||||||
│ ssh_utils.py │
|
│ ssh_utils.py │
|
||||||
│ (Transport Layer) │
|
│ (Transport Layer) │
|
||||||
│ _create_ssh_client │
|
│ _create_ssh_client │
|
||||||
│ _upload_firmware │
|
│ _upload_firmware │
|
||||||
│ _verify_firmware │
|
│ _verify_firmware │
|
||||||
│ _sync_and_sysupgr │
|
│ _sync_and_sysupgr │
|
||||||
└─────────────────────┘
|
└─────────────────────┘
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -138,7 +143,7 @@ Kết quả được merge theo IP và sort tăng dần trước khi trả về
|
|||||||
- Mặc định ẩn gateway và IP máy tính đang chạy (có thể bật "Show all")
|
- Mặc định ẩn gateway và IP máy tính đang chạy (có thể bật "Show all")
|
||||||
- Thiết bị đã flash trong session được đánh dấu "Already Flashed" và tự bỏ tick
|
- Thiết bị đã flash trong session được đánh dấu "Already Flashed" và tự bỏ tick
|
||||||
|
|
||||||
### 3. Flash Firmware
|
### 3. Flash Firmware (Thủ công)
|
||||||
|
|
||||||
Có 2 chế độ và 2 method, tổng cộng 3 luồng thực thi khác nhau:
|
Có 2 chế độ và 2 method, tổng cộng 3 luồng thực thi khác nhau:
|
||||||
|
|
||||||
@@ -169,7 +174,73 @@ Luồng `ssh_update_flash.py`, SSH trực tiếp (không qua Telnet):
|
|||||||
|
|
||||||
> ⚠️ Update Mode hiển thị cảnh báo nếu IP thiết bị khác `192.168.11.102` và yêu cầu xác nhận.
|
> ⚠️ Update Mode hiển thị cảnh báo nếu IP thiết bị khác `192.168.11.102` và yêu cầu xác nhận.
|
||||||
|
|
||||||
### 4. Xử lý song song
|
### 4. 🤖 Tự động hóa nạp FW (MỚI)
|
||||||
|
|
||||||
|
Tính năng nạp FW hoàn toàn tự động — chỉ cần cấu hình và nhấn bắt đầu:
|
||||||
|
|
||||||
|
```
|
||||||
|
Cấu hình → Auto Scan LAN → Phát hiện đủ thiết bị → Auto Flash → Auto Retry → Thông báo
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 4.1. Quy trình
|
||||||
|
|
||||||
|
| Phase | Mô tả |
|
||||||
|
| --------------------- | ------------------------------------------------------------------------- |
|
||||||
|
| **Phase 1: Scan LAN** | Scan mạng liên tục mỗi 5 giây, tối đa **15 lần**, cho đến khi đủ thiết bị |
|
||||||
|
| **Phase 2: Flash** | Nạp FW song song qua ThreadPoolExecutor, tự động **retry tối đa 3 lần** |
|
||||||
|
|
||||||
|
#### 4.2. Cấu hình
|
||||||
|
|
||||||
|
| Tham số | Mô tả | Mặc định |
|
||||||
|
| --------------- | --------------------------------------------- | ------------------- |
|
||||||
|
| **Firmware** | File firmware (.bin/.hex/.uf2) | Lấy từ cửa sổ chính |
|
||||||
|
| **Mạng** | Dải mạng LAN cần scan | Tự suy từ IP host |
|
||||||
|
| **Số lượng** | Số thiết bị cần nạp | 5 |
|
||||||
|
| **Phương thức** | API (LuCI) hoặc SSH | API (LuCI) |
|
||||||
|
| **Song song** | Số thiết bị nạp cùng lúc (0 = không giới hạn) | 10 |
|
||||||
|
|
||||||
|
#### 4.3. Auto-Retry nạp FW
|
||||||
|
|
||||||
|
Khi một thiết bị nạp thất bại, hệ thống tự động retry:
|
||||||
|
|
||||||
|
```
|
||||||
|
Lần 1 → FAIL: Connection timeout
|
||||||
|
⚠️ Log cảnh báo, chờ 2 giây...
|
||||||
|
Lần 2 → FAIL: Upload error
|
||||||
|
⚠️ Log cảnh báo, chờ 2 giây...
|
||||||
|
Lần 3 → DONE ✅ (hoặc ❌ báo lỗi sau 3 lần)
|
||||||
|
```
|
||||||
|
|
||||||
|
- Tối đa **3 lần retry** mỗi thiết bị (`MAX_FLASH_RETRIES`)
|
||||||
|
- Chờ **2 giây** giữa mỗi lần retry để thiết bị ổn định
|
||||||
|
- Nếu hết 3 lần vẫn fail → đánh dấu ❌, tiếp tục thiết bị tiếp theo
|
||||||
|
|
||||||
|
#### 4.4. Scan Timeout
|
||||||
|
|
||||||
|
- Nếu scan **15 lần** mà chưa đủ thiết bị → dừng và hiện cảnh báo
|
||||||
|
- Gợi ý kiểm tra: thiết bị đã bật chưa, dải mạng có đúng không
|
||||||
|
|
||||||
|
#### 4.5. Quy tắc bảo vệ IP `192.168.11.102`
|
||||||
|
|
||||||
|
| Chế độ | Được nạp 192.168.11.102? | Cơ chế |
|
||||||
|
| ------------------------ | :----------------------: | ----------------------------------- |
|
||||||
|
| **New Flash (thủ công)** | ❌ Không | Chặn trước khi flash, hiện cảnh báo |
|
||||||
|
| **Update FW (thủ công)** | ✅ Có | Cho phép bình thường |
|
||||||
|
| **Tự động hóa** | ❌ Không | Tự động lọc khỏi kết quả scan |
|
||||||
|
|
||||||
|
#### 4.6. Lịch sử nạp
|
||||||
|
|
||||||
|
Kết quả nạp được lưu ở 2 cấp:
|
||||||
|
|
||||||
|
| Nơi lưu | Phạm vi | Format |
|
||||||
|
| ------------------------------- | ---------------------- | ------------------------------------ |
|
||||||
|
| `AutoFlashWindow._auto_history` | Phiên tự động hiện tại | `list[(ip, mac, result, timestamp)]` |
|
||||||
|
| `App.flashed_macs` | Toàn bộ session app | `dict{MAC: (ip, mac, result, ts)}` |
|
||||||
|
|
||||||
|
- Cả thành công ✅ lẫn thất bại ❌ đều được ghi lại
|
||||||
|
- Nút "📋 Lịch sử nạp" hiển thị cùng format ở cả 2 cửa sổ: `[HH:MM:SS] ✅/❌ IP (MAC) — result`
|
||||||
|
|
||||||
|
### 5. Xử lý song song
|
||||||
|
|
||||||
| Tham số | Mô tả |
|
| Tham số | Mô tả |
|
||||||
| ---------------------- | ------------------------------------------------------------------------ |
|
| ---------------------- | ------------------------------------------------------------------------ |
|
||||||
@@ -188,6 +259,23 @@ Luồng `ssh_update_flash.py`, SSH trực tiếp (không qua Telnet):
|
|||||||
| **SSH Password** | `admin123a` | Hardcoded, không hiển thị trên UI |
|
| **SSH Password** | `admin123a` | Hardcoded, không hiển thị trên UI |
|
||||||
| **Concurrent devices** | `10` | Số luồng flash song song |
|
| **Concurrent devices** | `10` | Số luồng flash song song |
|
||||||
| **Show all** | Tắt | Ẩn gateway và IP máy host |
|
| **Show all** | Tắt | Ẩn gateway và IP máy host |
|
||||||
|
| **MAX_FLASH_RETRIES** | `3` | Số lần retry nạp FW (tự động) |
|
||||||
|
| **MAX_SCAN_ROUNDS** | `15` | Số lần scan tối đa (tự động) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🛡️ Xử lý lỗi tổng hợp
|
||||||
|
|
||||||
|
| Tình huống | Hành vi |
|
||||||
|
| -------------------------------- | --------------------------------------------------- |
|
||||||
|
| Scan exception (network error) | Log lỗi, chờ 3s, scan lại (đếm vào MAX_SCAN_ROUNDS) |
|
||||||
|
| Scan 15 lần chưa đủ thiết bị | Hiện popup cảnh báo, dừng tự động |
|
||||||
|
| Flash thất bại lần 1–2 (tự động) | Log cảnh báo, chờ 2s, retry tự động |
|
||||||
|
| Flash thất bại sau 3 lần retry | Log lỗi, đánh dấu ❌, tiếp tục device tiếp theo |
|
||||||
|
| User nhấn DỪNG | Set stop flag, dừng scan/flash an toàn |
|
||||||
|
| IP 192.168.11.102 + New Flash | Chặn ngay, hiện cảnh báo |
|
||||||
|
| Chưa chọn firmware | Hiện popup cảnh báo, không cho bắt đầu |
|
||||||
|
| Mạng không hợp lệ | Hiện popup cảnh báo, không cho bắt đầu |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
200
core/auto_flash_worker.py
Normal file
200
core/auto_flash_worker.py
Normal file
@@ -0,0 +1,200 @@
|
|||||||
|
"""
|
||||||
|
Worker thread cho chế độ "Tự động hóa nạp FW".
|
||||||
|
|
||||||
|
Flow:
|
||||||
|
1. Scan mạng LAN liên tục (tối đa max_scan_rounds lần)
|
||||||
|
2. Khi phát hiện đủ số thiết bị yêu cầu → bắt đầu nạp FW
|
||||||
|
3. Nạp FW theo phương thức đã chọn (API / SSH), tự động retry nếu lỗi
|
||||||
|
4. Thông báo khi hoàn thành
|
||||||
|
"""
|
||||||
|
|
||||||
|
import time
|
||||||
|
import ipaddress
|
||||||
|
from PyQt6.QtCore import QThread, pyqtSignal
|
||||||
|
from concurrent.futures import ThreadPoolExecutor
|
||||||
|
|
||||||
|
from core.scanner import scan_network
|
||||||
|
from core.api_flash import flash_device_api
|
||||||
|
from core.ssh_new_flash import flash_device_new_ssh
|
||||||
|
|
||||||
|
MAX_FLASH_RETRIES = 3 # Số lần retry nạp FW khi thất bại
|
||||||
|
MAX_SCAN_ROUNDS = 10 # Số lần scan tối đa trước khi báo không đủ thiết bị
|
||||||
|
|
||||||
|
|
||||||
|
class AutoFlashWorker(QThread):
|
||||||
|
"""Tự động scan → flash khi đủ số lượng thiết bị."""
|
||||||
|
|
||||||
|
# Signals
|
||||||
|
log_message = pyqtSignal(str) # log message cho UI
|
||||||
|
scan_found = pyqtSignal(int) # số device tìm thấy trong lần scan hiện tại
|
||||||
|
devices_ready = pyqtSignal(list) # danh sách devices sẵn sàng flash [{ip, mac}, ...]
|
||||||
|
device_status = pyqtSignal(str, str) # ip, status message
|
||||||
|
device_done = pyqtSignal(str, str, str) # ip, mac, result ("DONE"/"FAIL:...")
|
||||||
|
flash_progress = pyqtSignal(int, int) # done_count, total
|
||||||
|
all_done = pyqtSignal(int, int) # success_count, fail_count
|
||||||
|
scan_timeout = pyqtSignal(int, int) # found_count, target_count — scan hết lần mà chưa đủ
|
||||||
|
stopped = pyqtSignal() # khi dừng bởi user
|
||||||
|
|
||||||
|
def __init__(self, network, target_count, method, max_workers,
|
||||||
|
firmware_path, local_ip="", gateway_ip="",
|
||||||
|
ssh_user="root", ssh_password="admin123a",
|
||||||
|
ssh_backup_password="admin123a", set_passwd=True):
|
||||||
|
super().__init__()
|
||||||
|
self.network = network
|
||||||
|
self.target_count = target_count
|
||||||
|
self.method = method
|
||||||
|
self.max_workers = max_workers
|
||||||
|
self.firmware_path = firmware_path
|
||||||
|
self.local_ip = local_ip
|
||||||
|
self.gateway_ip = gateway_ip
|
||||||
|
self.ssh_user = ssh_user
|
||||||
|
self.ssh_password = ssh_password
|
||||||
|
self.ssh_backup_password = ssh_backup_password
|
||||||
|
self.set_passwd = set_passwd
|
||||||
|
self._stop_flag = False
|
||||||
|
|
||||||
|
def stop(self):
|
||||||
|
self._stop_flag = True
|
||||||
|
|
||||||
|
def run(self):
|
||||||
|
self.log_message.emit("🚀 Bắt đầu chế độ tự động hóa nạp FW...")
|
||||||
|
self.log_message.emit(f" Mục tiêu: {self.target_count} thiết bị | Phương thức: {self.method.upper()} | Song song: {self.max_workers}")
|
||||||
|
self.log_message.emit(f" Mạng: {self.network}")
|
||||||
|
self.log_message.emit("")
|
||||||
|
|
||||||
|
# ── Phase 1: Scan liên tục cho đến khi đủ thiết bị (tối đa MAX_SCAN_ROUNDS lần) ──
|
||||||
|
devices = []
|
||||||
|
scan_round = 0
|
||||||
|
excluded = {self.local_ip, self.gateway_ip}
|
||||||
|
best_found = 0
|
||||||
|
|
||||||
|
while not self._stop_flag:
|
||||||
|
scan_round += 1
|
||||||
|
self.log_message.emit(f"🔍 Scan lần {scan_round}/{MAX_SCAN_ROUNDS}...")
|
||||||
|
|
||||||
|
try:
|
||||||
|
results = scan_network(str(self.network))
|
||||||
|
except Exception as e:
|
||||||
|
self.log_message.emit(f"❌ Scan thất bại: {e}")
|
||||||
|
if self._stop_flag:
|
||||||
|
break
|
||||||
|
time.sleep(3)
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Lọc bỏ gateway, local IP, và 192.168.11.102 (chỉ update mới được nạp)
|
||||||
|
filtered = [d for d in results if d["ip"] not in excluded and d["ip"] != "192.168.11.102"]
|
||||||
|
found_count = len(filtered)
|
||||||
|
best_found = max(best_found, found_count)
|
||||||
|
self.scan_found.emit(found_count)
|
||||||
|
self.log_message.emit(f" Tìm thấy {found_count}/{self.target_count} thiết bị")
|
||||||
|
|
||||||
|
if found_count >= self.target_count:
|
||||||
|
# Chỉ lấy đúng số lượng yêu cầu
|
||||||
|
devices = filtered[:self.target_count]
|
||||||
|
self.log_message.emit(f"✅ Đủ {self.target_count} thiết bị! Bắt đầu nạp FW...")
|
||||||
|
self.log_message.emit("")
|
||||||
|
break
|
||||||
|
|
||||||
|
if self._stop_flag:
|
||||||
|
break
|
||||||
|
|
||||||
|
# Kiểm tra đã scan quá số lần tối đa
|
||||||
|
if scan_round >= MAX_SCAN_ROUNDS:
|
||||||
|
self.log_message.emit(f"⚠️ Đã scan {MAX_SCAN_ROUNDS} lần mà chỉ tìm thấy {best_found}/{self.target_count} thiết bị.")
|
||||||
|
self.scan_timeout.emit(best_found, self.target_count)
|
||||||
|
self.stopped.emit()
|
||||||
|
return
|
||||||
|
|
||||||
|
self.log_message.emit(f" Chưa đủ, chờ 5 giây rồi scan lại...")
|
||||||
|
# Chờ 5 giây nhưng check stop flag mỗi 0.5s
|
||||||
|
for _ in range(10):
|
||||||
|
if self._stop_flag:
|
||||||
|
break
|
||||||
|
time.sleep(0.5)
|
||||||
|
|
||||||
|
if self._stop_flag:
|
||||||
|
self.log_message.emit("⛔ Đã dừng bởi người dùng.")
|
||||||
|
self.stopped.emit()
|
||||||
|
return
|
||||||
|
|
||||||
|
# ── Phase 2: Flash ──
|
||||||
|
total = len(devices)
|
||||||
|
success_count = 0
|
||||||
|
fail_count = 0
|
||||||
|
done_count = 0
|
||||||
|
|
||||||
|
self.flash_progress.emit(0, total)
|
||||||
|
|
||||||
|
# Gửi danh sách devices cho UI để populate bảng trước khi flash
|
||||||
|
self.devices_ready.emit(devices)
|
||||||
|
|
||||||
|
# Log danh sách thiết bị
|
||||||
|
for d in devices:
|
||||||
|
self.log_message.emit(f" 📱 {d['ip']} ({d['mac']})")
|
||||||
|
self.log_message.emit("")
|
||||||
|
|
||||||
|
def _flash_one(dev):
|
||||||
|
nonlocal success_count, fail_count, done_count
|
||||||
|
ip = dev["ip"]
|
||||||
|
mac = dev.get("mac", "N/A")
|
||||||
|
result = ""
|
||||||
|
|
||||||
|
for attempt in range(1, MAX_FLASH_RETRIES + 1):
|
||||||
|
if self._stop_flag:
|
||||||
|
result = "FAIL: Dừng bởi người dùng"
|
||||||
|
break
|
||||||
|
|
||||||
|
if attempt > 1:
|
||||||
|
self.log_message.emit(f"🔄 [{ip}] Retry lần {attempt}/{MAX_FLASH_RETRIES}...")
|
||||||
|
self.device_status.emit(ip, f"Retry lần {attempt}/{MAX_FLASH_RETRIES}...")
|
||||||
|
time.sleep(2) # chờ thiết bị ổn định trước khi retry
|
||||||
|
|
||||||
|
try:
|
||||||
|
def on_status(msg):
|
||||||
|
self.device_status.emit(ip, msg)
|
||||||
|
|
||||||
|
if self.method == "ssh":
|
||||||
|
result = flash_device_new_ssh(
|
||||||
|
ip, self.firmware_path,
|
||||||
|
user=self.ssh_user,
|
||||||
|
password=self.ssh_password,
|
||||||
|
backup_password=self.ssh_backup_password,
|
||||||
|
set_passwd=self.set_passwd,
|
||||||
|
status_cb=on_status,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
result = flash_device_api(
|
||||||
|
ip, self.firmware_path,
|
||||||
|
status_cb=on_status,
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
result = f"FAIL: {e}"
|
||||||
|
|
||||||
|
if result.startswith("DONE"):
|
||||||
|
break
|
||||||
|
else:
|
||||||
|
if attempt < MAX_FLASH_RETRIES:
|
||||||
|
self.log_message.emit(f"⚠️ [{ip}] Lần {attempt} thất bại: {result}")
|
||||||
|
else:
|
||||||
|
self.log_message.emit(f"❌ [{ip}] Thất bại sau {MAX_FLASH_RETRIES} lần thử: {result}")
|
||||||
|
|
||||||
|
self.device_done.emit(ip, mac, result)
|
||||||
|
|
||||||
|
if result.startswith("DONE"):
|
||||||
|
success_count += 1
|
||||||
|
else:
|
||||||
|
fail_count += 1
|
||||||
|
done_count += 1
|
||||||
|
self.flash_progress.emit(done_count, total)
|
||||||
|
|
||||||
|
workers = self.max_workers if self.max_workers > 0 else total
|
||||||
|
with ThreadPoolExecutor(max_workers=max(workers, 1)) as executor:
|
||||||
|
futures = [executor.submit(_flash_one, dev) for dev in devices]
|
||||||
|
for f in futures:
|
||||||
|
f.result()
|
||||||
|
if self._stop_flag:
|
||||||
|
break
|
||||||
|
|
||||||
|
self.log_message.emit("")
|
||||||
|
self.log_message.emit(f"🏁 Hoàn thành! Thành công: {success_count} | Thất bại: {fail_count}")
|
||||||
|
self.all_done.emit(success_count, fail_count)
|
||||||
124
core/scanner.py
124
core/scanner.py
@@ -1,7 +1,5 @@
|
|||||||
import subprocess
|
import subprocess
|
||||||
import re
|
|
||||||
import sys
|
import sys
|
||||||
import time
|
|
||||||
import ipaddress
|
import ipaddress
|
||||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||||
|
|
||||||
@@ -10,146 +8,72 @@ _NO_WINDOW = subprocess.CREATE_NO_WINDOW if sys.platform == "win32" else 0
|
|||||||
|
|
||||||
|
|
||||||
def _ping_one(ip, is_win):
|
def _ping_one(ip, is_win):
|
||||||
"""Ping a single IP to populate ARP table."""
|
"""Ping a single IP. Returns True if host responds."""
|
||||||
try:
|
try:
|
||||||
if is_win:
|
if is_win:
|
||||||
subprocess.run(
|
r = subprocess.run(
|
||||||
["ping", "-n", "1", "-w", "300", str(ip)], # 300ms (was 600ms)
|
["ping", "-n", "1", "-w", "300", str(ip)],
|
||||||
stdout=subprocess.DEVNULL,
|
stdout=subprocess.DEVNULL,
|
||||||
stderr=subprocess.DEVNULL,
|
stderr=subprocess.DEVNULL,
|
||||||
timeout=2,
|
timeout=2,
|
||||||
creationflags=_NO_WINDOW
|
creationflags=_NO_WINDOW
|
||||||
)
|
)
|
||||||
elif sys.platform == "darwin":
|
elif sys.platform == "darwin":
|
||||||
subprocess.run(
|
r = subprocess.run(
|
||||||
["ping", "-c", "1", "-W", "500", str(ip)], # 500ms — macOS: -W unit là ms
|
["ping", "-c", "1", "-W", "300", str(ip)],
|
||||||
stdout=subprocess.DEVNULL,
|
stdout=subprocess.DEVNULL,
|
||||||
stderr=subprocess.DEVNULL,
|
stderr=subprocess.DEVNULL,
|
||||||
timeout=2
|
timeout=2
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
subprocess.run(
|
r = subprocess.run(
|
||||||
["ping", "-c", "1", "-W", "1", str(ip)], # 1s — Linux: -W unit là giây
|
["ping", "-c", "1", "-W", "1", str(ip)],
|
||||||
stdout=subprocess.DEVNULL,
|
stdout=subprocess.DEVNULL,
|
||||||
stderr=subprocess.DEVNULL,
|
stderr=subprocess.DEVNULL,
|
||||||
timeout=2
|
timeout=2
|
||||||
)
|
)
|
||||||
|
return r.returncode == 0
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
return False
|
||||||
|
|
||||||
|
|
||||||
def _ping_sweep(network, progress_cb=None):
|
def _ping_sweep(network, progress_cb=None):
|
||||||
"""Ping tất cả host trong network đồng thời để điền ARP cache.
|
"""Ping tất cả host trong network đồng thời.
|
||||||
Gọi progress_cb(done, total) sau mỗi ping nếu được cung cấp.
|
Trả về list IP đã phản hồi. Gọi progress_cb(done, total) sau mỗi ping.
|
||||||
"""
|
"""
|
||||||
net = ipaddress.ip_network(network, strict=False)
|
net = ipaddress.ip_network(network, strict=False)
|
||||||
|
|
||||||
# Chỉ ping sweep cho /24 hoặc nhỏ hơn
|
|
||||||
if net.num_addresses > 256:
|
if net.num_addresses > 256:
|
||||||
return
|
return []
|
||||||
|
|
||||||
is_win = sys.platform == "win32"
|
is_win = sys.platform == "win32"
|
||||||
hosts = list(net.hosts())
|
hosts = list(net.hosts())
|
||||||
total = len(hosts)
|
total = len(hosts)
|
||||||
done_count = [0]
|
done_count = [0]
|
||||||
|
alive = []
|
||||||
|
|
||||||
def _ping_and_track(ip):
|
def _ping_and_track(ip):
|
||||||
_ping_one(ip, is_win)
|
ok = _ping_one(ip, is_win)
|
||||||
done_count[0] += 1
|
done_count[0] += 1
|
||||||
if progress_cb:
|
if progress_cb:
|
||||||
progress_cb(done_count[0], total)
|
progress_cb(done_count[0], total)
|
||||||
|
return (str(ip), ok)
|
||||||
|
|
||||||
# Tất cả host cùng lúc — loại bỏ overhead batching (was max_workers=100)
|
|
||||||
with ThreadPoolExecutor(max_workers=len(hosts)) as executor:
|
with ThreadPoolExecutor(max_workers=len(hosts)) as executor:
|
||||||
futures = [executor.submit(_ping_and_track, ip) for ip in hosts]
|
futures = [executor.submit(_ping_and_track, ip) for ip in hosts]
|
||||||
for f in as_completed(futures):
|
for f in as_completed(futures):
|
||||||
pass
|
ip_str, ok = f.result()
|
||||||
|
if ok:
|
||||||
|
alive.append(ip_str)
|
||||||
|
|
||||||
|
return alive
|
||||||
|
|
||||||
|
|
||||||
def scan_network(network, progress_cb=None, stage_cb=None):
|
def scan_network(network, progress_cb=None, stage_cb=None):
|
||||||
"""Scan network: ping sweep → ARP table + Scapy song song."""
|
"""Scan network: chỉ dùng ping để xác định thiết bị online."""
|
||||||
# Phase 1: Ping sweep
|
|
||||||
if stage_cb:
|
if stage_cb:
|
||||||
stage_cb("ping")
|
stage_cb("ping")
|
||||||
_ping_sweep(network, progress_cb)
|
alive_ips = _ping_sweep(network, progress_cb)
|
||||||
time.sleep(0.3) # Giảm từ 1s xuống 0.3s
|
|
||||||
|
|
||||||
# Phase 2 + 3: ARP table và Scapy chạy song song
|
results = [{"ip": ip_str, "mac": "N/A"} for ip_str in alive_ips]
|
||||||
if stage_cb:
|
return sorted(results, key=lambda d: ipaddress.ip_address(d["ip"]))
|
||||||
stage_cb("arp")
|
|
||||||
|
|
||||||
def _collect_arp():
|
|
||||||
result = {}
|
|
||||||
try:
|
|
||||||
output = subprocess.check_output(
|
|
||||||
["arp", "-a"], text=True, creationflags=_NO_WINDOW
|
|
||||||
)
|
|
||||||
net = ipaddress.ip_network(network, strict=False)
|
|
||||||
if sys.platform == "win32":
|
|
||||||
pattern = re.compile(
|
|
||||||
r"(\d+\.\d+\.\d+\.\d+)\s+"
|
|
||||||
r"([0-9a-fA-F]{2}-[0-9a-fA-F]{2}-[0-9a-fA-F]{2}-"
|
|
||||||
r"[0-9a-fA-F]{2}-[0-9a-fA-F]{2}-[0-9a-fA-F]{2})\s+"
|
|
||||||
r"(dynamic|static)",
|
|
||||||
re.IGNORECASE
|
|
||||||
)
|
|
||||||
for line in output.splitlines():
|
|
||||||
m = pattern.search(line)
|
|
||||||
if m:
|
|
||||||
ip_str = m.group(1)
|
|
||||||
mac = m.group(2).replace("-", ":")
|
|
||||||
if mac.upper() != "FF:FF:FF:FF:FF:FF":
|
|
||||||
try:
|
|
||||||
if ipaddress.ip_address(ip_str) in net:
|
|
||||||
result[ip_str] = {"ip": ip_str, "mac": mac}
|
|
||||||
except ValueError:
|
|
||||||
pass
|
|
||||||
else:
|
|
||||||
pattern = re.compile(
|
|
||||||
r"\((\d+\.\d+\.\d+\.\d+)\)\s+at\s+([0-9a-fA-F:]+)"
|
|
||||||
)
|
|
||||||
for line in output.splitlines():
|
|
||||||
m = pattern.search(line)
|
|
||||||
if m:
|
|
||||||
ip_str, mac = m.group(1), m.group(2)
|
|
||||||
if mac.lower() not in ("(incomplete)", "ff:ff:ff:ff:ff:ff"):
|
|
||||||
try:
|
|
||||||
if ipaddress.ip_address(ip_str) in net:
|
|
||||||
result[ip_str] = {"ip": ip_str, "mac": mac}
|
|
||||||
except ValueError:
|
|
||||||
pass
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
return result
|
|
||||||
|
|
||||||
def _collect_scapy():
|
|
||||||
result = {}
|
|
||||||
try:
|
|
||||||
import io
|
|
||||||
_stderr = sys.stderr
|
|
||||||
sys.stderr = io.StringIO()
|
|
||||||
try:
|
|
||||||
from scapy.all import ARP, Ether, srp
|
|
||||||
finally:
|
|
||||||
sys.stderr = _stderr
|
|
||||||
|
|
||||||
arp = ARP(pdst=str(network))
|
|
||||||
ether = Ether(dst="ff:ff:ff:ff:ff:ff")
|
|
||||||
raw = srp(ether / arp, timeout=1, verbose=0)[0] # Giảm từ 2s xuống 1s
|
|
||||||
for _, received in raw:
|
|
||||||
result[received.psrc] = {"ip": received.psrc, "mac": received.hwsrc}
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
return result
|
|
||||||
|
|
||||||
# Chạy ARP read và Scapy đồng thời
|
|
||||||
with ThreadPoolExecutor(max_workers=2) as executor:
|
|
||||||
f_arp = executor.submit(_collect_arp)
|
|
||||||
f_scapy = executor.submit(_collect_scapy)
|
|
||||||
seen = f_arp.result()
|
|
||||||
# Scapy bổ sung những IP chưa có trong ARP table
|
|
||||||
for ip, dev in f_scapy.result().items():
|
|
||||||
if ip not in seen:
|
|
||||||
seen[ip] = dev
|
|
||||||
|
|
||||||
return sorted(seen.values(), key=lambda d: ipaddress.ip_address(d["ip"]))
|
|
||||||
|
|||||||
271
docs/auto_flash_docs.md
Normal file
271
docs/auto_flash_docs.md
Normal file
@@ -0,0 +1,271 @@
|
|||||||
|
# Tài liệu Kỹ thuật: Tự động hóa nạp FW (`core/auto_flash_worker.py`)
|
||||||
|
|
||||||
|
Module **Tự động hóa nạp FW** tự động quét mạng LAN, phát hiện thiết bị đích, và nạp firmware hàng loạt mà không cần thao tác thủ công. Được thiết kế cho môi trường sản xuất cần nạp FW nhanh cho nhiều thiết bị OpenWrt cùng lúc.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Kiến Trúc — Vai Trò File
|
||||||
|
|
||||||
|
| File | Vai trò |
|
||||||
|
| --------------------------- | ------------------------------------------------------------------------ |
|
||||||
|
| `core/auto_flash_worker.py` | `AutoFlashWorker` — QThread xử lý toàn bộ quy trình scan → flash tự động |
|
||||||
|
| `core/scanner.py` | `scan_network()` — quét mạng LAN (ping sweep + ARP table + Scapy) |
|
||||||
|
| `core/api_flash.py` | `flash_device_api()` — nạp FW qua LuCI HTTP API |
|
||||||
|
| `core/ssh_new_flash.py` | `flash_device_new_ssh()` — nạp FW qua SSH (paramiko/scp) |
|
||||||
|
| `main.py` | `AutoFlashWindow` — UI cửa sổ tự động hóa (PyQt6) |
|
||||||
|
| `ui/styles.py` | `AUTO_STYLE` — stylesheet riêng cho cửa sổ tự động hóa (tím) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Sơ Đồ Luồng Tổng Quan
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart TD
|
||||||
|
A[👤 Người dùng nhấn XÁC NHẬN & BẮT ĐẦU] --> B[AutoFlashWorker.run]
|
||||||
|
|
||||||
|
B --> P1["── Phase 1: Scan LAN ──"]
|
||||||
|
P1 --> S1["scan_network(network)"]
|
||||||
|
S1 --> F1{"Lọc bỏ:\n• Local IP\n• Gateway IP\n• 192.168.11.102"}
|
||||||
|
F1 --> C1{Đủ số lượng\nthiết bị?}
|
||||||
|
C1 -->|Có| P2["── Phase 2: Flash ──"]
|
||||||
|
C1 -->|Chưa đủ| C2{Đã scan\n≥ 15 lần?}
|
||||||
|
C2 -->|Chưa| W1["Chờ 5 giây\n→ scan lại"]
|
||||||
|
W1 --> S1
|
||||||
|
C2 -->|Rồi| T1["⚠️ scan_timeout\nThông báo lỗi"]
|
||||||
|
|
||||||
|
P2 --> D1["ThreadPoolExecutor\nNạp FW song song"]
|
||||||
|
D1 --> D2["_flash_one(device)"]
|
||||||
|
D2 --> D3{Kết quả?}
|
||||||
|
D3 -->|DONE| D4["✅ Thành công"]
|
||||||
|
D3 -->|FAIL| D5{Retry < 3?}
|
||||||
|
D5 -->|Có| D6["🔄 Chờ 2s → Retry"]
|
||||||
|
D6 --> D2
|
||||||
|
D5 -->|Không| D7["❌ Thất bại\nsau 3 lần"]
|
||||||
|
|
||||||
|
D4 --> D8["Tổng hợp kết quả"]
|
||||||
|
D7 --> D8
|
||||||
|
D8 --> D9["🏁 all_done(success, fail)"]
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Cấu Hình & Hằng Số
|
||||||
|
|
||||||
|
| Hằng số | Giá trị | Mô tả |
|
||||||
|
| ------------------- | ------- | ------------------------------------------------------ |
|
||||||
|
| `MAX_FLASH_RETRIES` | 3 | Số lần retry tối đa khi nạp FW thất bại cho 1 thiết bị |
|
||||||
|
| `MAX_SCAN_ROUNDS` | 15 | Số lần scan LAN tối đa trước khi báo timeout |
|
||||||
|
| Scan interval | 5 giây | Khoảng cách giữa các lần scan (check stop mỗi 0.5s) |
|
||||||
|
| Retry delay | 2 giây | Thời gian chờ trước khi retry nạp FW |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Tham Số Khởi Tạo Worker
|
||||||
|
|
||||||
|
```python
|
||||||
|
AutoFlashWorker(
|
||||||
|
network="192.168.11.0/24", # Dải mạng cần scan
|
||||||
|
target_count=5, # Số lượng thiết bị cần nạp
|
||||||
|
method="api", # "api" (LuCI) hoặc "ssh"
|
||||||
|
max_workers=10, # Số luồng nạp song song (0 = không giới hạn)
|
||||||
|
firmware_path="/path/fw.bin", # Đường dẫn file firmware
|
||||||
|
local_ip="192.168.11.50", # IP máy host (sẽ bị loại khỏi scan)
|
||||||
|
gateway_ip="192.168.11.1", # IP gateway (sẽ bị loại khỏi scan)
|
||||||
|
ssh_user="root", # SSH username (mặc định: root)
|
||||||
|
ssh_password="admin123a", # SSH password chính
|
||||||
|
ssh_backup_password="admin123a", # SSH password dự phòng
|
||||||
|
set_passwd=True, # Có đặt lại mật khẩu sau flash không
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Signal — Giao Tiếp Worker ↔ UI
|
||||||
|
|
||||||
|
| Signal | Kiểu dữ liệu | Khi nào emit |
|
||||||
|
| ---------------- | --------------- | --------------------------------------------------- |
|
||||||
|
| `log_message` | `str` | Mỗi dòng log (scan, flash, retry, kết quả) |
|
||||||
|
| `scan_found` | `int` | Sau mỗi lần scan — số thiết bị tìm thấy |
|
||||||
|
| `devices_ready` | `list[dict]` | Khi đủ thiết bị — danh sách `{ip, mac}` trước flash |
|
||||||
|
| `device_status` | `str, str` | Cập nhật trạng thái real-time: `(ip, message)` |
|
||||||
|
| `device_done` | `str, str, str` | Mỗi thiết bị xong: `(ip, mac, result)` |
|
||||||
|
| `flash_progress` | `int, int` | Tiến trình: `(done_count, total)` |
|
||||||
|
| `all_done` | `int, int` | Kết thúc: `(success_count, fail_count)` |
|
||||||
|
| `scan_timeout` | `int, int` | Scan hết lần: `(best_found, target_count)` |
|
||||||
|
| `stopped` | — | Khi worker dừng (bởi user hoặc timeout) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Chi Tiết Quy Trình
|
||||||
|
|
||||||
|
### 6.1. Phase 1 — Scan Mạng LAN
|
||||||
|
|
||||||
|
```
|
||||||
|
Lần 1/15 → scan_network("192.168.11.0/24")
|
||||||
|
→ Lọc bỏ: local_ip, gateway_ip, 192.168.11.102
|
||||||
|
→ Tìm thấy 3/5 thiết bị → chưa đủ
|
||||||
|
→ Chờ 5 giây...
|
||||||
|
|
||||||
|
Lần 2/15 → scan_network(...)
|
||||||
|
→ Tìm thấy 5/5 thiết bị → ĐỦ!
|
||||||
|
→ Chuyển sang Phase 2
|
||||||
|
```
|
||||||
|
|
||||||
|
**Quy tắc lọc IP:**
|
||||||
|
|
||||||
|
- `local_ip` — IP của máy host (tránh nạp FW vào chính máy mình)
|
||||||
|
- `gateway_ip` — IP của router/gateway
|
||||||
|
- `192.168.11.102` — IP thiết bị đã cài FW sẵn, **chỉ được nạp ở chế độ Update FW**
|
||||||
|
|
||||||
|
**Timeout:**
|
||||||
|
|
||||||
|
- Sau **15 lần scan** mà chưa đủ thiết bị → emit `scan_timeout`
|
||||||
|
- UI hiện popup cảnh báo kèm gợi ý kiểm tra:
|
||||||
|
- Thiết bị đã bật và kết nối mạng chưa
|
||||||
|
- Dải mạng có đúng không
|
||||||
|
- Thử lại sau khi kiểm tra
|
||||||
|
|
||||||
|
### 6.2. Phase 2 — Nạp FW (có Auto-Retry)
|
||||||
|
|
||||||
|
Mỗi thiết bị được nạp trong `ThreadPoolExecutor` (chạy song song):
|
||||||
|
|
||||||
|
```
|
||||||
|
[192.168.11.103] Lần 1 → flash_device_api(...) → FAIL: Connection timeout
|
||||||
|
⚠️ Lần 1 thất bại
|
||||||
|
Chờ 2 giây...
|
||||||
|
[192.168.11.103] Lần 2 → flash_device_api(...) → FAIL: Upload error
|
||||||
|
⚠️ Lần 2 thất bại
|
||||||
|
Chờ 2 giây...
|
||||||
|
[192.168.11.103] Lần 3 → flash_device_api(...) → DONE
|
||||||
|
✅ Thành công (lần thứ 3)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Quy trình retry:**
|
||||||
|
|
||||||
|
1. Thực hiện nạp FW (API hoặc SSH)
|
||||||
|
2. Nếu kết quả bắt đầu bằng `"DONE"` → thành công, dừng retry
|
||||||
|
3. Nếu thất bại và còn lần retry → log cảnh báo, chờ 2 giây, thử lại
|
||||||
|
4. Nếu thất bại sau 3 lần → log lỗi, báo kết quả `FAIL`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Giao Diện Cửa Sổ Tự Động (AutoFlashWindow)
|
||||||
|
|
||||||
|
### 7.1. Bố Cục
|
||||||
|
|
||||||
|
```
|
||||||
|
┌──────────────────────────────────────────┐
|
||||||
|
│ 🤖 Tự động hóa nạp FW │
|
||||||
|
├──────────────────────────────────────────┤
|
||||||
|
│ ⚙️ Cấu hình nạp (thu gọn được) │
|
||||||
|
│ FW: V3.0.6p5.bin │ Mạng: .11.0/24 │
|
||||||
|
│ Số lượng: 5 │ API (LuCI) │ Song song:10│
|
||||||
|
├──────────────────────────────────────────┤
|
||||||
|
│ [▶ XÁC NHẬN & BẮT ĐẦU] [■ DỪNG] │
|
||||||
|
│ 🔍 Đang scan: 3/5 thiết bị... ████░░ │
|
||||||
|
├──────────────────────────────────────────┤
|
||||||
|
│ 📋 Danh sách thiết bị │
|
||||||
|
│ ┌───┬──────────────┬────────────┬──────┐ │
|
||||||
|
│ │ # │ IP │ MAC │Kết quả│ │
|
||||||
|
│ ├───┼──────────────┼────────────┼──────┤ │
|
||||||
|
│ │ 1 │192.168.11.103│ AA:BB:CC.. │✅DONE│ │
|
||||||
|
│ │ 2 │192.168.11.104│ DD:EE:FF.. │⏳... │ │
|
||||||
|
│ │ 3 │192.168.11.105│ 11:22:33.. │🔄 R2 │ │
|
||||||
|
│ └───┴──────────────┴────────────┴──────┘ │
|
||||||
|
│ Tổng: 5 | Xong: 3 | ✅ 2 | ❌ 1 [📋Lịch sử]│
|
||||||
|
├──────────────────────────────────────────┤
|
||||||
|
│ 📝 Log (thu gọn được) │
|
||||||
|
│ 🚀 Bắt đầu chế độ tự động hóa nạp FW...│
|
||||||
|
│ 🔍 Scan lần 1/15... │
|
||||||
|
│ Tìm thấy 5/5 thiết bị │
|
||||||
|
│ ✅ Đủ 5 thiết bị! Bắt đầu nạp FW... │
|
||||||
|
│ ⚠️ [192.168.11.105] Lần 1 thất bại... │
|
||||||
|
│ 🔄 [192.168.11.105] Retry lần 2/3... │
|
||||||
|
└──────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### 7.2. Các Thành Phần UI
|
||||||
|
|
||||||
|
| Thành phần | Mô tả |
|
||||||
|
| ------------------------- | ---------------------------------------------------------- |
|
||||||
|
| **Cấu hình nạp** | CollapsibleGroupBox — chọn FW, mạng, số lượng, phương thức |
|
||||||
|
| **Nút điều khiển** | XÁC NHẬN & BẮT ĐẦU / DỪNG — enable/disable theo trạng thái |
|
||||||
|
| **Trạng thái + Progress** | Hiển thị inline trạng thái scan/flash + progress bar |
|
||||||
|
| **Bảng thiết bị** | 4 cột: #, IP, MAC, Kết quả — cập nhật real-time |
|
||||||
|
| **Tổng hợp + Lịch sử** | Bộ đếm ✅/❌ + nút "📋 Lịch sử nạp" xem chi tiết |
|
||||||
|
| **Log** | CollapsibleGroupBox — log chi tiết toàn bộ quá trình |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Lịch Sử Nạp (Flash History)
|
||||||
|
|
||||||
|
Kết quả nạp được lưu ở **2 nơi** với cùng format:
|
||||||
|
|
||||||
|
| Nơi lưu | Phạm vi | Dữ liệu |
|
||||||
|
| ------------------------------- | ---------------------- | ------------------------------------ |
|
||||||
|
| `AutoFlashWindow._auto_history` | Phiên tự động hiện tại | `list[(ip, mac, result, timestamp)]` |
|
||||||
|
| `App.flashed_macs` | Toàn bộ session app | `dict{MAC: (ip, mac, result, ts)}` |
|
||||||
|
|
||||||
|
- Cả thành công ✅ lẫn thất bại ❌ đều được ghi lại
|
||||||
|
- Nút "📋 Lịch sử nạp" hiển thị danh sách với format: `[HH:MM:SS] ✅/❌ IP (MAC) — result`
|
||||||
|
- Lịch sử `_auto_history` reset mỗi khi nhấn "XÁC NHẬN & BẮT ĐẦU" lần mới
|
||||||
|
- Lịch sử `flashed_macs` tồn tại suốt phiên chạy app (cả manual và auto)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Quy Tắc Bảo Vệ IP `192.168.11.102`
|
||||||
|
|
||||||
|
| Chế độ | Được nạp 192.168.11.102? | Cơ chế |
|
||||||
|
| ------------------------ | :----------------------: | --------------------------------------- |
|
||||||
|
| **New Flash (thủ công)** | ❌ Không | Kiểm tra trước khi flash, hiện cảnh báo |
|
||||||
|
| **Update FW (thủ công)** | ✅ Có | Cho phép bình thường |
|
||||||
|
| **Tự động hóa** | ❌ Không | Lọc khỏi kết quả scan tự động |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Xử Lý Lỗi Tổng Hợp
|
||||||
|
|
||||||
|
| Tình huống | Hành vi |
|
||||||
|
| ------------------------------ | --------------------------------------------------- |
|
||||||
|
| Scan exception (network error) | Log lỗi, chờ 3s, scan lại (đếm vào MAX_SCAN_ROUNDS) |
|
||||||
|
| Scan 15 lần chưa đủ thiết bị | Emit `scan_timeout`, hiện popup cảnh báo, dừng |
|
||||||
|
| Flash thất bại lần 1-2 | Log cảnh báo, chờ 2s, retry tự động |
|
||||||
|
| Flash thất bại sau 3 lần retry | Log lỗi, đánh dấu ❌, tiếp tục device tiếp theo |
|
||||||
|
| User nhấn DỪNG | Set `_stop_flag`, dừng scan/flash, emit `stopped` |
|
||||||
|
| Chưa chọn firmware | Hiện popup cảnh báo, không cho bắt đầu |
|
||||||
|
| Mạng không hợp lệ | Hiện popup cảnh báo, không cho bắt đầu |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. Hướng Dẫn Sử Dụng
|
||||||
|
|
||||||
|
### Bước 1: Mở tính năng
|
||||||
|
|
||||||
|
Nhấn nút **"🤖 Tự động hóa nạp FW"** ở cuối cửa sổ chính.
|
||||||
|
|
||||||
|
### Bước 2: Cấu hình
|
||||||
|
|
||||||
|
1. **Chọn firmware** — nhấn 📁 hoặc tự động lấy từ cửa sổ chính
|
||||||
|
2. **Dải mạng** — mặc định lấy từ IP máy host (ví dụ: `192.168.11.0/24`)
|
||||||
|
3. **Số lượng** — số thiết bị cần nạp (1–500)
|
||||||
|
4. **Phương thức** — API (LuCI) hoặc SSH
|
||||||
|
5. **Song song** — số thiết bị nạp cùng lúc (0 = tất cả cùng lúc)
|
||||||
|
|
||||||
|
### Bước 3: Bắt đầu
|
||||||
|
|
||||||
|
Nhấn **"▶ XÁC NHẬN & BẮT ĐẦU"** → xác nhận popup → hệ thống tự động:
|
||||||
|
|
||||||
|
1. Scan mạng liên tục cho đến khi đủ thiết bị (tối đa 15 lần)
|
||||||
|
2. Nạp FW song song cho tất cả thiết bị tìm được
|
||||||
|
3. Tự động retry nếu thiết bị nào bị lỗi (tối đa 3 lần)
|
||||||
|
4. Hiện thông báo tổng hợp khi hoàn thành
|
||||||
|
|
||||||
|
### Bước 4: Theo dõi
|
||||||
|
|
||||||
|
- **Bảng thiết bị** — trạng thái real-time từng thiết bị
|
||||||
|
- **Log** — chi tiết quá trình scan, flash, retry
|
||||||
|
- **Lịch sử nạp** — nhấn 📋 để xem danh sách đã nạp
|
||||||
|
|
||||||
|
### Dừng giữa chừng
|
||||||
|
|
||||||
|
Nhấn **"■ DỪNG"** — worker sẽ dừng an toàn sau khi hoàn thành device đang xử lý.
|
||||||
@@ -2,37 +2,40 @@
|
|||||||
|
|
||||||
## 1. Tổng quan
|
## 1. Tổng quan
|
||||||
|
|
||||||
Thành phần quét IP trong `scanner.py` dò tìm và liệt kê tất cả thiết bị đang hoạt động trên một dải mạng (ví dụ: `192.168.1.0/24`), trả về danh sách chứa **IP** và **MAC Address** của từng thiết bị.
|
Thành phần quét IP trong `scanner.py` dò tìm và liệt kê tất cả thiết bị **đang thực sự online** trên một dải mạng (ví dụ: `192.168.1.0/24`), trả về danh sách chứa **IP** của từng thiết bị.
|
||||||
|
|
||||||
Để đảm bảo tỷ lệ phát hiện cao trên mọi hệ điều hành (Windows, macOS, Linux), scanner kết hợp 3 giai đoạn:
|
Scanner chỉ sử dụng **ICMP Ping** — thiết bị phải phản hồi ping thì mới được liệt kê. Cơ chế này đảm bảo kết quả chính xác, tránh hiện tượng hiển thị thiết bị đã ngắt kết nối do ARP cache cũ trên router/OS.
|
||||||
|
|
||||||
1. **Ping Sweep** — đánh thức thiết bị, điền ARP cache
|
> **Lý do loại bỏ ARP và Scapy:** Bảng ARP của hệ điều hành và router giữ cache entry trong nhiều phút, khiến thiết bị đã rút vẫn xuất hiện trong kết quả scan. Ping trực tiếp là phương pháp duy nhất xác nhận thiết bị thực sự đang hoạt động tại thời điểm scan.
|
||||||
2. **Đọc bảng ARP hệ điều hành** (`arp -a`) — không cần quyền Admin
|
|
||||||
3. **Scapy ARP broadcast** — bổ sung thiết bị chặn ICMP
|
|
||||||
|
|
||||||
Bước 2 và 3 chạy **song song** để giảm tổng thời gian scan.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 2. Luồng hoạt động chính (Hàm `scan_network`)
|
## 2. Luồng hoạt động chính (Hàm `scan_network`)
|
||||||
|
|
||||||
|
```
|
||||||
|
scan_network(network)
|
||||||
|
│
|
||||||
|
├── stage_cb("ping") ← Thông báo UI bắt đầu quét
|
||||||
|
│
|
||||||
|
├── _ping_sweep(network) ← Ping đồng thời toàn bộ host
|
||||||
|
│ │
|
||||||
|
│ ├── _ping_one(ip) × N ← Mỗi host 1 thread
|
||||||
|
│ │
|
||||||
|
│ └── return [alive IPs]
|
||||||
|
│
|
||||||
|
└── return [{"ip": ..., "mac": "N/A"}, ...] ← Sorted by IP
|
||||||
|
```
|
||||||
|
|
||||||
**Bước 1 — Ping Sweep**
|
**Bước 1 — Ping Sweep**
|
||||||
|
|
||||||
- Gọi `_ping_sweep(network)`: gửi ICMP Echo Request đồng thời tới **toàn bộ host** trong dải mạng.
|
- Gọi `_ping_sweep(network)`: gửi ICMP Echo Request đồng thời tới **toàn bộ host** trong dải mạng.
|
||||||
- Mỗi thiết bị phản hồi sẽ khiến hệ điều hành **tự ghi MAC vào ARP Cache**.
|
- Chỉ các IP có `returncode == 0` (phản hồi thành công) mới được đưa vào kết quả.
|
||||||
- Sau khi sweep xong, chờ `0.3s` để OS kịp finalize ARP cache (giảm từ 1s trước đây).
|
|
||||||
|
|
||||||
**Bước 2 + 3 — ARP Table & Scapy (song song)**
|
**Bước 2 — Trả về kết quả**
|
||||||
|
|
||||||
- Hai hàm `_collect_arp()` và `_collect_scapy()` được submit vào `ThreadPoolExecutor(max_workers=2)` và chạy đồng thời:
|
|
||||||
- `_collect_arp()`: đọc `arp -a`, parse regex lấy IP + MAC.
|
|
||||||
- `_collect_scapy()`: gửi ARP broadcast, nhận phản hồi trực tiếp từ thiết bị.
|
|
||||||
- Kết quả merge theo IP: ARP table làm nền, Scapy bổ sung IP còn thiếu.
|
|
||||||
|
|
||||||
**Bước 4 — Trả về kết quả**
|
|
||||||
|
|
||||||
- Danh sách sort tăng dần theo IP:
|
- Danh sách sort tăng dần theo IP:
|
||||||
`[{"ip": "192.168.1.2", "mac": "aa:bb:cc:dd:ee:ff"}, ...]`
|
`[{"ip": "192.168.1.2", "mac": "N/A"}, ...]`
|
||||||
|
- Trường `mac` luôn là `"N/A"` — giữ nguyên cấu trúc dict để tương thích với UI và các module khác.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -40,48 +43,45 @@ Bước 2 và 3 chạy **song song** để giảm tổng thời gian scan.
|
|||||||
|
|
||||||
### `_ping_one(ip, is_win)`
|
### `_ping_one(ip, is_win)`
|
||||||
|
|
||||||
Ping một IP đơn lẻ với timeout tối ưu theo nền tảng:
|
Ping một IP đơn lẻ, trả về `True` nếu host phản hồi, `False` nếu không.
|
||||||
|
|
||||||
| OS | Lệnh | Timeout wait | Timeout process |
|
Timeout tối ưu theo nền tảng:
|
||||||
| ------- | ------------------ | ----------------- | --------------- |
|
|
||||||
| Windows | `ping -n 1 -w 300` | 300ms | 2s |
|
|
||||||
| macOS | `ping -c 1 -W 500` | 500ms (đơn vị ms) | 2s |
|
|
||||||
| Linux | `ping -c 1 -W 1` | 1s (đơn vị giây) | 2s |
|
|
||||||
|
|
||||||
> macOS và Linux dùng cùng flag `-W` nhưng đơn vị khác nhau — được xử lý tách biệt theo `sys.platform`.
|
| OS | Lệnh | Timeout wait | Timeout process |
|
||||||
|
| ------- | ------------------ | ------------ | --------------- |
|
||||||
|
| Windows | `ping -n 1 -w 300` | 300ms | 2s |
|
||||||
|
| macOS | `ping -c 1 -W 300` | 300ms | 2s |
|
||||||
|
| Linux | `ping -c 1 -W 1` | 1s | 2s |
|
||||||
|
|
||||||
|
> macOS và Linux dùng cùng flag `-W` nhưng đơn vị khác nhau (macOS: ms, Linux: giây) — được xử lý tách biệt theo `sys.platform`.
|
||||||
|
|
||||||
|
Windows sử dụng `CREATE_NO_WINDOW` flag để tránh mở cửa sổ console cho mỗi subprocess.
|
||||||
|
|
||||||
### `_ping_sweep(network, progress_cb)`
|
### `_ping_sweep(network, progress_cb)`
|
||||||
|
|
||||||
- Tạo `ThreadPoolExecutor(max_workers=len(hosts))` — **toàn bộ host ping đồng thời**, không batching.
|
- Tạo `ThreadPoolExecutor(max_workers=len(hosts))` — **toàn bộ host ping đồng thời**, không batching.
|
||||||
- Giới hạn an toàn: chỉ chạy với subnet `/24` trở xuống (`num_addresses <= 256`).
|
- Giới hạn an toàn: chỉ chạy với subnet `/24` trở xuống (`num_addresses <= 256`).
|
||||||
|
- Trả về danh sách IP (string) đã phản hồi thành công.
|
||||||
- Gọi `progress_cb(done, total)` sau mỗi ping để UI cập nhật thanh tiến độ.
|
- Gọi `progress_cb(done, total)` sau mỗi ping để UI cập nhật thanh tiến độ.
|
||||||
|
|
||||||
### `_collect_arp()` (nội bộ trong `scan_network`)
|
### `scan_network(network, progress_cb, stage_cb)`
|
||||||
|
|
||||||
Đọc và parse output `arp -a`, hỗ trợ đa nền tảng:
|
- Entry point chính.
|
||||||
|
- `stage_cb("ping")` thông báo UI giai đoạn hiện tại.
|
||||||
- **Windows:** Regex nhận dạng dạng `cc-2d-21-a5-85-b0 dynamic`, chuẩn hóa MAC sang `cc:2d:21:a5:85:b0`.
|
- Trả về `list[dict]` với format `{"ip": str, "mac": "N/A"}`, sorted theo IP tăng dần.
|
||||||
- **macOS/Linux:** Regex nhận dạng dạng `(192.168.1.1) at aa:bb:cc:dd:ee:ff`, bỏ qua entry `(incomplete)`.
|
|
||||||
|
|
||||||
### `_collect_scapy()` (nội bộ trong `scan_network`)
|
|
||||||
|
|
||||||
- Gửi ARP `Who-has` broadcast (`Ether/ARP` qua `srp`) với **timeout 1s** (giảm từ 2s).
|
|
||||||
- Stderr bị redirect tạm thời khi import scapy để tránh spam log ra console.
|
|
||||||
- Tự động bỏ qua nếu scapy không khả dụng (không có Npcap / không có quyền root).
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 4. So sánh hiệu năng (trước và sau tối ưu)
|
## 4. Hiệu năng
|
||||||
|
|
||||||
| Thay đổi | Trước | Sau |
|
| Thông số | Giá trị |
|
||||||
| --------------------------- | --------------------- | ------------------------------------ |
|
| --------------------------- | ------------------------------------ |
|
||||||
| Ping workers | 100 (batching ~3 đợt) | `len(hosts)` (~254, tất cả cùng lúc) |
|
| Ping workers | `len(hosts)` (~254, tất cả cùng lúc) |
|
||||||
| Ping timeout — Windows | 600ms | 300ms |
|
| Ping timeout — Windows | 300ms |
|
||||||
| Ping timeout — macOS | 1ms (sai đơn vị) | 500ms |
|
| Ping timeout — macOS | 300ms |
|
||||||
| Sleep sau ping sweep | 1.0s | 0.3s |
|
| Ping timeout — Linux | 1s |
|
||||||
| ARP + Scapy | Tuần tự | **Song song** |
|
| Process timeout | 2s |
|
||||||
| Scapy timeout | 2s | 1s |
|
| **Tổng thời gian scan /24** | **~1–2s** |
|
||||||
| **Tổng thời gian scan /24** | ~5–7s | **~1.5–2s** |
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -89,13 +89,14 @@ Ping một IP đơn lẻ với timeout tối ưu theo nền tảng:
|
|||||||
|
|
||||||
**Ưu điểm:**
|
**Ưu điểm:**
|
||||||
|
|
||||||
- Tỷ lệ phát hiện thiết bị cao nhờ kết hợp 3 lớp.
|
- **Kết quả chính xác** — chỉ thiết bị thực sự online mới xuất hiện, không bị ảnh hưởng bởi ARP cache cũ.
|
||||||
- Không cần quyền Admin/Root — ping sweep + ARP table vẫn tìm được ~90% thiết bị.
|
- Không cần quyền Admin/Root.
|
||||||
- Tương thích đa nền tảng (Windows/macOS/Linux) qua xử lý riêng từng OS.
|
- Không phụ thuộc thư viện bên ngoài (không cần Scapy, Npcap).
|
||||||
- ARP table và Scapy chạy song song → không cộng dồn thời gian chờ.
|
- Tương thích đa nền tảng (Windows/macOS/Linux).
|
||||||
|
- Nhanh (~1–2s cho /24) nhờ ping toàn bộ host đồng thời.
|
||||||
|
|
||||||
**Nhược điểm:**
|
**Nhược điểm:**
|
||||||
|
|
||||||
- Vẫn cần `sleep(0.3s)` để OS kịp ghi ARP cache sau ping sweep.
|
- Không có thông tin MAC address.
|
||||||
- Thiết bị tắt hoàn toàn cả ICMP lẫn ARP sẽ không bị phát hiện.
|
- Thiết bị chặn ICMP (tắt ping) sẽ không bị phát hiện.
|
||||||
- Spawn ~254 process `ping` đồng thời trên Windows có overhead cao hơn Unix do Windows tạo process chậm hơn.
|
- Spawn ~254 process `ping` đồng thời trên Windows có overhead cao hơn Unix.
|
||||||
|
|||||||
199
ui/styles.py
199
ui/styles.py
@@ -278,3 +278,202 @@ QCheckBox::indicator:hover {
|
|||||||
border-color: #7eb8f7;
|
border-color: #7eb8f7;
|
||||||
}
|
}
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
AUTO_STYLE = """
|
||||||
|
QWidget {
|
||||||
|
background-color: #1a1b2e;
|
||||||
|
color: #e2e8f0;
|
||||||
|
font-family: 'Segoe UI', 'SF Pro Display', sans-serif;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
QGroupBox {
|
||||||
|
border: 1px solid #2d3748;
|
||||||
|
border-radius: 8px;
|
||||||
|
margin-top: 10px;
|
||||||
|
padding: 20px 8px 6px 8px;
|
||||||
|
font-weight: bold;
|
||||||
|
color: #c4b5fd;
|
||||||
|
background-color: #1e2035;
|
||||||
|
}
|
||||||
|
|
||||||
|
QGroupBox::title {
|
||||||
|
subcontrol-origin: margin;
|
||||||
|
subcontrol-position: top left;
|
||||||
|
left: 14px;
|
||||||
|
top: 5px;
|
||||||
|
padding: 0px 8px;
|
||||||
|
background-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
QLabel {
|
||||||
|
background-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
QLabel#title {
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: bold;
|
||||||
|
color: #c4b5fd;
|
||||||
|
letter-spacing: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
QPushButton {
|
||||||
|
background-color: #2d3352;
|
||||||
|
border: 1px solid #3d4a6b;
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 4px 12px;
|
||||||
|
color: #e2e8f0;
|
||||||
|
font-weight: 600;
|
||||||
|
min-height: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
QPushButton:hover {
|
||||||
|
background-color: #3d4a6b;
|
||||||
|
border-color: #c4b5fd;
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
QPushButton#start_btn {
|
||||||
|
background: qlineargradient(x1:0, y1:0, x2:1, y2:0,
|
||||||
|
stop:0 #7c3aed, stop:1 #a78bfa);
|
||||||
|
border-color: #7c3aed;
|
||||||
|
color: #ffffff;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: bold;
|
||||||
|
letter-spacing: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
QPushButton#start_btn:hover {
|
||||||
|
background: qlineargradient(x1:0, y1:0, x2:1, y2:0,
|
||||||
|
stop:0 #8b5cf6, stop:1 #c4b5fd);
|
||||||
|
}
|
||||||
|
|
||||||
|
QPushButton#start_btn:disabled {
|
||||||
|
background: #3d3d5c;
|
||||||
|
color: #6b7280;
|
||||||
|
}
|
||||||
|
|
||||||
|
QPushButton#stop_btn {
|
||||||
|
background: qlineargradient(x1:0, y1:0, x2:1, y2:0,
|
||||||
|
stop:0 #dc2626, stop:1 #ef4444);
|
||||||
|
border-color: #dc2626;
|
||||||
|
color: #ffffff;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
QPushButton#stop_btn:hover {
|
||||||
|
background: qlineargradient(x1:0, y1:0, x2:1, y2:0,
|
||||||
|
stop:0 #ef4444, stop:1 #f87171);
|
||||||
|
}
|
||||||
|
|
||||||
|
QPushButton#stop_btn:disabled {
|
||||||
|
background: #3d3d5c;
|
||||||
|
color: #6b7280;
|
||||||
|
}
|
||||||
|
|
||||||
|
QLineEdit {
|
||||||
|
background-color: #13141f;
|
||||||
|
border: 1px solid #2d3748;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 7px 12px;
|
||||||
|
color: #e2e8f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
QLineEdit:focus {
|
||||||
|
border-color: #7c3aed;
|
||||||
|
background-color: #161727;
|
||||||
|
}
|
||||||
|
|
||||||
|
QComboBox {
|
||||||
|
background-color: #1e1e2e;
|
||||||
|
border: 1px solid #3d4a6b;
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 4px 10px;
|
||||||
|
color: #ffffff;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
QSpinBox {
|
||||||
|
background-color: #13141f;
|
||||||
|
border: 1px solid #2d3748;
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 4px 8px;
|
||||||
|
color: #e2e8f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
QTableWidget {
|
||||||
|
background-color: #13141f;
|
||||||
|
alternate-background-color: #1a1b2e;
|
||||||
|
border: 1px solid #2d3748;
|
||||||
|
border-radius: 8px;
|
||||||
|
gridline-color: #2d3748;
|
||||||
|
selection-background-color: #2d3a5a;
|
||||||
|
selection-color: #e2e8f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
QTableWidget::item {
|
||||||
|
padding: 2px 6px;
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
QHeaderView::section {
|
||||||
|
background-color: #1e2035;
|
||||||
|
color: #c4b5fd;
|
||||||
|
border: none;
|
||||||
|
border-bottom: 2px solid #7c3aed;
|
||||||
|
border-right: 1px solid #2d3748;
|
||||||
|
padding: 4px 6px;
|
||||||
|
font-weight: bold;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
QHeaderView::section:last {
|
||||||
|
border-right: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
QProgressBar {
|
||||||
|
border: 1px solid #2d3748;
|
||||||
|
border-radius: 6px;
|
||||||
|
text-align: center;
|
||||||
|
background-color: #13141f;
|
||||||
|
color: #e2e8f0;
|
||||||
|
height: 20px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
QProgressBar::chunk {
|
||||||
|
background: qlineargradient(x1:0, y1:0, x2:1, y2:0,
|
||||||
|
stop:0 #7c3aed, stop:1 #a78bfa);
|
||||||
|
border-radius: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
QScrollBar:vertical {
|
||||||
|
background: #13141f;
|
||||||
|
width: 10px;
|
||||||
|
border-radius: 5px;
|
||||||
|
margin: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
QScrollBar::handle:vertical {
|
||||||
|
background: #3d4a6b;
|
||||||
|
border-radius: 5px;
|
||||||
|
min-height: 30px;
|
||||||
|
}
|
||||||
|
|
||||||
|
QScrollBar::handle:vertical:hover {
|
||||||
|
background: #c4b5fd;
|
||||||
|
}
|
||||||
|
|
||||||
|
QScrollBar::add-line:vertical,
|
||||||
|
QScrollBar::sub-line:vertical {
|
||||||
|
height: 0px;
|
||||||
|
}
|
||||||
|
|
||||||
|
QScrollBar::add-page:vertical,
|
||||||
|
QScrollBar::sub-page:vertical {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
1.1.3
|
1.2.2
|
||||||
|
|||||||
Reference in New Issue
Block a user