From 47f3320c3d58b6014bfa8c52fe7a84c25a6cd9e3 Mon Sep 17 00:00:00 2001 From: MinhNN Date: Mon, 9 Mar 2026 17:20:07 +0700 Subject: [PATCH] =?UTF-8?q?Th=C3=AAm=20t=C3=ADnh=20n=C4=83ng=20t=E1=BB=B1?= =?UTF-8?q?=20=C4=91=E1=BB=99ng=20h=C3=B3a=20n=E1=BA=A1p=20FW?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 2 +- core/auto_flash_worker.py | 200 +++++++++ docs/auto_flash_docs.md | 271 ++++++++++++ main.py | 868 ++++++++++++++++++++++++++++++-------- ui/styles.py | 199 +++++++++ 5 files changed, 1356 insertions(+), 184 deletions(-) create mode 100644 core/auto_flash_worker.py create mode 100644 docs/auto_flash_docs.md diff --git a/README.md b/README.md index d895e50..ba26cac 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,7 @@ 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. > **Tech stack:** Python 3.9+ · PyQt6 · Paramiko/SCP · Scapy · Requests · PyInstaller -> **Phiên bản:** `1.1.2` +> **Phiên bản:** `1.1.3` --- diff --git a/core/auto_flash_worker.py b/core/auto_flash_worker.py new file mode 100644 index 0000000..2f7e460 --- /dev/null +++ b/core/auto_flash_worker.py @@ -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 = 15 # 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) diff --git a/docs/auto_flash_docs.md b/docs/auto_flash_docs.md new file mode 100644 index 0000000..4e2d7a5 --- /dev/null +++ b/docs/auto_flash_docs.md @@ -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≥ 20 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` | 20 | 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/20 → 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/20 → 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 **20 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/20... │ +│ 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 20 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 20 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ý. diff --git a/main.py b/main.py index 81ccc9d..13945e4 100644 --- a/main.py +++ b/main.py @@ -26,6 +26,7 @@ from core.scanner import scan_network from core.workers import ScanThread from core.flash_new_worker import NewFlashThread from core.flash_update_worker import UpdateFlashThread +from core.auto_flash_worker import AutoFlashWorker from utils.network import _resolve_hostname, get_default_network from utils.system import resource_path, get_machine_info, get_version @@ -33,7 +34,7 @@ from utils.system import resource_path, get_machine_info, get_version from ui.components import CollapsibleGroupBox -from ui.styles import STYLE +from ui.styles import STYLE, AUTO_STYLE @@ -55,7 +56,7 @@ class App(QWidget): self.firmware = None self.all_devices = [] # raw list from scanner self.devices = [] # filtered list for table - self.flashed_macs = {} # MAC addresses flashed successfully in session (MAC -> timestamp) + self.flashed_macs = {} # MAC -> (ip, mac, result, timestamp) self.scan_thread = None info = get_machine_info() @@ -63,102 +64,111 @@ class App(QWidget): self.gateway_ip = self._guess_gateway(self.local_ip) layout = QVBoxLayout() - layout.setSpacing(4) + layout.setSpacing(3) layout.setContentsMargins(8, 6, 8, 6) - # ── Title ── + # ── Title (single line) ── + title_row = QHBoxLayout() + title_row.setSpacing(0) title = QLabel("⚡ Mira Firmware Loader") title.setObjectName("title") - title.setAlignment(Qt.AlignmentFlag.AlignCenter) - layout.addWidget(title) - + title_row.addWidget(title) + title_row.addStretch() copyright_label = QLabel(f"© {datetime.datetime.now().year} Smatec — R&D Software Team. v{get_version()}") - copyright_label.setAlignment(Qt.AlignmentFlag.AlignCenter) copyright_label.setStyleSheet( - "color: #9399b2; font-size: 11px; font-weight: 500; font-family: -apple-system, BlinkMacSystemFont, Segoe UI, Roboto;" + "color: #9399b2; font-size: 15px; font-weight: 500;" ) - layout.addWidget(copyright_label) + title_row.addWidget(copyright_label) + layout.addLayout(title_row) - # ── Machine Info Group ── + # ── Machine Info (single compact row) ── info_group = CollapsibleGroupBox("🖥 Machine Info") - info_layout = QVBoxLayout() - info_layout.setSpacing(2) - info_layout.setContentsMargins(0, 0, 0, 0) - - row1 = QHBoxLayout() - row1.addWidget(self._info_label("Hostname:")) - row1.addWidget(self._info_value(info["hostname"])) - row1.addStretch() - row1.addWidget(self._info_label("IP:")) - row1.addWidget(self._info_value(info["ip"])) - info_layout.addLayout(row1) - - row2 = QHBoxLayout() - row2.addWidget(self._info_label("OS:")) - row2.addWidget(self._info_value(info["os"])) - row2.addStretch() - row2.addWidget(self._info_label("MAC:")) - row2.addWidget(self._info_value(info["mac"])) - info_layout.addLayout(row2) - - info_group.set_content_layout(info_layout) + info_row = QHBoxLayout() + info_row.setSpacing(6) + info_row.setContentsMargins(0, 0, 0, 0) + for label, value in [("Host:", info["hostname"]), ("IP:", info["ip"]), + ("OS:", info["os"]), ("MAC:", info["mac"])]: + lbl = QLabel(label) + lbl.setObjectName("info") + info_row.addWidget(lbl) + val = QLabel(value) + val.setStyleSheet("color: #cdd6f4; font-weight: bold; font-size: 11px;") + info_row.addWidget(val) + if label != "MAC:": + sep = QLabel("│") + sep.setStyleSheet("color: #3d4a6b;") + info_row.addWidget(sep) + info_row.addStretch() + info_group.set_content_layout(info_row) layout.addWidget(info_group) - # ── Firmware Selection ── - fw_group = CollapsibleGroupBox("📦 Firmware") - fw_layout = QHBoxLayout() - fw_layout.setContentsMargins(0, 0, 0, 0) + # ── Firmware + Network Scan (combined compact row) ── + fw_scan_group = CollapsibleGroupBox("📦 FW & 📡 Scan") + fw_scan_layout = QVBoxLayout() + fw_scan_layout.setSpacing(4) + fw_scan_layout.setContentsMargins(0, 0, 0, 0) + # Row 1: FW selection + Scan + top_row = QHBoxLayout() + top_row.setSpacing(8) + + fw_lbl = QLabel("FW:") + fw_lbl.setStyleSheet("font-weight: bold; font-size: 12px;") + top_row.addWidget(fw_lbl) self.fw_label = QLabel("No firmware selected") self.fw_label.setObjectName("info") - self.fw_label.setWordWrap(True) - fw_layout.addWidget(self.fw_label, 1) - - btn_fw = QPushButton("📁 Select File") + self.fw_label.setStyleSheet("font-size: 11px; color: #94a3b8;") + top_row.addWidget(self.fw_label) + btn_fw = QPushButton("📁") + btn_fw.setFixedSize(32, 26) + btn_fw.setToolTip("Select firmware file") btn_fw.clicked.connect(self.select_fw) - btn_fw.setFixedWidth(110) - fw_layout.addWidget(btn_fw) + top_row.addWidget(btn_fw) - fw_group.set_content_layout(fw_layout) - layout.addWidget(fw_group) + sep = QLabel("│") + sep.setStyleSheet("color: #3d4a6b; font-size: 14px;") + top_row.addWidget(sep) - # ── Network Scan ── - scan_group = CollapsibleGroupBox("📡 Network Scan") - scan_layout = QVBoxLayout() - scan_layout.setContentsMargins(0, 0, 0, 0) - - net_row = QHBoxLayout() - net_row.addWidget(QLabel("Network:")) + net_lbl = QLabel("Network:") + net_lbl.setStyleSheet("font-weight: bold; font-size: 12px;") + top_row.addWidget(net_lbl) self.net_input = QLineEdit(get_default_network(self.local_ip)) self.net_input.setPlaceholderText("e.g. 192.168.4.0/24") - net_row.addWidget(self.net_input, 1) + self.net_input.setMaximumWidth(170) + self.net_input.setFixedHeight(26) + self.net_input.setStyleSheet("QLineEdit { font-size: 12px; padding: 2px 8px; }") + top_row.addWidget(self.net_input) - self.btn_scan = QPushButton("🔍 Scan LAN") + self.btn_scan = QPushButton("🔍 Scan LAN") self.btn_scan.setObjectName("scan") self.btn_scan.clicked.connect(self.scan) - self.btn_scan.setFixedWidth(110) - net_row.addWidget(self.btn_scan) - scan_layout.addLayout(net_row) + self.btn_scan.setFixedHeight(26) + self.btn_scan.setFixedWidth(100) + top_row.addWidget(self.btn_scan) + fw_scan_layout.addLayout(top_row) + # Scan progress + status (hidden by default) self.scan_progress_bar = QProgressBar() self.scan_progress_bar.setObjectName("scan_bar") self.scan_progress_bar.setRange(0, 0) self.scan_progress_bar.setFormat("") + self.scan_progress_bar.setFixedHeight(14) self.scan_progress_bar.setVisible(False) - scan_layout.addWidget(self.scan_progress_bar) + fw_scan_layout.addWidget(self.scan_progress_bar) self.scan_status = QLabel("") self.scan_status.setObjectName("info") - scan_layout.addWidget(self.scan_status) + self.scan_status.setStyleSheet("font-size: 10px;") + fw_scan_layout.addWidget(self.scan_status) - scan_group.set_content_layout(scan_layout) - layout.addWidget(scan_group) + fw_scan_group.set_content_layout(fw_scan_layout) + layout.addWidget(fw_scan_group) - # ── Device Table ── + # ── Device Table (MAIN area — maximized) ── dev_group = QGroupBox("📋 Devices Found") dev_layout = QVBoxLayout() - dev_layout.setSpacing(4) - dev_layout.setContentsMargins(4, 4, 4, 4) + dev_layout.setSpacing(2) + dev_layout.setContentsMargins(4, 12, 4, 4) self.table = QTableWidget() self.table.setColumnCount(4) @@ -166,15 +176,12 @@ class App(QWidget): self.table.setAlternatingRowColors(True) header = self.table.horizontalHeader() header.setSectionResizeMode(0, QHeaderView.ResizeMode.Fixed) - header.resizeSection(0, 40) - - # IP and MAC can be fixed/stretch, Status to stretch to cover full info + header.resizeSection(0, 32) header.setSectionResizeMode(1, QHeaderView.ResizeMode.Interactive) - header.resizeSection(1, 120) + header.resizeSection(1, 115) header.setSectionResizeMode(2, QHeaderView.ResizeMode.Interactive) - header.resizeSection(2, 140) + header.resizeSection(2, 135) header.setSectionResizeMode(3, QHeaderView.ResizeMode.Stretch) - self.table.verticalHeader().setVisible(False) self.table.setSelectionBehavior( QTableWidget.SelectionBehavior.SelectRows @@ -182,26 +189,33 @@ class App(QWidget): dev_layout.addWidget(self.table) filter_row = QHBoxLayout() + filter_row.setSpacing(4) self.device_count_label = QLabel("Total: 0 devices") self.device_count_label.setObjectName("info") filter_row.addWidget(self.device_count_label) filter_row.addStretch() - btn_history = QPushButton("📋 Flash History") + btn_history = QPushButton("📋 Lịch sử nạp") + btn_history.setFixedHeight(24) btn_history.clicked.connect(self._show_history) - btn_history.setStyleSheet("background-color: #313244; color: #cdd6f4;") + btn_history.setStyleSheet("background-color: #313244; color: #cdd6f4; font-size: 11px; padding: 2px 8px;") filter_row.addWidget(btn_history) - btn_select_all = QPushButton("☑ Select All") + btn_select_all = QPushButton("☑ All") + btn_select_all.setFixedHeight(24) + btn_select_all.setStyleSheet("font-size: 11px; padding: 2px 8px;") btn_select_all.clicked.connect(self._select_all_devices) filter_row.addWidget(btn_select_all) - btn_deselect_all = QPushButton("☐ Deselect All") + btn_deselect_all = QPushButton("☐ None") + btn_deselect_all.setFixedHeight(24) + btn_deselect_all.setStyleSheet("font-size: 11px; padding: 2px 8px;") btn_deselect_all.clicked.connect(self._deselect_all_devices) filter_row.addWidget(btn_deselect_all) - self.show_all_cb = QCheckBox("Show all (include gateway & self)") + self.show_all_cb = QCheckBox("Show all") self.show_all_cb.setChecked(False) + self.show_all_cb.setToolTip("Include gateway & self") self.show_all_cb.stateChanged.connect(self._refresh_table) filter_row.addWidget(self.show_all_cb) dev_layout.addLayout(filter_row) @@ -209,146 +223,167 @@ class App(QWidget): dev_group.setLayout(dev_layout) layout.addWidget(dev_group, stretch=1) - # ── Flash Controls ── - flash_group = QGroupBox("🚀 Flash Controls") + # ── Flash Controls (collapsible, compact) ── + flash_group = CollapsibleGroupBox("🚀 Flash Controls") flash_layout = QVBoxLayout() - flash_layout.setSpacing(12) - flash_layout.setContentsMargins(10, 15, 10, 12) + flash_layout.setSpacing(6) + flash_layout.setContentsMargins(8, 4, 8, 6) self.progress = QProgressBar() self.progress.setFormat("%v / %m devices (%p%)") - self.progress.setMinimumHeight(22) + self.progress.setFixedHeight(18) flash_layout.addWidget(self.progress) - # Mode selector row (Nạp mới vs Update) - mode_row = QHBoxLayout() - mode_lbl = QLabel("Flash Mode:") - mode_lbl.setStyleSheet("font-size: 14px; font-weight: bold;") - mode_row.addWidget(mode_lbl) - + # Row 1: Mode + Method + mode_method_row = QHBoxLayout() + mode_method_row.setSpacing(10) + + mode_lbl = QLabel("Mode:") + mode_lbl.setStyleSheet("font-size: 12px; font-weight: bold;") + mode_method_row.addWidget(mode_lbl) + self.mode_combo = QComboBox() - self.mode_combo.addItem("⚡ New Flash (Raw / Factory Reset)", "new") - self.mode_combo.addItem("🔄 Update Firmware (Pre-installed)", "update") - self.mode_combo.setMinimumWidth(380) - self.mode_combo.setMinimumHeight(35) + self.mode_combo.addItem("⚡ New Flash (Factory Reset)", "new") + self.mode_combo.addItem("🔄 Update FW (Pre-installed)", "update") + self.mode_combo.setMinimumWidth(230) + self.mode_combo.setFixedHeight(28) self.mode_combo.setStyleSheet(""" QComboBox { background-color: #2d3352; border: 1px solid #3d4a6b; border-radius: 4px; - padding: 4px 10px; color: #ffffff; font-size: 14px; font-weight: bold; + padding: 2px 8px; color: #ffffff; font-size: 12px; font-weight: bold; } """) self.mode_combo.currentIndexChanged.connect(self._on_mode_changed) - mode_row.addWidget(self.mode_combo) - mode_row.addStretch() - flash_layout.addLayout(mode_row) + mode_method_row.addWidget(self.mode_combo) - # Container cho cấu hình tuỳ chọn "Nạp Mới FW" + sep_m = QLabel("│") + sep_m.setStyleSheet("color: #3d4a6b;") + mode_method_row.addWidget(sep_m) + + # Container cho method (ẩn khi Update mode) self.method_container = QWidget() - method_layout = QVBoxLayout(self.method_container) - method_layout.setContentsMargins(0, 0, 0, 0) - method_layout.setSpacing(10) + mc_layout = QHBoxLayout(self.method_container) + mc_layout.setContentsMargins(0, 0, 0, 0) + mc_layout.setSpacing(6) + + meth_lbl = QLabel("Method:") + meth_lbl.setStyleSheet("font-size: 12px; font-weight: bold;") + mc_layout.addWidget(meth_lbl) - # Method selector row - method_row = QHBoxLayout() - method_lbl = QLabel("Method:") - method_lbl.setStyleSheet("font-size: 13px; font-weight: bold;") - method_row.addWidget(method_lbl) - self.method_combo = QComboBox() - self.method_combo.addItem("🌐 API (LuCI - Recommended)", "api") - self.method_combo.addItem("💻 SSH (paramiko/scp)", "ssh") - self.method_combo.setMinimumWidth(220) - self.method_combo.setMinimumHeight(32) + self.method_combo.addItem("🌐 API (LuCI)", "api") + self.method_combo.addItem("💻 SSH", "ssh") + self.method_combo.setMinimumWidth(140) + self.method_combo.setFixedHeight(28) self.method_combo.setStyleSheet(""" QComboBox { background-color: #1e1e2e; border: 1px solid #3d4a6b; border-radius: 4px; - padding: 4px 10px; color: #ffffff; font-size: 13px; font-weight: bold; + padding: 2px 8px; color: #ffffff; font-size: 12px; font-weight: bold; } """) self.method_combo.currentIndexChanged.connect(self._on_method_changed) - method_row.addWidget(self.method_combo) - method_row.addStretch() - method_layout.addLayout(method_row) + mc_layout.addWidget(self.method_combo) + + mode_method_row.addWidget(self.method_container) + + # Warning label for Update Mode (inline) + self.update_warning_lbl = QLabel("⚠️ Update: SSH only, target 192.168.11.102") + self.update_warning_lbl.setStyleSheet("color: #f38ba8; font-size: 11px; font-weight: bold;") + self.update_warning_lbl.setVisible(False) + mode_method_row.addWidget(self.update_warning_lbl) + + mode_method_row.addStretch() + flash_layout.addLayout(mode_method_row) # SSH credentials (hidden by default) self.ssh_creds_widget = QWidget() self.ssh_creds_widget.setStyleSheet("background-color: #11121d; border-radius: 6px; border: 1px dashed #3d4a6b;") - ssh_creds_layout = QVBoxLayout(self.ssh_creds_widget) - ssh_creds_layout.setContentsMargins(15, 12, 15, 12) - ssh_creds_layout.setSpacing(10) + ssh_creds_layout = QHBoxLayout(self.ssh_creds_widget) + ssh_creds_layout.setContentsMargins(10, 6, 10, 6) + ssh_creds_layout.setSpacing(8) - ssh_row1 = QHBoxLayout() - ssh_lbl1 = QLabel("SSH User:") - ssh_lbl1.setStyleSheet("font-size: 12px; font-weight: bold;") - ssh_row1.addWidget(ssh_lbl1) + ssh_lbl1 = QLabel("User:") + ssh_lbl1.setStyleSheet("font-size: 11px; font-weight: bold;") + ssh_creds_layout.addWidget(ssh_lbl1) + str_qlineedit = "QLineEdit { background-color: #1a1b2e; font-size: 12px; padding: 3px; border: 1px solid #3d4a6b; color: #ffffff; }" self.ssh_user_input = QLineEdit("root") - self.ssh_user_input.setFixedWidth(130) - str_qlineedit = "QLineEdit { background-color: #1a1b2e; font-size: 13px; padding: 4px; border: 1px solid #3d4a6b; color: #ffffff; }" + self.ssh_user_input.setFixedWidth(80) self.ssh_user_input.setStyleSheet(str_qlineedit) - ssh_row1.addWidget(self.ssh_user_input) - - ssh_row1.addSpacing(25) - - ssh_lbl2 = QLabel("SSH Password:") - ssh_lbl2.setStyleSheet("font-size: 12px; font-weight: bold;") - ssh_row1.addWidget(ssh_lbl2) + ssh_creds_layout.addWidget(self.ssh_user_input) + + ssh_lbl2 = QLabel("Pass:") + ssh_lbl2.setStyleSheet("font-size: 11px; font-weight: bold;") + ssh_creds_layout.addWidget(ssh_lbl2) self.ssh_pass_input = QLineEdit("admin123a") self.ssh_pass_input.setEchoMode(QLineEdit.EchoMode.Password) - self.ssh_pass_input.setFixedWidth(130) + self.ssh_pass_input.setFixedWidth(100) self.ssh_pass_input.setStyleSheet(str_qlineedit) - ssh_row1.addWidget(self.ssh_pass_input) - ssh_row1.addStretch() - ssh_creds_layout.addLayout(ssh_row1) + ssh_creds_layout.addWidget(self.ssh_pass_input) - self.set_passwd_cb = QCheckBox("Set password before flash (passwd → admin123a)") + self.set_passwd_cb = QCheckBox("Set passwd") self.set_passwd_cb.setChecked(True) - self.set_passwd_cb.setStyleSheet("color: #94a3b8; font-size: 12px; font-weight: bold;") + self.set_passwd_cb.setStyleSheet("color: #94a3b8; font-size: 11px;") + self.set_passwd_cb.setToolTip("Set password before flash (passwd → admin123a)") ssh_creds_layout.addWidget(self.set_passwd_cb) + ssh_creds_layout.addStretch() self.ssh_creds_widget.setVisible(False) - method_layout.addWidget(self.ssh_creds_widget) + flash_layout.addWidget(self.ssh_creds_widget) - flash_layout.addWidget(self.method_container) + # Row 2: Concurrent + Flash button + action_row = QHBoxLayout() + action_row.setSpacing(10) - # Warning UI for Update Mode - self.update_warning_lbl = QLabel("⚠️ FW Update Mode: Forced to SSH. Target IP [192.168.11.102]. Other IPs require confirmation.") - self.update_warning_lbl.setStyleSheet("color: #f38ba8; font-size: 13px; font-weight: bold; padding: 8px; border: 1px dotted #f38ba8;") - self.update_warning_lbl.setWordWrap(True) - self.update_warning_lbl.setVisible(False) - flash_layout.addWidget(self.update_warning_lbl) + par_lbl = QLabel("Concurrent:") + par_lbl.setStyleSheet("font-size: 12px; font-weight: bold;") + action_row.addWidget(par_lbl) - # Parallel count row - parallel_row = QHBoxLayout() - parallel_lbl = QLabel("Concurrent devices:") - parallel_lbl.setStyleSheet("font-size: 14px; font-weight: bold;") - parallel_row.addWidget(parallel_lbl) - self.parallel_spin = QSpinBox() self.parallel_spin.setRange(0, 100) self.parallel_spin.setValue(10) - self.parallel_spin.setSpecialValueText("0 (Unlimited)") - self.parallel_spin.setToolTip("0 = unlimited (all devices at once)") - self.parallel_spin.setMinimumWidth(160) - self.parallel_spin.setMinimumHeight(35) - self.parallel_spin.setStyleSheet(""" - QSpinBox { font-size: 15px; font-weight: bold; text-align: center; } - """) - parallel_row.addWidget(self.parallel_spin) - parallel_row.addStretch() - flash_layout.addLayout(parallel_row) + self.parallel_spin.setSpecialValueText("∞") + self.parallel_spin.setToolTip("0 = unlimited (all at once)") + self.parallel_spin.setFixedWidth(65) + self.parallel_spin.setFixedHeight(28) + self.parallel_spin.setStyleSheet("QSpinBox { font-size: 13px; font-weight: bold; }") + action_row.addWidget(self.parallel_spin) self.btn_flash = QPushButton("⚡ FLASH SELECTED DEVICES") self.btn_flash.setObjectName("flash") - self.btn_flash.setMinimumHeight(45) - self.btn_flash.setStyleSheet("QPushButton#flash { font-size: 16px; font-weight: bold; letter-spacing: 1px; }") + self.btn_flash.setFixedHeight(34) + self.btn_flash.setStyleSheet("QPushButton#flash { font-size: 13px; font-weight: bold; letter-spacing: 1px; }") self.btn_flash.clicked.connect(self.flash_all) - flash_layout.addWidget(self.btn_flash) + action_row.addWidget(self.btn_flash, 1) + flash_layout.addLayout(action_row) - flash_group.setLayout(flash_layout) + flash_group.set_content_layout(flash_layout) layout.addWidget(flash_group) + # ── Automation Button ── + self.btn_auto = QPushButton("🤖 Tự động hóa nạp FW") + self.btn_auto.setObjectName("auto") + self.btn_auto.setFixedHeight(32) + self.btn_auto.setStyleSheet(""" + QPushButton#auto { + background: qlineargradient(x1:0, y1:0, x2:1, y2:0, + stop:0 #7c3aed, stop:1 #a78bfa); + border-color: #7c3aed; color: #ffffff; + font-size: 12px; font-weight: bold; letter-spacing: 1px; + border-radius: 6px; + } + QPushButton#auto:hover { + background: qlineargradient(x1:0, y1:0, x2:1, y2:0, + stop:0 #8b5cf6, stop:1 #c4b5fd); + } + """) + self.btn_auto.clicked.connect(self._open_auto_flash) + layout.addWidget(self.btn_auto) + self.setLayout(layout) + # ── AUTO TAB (hidden by default) ── + self.auto_widget = None + # ── Helpers ── def _guess_gateway(self, ip): @@ -444,19 +479,17 @@ class App(QWidget): item.setCheckState(Qt.CheckState.Unchecked) def _show_history(self): - """Show list of successfully flashed MACs this session.""" + """Hiển thị lịch sử thiết bị đã nạp trong phiên.""" if not self.flashed_macs: - QMessageBox.information(self, "Flash History", "No successful flashes during this session.") + QMessageBox.information(self, "Lịch sử nạp", "Chưa có thiết bị nào được nạp trong phiên này.") else: - # Sort by MAC and format with timestamp - history_lines = [] + lines = [] for mac in sorted(self.flashed_macs.keys()): - time_str = self.flashed_macs[mac] - history_lines.append(f"[{time_str}] {mac}") - - macs_str = "\n".join(history_lines) - msg = f"Successfully flashed devices in this session ({len(self.flashed_macs)}):\n\n{macs_str}" - QMessageBox.information(self, "Flash History", msg) + ip, _, result, ts = self.flashed_macs[mac] + icon = "✅" if result.startswith("DONE") else "❌" + lines.append(f"[{ts}] {icon} {ip} ({mac}) — {result}") + msg = f"Lịch sử nạp FW ({len(self.flashed_macs)} thiết bị):\n\n" + "\n".join(lines) + QMessageBox.information(self, "Lịch sử nạp", msg) # ── Actions ── @@ -468,8 +501,8 @@ class App(QWidget): if file: self.firmware = file name = file.split("/")[-1] - self.fw_label.setText(f"✅ {name}") - self.fw_label.setStyleSheet("color: #a6e3a1;") + self.fw_label.setText(f"✅ {name}") + self.fw_label.setStyleSheet("color: #a6e3a1; font-weight: bold; font-size: 11px;") def scan(self): network_str = self.net_input.text().strip() @@ -604,6 +637,20 @@ class App(QWidget): self.progress.setMaximum(total) self.progress.setValue(0) + # Determine flash method and SSH credentials + mode = self.mode_combo.currentData() + + # Validate: IP 192.168.11.102 chỉ được nạp ở chế độ Update FW + if mode != "update": + blocked = [dev["ip"] for _, dev in selected if dev["ip"] == "192.168.11.102"] + if blocked: + QMessageBox.warning( + self, "Không được phép", + "⚠️ Thiết bị 192.168.11.102 chỉ được nạp ở chế độ Update FW.\n" + "Vui lòng bỏ chọn thiết bị này hoặc chuyển sang chế độ Update FW." + ) + return + # Build list with row indices for UI updates self._flash_row_map = {} flash_devices = [] @@ -611,9 +658,6 @@ class App(QWidget): self._flash_row_map[idx] = row flash_devices.append(dev) - # Determine flash method and SSH credentials - mode = self.mode_combo.currentData() - if mode == "update": # Hỏi xác nhận nếu có device nào khác 192.168.11.102 strange_ips = [dev["ip"] for _, dev in selected if dev["ip"] != "192.168.11.102"] @@ -673,16 +717,16 @@ class App(QWidget): def _on_flash_done(self, index, result): """One device finished flashing.""" row = self._flash_row_map.get(index, index) + mac_item = self.table.item(row, 2) + ip_item = self.table.item(row, 1) + import datetime + now_str = datetime.datetime.now().strftime("%H:%M:%S") + mac_str = mac_item.text().strip() if mac_item else "" + ip_str = ip_item.text().strip() if ip_item else "" + if result.startswith("DONE"): item = QTableWidgetItem(f"✅ {result}") item.setForeground(QColor("#a6e3a1")) - - # Save MAC to history with current timestamp - mac_item = self.table.item(row, 2) - if mac_item: - import datetime - now_str = datetime.datetime.now().strftime("%H:%M:%S") - self.flashed_macs[mac_item.text().strip()] = now_str # Auto-uncheck so it won't be flashed again cb = self.table.item(row, 0) @@ -691,6 +735,11 @@ class App(QWidget): else: item = QTableWidgetItem(f"❌ {result}") item.setForeground(QColor("#f38ba8")) + + # Lưu vào lịch sử nạp + if mac_str: + self.flashed_macs[mac_str] = (ip_str, mac_str, result, now_str) + self.table.setItem(row, 3, item) self.progress.setValue(self.progress.value() + 1) @@ -699,6 +748,459 @@ class App(QWidget): self.btn_flash.setEnabled(True) QMessageBox.information(self, "Flash Complete", "All devices have been processed.") + def _open_auto_flash(self): + """Mở cửa sổ Tự động hóa nạp FW.""" + if self.auto_widget is None or not self.auto_widget.isVisible(): + self.auto_widget = AutoFlashWindow( + firmware=self.firmware, + network=self.net_input.text().strip(), + local_ip=self.local_ip, + gateway_ip=self.gateway_ip, + parent_app=self, + ) + self.auto_widget.resize(700, 750) + self.auto_widget.show() + else: + self.auto_widget.raise_() + self.auto_widget.activateWindow() + + +class AutoFlashWindow(QWidget): + """Cửa sổ Tự động hóa nạp FW — scan + flash tự động.""" + + def __init__(self, firmware=None, network="", local_ip="", gateway_ip="", parent_app=None): + super().__init__() + self.setWindowTitle("🤖 Tự động hóa nạp FW") + self.setWindowIcon(QIcon(resource_path("icon.ico"))) + self.firmware = firmware + self.local_ip = local_ip + self.gateway_ip = gateway_ip + self.parent_app = parent_app + self.worker = None + self._device_rows = {} # ip -> row index in result table + + self.setStyleSheet(AUTO_STYLE) + + layout = QVBoxLayout() + layout.setSpacing(4) + layout.setContentsMargins(10, 8, 10, 8) + + # ── Title row (compact) ── + title = QLabel("🤖 Tự động hóa nạp FW") + title.setObjectName("title") + title.setAlignment(Qt.AlignmentFlag.AlignCenter) + layout.addWidget(title) + + # ── Top bar: FW + Network + Config (compact, 2 rows) ── + config_group = CollapsibleGroupBox("⚙️ Cấu hình nạp") + config_layout = QVBoxLayout() + config_layout.setSpacing(6) + config_layout.setContentsMargins(8, 4, 8, 4) + + # Row 1: Firmware + Network + row1 = QHBoxLayout() + row1.setSpacing(12) + fw_lbl = QLabel("FW:") + fw_lbl.setStyleSheet("font-weight: bold; font-size: 12px;") + row1.addWidget(fw_lbl) + self.fw_label = QLabel(os.path.basename(firmware) if firmware else "Chưa chọn") + self.fw_label.setStyleSheet( + "color: #a6e3a1; font-weight: bold; font-size: 12px;" if firmware + else "color: #f38ba8; font-size: 12px;" + ) + row1.addWidget(self.fw_label) + btn_fw = QPushButton("📁") + btn_fw.setFixedSize(32, 28) + btn_fw.setToolTip("Chọn firmware") + btn_fw.clicked.connect(self._select_firmware) + row1.addWidget(btn_fw) + + sep1 = QLabel("│") + sep1.setStyleSheet("color: #3d4a6b; font-size: 14px;") + row1.addWidget(sep1) + + net_lbl = QLabel("Mạng:") + net_lbl.setStyleSheet("font-weight: bold; font-size: 12px;") + row1.addWidget(net_lbl) + self.net_input = QLineEdit(network or get_default_network(self.local_ip)) + self.net_input.setPlaceholderText("e.g. 192.168.4.0/24") + self.net_input.setMaximumWidth(180) + self.net_input.setFixedHeight(28) + self.net_input.setStyleSheet("QLineEdit { font-size: 12px; padding: 3px 8px; }") + row1.addWidget(self.net_input) + row1.addStretch() + config_layout.addLayout(row1) + + # Row 2: Target count + Method + Concurrent + row2 = QHBoxLayout() + row2.setSpacing(12) + + cnt_lbl = QLabel("Số lượng:") + cnt_lbl.setStyleSheet("font-weight: bold; font-size: 12px;") + row2.addWidget(cnt_lbl) + self.target_spin = QSpinBox() + self.target_spin.setRange(1, 500) + self.target_spin.setValue(5) + self.target_spin.setFixedWidth(70) + self.target_spin.setFixedHeight(28) + self.target_spin.setStyleSheet("QSpinBox { font-size: 13px; font-weight: bold; }") + row2.addWidget(self.target_spin) + + sep2 = QLabel("│") + sep2.setStyleSheet("color: #3d4a6b; font-size: 14px;") + row2.addWidget(sep2) + + meth_lbl = QLabel("Phương thức:") + meth_lbl.setStyleSheet("font-weight: bold; font-size: 12px;") + row2.addWidget(meth_lbl) + self.method_combo = QComboBox() + self.method_combo.addItem("🌐 API (LuCI)", "api") + self.method_combo.addItem("💻 SSH", "ssh") + self.method_combo.setFixedHeight(28) + self.method_combo.setMinimumWidth(140) + row2.addWidget(self.method_combo) + + sep3 = QLabel("│") + sep3.setStyleSheet("color: #3d4a6b; font-size: 14px;") + row2.addWidget(sep3) + + par_lbl = QLabel("Song song:") + par_lbl.setStyleSheet("font-weight: bold; font-size: 12px;") + row2.addWidget(par_lbl) + self.parallel_spin = QSpinBox() + self.parallel_spin.setRange(0, 100) + self.parallel_spin.setValue(10) + self.parallel_spin.setSpecialValueText("∞") + self.parallel_spin.setToolTip("0 = không giới hạn") + self.parallel_spin.setFixedWidth(65) + self.parallel_spin.setFixedHeight(28) + self.parallel_spin.setStyleSheet("QSpinBox { font-size: 13px; font-weight: bold; }") + row2.addWidget(self.parallel_spin) + row2.addStretch() + config_layout.addLayout(row2) + + config_group.set_content_layout(config_layout) + layout.addWidget(config_group) + + # ── Control Buttons (compact) ── + btn_row = QHBoxLayout() + btn_row.setSpacing(8) + self.btn_start = QPushButton("▶ XÁC NHẬN & BẮT ĐẦU") + self.btn_start.setObjectName("start_btn") + self.btn_start.setFixedHeight(36) + self.btn_start.clicked.connect(self._on_start) + btn_row.addWidget(self.btn_start) + + self.btn_stop = QPushButton("⏹ DỪNG") + self.btn_stop.setObjectName("stop_btn") + self.btn_stop.setFixedHeight(36) + self.btn_stop.setEnabled(False) + self.btn_stop.clicked.connect(self._on_stop) + btn_row.addWidget(self.btn_stop) + layout.addLayout(btn_row) + + # ── Status + Progress (inline) ── + status_row = QHBoxLayout() + status_row.setSpacing(8) + self.status_label = QLabel("⏸ Chờ bắt đầu...") + self.status_label.setStyleSheet("font-size: 12px; font-weight: bold; color: #94a3b8;") + status_row.addWidget(self.status_label, 1) + + self.progress_bar = QProgressBar() + self.progress_bar.setFormat("%v / %m (%p%)") + self.progress_bar.setFixedHeight(18) + self.progress_bar.setFixedWidth(250) + self.progress_bar.setVisible(False) + status_row.addWidget(self.progress_bar) + layout.addLayout(status_row) + + # ── Device Table (MAIN area — stretch) ── + dev_group = QGroupBox("📋 Danh sách thiết bị") + dev_layout = QVBoxLayout() + dev_layout.setSpacing(2) + dev_layout.setContentsMargins(4, 12, 4, 4) + + self.result_table = QTableWidget() + self.result_table.setColumnCount(4) + self.result_table.setHorizontalHeaderLabels(["#", "IP", "MAC", "Kết quả"]) + self.result_table.setAlternatingRowColors(True) + header = self.result_table.horizontalHeader() + header.setSectionResizeMode(0, QHeaderView.ResizeMode.Fixed) + header.resizeSection(0, 35) + header.setSectionResizeMode(1, QHeaderView.ResizeMode.Interactive) + header.resizeSection(1, 120) + header.setSectionResizeMode(2, QHeaderView.ResizeMode.Interactive) + header.resizeSection(2, 140) + header.setSectionResizeMode(3, QHeaderView.ResizeMode.Stretch) + self.result_table.verticalHeader().setVisible(False) + self.result_table.setStyleSheet("QTableWidget { font-size: 12px; }") + dev_layout.addWidget(self.result_table) + + # Summary row below table + summary_row = QHBoxLayout() + summary_row.setSpacing(6) + self.summary_label = QLabel("") + self.summary_label.setStyleSheet("color: #94a3b8; font-size: 11px; padding: 2px;") + summary_row.addWidget(self.summary_label, 1) + + btn_auto_history = QPushButton("📋 Lịch sử nạp") + btn_auto_history.setFixedHeight(24) + btn_auto_history.setStyleSheet("background-color: #313244; color: #cdd6f4; font-size: 11px; padding: 2px 8px;") + btn_auto_history.clicked.connect(self._show_auto_history) + summary_row.addWidget(btn_auto_history) + dev_layout.addLayout(summary_row) + + dev_group.setLayout(dev_layout) + layout.addWidget(dev_group, stretch=3) + + # ── Log (collapsible, compact) ── + log_group = CollapsibleGroupBox("📝 Log") + log_layout = QVBoxLayout() + log_layout.setContentsMargins(4, 2, 4, 2) + + self.log_area = QScrollArea() + self.log_content = QLabel("") + self.log_content.setWordWrap(True) + self.log_content.setAlignment(Qt.AlignmentFlag.AlignTop) + self.log_content.setStyleSheet( + "color: #cdd6f4; font-size: 10px; font-family: 'SF Mono', 'Menlo', monospace;" + "padding: 4px; background-color: #11121d; border-radius: 4px;" + ) + self.log_content.setTextFormat(Qt.TextFormat.PlainText) + self.log_area.setWidget(self.log_content) + self.log_area.setWidgetResizable(True) + self.log_area.setMinimumHeight(120) + self.log_area.setMaximumHeight(280) + self.log_area.setStyleSheet("QScrollArea { border: 1px solid #2d3748; border-radius: 4px; background-color: #11121d; }") + log_layout.addWidget(self.log_area) + + log_group.set_content_layout(log_layout) + layout.addWidget(log_group, stretch=1) + + self.setLayout(layout) + self._log_lines = [] + self._success_count = 0 + self._fail_count = 0 + self._auto_history = [] # list of (ip, mac, result, timestamp) + + def _show_auto_history(self): + """Hiển thị lịch sử thiết bị đã nạp trong phiên tự động hóa.""" + if not self._auto_history: + QMessageBox.information(self, "Lịch sử nạp", "Chưa có thiết bị nào được nạp trong phiên này.") + return + lines = [] + for ip, mac, result, ts in self._auto_history: + icon = "✅" if result.startswith("DONE") else "❌" + lines.append(f"[{ts}] {icon} {ip} ({mac}) — {result}") + msg = f"Lịch sử nạp FW ({len(self._auto_history)} thiết bị):\n\n" + "\n".join(lines) + QMessageBox.information(self, "Lịch sử nạp", msg) + + def _select_firmware(self): + file, _ = QFileDialog.getOpenFileName( + self, "Chọn Firmware", "", + "Firmware Files (*.bin *.hex *.uf2);;All Files (*)" + ) + if file: + self.firmware = file + self.fw_label.setText(os.path.basename(file)) + self.fw_label.setStyleSheet("color: #a6e3a1; font-weight: bold;") + + def _append_log(self, msg): + self._log_lines.append(msg) + # Giới hạn 500 dòng log + if len(self._log_lines) > 500: + self._log_lines = self._log_lines[-500:] + self.log_content.setText("\n".join(self._log_lines)) + # Scroll to bottom + sb = self.log_area.verticalScrollBar() + sb.setValue(sb.maximum()) + + def _on_start(self): + if not self.firmware: + QMessageBox.warning(self, "Chưa chọn FW", "Vui lòng chọn file firmware trước.") + return + + network_str = self.net_input.text().strip() + try: + ipaddress.ip_network(network_str, strict=False) + except ValueError: + QMessageBox.warning(self, "Mạng không hợp lệ", f"'{network_str}' không phải network hợp lệ.\nVí dụ: 192.168.4.0/24") + return + + target = self.target_spin.value() + method = self.method_combo.currentData() + max_workers = self.parallel_spin.value() + + # Confirm + reply = QMessageBox.question( + self, "Xác nhận", + f"Bắt đầu tự động nạp FW?\n\n" + f" Firmware: {os.path.basename(self.firmware)}\n" + f" Mạng: {network_str}\n" + f" Số lượng: {target} thiết bị\n" + f" Phương thức: {method.upper()}\n" + f" Song song: {max_workers if max_workers > 0 else 'Không giới hạn'}\n", + QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, + QMessageBox.StandardButton.Yes, + ) + if reply != QMessageBox.StandardButton.Yes: + return + + # Reset state + self._log_lines = [] + self.log_content.setText("") + self.result_table.setRowCount(0) + self._device_rows = {} + self._success_count = 0 + self._fail_count = 0 + self._auto_history.clear() + self.summary_label.setText("") + self.progress_bar.setVisible(False) + + self.btn_start.setEnabled(False) + self.btn_stop.setEnabled(True) + self.net_input.setEnabled(False) + self.target_spin.setEnabled(False) + self.method_combo.setEnabled(False) + self.parallel_spin.setEnabled(False) + + self.status_label.setText("🔍 Đang scan mạng LAN...") + self.status_label.setStyleSheet("font-size: 12px; font-weight: bold; color: #f9e2af;") + + self.worker = AutoFlashWorker( + network=network_str, + target_count=target, + method=method, + max_workers=max_workers, + firmware_path=self.firmware, + local_ip=self.local_ip, + gateway_ip=self.gateway_ip, + ) + self.worker.log_message.connect(self._append_log) + self.worker.scan_found.connect(self._on_scan_found) + self.worker.devices_ready.connect(self._on_devices_ready) + self.worker.device_status.connect(self._on_device_status) + self.worker.device_done.connect(self._on_device_done) + self.worker.flash_progress.connect(self._on_flash_progress) + self.worker.all_done.connect(self._on_all_done) + self.worker.scan_timeout.connect(self._on_scan_timeout) + self.worker.stopped.connect(self._on_stopped) + self.worker.start() + + def _on_stop(self): + if self.worker: + self.worker.stop() + self.btn_stop.setEnabled(False) + self.status_label.setText("⏳ Đang dừng...") + + def _on_scan_found(self, count): + target = self.target_spin.value() + self.status_label.setText(f"🔍 Scan: tìm thấy {count}/{target} thiết bị...") + if count >= target: + self.status_label.setText(f"⚡ Đủ {target} thiết bị — đang nạp FW...") + self.status_label.setStyleSheet("font-size: 12px; font-weight: bold; color: #a6e3a1;") + + def _on_devices_ready(self, devices): + """Pre-populate bảng kết quả trước khi bắt đầu flash.""" + self.result_table.setRowCount(0) + self._device_rows = {} + for i, dev in enumerate(devices): + self.result_table.insertRow(i) + num_item = QTableWidgetItem(str(i + 1)) + num_item.setTextAlignment(Qt.AlignmentFlag.AlignCenter) + self.result_table.setItem(i, 0, num_item) + self.result_table.setItem(i, 1, QTableWidgetItem(dev["ip"])) + self.result_table.setItem(i, 2, QTableWidgetItem(dev.get("mac", "N/A").upper())) + waiting_item = QTableWidgetItem("⏳ Đang chờ...") + waiting_item.setForeground(QColor("#94a3b8")) + self.result_table.setItem(i, 3, waiting_item) + self._device_rows[dev["ip"]] = i + self.summary_label.setText(f"Tổng: {len(devices)} thiết bị") + + def _on_device_status(self, ip, msg): + row = self._device_rows.get(ip) + if row is not None: + item = QTableWidgetItem(f"⏳ {msg}") + item.setForeground(QColor("#f9e2af")) + self.result_table.setItem(row, 3, item) + + def _on_device_done(self, ip, mac, result): + # Thêm dòng mới vào bảng kết quả nếu chưa có + if ip not in self._device_rows: + row = self.result_table.rowCount() + self.result_table.insertRow(row) + num_item = QTableWidgetItem(str(row + 1)) + num_item.setTextAlignment(Qt.AlignmentFlag.AlignCenter) + self.result_table.setItem(row, 0, num_item) + self.result_table.setItem(row, 1, QTableWidgetItem(ip)) + self.result_table.setItem(row, 2, QTableWidgetItem(mac.upper())) + self._device_rows[ip] = row + + row = self._device_rows[ip] + now_str = datetime.datetime.now().strftime("%H:%M:%S") + if result.startswith("DONE"): + item = QTableWidgetItem(f"✅ {result}") + item.setForeground(QColor("#a6e3a1")) + self._success_count += 1 + # Lưu vào FlashHistory của cửa sổ chính + if self.parent_app: + self.parent_app.flashed_macs[mac.upper()] = (ip, mac.upper(), result, now_str) + else: + item = QTableWidgetItem(f"❌ {result}") + item.setForeground(QColor("#f38ba8")) + self._fail_count += 1 + self.result_table.setItem(row, 3, item) + self._auto_history.append((ip, mac.upper(), result, now_str)) + total = len(self._device_rows) + done = self._success_count + self._fail_count + self.summary_label.setText( + f"Tổng: {total} | Xong: {done} | ✅ {self._success_count} | ❌ {self._fail_count}" + ) + + def _on_flash_progress(self, done, total): + self.progress_bar.setVisible(True) + self.progress_bar.setMaximum(total) + self.progress_bar.setValue(done) + self.status_label.setText(f"⚡ Nạp FW: {done}/{total} thiết bị...") + + def _on_all_done(self, success, fail): + self._reset_controls() + self.status_label.setText(f"🏁 Hoàn thành! ✅ {success} | ❌ {fail}") + self.status_label.setStyleSheet("font-size: 12px; font-weight: bold; color: #a6e3a1;") + QMessageBox.information( + self, "Hoàn thành", + f"Tự động nạp FW hoàn thành!\n\n" + f"✅ Thành công: {success}\n" + f"❌ Thất bại: {fail}", + ) + + def _on_stopped(self): + self._reset_controls() + self.status_label.setText("⛔ Đã dừng bởi người dùng") + self.status_label.setStyleSheet("font-size: 12px; font-weight: bold; color: #f38ba8;") + + def _on_scan_timeout(self, found, target): + self._reset_controls() + self.status_label.setText(f"⚠️ Scan hết lần: chỉ tìm thấy {found}/{target} thiết bị") + self.status_label.setStyleSheet("font-size: 12px; font-weight: bold; color: #fab387;") + QMessageBox.warning( + self, "Không đủ thiết bị", + f"Đã scan tối đa nhưng chỉ tìm thấy {found}/{target} thiết bị.\n\n" + f"Vui lòng kiểm tra:\n" + f" • Thiết bị đã bật và kết nối mạng chưa\n" + f" • Dải mạng ({self.net_input.text()}) có đúng không\n" + f" • Thử lại sau khi kiểm tra", + ) + + def _reset_controls(self): + self.btn_start.setEnabled(True) + self.btn_stop.setEnabled(False) + self.net_input.setEnabled(True) + self.target_spin.setEnabled(True) + self.method_combo.setEnabled(True) + self.parallel_spin.setEnabled(True) + self.worker = None + if __name__ == "__main__": app = QApplication(sys.argv) diff --git a/ui/styles.py b/ui/styles.py index 96189f6..4cac2b1 100644 --- a/ui/styles.py +++ b/ui/styles.py @@ -278,3 +278,202 @@ QCheckBox::indicator:hover { 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; +} +"""