24 Commits
V1.1.0 ... main

Author SHA1 Message Date
50c20c841d update UI 2026-03-13 17:26:09 +07:00
52c7b4b968 update doc 2026-03-12 23:55:26 +07:00
aa36758ec9 update scan ip with MAC, update UI show history 2026-03-12 23:51:43 +07:00
22e4436518 update README.md 2026-03-12 23:35:37 +07:00
aa6a114071 update default loadFW (SSH) 2026-03-12 23:21:21 +07:00
627197e29e fix bug scan ip 2026-03-10 18:55:03 +07:00
adf7253350 fix bug scan ip (win) 2026-03-10 18:10:43 +07:00
f5ee209726 fix bug scan ip 2026-03-10 17:54:56 +07:00
466dadf1c9 update UI 2026-03-09 20:49:13 +07:00
b4745214f2 update UI 2026-03-09 20:29:48 +07:00
910307967b fix bug UI 2026-03-09 19:51:45 +07:00
042c50536c update UI, version 2026-03-09 19:33:00 +07:00
19febeaf3f update version 2026-03-09 17:20:30 +07:00
47f3320c3d Thêm tính năng tự động hóa nạp FW 2026-03-09 17:20:07 +07:00
56b688766e update doc 2026-03-09 14:12:43 +07:00
69b620f832 update doc 2026-03-09 14:11:44 +07:00
a7c41a7235 update scan ip 2026-03-09 13:53:10 +07:00
594d13c0cc update README.md 2026-03-09 13:19:00 +07:00
564b2567b6 Bỏ chức năng tìm hostname ở ip 2026-03-09 12:31:38 +07:00
24dc073b21 update version 2026-03-09 12:24:51 +07:00
1297511122 refactor code, ẩn thông tin ssh 2026-03-09 11:59:55 +07:00
9f6bd8c35a update header ứng dụng hiển thị version 2026-03-08 22:06:16 +07:00
a113bf56bd fix bug giao diện 2026-03-08 21:22:49 +07:00
ec78c984ad update version, fix bug / validate flash fw 2026-03-08 20:53:26 +07:00
22 changed files with 3146 additions and 1005 deletions

294
README.md
View File

@@ -1,31 +1,44 @@
# ⚡ IoT Firmware Loader (MiraV3)
# ⚡ 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 (qua giao diện LuCI) 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 · Scapy · Requests · PyInstaller
> **Tech stack:** Python 3.9+ · PyQt6 · Paramiko/SCP · Requests · PyInstaller
> **Phiên bản:** `1.2.0`
---
## 📁 Cấu trúc dự án
```text
iot_fw_loader/
├── main.py # UI chính (PyQt6)
├── core/ # Các thành phần cốt lõi và xử lý đa luồng
│ ├── workers.py # Quản lý luồng dùng chung (ScanThread, FlashThread)
│ ├── scanner.py # Quét thiết bị mạng đa lớp (Ping sweep + ARP + Scapy)
│ ├── flasher.py # Flash firmware và tự động hóa qua OpenWrt LuCI bằng API
── ssh_flasher.py # Load/Update firmware qua đường dẫn SSH
├── ui/ # Các component thiết kế giao diện
│ ├── components.py # Custom Qt Widgets (CollapsibleGroupBox, etc.)
── styles.py # Các cấu hình Stylesheet
├── utils/ # Các hàm helper tiện ích
│ ├── network.py # Các hàm xử lý IP, Hostname
── system.py # Các hàm lấy thông tin máy và resources
Mira_Firmware_Loader/
├── main.py # UI chính (PyQt6) — App + AutoFlashWindow
├── version.txt # Số phiên bản ứng dụng
├── requirements.txt # Danh sách dependencies
├── core/
│ ├── scanner.py # Quét thiết bị mạng (Ping sweep đa luồng)
── workers.py # ScanThread — chạy scanner trong background thread
│ ├── api_flash.py # Flash firmware qua LuCI HTTP API
│ ├── ssh_utils.py # SSH/SCP transport helpers dùng chung
── 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)
│ ├── 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
│ └── auto_flash_worker.py # AutoFlashWorker — QThread tự động scan → flash → retry
├── ui/
│ ├── components.py # Custom Qt Widgets (CollapsibleGroupBox)
│ └── styles.py # Stylesheet toàn ứng dụng (STYLE + AUTO_STYLE)
├── utils/
│ ├── network.py # Helper IP / network (get_local_ip, get_default_network)
│ └── system.py # Lấy thông tin máy, resource path cho PyInstaller
├── docs/
│ ├── 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)
│ ├── 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.bat # Script khởi chạy (Windows)
── build_windows.bat # Script đóng gói thành file .exe độc lập (Windows)
└── venv/ # Python virtual environment (tự tạo)
── build_windows.bat # Script đóng gói thành .exe (Windows, PyInstaller)
```
---
@@ -34,11 +47,10 @@ iot_fw_loader/
### Yêu cầu
- Python 3.9+
- Các thư viện: `PyQt6`, `scapy`, `requests`, `pyinstaller` (để build).
- _Trên Windows:_ Cần cài đặt [Npcap](https://npcap.com/) để `scapy` có thể quét ARP ở chế độ sâu (không bắt buộc, có fallback dự phòng).
- Python **3.9+**
- Thư viện: `PyQt6`, `requests`, `paramiko`, `scp`, `pyinstaller` (để build)
### Khởi chạy nhanh (Môi trường Dev)
### Khởi chạy nhanh
**macOS / Linux:**
@@ -54,48 +66,58 @@ run.bat
> Script tự tạo `venv` và cài dependencies nếu chưa có.
### 📦 Build ra file chạy độc lập (.exe) cho Windows
Chạy script sau để tự động đóng gói ứng dụng thành 1 file `.exe` duy nhất (không cần cài Python trên máy đích):
### 📦 Build file chạy độc lập (.exe) cho Windows
```bat
build_windows.bat
```
File nhận được sẽ nằm ở: `dist\IoT_Firmware_Loader.exe`.
Output: `dist\Mira_Firmware_Loader.exe` — không cần cài Python trên máy đích.
---
## 🏗 Kiến trúc hệ thống
```text
┌─────────────────────────────────────────────────────────────
```
┌──────────────────────────────────────────────────────────┐
│ main.py (UI) │
┌───────────┐ ┌───────────┐ ┌────────────────────────────┐
Machine │ Firmware │ Network Scan (QThread)
│ Info │ │ Selector │ │ Tham số: Network (CIDR) │
└───────────┘ └───────────┘ └────────┬───────────────────┘
────────────────────────────────────┼───────────────────
│ Device Table
[ ] IP │ Name │ MAC │ Status │
(Hỗ trợ lọc ẩn Gateway & Self) │
└────────────────────────────────────┼───────────────────┘
┌────────────────────────────────────┼───────────────────┐
│ Flash Controls + Progress Bar │ │
│ (ThreadPoolExecutor) │
└────────────────────────────────────┼───────────────────┘ │
└───────────────────────────────────────┼─────────────────────┘
│ Machine Info │ Firmware Selector │ Network Scan
─────────────────────────────────────────────────────
Device Table [ ] IP │ MAC │ Status
│ ─────────────────────────────────────────────────────
Flash Controls
Flash Mode: [ New Flash | Update Firmware ]
Method: [ API (LuCI) | SSH (paramiko) ]
Concurrent devices: [SpinBox]
[ ⚡ FLASH SELECTED DEVICES ]
─────────────────────────────────────────────────────
[ 🤖 Tự động hóa nạp FW ]
└────────────────────────┬─────────────────────────────────
┌───────────────────┼───────────────────
┌─────▼─────┐ ┌──────────┐
│ scanner.py│ flasher.py
│ 1. Ping │ LuCI HTTP
│ Sweep │ /cgi-bin/
│ 2. arp -a │ │ luci
│ 3. Scapy │
└───────────┘ └───────────┘
┌─────────────────┼─────────────────┐
─────▼─────┌───────▼──────────┐ ┌─────────────────────┐
│ scanner.py │ Flash Workers AutoFlashWorker
│ (thủ công) (tự động hóa)
Ping Sweep │ │ │ │
│ (Multithread│ │ NewFlashThread│ Phase 1: Scan LAN
│ gọi OS │ │ ├─ api_flash (tối đa 15 lần)
│ ping cmd) └─ ssh_new_flash│ │ Phase 2: Flash
│ │ (auto-retry x3) │
└─────────────┘ │ UpdateFlash │ │ ├─ api_flash │
│ Thread │ │ └─ ssh_new_flash │
│ └─ ssh_update │ └──────────────────────┘
└──────────────────┘
┌──────────▼──────────┐
│ ssh_utils.py │
│ (Transport Layer) │
│ _create_ssh_client │
│ _upload_firmware │
│ _verify_firmware │
│ _sync_and_sysupgr │
└─────────────────────┘
```
---
@@ -104,44 +126,160 @@ File nhận được sẽ nằm ở: `dist\IoT_Firmware_Loader.exe`.
### 1. Quét mạng (Network Scan)
Module `scanner.py` sử dụng chiến lược 3 lớp để đảm bảo phát hiện đa dạng các thiết bị mạng mà vẫn duy trì khả năng tránh spam/lag cho OS:
`scanner.py` sử dụng chiến lược **Ping trước → ARP sau**, đảm bảo vừa lấy được MAC vừa loại bỏ hoàn toàn lỗi thiết bị ảo do ARP cache cũ:
1. **Ping Sweep:** Gửi gói tin Ping song song (tối đa 50 threads) để đánh thức thiết bị và điền IP/MAC vào bảng ARP Cache của hệ điều hành.
2. **ARP Table Fallback:** Đọc bảng ARP nội bộ của OS (`arp -a`) bằng Regex. Hoạt động đa nền tảng (Windows/macOS/Linux) mà không cần quyền Admin/Root.
3. **Scapy ARP (Tính năng nâng cao):** Gửi các gói tin ARP Broadcast trực tiếp để đảm bảo bao phủ gap nếu có. Yêu cầu quyền Root trên Linux/macOS hoặc Npcap trên Windows.
_Ngoài ra, công cụ tự động dò Hostname (`socket.gethostbyaddr`) song song để lấy tên thiết bị._
| Giai đoạn | Mô tả |
| -------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------ |
| **Ping Sweep** | Gửi ping đồng thời tới toàn bộ host trong dải mạng bằng tiến trình con (đa luồng). Nhằm xác định chính xác thiết bị nào đang thực sự cấp nguồn online. |
| **ARP Lookup** | Ngay sau khi ping, query ARP table hệ điều hành (song song) _chỉ cho các IP vừa ping thành công_, lấy MAC Address chuẩn xác trước khi cache cũ hết hạn. |
### 2. Giao diện thiết bị (Device Table)
Kết quả (IP và MAC) được sort tăng dần theo IP trước khi trả về UI.
- Thiết bị được thu thập sẽ hiện ra trên UI dạng bảng, với tính năng tuỳ chọn hiển thị (checkbox ẩn Gateway mạng hiện tại và chính máy tính đang quét phần mềm).
- Trạng thái các luồng UI được tách rời bằng QThread + QPropertyAnimation cho hộp Collapsible nhằm tự động cuộn khi ẩn hiện nội dung, không làm treo ứng dụng.
### 2. Bảng thiết bị (Device Table)
### 3. Nạp Firmware (OpenWrt Flasher)
- Hiển thị cột: checkbox, IP, MAC, Status
- 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
Từ phiên bản hiện tại, phương thức ESP32 OTA không còn được áp dụng, thay vào đó module `flasher.py` tự động hóa quá trình update của router **OpenWrt (Barrier Breaker 14.07)**:
### 3. Flash Firmware (Thủ công)
- **Bước 1:** Gửi `POST` chứa thông tin đăng nhập (username, password mặc định là root/trống, hoặc luci_username tuỳ thuộc firmware) để lấy session cookie `stok`.
- **Bước 2:** Đẩy file firmware `*.bin` dạng `multipart/form-data` lên `/cgi-bin/luci/;stok=.../admin/system/flashops`.
- **Bước 3:** Gửi lệnh tiếp tục (Kèm tuỳ chọn giữ cấu hình `keep=on` nếu có).
- **Bước 4:** Thiết bị xác nhận và tự khởi động lại. Cập nhật Status trên Application.
- 🛠 Hỗ trợ `ThreadPoolExecutor` để Flash đồng thời nhiều thiết bị, với lựa chọn số luồng đồng thời tuỳ chỉnh.
Có 2 chế độ và 2 method, tổng cộng 3 luồng thực thi khác nhau:
#### Chế độ `New Flash` — dùng cho thiết bị vừa reset cứng
| Method | Luồng | Mô tả |
| -------------- | ------------------ | ------------------------------------------------------------------------------------------- |
| **API (LuCI)** | `api_flash.py` | Đăng nhập LuCI → upload firmware → Proceed. Hỗ trợ Barrier Breaker 14.07 và OpenWrt mới hơn |
| **SSH** | `ssh_new_flash.py` | Kết nối Telnet → đặt password mới → SSH → SCP upload → sysupgrade |
**Luồng SSH New Flash chi tiết:**
1. Telnet port 23 (thiết bị vừa reset, chưa có pass)
2. Đặt password `admin123a` qua lệnh `passwd`
3. SSH vào với password vừa đặt
4. SCP upload firmware lên `/tmp/`
5. Verify file tồn tại
6. `sync && sysupgrade -F -v -n` → thiết bị reboot (connection drop = DONE)
#### Chế độ `Update Firmware` — dùng cho thiết bị đang chạy OpenWrt
Luồng `ssh_update_flash.py`, SSH trực tiếp (không qua Telnet):
1. SSH kết nối với `root` / `admin123a` (fallback: `admin`)
2. SCP upload firmware lên `/tmp/`
3. Verify file tồn tại
4. `sync && sysupgrade -F -v -n` → thiết bị reboot
> ⚠️ 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. 🤖 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ả |
| ---------------------- | ------------------------------------------------------------------------ |
| **Concurrent devices** | Số thiết bị flash đồng thời (`ThreadPoolExecutor`). `0` = không giới hạn |
---
## ⚙️ Cấu hình
## ⚙️ Cấu hình mặc định
| Tham số | Mô tả |
| ---------------------- | ------------------------------------------------------------------- |
| **Network** | Dải mạng quét (vd: `192.168.1.0/24`). Tự động tính từ local IP app. |
| **Show all** | Tuỳ chọn cho phép liệt kê cả Gateway IP máy chủ. |
| **Concurrent devices** | Số luồng để đĩa flash song song. `0` = Không giới hạn. |
| **Firmware filter** | Mặc định hiển thị và hỗ trợ `.bin`, `.hex`, `.uf2`. |
| Tham số | Giá trị mặc định | Mô tả |
| ---------------------- | ------------------------------------ | --------------------------------- |
| **Network** | Tự suy ra từ local IP (`x.x.x.0/24`) | Dải mạng để quét |
| **Flash Mode** | `New Flash` | Nạp mới hoặc Update |
| **Method** | `API (LuCI)` | Phương thức flash cho New Flash |
| **SSH User** | `root` | 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 |
| **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 12 (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 |
---
## 🔒 Lưu ý bảo mật & Quyền
- **Quyền Scanner:** Ở chế độ quét Scapy (Sâu), cần khởi chạy bằng quyền Admin (Windows) hoặc Sudo (macOS/Linux), tuy nhiên App có thể chạy kể cả không có quyền Admin nhờ chiến lược Ping Sweep + `arp -a`.
- **Ẩn Console (Window):** Khi gọi subproces (`ping`, `arp`), ứng dụng có tích hợp thuộc tính platform `CREATE_NO_WINDOW` để ngăn chặn các terminal đen giật nháy trên màn hình Windows.
- **Bảo mật mạng:** Quá trình tải firmware lên thiết bị thông qua HTTP thuần, chỉ nên sử dụng ở mạng LAN nội bộ, tránh những mạng mở hoặc public để tránh lộ file firmware.
- **LuCI Login:** API này nhắm thẳng vào quá trình đăng nhập qua form, nên sẽ tự động handle các router đang dùng OpenWrt Barrier Breaker mà không cần phải can thiệp tay.
- **Quyền hệ thống:** Quá trình quét của `scanner.py` chỉ chạy lệnh ping từ hệ điều hành, vì vậy ứng dụng có thể chạy hoàn toàn **không cần** quyền Admin (trên Windows) / Root (trên macOS/Linux).
- **CREATE_NO_WINDOW:** Khi gọi subprocess lệnh `ping`, ứng dụng dùng flag `CREATE_NO_WINDOW` trên Windows để ngăn cửa sổ console command prompt (cmd) liên tục hiện lên màn hình.
- **HTTP thuần:** Firmware được upload qua HTTP (không HTTPS). Chỉ dùng trong mạng LAN nội bộ, không nên dùng trên mạng mở.
- **Credentials cố định:** SSH credentials (`root`/`admin123a`) được hardcode trong `flash_update_worker.py` và truyền từ `main.py`, không hiển thị trên UI.

View File

@@ -11,6 +11,10 @@ echo.
cd /d "%~dp0"
REM Doc version tu file
set /p APP_VERSION=<version.txt
echo Building version: v%APP_VERSION%
REM 1. Tao venv neu chua co
if not exist "venv" (
echo [1/4] Creating virtual environment...
@@ -27,9 +31,10 @@ pip install -r requirements.txt pyinstaller --quiet
REM 4. Build .exe
echo [3/4] Building executable...
pyinstaller ^
--name "Mira_Firmware_Loader_v1.1.0" ^
--name "Mira_Firmware_Loader_v%APP_VERSION%" ^
--icon "icon.ico" ^
--add-data "icon.ico;." ^
--add-data "version.txt;." ^
--onefile ^
--windowed ^
--noconfirm ^
@@ -47,11 +52,11 @@ echo.
echo [4/4] Build complete!
echo.
if exist "dist\Mira_Firmware_Loader_v1.1.0.exe" (
echo SUCCESS: dist\Mira_Firmware_Loader_v1.1.0.exe
if exist "dist\Mira_Firmware_Loader_v%APP_VERSION%.exe" (
echo SUCCESS: dist\Mira_Firmware_Loader_v%APP_VERSION%.exe
echo.
echo File size:
for %%A in ("dist\Mira_Firmware_Loader_v1.1.0.exe") do echo %%~zA bytes
for %%A in ("dist\Mira_Firmware_Loader_v%APP_VERSION%.exe") do echo %%~zA bytes
echo.
echo Ban co the copy file .exe nay sang may khac va chay truc tiep.
) else (

141
core/api_flash.py Normal file
View File

@@ -0,0 +1,141 @@
"""
LuCI HTTP Firmware Flasher for OpenWrt devices.
Tự động hoá 3 bước flash qua web interface LuCI:
1. Login → lấy sysauth cookie + stok token
2. Upload firmware.bin (multipart)
3. Confirm (Proceed) → thiết bị flash và reboot
Tương thích:
- Barrier Breaker 14.07 (field: username/password)
- OpenWrt mới hơn (field: luci_username/luci_password)
"""
import re
import time
import os
from typing import Optional
import requests
def _extract_stok(text: str) -> Optional[str]:
"""Tách stok token từ URL hoặc body HTML. Trả về None nếu không tìm thấy."""
m = re.search(r";stok=([a-f0-9]+)", text)
return m.group(1) if m else None
def flash_device_api(ip, firmware_path, username="root", password="",
keep_settings=False, status_cb=None):
"""
Flash firmware lên thiết bị OpenWrt qua LuCI HTTP.
Args:
ip : địa chỉ IP thiết bị
firmware_path : đường dẫn file .bin trên máy tính
username : LuCI username (mặc định "root")
password : LuCI password (mặc định rỗng — Barrier Breaker không có pass)
keep_settings : True = giữ cấu hình cũ sau flash
status_cb : callback(str) cập nhật tiến độ lên UI
Returns:
"DONE" — flash thành công
"FAIL: …" — thất bại, kèm lý do
"""
base_url = f"http://{ip}"
login_url = f"{base_url}/cgi-bin/luci"
session = requests.Session()
try:
# ── STEP 1: Login ────────────────────────────────────────────
if status_cb:
status_cb("Logging in...")
# Phát hiện field name tương thích Barrier Breaker vs OpenWrt mới
try:
page_html = session.get(login_url, timeout=10).text
except Exception:
page_html = ""
if 'name="luci_username"' in page_html:
login_data = {"luci_username": username, "luci_password": password}
else:
login_data = {"username": username, "password": password}
resp = session.post(login_url, data=login_data,
timeout=10, allow_redirects=True)
if resp.status_code == 403:
return "FAIL: Login denied (403)"
# Lấy stok từ URL → body → redirect history
stok = (_extract_stok(resp.url)
or _extract_stok(resp.text)
or next(
(_extract_stok(h.headers.get("Location", ""))
for h in resp.history
if _extract_stok(h.headers.get("Location", ""))),
None
))
if not stok and "sysauth" not in str(session.cookies):
return "FAIL: Login failed — no session"
flash_url = (
f"{base_url}/cgi-bin/luci/;stok={stok}/admin/system/flashops"
if stok else
f"{base_url}/cgi-bin/luci/admin/system/flashops"
)
# ── STEP 2: Upload Firmware ──────────────────────────────────
if status_cb:
status_cb("Uploading firmware...")
filename = os.path.basename(firmware_path)
extra = {"keep": "on"} if keep_settings else {}
with open(firmware_path, "rb") as f:
resp = session.post(
flash_url,
data=extra,
files={"image": (filename, f, "application/octet-stream")},
timeout=300,
)
if resp.status_code != 200:
return f"FAIL: Upload HTTP {resp.status_code}"
body = resp.text.lower()
if "invalid image" in body or "bad image" in body:
return "FAIL: Invalid firmware image"
if "unsupported" in body or "not compatible" in body:
return "FAIL: Firmware not compatible"
if "verify" not in body or "proceed" not in body:
return ("FAIL: Upload ignored by server"
if 'name="image"' in resp.text
else "FAIL: Unexpected response after upload")
# ── STEP 3: Confirm (Proceed) ────────────────────────────────
if status_cb:
status_cb("Confirming (Proceed)...")
confirm = {"step": "2", "keep": "on" if keep_settings else ""}
try:
session.post(flash_url, data=confirm, timeout=300)
except (requests.exceptions.ConnectionError,
requests.exceptions.ReadTimeout):
pass # Đứt kết nối khi reboot = bình thường
if status_cb:
status_cb("Rebooting...")
time.sleep(3)
return "DONE"
except requests.ConnectionError:
return "FAIL: Cannot connect"
except requests.Timeout:
return "DONE (rebooting)"
except Exception as e:
return f"FAIL: {e}"
finally:
session.close()

200
core/auto_flash_worker.py Normal file
View 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)

79
core/flash_new_worker.py Normal file
View File

@@ -0,0 +1,79 @@
"""
Worker thread cho chế độ "Nạp Mới FW" (New Flash / Factory Reset).
Hỗ trợ 2 method:
- "api" : Flash qua LuCI HTTP (core/api_flash.py)
- "ssh" : Flash qua SSH/SCP (core/ssh_new_flash.py), có tuỳ chọn set_passwd
"""
from PyQt6.QtCore import QThread, pyqtSignal
from concurrent.futures import ThreadPoolExecutor
from core.api_flash import flash_device_api
from core.ssh_new_flash import flash_device_new_ssh
class NewFlashThread(QThread):
"""Flash firmware lên thiết bị OpenWrt vừa reset / chưa có pass."""
device_status = pyqtSignal(int, str) # index, status message
device_done = pyqtSignal(int, str) # index, result ("DONE" / "FAIL: ...")
all_done = pyqtSignal()
def __init__(self, devices, firmware_path, max_workers=10,
method="api",
ssh_user="root", ssh_password="admin123a",
ssh_backup_password="", set_passwd=True):
"""
Args:
devices : list[dict] — [{ip, mac, ...}, ...]
firmware_path : str — đường dẫn file firmware
max_workers : int — số thiết bị flash song song (0 = unlimited)
method : "api"|"ssh"
ssh_user : str — SSH username (chỉ dùng với method="ssh")
ssh_password : str — SSH password mới sẽ đặt / đăng nhập
ssh_backup_password : str — password dự phòng nếu login lần đầu thất bại
set_passwd : bool — True = gọi passwd trước khi flash (thiết bị vừa reset)
"""
super().__init__()
self.devices = devices
self.firmware_path = firmware_path
self.max_workers = max_workers
self.method = method
self.ssh_user = ssh_user
self.ssh_password = ssh_password
self.ssh_backup_password = ssh_backup_password
self.set_passwd = set_passwd
def run(self):
def _flash_one(i, dev):
try:
def on_status(msg):
self.device_status.emit(i, msg)
if self.method == "ssh":
result = flash_device_new_ssh(
dev["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(
dev["ip"], self.firmware_path,
status_cb=on_status,
)
self.device_done.emit(i, result)
except Exception as e:
self.device_done.emit(i, f"FAIL: {e}")
workers = self.max_workers if self.max_workers > 0 else len(self.devices)
with ThreadPoolExecutor(max_workers=max(workers, 1)) as executor:
futures = [executor.submit(_flash_one, i, dev)
for i, dev in enumerate(self.devices)]
for f in futures:
f.result()
self.all_done.emit()

View File

@@ -0,0 +1,65 @@
"""
Worker thread cho chế độ "Update Firmware" (thiết bị đã cài sẵn OpenWrt).
Đặc điểm:
- Chỉ dùng SSH (không có LuCI API).
- Credentials cố định: root / admin123a (backup: admin).
- Không thực hiện bước đặt lại mật khẩu (set_passwd=False) vì thiết bị
đã có SSH đang chạy với pass đã biết.
"""
from PyQt6.QtCore import QThread, pyqtSignal
from concurrent.futures import ThreadPoolExecutor
from core.ssh_update_flash import flash_device_update_ssh
# Credentials mặc định cho Update Mode
_UPDATE_SSH_USER = "root"
_UPDATE_SSH_PASSWORD = "admin123a"
_UPDATE_SSH_BACKUP = "admin"
class UpdateFlashThread(QThread):
"""Cập nhật firmware lên thiết bị OpenWrt đang chạy qua SSH / sysupgrade."""
device_status = pyqtSignal(int, str) # index, status message
device_done = pyqtSignal(int, str) # index, result ("DONE" / "FAIL: ...")
all_done = pyqtSignal()
def __init__(self, devices, firmware_path, max_workers=10):
"""
Args:
devices : list[dict] — [{ip, mac, ...}, ...]
firmware_path : str — đường dẫn file firmware
max_workers : int — số thiết bị update song song (0 = unlimited)
"""
super().__init__()
self.devices = devices
self.firmware_path = firmware_path
self.max_workers = max_workers
def run(self):
def _update_one(i, dev):
try:
def on_status(msg):
self.device_status.emit(i, msg)
result = flash_device_update_ssh(
dev["ip"], self.firmware_path,
user=_UPDATE_SSH_USER,
password=_UPDATE_SSH_PASSWORD,
backup_password=_UPDATE_SSH_BACKUP,
status_cb=on_status,
)
self.device_done.emit(i, result)
except Exception as e:
self.device_done.emit(i, f"FAIL: {e}")
workers = self.max_workers if self.max_workers > 0 else len(self.devices)
with ThreadPoolExecutor(max_workers=max(workers, 1)) as executor:
futures = [executor.submit(_update_one, i, dev)
for i, dev in enumerate(self.devices)]
for f in futures:
f.result()
self.all_done.emit()

View File

@@ -1,164 +0,0 @@
"""
OpenWrt LuCI Firmware Flasher (Barrier Breaker 14.07)
Automates the 3-step firmware flash process via LuCI web interface:
Step 1: POST /cgi-bin/luci → username=root&password= → get sysauth cookie + stok
Step 2: POST /cgi-bin/luci/;stok=XXX/admin/system/flashops
→ multipart: image=firmware.bin → get verification page
Step 3: POST /cgi-bin/luci/;stok=XXX/admin/system/flashops
→ step=2&keep= → device flashes and reboots
"""
import requests
import re
import time
import os
def flash_device(ip, firmware_path, username="root", password="",
keep_settings=False, status_cb=None):
"""
Flash firmware to an OpenWrt device via LuCI.
Returns:
"DONE" on success, "FAIL: reason" on error
"""
base_url = f"http://{ip}"
session = requests.Session()
try:
# ═══════════════════════════════════════════
# STEP 1: Login
# ═══════════════════════════════════════════
if status_cb:
status_cb("Logging in...")
# GET login page to detect form field names
login_url = f"{base_url}/cgi-bin/luci"
try:
get_resp = session.get(login_url, timeout=10)
page_html = get_resp.text
except Exception:
page_html = ""
# Barrier Breaker uses "username"/"password"
# Newer LuCI uses "luci_username"/"luci_password"
if 'name="luci_username"' in page_html:
login_data = {"luci_username": username, "luci_password": password}
else:
login_data = {"username": username, "password": password}
resp = session.post(login_url, data=login_data,
timeout=10, allow_redirects=True)
if resp.status_code == 403:
return "FAIL: Login denied (403)"
# Extract stok from response body or URL
stok = None
for source in [resp.url, resp.text]:
match = re.search(r";stok=([a-f0-9]+)", source)
if match:
stok = match.group(1)
break
# Also check redirect history
if not stok:
for hist in resp.history:
match = re.search(r";stok=([a-f0-9]+)",
hist.headers.get("Location", ""))
if match:
stok = match.group(1)
break
# Verify login succeeded
has_cookie = "sysauth" in str(session.cookies)
if not stok and not has_cookie:
return "FAIL: Login failed — no session"
# Build flash URL with stok
if stok:
flash_url = f"{base_url}/cgi-bin/luci/;stok={stok}/admin/system/flashops"
else:
flash_url = f"{base_url}/cgi-bin/luci/admin/system/flashops"
# ═══════════════════════════════════════════
# STEP 2: Upload firmware image
# ═══════════════════════════════════════════
if status_cb:
status_cb("Uploading firmware...")
filename = os.path.basename(firmware_path)
with open(firmware_path, "rb") as f:
# Don't send "keep" field = uncheck "Keep settings"
# Send "keep": "on" only if keep_settings is True
data = {}
if keep_settings:
data["keep"] = "on"
resp = session.post(
flash_url,
data=data,
files={"image": (filename, f, "application/octet-stream")},
timeout=300,
)
if resp.status_code != 200:
return f"FAIL: Upload HTTP {resp.status_code}"
resp_lower = resp.text.lower()
# Check for errors
if "invalid image" in resp_lower or "bad image" in resp_lower:
return "FAIL: Invalid firmware image"
if "unsupported" in resp_lower or "not compatible" in resp_lower:
return "FAIL: Firmware not compatible"
# Verify we got the "Flash Firmware - Verify" page
if "verify" not in resp_lower or "proceed" not in resp_lower:
# Still on flash form = upload was ignored
if 'name="image"' in resp.text:
return "FAIL: Upload ignored by server"
return "FAIL: Unexpected response after upload"
# ═══════════════════════════════════════════
# STEP 3: Click "Proceed" to start flash
# ═══════════════════════════════════════════
if status_cb:
status_cb("Confirming (Proceed)...")
# The Proceed form from the verification page:
# <input type="hidden" name="step" value="2" />
# <input type="hidden" name="keep" value="" />
confirm_data = {
"step": "2",
"keep": "",
}
if keep_settings:
confirm_data["keep"] = "on"
try:
session.post(flash_url, data=confirm_data, timeout=300)
except requests.exceptions.ConnectionError:
# Device rebooting — this is expected and means SUCCESS
pass
except requests.exceptions.ReadTimeout:
# Also expected during reboot
pass
if status_cb:
status_cb("Rebooting...")
time.sleep(3)
return "DONE"
except requests.ConnectionError:
return "FAIL: Cannot connect"
except requests.Timeout:
return "DONE (rebooting)"
except Exception as e:
return f"FAIL: {e}"
finally:
session.close()

View File

@@ -1,224 +1,172 @@
import subprocess
import re
import subprocess
import sys
import time
import ipaddress
import threading
from concurrent.futures import ThreadPoolExecutor, as_completed
# Windows: prevent subprocess from opening visible console windows
_NO_WINDOW = subprocess.CREATE_NO_WINDOW if sys.platform == "win32" else 0
def _scan_with_scapy(network):
"""Scan using scapy (requires root/sudo, and Npcap on Windows)."""
from scapy.all import ARP, Ether, srp
arp = ARP(pdst=str(network))
ether = Ether(dst="ff:ff:ff:ff:ff:ff")
packet = ether / arp
result = srp(packet, timeout=3, verbose=0)[0]
devices = []
for sent, received in result:
devices.append({
"ip": received.psrc,
"mac": received.hwsrc
})
return devices
# Concurrent ping workers per platform
_MAX_WORKERS_WIN = 50
_MAX_WORKERS_OTHER = 64
def _ping_one(ip, is_win):
"""Ping a single IP to populate ARP table."""
"""Ping a single IP. Returns True if host responds."""
try:
if is_win:
subprocess.run(
["ping", "-n", "1", "-w", "600", str(ip)],
stdout=subprocess.DEVNULL,
# Capture stdout to check for TTL= — more reliable than returncode
# on Windows (returncode can be 0 even for "Destination unreachable")
r = subprocess.run(
["ping", "-n", "1", "-w", "500", str(ip)],
stdout=subprocess.PIPE,
stderr=subprocess.DEVNULL,
timeout=3,
creationflags=_NO_WINDOW
)
return r.returncode == 0 and b"TTL=" in r.stdout
elif sys.platform == "darwin":
r = subprocess.run(
["ping", "-c", "1", "-W", "300", str(ip)],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
timeout=2
)
else:
subprocess.run(
r = subprocess.run(
["ping", "-c", "1", "-W", "1", str(ip)],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
timeout=2
)
return r.returncode == 0
except Exception:
return False
def _get_mac_from_arp(ip):
"""
Lấy MAC address của một IP qua ARP table.
Chỉ được gọi SAU KHI IP đã phản hồi ping thành công — đảm bảo ARP
cache vừa được OS cập nhật với thông tin mới nhất (không bị stale).
Trả về chuỗi MAC dạng 'AA:BB:CC:DD:EE:FF' hoặc 'N/A' nếu không tra được.
"""
try:
if sys.platform == "win32":
# arp -a <ip> → " 192.168.4.5 aa-bb-cc-dd-ee-ff dynamic"
r = subprocess.run(
["arp", "-a", str(ip)],
stdout=subprocess.PIPE,
stderr=subprocess.DEVNULL,
timeout=3,
creationflags=_NO_WINDOW
)
output = r.stdout.decode(errors="ignore")
# Dạng Windows: aa-bb-cc-dd-ee-ff
match = re.search(
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})",
output
)
else:
# macOS / Linux: arp -n <ip>
# macOS output: "? (192.168.4.5) at aa:bb:cc:dd:ee:ff on en0 ..."
# Linux output: "192.168.4.5 ether aa:bb:cc:dd:ee:ff C eth0"
r = subprocess.run(
["arp", "-n", str(ip)],
stdout=subprocess.PIPE,
stderr=subprocess.DEVNULL,
timeout=3
)
output = r.stdout.decode(errors="ignore")
match = re.search(
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})",
output
)
if match:
# Chuẩn hoá sang dấu ':' và chữ hoa
mac = match.group(1).replace("-", ":").upper()
return mac
except Exception:
pass
return "N/A"
def _ping_sweep(network, progress_cb=None):
"""Ping all IPs in network concurrently to populate ARP table.
Calls progress_cb(done, total) after each ping completes if provided.
"""Ping tất cả host trong network đồng thời.
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)
# Only ping sweep for /24 or smaller to avoid flooding
if net.num_addresses > 256:
return
return []
is_win = sys.platform == "win32"
hosts = list(net.hosts())
total = len(hosts)
workers = min(_MAX_WORKERS_WIN if is_win else _MAX_WORKERS_OTHER, len(hosts))
done_count = [0]
lock = threading.Lock()
alive = []
def _ping_and_track(ip):
_ping_one(ip, is_win)
ok = _ping_one(ip, is_win)
with lock:
done_count[0] += 1
current = done_count[0]
if progress_cb:
progress_cb(done_count[0], total)
progress_cb(current, total)
return (str(ip), ok)
with ThreadPoolExecutor(max_workers=70) as executor:
with ThreadPoolExecutor(max_workers=workers) as executor:
futures = [executor.submit(_ping_and_track, ip) for ip in hosts]
for f in as_completed(futures):
pass
ip_str, ok = f.result()
if ok:
alive.append(ip_str)
def _scan_with_arp_table(network):
"""Fallback: read ARP table using system 'arp -a' (no root needed).
Supports both macOS and Windows output formats.
"""
# Ping sweep first to populate ARP table with active devices
_ping_sweep(network)
# Brief pause to let the OS finalize ARP cache entries
time.sleep(1)
try:
output = subprocess.check_output(
["arp", "-a"], text=True, creationflags=_NO_WINDOW
)
except Exception:
return []
devices = []
net = ipaddress.ip_network(network, strict=False)
if sys.platform == "win32":
# Windows format:
# 192.168.4.1 cc-2d-21-a5-85-b0 dynamic
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)
# Convert Windows MAC format (cc-2d-21-a5-85-b0) to standard (cc:2d:21:a5:85:b0)
mac = m.group(2).replace("-", ":")
if mac.upper() != "FF:FF:FF:FF:FF:FF":
try:
if ipaddress.ip_address(ip_str) in net:
devices.append({"ip": ip_str, "mac": mac})
except ValueError:
pass
else:
# macOS/Linux format:
# ? (192.168.1.1) at aa:bb:cc:dd:ee:ff on en0
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() != "(incomplete)" and mac != "ff:ff:ff:ff:ff:ff":
try:
if ipaddress.ip_address(ip_str) in net:
devices.append({"ip": ip_str, "mac": mac})
except ValueError:
pass
return devices
return alive
def scan_network(network, progress_cb=None, stage_cb=None):
"""Scan network: ping sweep first, then merge scapy ARP + arp table."""
# Phase 1: Ping sweep — wake up devices and populate ARP cache
"""Scan network: ping → lấy MAC từ ARP (chỉ cho IP đang online).
Flow:
1. Ping sweep — xác định thiết bị online
2. MAC lookup — query ARP cho từng IP vừa phản hồi ping (song song)
=> ARP cache vừa được OS cập nhật sau ping, không bị stale.
"""
# ── Stage 1: Ping ──
if stage_cb:
stage_cb("ping")
_ping_sweep(network, progress_cb)
time.sleep(1)
alive_ips = _ping_sweep(network, progress_cb)
# Collect results from both methods and merge by IP
seen = {} # ip -> device dict
# Phase 2: ARP table (populated by ping sweep above)
# ── Stage 2: MAC lookup ──
if stage_cb:
stage_cb("arp")
stage_cb("mac")
results = []
if alive_ips:
# Chạy song song để tra MAC nhanh hơn
mac_workers = min(32, len(alive_ips))
macs = {}
with ThreadPoolExecutor(max_workers=mac_workers) as executor:
future_to_ip = {executor.submit(_get_mac_from_arp, ip): ip for ip in alive_ips}
for future in as_completed(future_to_ip):
ip = future_to_ip[future]
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:
seen[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:
seen[ip_str] = {"ip": ip_str, "mac": mac}
except ValueError:
pass
macs[ip] = future.result()
except Exception:
pass
macs[ip] = "N/A"
# Phase 3: scapy ARP scan (if Npcap available) — fills in any gaps
if stage_cb:
stage_cb("scapy")
try:
import io, os
_stderr = sys.stderr
sys.stderr = io.StringIO()
try:
from scapy.all import ARP, Ether, srp
finally:
sys.stderr = _stderr
for ip_str in alive_ips:
results.append({"ip": ip_str, "mac": macs.get(ip_str, "N/A")})
arp = ARP(pdst=str(network))
ether = Ether(dst="ff:ff:ff:ff:ff:ff")
result = srp(ether / arp, timeout=2, verbose=0)[0]
for sent, received in result:
ip = received.psrc
if ip not in seen:
seen[ip] = {"ip": ip, "mac": received.hwsrc}
except Exception:
pass
return sorted(seen.values(), key=lambda d: ipaddress.ip_address(d["ip"]))
return sorted(results, key=lambda d: ipaddress.ip_address(d["ip"]))

228
core/ssh_new_flash.py Normal file
View File

@@ -0,0 +1,228 @@
"""
Luồng SSH cho chế độ "Nạp Mới FW" (Factory Reset / Raw device).
Đặc điểm:
- Thiết bị vừa reset cứng → Telnet port 23 mở, SSH chưa có pass.
- Bước 0 (tuỳ chọn): đặt password qua Telnet → SSH (set_passwd=True).
- Bước 14: kết nối SSH, upload SCP, verify, sync + sysupgrade.
Public API:
set_device_password(ip, user, old_password, new_password, status_cb)
flash_device_new_ssh(ip, firmware_path, user, password,
backup_password, set_passwd, status_cb)
"""
import socket
import time
import random
import paramiko
from core.ssh_utils import (
_create_ssh_client,
_upload_firmware,
_verify_firmware,
_sync_and_sysupgrade,
)
# ──────────────────────────────────────────────────
# Telnet client thủ công (telnetlib bị xoá Python 3.13+)
# ──────────────────────────────────────────────────
class _SimpleTelnet:
"""Minimal Telnet client dùng raw socket."""
def __init__(self, host, port=23, timeout=10):
self._sock = socket.create_connection((host, port), timeout=timeout)
def read_very_eager(self):
try:
self._sock.setblocking(False)
data = b""
while True:
try:
chunk = self._sock.recv(4096)
if not chunk:
break
data += chunk
except (BlockingIOError, OSError):
break
self._sock.setblocking(True)
return data
except Exception:
return b""
def write(self, data: bytes):
self._sock.sendall(data)
def close(self):
try:
self._sock.close()
except Exception:
pass
# ──────────────────────────────────────────────────
# Đặt mật khẩu thiết bị (Telnet → SSH fallback)
# ──────────────────────────────────────────────────
def set_device_password(ip, user="root", old_password="",
new_password="admin123a", status_cb=None):
"""
Đặt mật khẩu thiết bị OpenWrt.
Thứ tự thử:
1. Telnet port 23 — thiết bị vừa reset (chưa có pass, SSH chưa mở)
2. SSH — thiết bị cũ có SSH nhưng cần đổi pass
Returns:
"DONE" — thành công
"FAIL: …" — thất bại sau tất cả các retry
"""
# Jitter để không hammering khi chạy song song nhiều thiết bị
time.sleep(random.uniform(0.1, 1.5))
# ── 1. Thử Telnet ──────────────────────────────────────────────
if status_cb:
status_cb("Checking Telnet port for raw device...")
try:
tn = _SimpleTelnet(ip, timeout=5)
if status_cb:
status_cb("Telnet connected! Setting password...")
time.sleep(1)
tn.read_very_eager() # Flush banner OpenWrt
tn.write(b"passwd\n"); time.sleep(1)
tn.write(new_password.encode("ascii") + b"\n"); time.sleep(1)
tn.write(new_password.encode("ascii") + b"\n"); time.sleep(1)
tn.write(b"exit\n"); time.sleep(0.5)
tn.close()
if status_cb:
status_cb("Password set via Telnet. Waiting for SSH to start...")
time.sleep(3) # Chờ Dropbear (SSH daemon) khởi động
return "DONE"
except Exception:
# Telnet không truy cập được → thử SSH bên dưới
pass
# ── 2. Fallback SSH ─────────────────────────────────────────────
if status_cb:
status_cb("Connecting SSH for password update...")
last_err = None
for attempt in range(3):
try:
client = _create_ssh_client(ip, user, old_password, timeout=10)
if status_cb:
status_cb("Setting password via SSH...")
shell = client.invoke_shell()
time.sleep(1)
if shell.recv_ready():
shell.recv(65535)
shell.send("passwd\n"); time.sleep(2)
shell.send(f"{new_password}\n"); time.sleep(1)
shell.send(f"{new_password}\n"); time.sleep(2)
if shell.recv_ready():
shell.recv(65535)
shell.send("exit\n"); time.sleep(0.5)
shell.close()
client.close()
if status_cb:
status_cb("Password set ✓")
return "DONE"
except paramiko.AuthenticationException as e:
return f"FAIL: Cannot connect SSH (Old pass incorrect?) — {e}"
except Exception as e:
last_err = e
try:
client.close()
except Exception:
pass
time.sleep(2)
return f"FAIL: Cannot connect SSH after 3 attempts — {last_err}"
# ──────────────────────────────────────────────────
# Flash firmware — Nạp Mới (Factory Reset)
# ──────────────────────────────────────────────────
def flash_device_new_ssh(ip, firmware_path, user="root", password="admin123a",
backup_password="", set_passwd=False, status_cb=None):
"""
Flash firmware lên thiết bị OpenWrt vừa reset / chưa có mật khẩu.
Luồng:
0. (Tuỳ chọn) Đặt mật khẩu qua Telnet / SSH
1. Kết nối SSH
2. Upload firmware qua SCP lên /tmp/
3. Verify file tồn tại
4. sync + sysupgrade
Args:
ip : địa chỉ IP thiết bị
firmware_path : đường dẫn file .bin trên máy tính
user : SSH username (mặc định "root")
password : mật khẩu SSH (hoặc mật khẩu mới sẽ đặt)
backup_password : mật khẩu dự phòng nếu password chính không vào được
set_passwd : True = chạy bước đặt mật khẩu trước khi flash
status_cb : callback(str) để cập nhật trạng thái lên UI
Returns:
"DONE" — flash thành công
"FAIL: …" — thất bại, kèm lý do
"""
# ── STEP 0: Đặt mật khẩu (tuỳ chọn) ──────────────────────────
if set_passwd:
result = set_device_password(ip, user, "", password, status_cb)
if result.startswith("FAIL"):
# Thử với backup_password nếu có
if backup_password:
result = set_device_password(ip, user, backup_password, password, status_cb)
# Thử xem password đã được đặt chưa (idempotent)
if result.startswith("FAIL"):
result = set_device_password(ip, user, password, password, status_cb)
if result.startswith("FAIL"):
return result
else:
time.sleep(random.uniform(0.1, 1.5)) # Jitter khi không set_passwd
# ── STEP 1: Kết nối SSH ─────────────────────────────────────────
if status_cb:
status_cb("Connecting SSH...")
try:
client = _create_ssh_client(ip, user, password)
except paramiko.AuthenticationException:
return "FAIL: SSH authentication failed"
except paramiko.SSHException as e:
return f"FAIL: SSH error — {e}"
except Exception as e:
return f"FAIL: Cannot connect — {e}"
# ── STEP 24: Upload → Verify → sysupgrade ─────────────────────
try:
remote_path = _upload_firmware(client, firmware_path, status_cb)
_verify_firmware(client, remote_path, status_cb)
return _sync_and_sysupgrade(client, remote_path, status_cb)
except RuntimeError as e:
return f"FAIL: {e}"
except Exception as e:
return f"FAIL: {e}"
finally:
try:
client.close()
except Exception:
pass

92
core/ssh_update_flash.py Normal file
View File

@@ -0,0 +1,92 @@
"""
Luồng SSH cho chế độ "Update Firmware" (thiết bị đã cài sẵn OpenWrt).
Đặc điểm:
- Thiết bị đang chạy bình thường, SSH đã mở sẵn với pass đã biết.
- Không có bước Telnet / đặt lại mật khẩu.
- Kết nối trực tiếp → upload SCP → verify → sync + sysupgrade.
Public API:
flash_device_update_ssh(ip, firmware_path, user, password,
backup_password, status_cb)
"""
import time
import paramiko
from core.ssh_utils import (
_create_ssh_client,
_upload_firmware,
_verify_firmware,
_sync_and_sysupgrade,
)
def flash_device_update_ssh(ip, firmware_path, user="root",
password="admin123a", backup_password="",
status_cb=None):
"""
Cập nhật firmware lên thiết bị OpenWrt đang chạy qua SSH / sysupgrade.
Luồng:
1. Kết nối SSH (thử password, nếu lỗi auth thử backup_password)
2. Upload firmware qua SCP lên /tmp/
3. Verify file tồn tại
4. sync + sysupgrade
Args:
ip : địa chỉ IP thiết bị
firmware_path : đường dẫn file .bin trên máy tính
user : SSH username (mặc định "root")
password : SSH password chính
backup_password : SSH password dự phòng nếu password chính sai
status_cb : callback(str) để cập nhật trạng thái lên UI
Returns:
"DONE" — update thành công
"FAIL: …" — thất bại, kèm lý do
"""
# ── STEP 1: Kết nối SSH ─────────────────────────────────────────
if status_cb:
status_cb("Connecting SSH...")
client = None
# Thử password chính trước
try:
client = _create_ssh_client(ip, user, password)
except paramiko.AuthenticationException:
# Thử backup_password nếu có
if backup_password:
try:
client = _create_ssh_client(ip, user, backup_password)
except paramiko.AuthenticationException:
return "FAIL: SSH authentication failed (wrong password)"
except paramiko.SSHException as e:
return f"FAIL: SSH error — {e}"
except Exception as e:
return f"FAIL: Cannot connect — {e}"
else:
return "FAIL: SSH authentication failed"
except paramiko.SSHException as e:
return f"FAIL: SSH error — {e}"
except Exception as e:
return f"FAIL: Cannot connect — {e}"
# ── STEP 24: Upload → Verify → sysupgrade ─────────────────────
try:
remote_path = _upload_firmware(client, firmware_path, status_cb)
_verify_firmware(client, remote_path, status_cb)
return _sync_and_sysupgrade(client, remote_path, status_cb)
except RuntimeError as e:
return f"FAIL: {e}"
except Exception as e:
return f"FAIL: {e}"
finally:
try:
client.close()
except Exception:
pass

120
core/ssh_utils.py Normal file
View File

@@ -0,0 +1,120 @@
"""
SSH/SCP helper functions dùng chung cho cả 2 luồng flash.
Không chứa logic nghiệp vụ — chỉ là transport layer:
_create_ssh_client() — kết nối SSH với retry
_upload_firmware() — upload file qua SCP lên /tmp/
_verify_firmware() — kiểm tra file tồn tại trên device
_sync_and_sysupgrade() — sync + sysupgrade, trả về "DONE" / "FAIL: ..."
"""
import os
import time
import paramiko
from scp import SCPClient
def _create_ssh_client(ip, user, password, timeout=15):
"""Tạo SSH client với AutoAddPolicy, có retry 3 lần."""
last_err = None
for attempt in range(3):
try:
client = paramiko.SSHClient()
client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
client.connect(
ip, username=user, password=password,
timeout=timeout, look_for_keys=False, allow_agent=False,
)
return client
except paramiko.AuthenticationException:
# Sai password — retry không giúp ích gì
raise
except Exception as e:
last_err = e
if attempt < 2:
time.sleep(1)
raise last_err
def _upload_firmware(client, firmware_path, status_cb=None):
"""
Upload firmware qua SCP lên /tmp/<filename>.
Trả về remote_path khi thành công, raise RuntimeError nếu thất bại.
"""
if status_cb:
status_cb("Uploading firmware via SCP...")
filename = os.path.basename(firmware_path)
remote_path = f"/tmp/{filename}"
last_err = None
for attempt in range(3):
try:
scp = SCPClient(client.get_transport(), socket_timeout=350)
scp.put(firmware_path, remote_path)
scp.close()
time.sleep(2)
return remote_path
except Exception as e:
last_err = e
time.sleep(1.5)
raise RuntimeError(f"SCP upload failed (Check Network) — {last_err}")
def _verify_firmware(client, remote_path, status_cb=None):
"""
Xác nhận file firmware tồn tại trên thiết bị.
Raise RuntimeError nếu file không có.
"""
if status_cb:
status_cb("Verifying firmware...")
_, stdout, _ = client.exec_command(
f"test -f {remote_path} && ls -lh {remote_path}", timeout=10
)
output = stdout.read().decode("utf-8", errors="ignore").strip()
if not output:
raise RuntimeError("Firmware file not found on device after upload")
def _sync_and_sysupgrade(client, remote_path, status_cb=None):
"""
Sync filesystem rồi chạy sysupgrade.
Trả về "DONE" khi thành công, "FAIL: ..." khi sysupgrade lỗi sớm.
Connection drop trong lúc sysupgrade = thành công (thiết bị đang reboot).
"""
if status_cb:
status_cb("Syncing filesystem...")
client.exec_command("sync", timeout=10)
time.sleep(2)
if status_cb:
status_cb("Flashing firmware (sysupgrade)...")
try:
# -F: bỏ qua kiểm tra metadata (cần cho uImage)
# -v: verbose -n: không giữ cấu hình cũ
_, stdout, _ = client.exec_command(
f"sysupgrade -F -v -n {remote_path} > /tmp/sysup.log 2>&1"
)
# Chờ tối đa 4 giây — nếu exit quá sớm thì coi như lỗi
time.sleep(4)
if stdout.channel.exit_status_ready():
exit_code = stdout.channel.recv_exit_status()
_, log_out, _ = client.exec_command("cat /tmp/sysup.log")
err_msg = log_out.read().decode("utf-8", errors="ignore").strip()
return (
f"FAIL: sysupgrade terminated early "
f"(Code {exit_code}). Details:\n{err_msg}"
)
except Exception:
# Connection drop = device đang reboot = thành công
pass
if status_cb:
status_cb("Rebooting...")
time.sleep(3)
return "DONE"

View File

@@ -1,18 +1,26 @@
from PyQt6.QtCore import QThread, pyqtSignal
from concurrent.futures import ThreadPoolExecutor, as_completed
"""
Workers module — chứa các QThread dùng cho tác vụ nền.
Scan:
ScanThread — quét mạng LAN tìm thiết bị OpenWrt.
Flash (tách thành 2 module riêng):
core.flash_new_worker → NewFlashThread (Nạp Mới FW)
core.flash_update_worker → UpdateFlashThread (Update FW)
"""
from PyQt6.QtCore import QThread, pyqtSignal
# We need these imports inside workers since they use them
from core.scanner import scan_network
from core.flasher import flash_device
from core.ssh_flasher import flash_device_ssh
from utils.network import _resolve_hostname
class ScanThread(QThread):
"""Run network scan in a background thread so UI doesn't freeze."""
"""Quét mạng LAN trong background thread để không đóng băng UI."""
finished = pyqtSignal(list)
error = pyqtSignal(str)
scan_progress = pyqtSignal(int, int) # done, total (ping sweep)
stage = pyqtSignal(str) # current scan phase
stage = pyqtSignal(str) # tên giai đoạn hiện tại
def __init__(self, network):
super().__init__()
@@ -29,74 +37,7 @@ class ScanThread(QThread):
results = scan_network(self.network,
progress_cb=on_ping_progress,
stage_cb=on_stage)
# Resolve hostnames in parallel
self.stage.emit("hostname")
with ThreadPoolExecutor(max_workers=50) as executor:
future_to_dev = {
executor.submit(_resolve_hostname, d["ip"]): d
for d in results
}
for future in as_completed(future_to_dev):
dev = future_to_dev[future]
try:
dev["name"] = future.result(timeout=3)
except Exception:
dev["name"] = ""
self.finished.emit(results)
except Exception as e:
self.error.emit(str(e))
class FlashThread(QThread):
"""Run firmware flash in background so UI stays responsive."""
device_status = pyqtSignal(int, str) # index, status message
device_done = pyqtSignal(int, str) # index, result
all_done = pyqtSignal()
def __init__(self, devices, firmware_path, max_workers=10,
method="api", ssh_user="root", ssh_password="admin123a",
ssh_backup_password="", set_passwd=False):
super().__init__()
self.devices = devices
self.firmware_path = firmware_path
self.max_workers = max_workers
self.method = method
self.ssh_user = ssh_user
self.ssh_password = ssh_password
self.ssh_backup_password = ssh_backup_password
self.set_passwd = set_passwd
def run(self):
def _flash_one(i, dev):
try:
def on_status(msg):
self.device_status.emit(i, msg)
if self.method == "ssh":
result = flash_device_ssh(
dev["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(
dev["ip"], self.firmware_path,
status_cb=on_status
)
self.device_done.emit(i, result)
except Exception as e:
self.device_done.emit(i, f"FAIL: {e}")
# Use configured max_workers (0 = unlimited = one per device)
workers = self.max_workers if self.max_workers > 0 else len(self.devices)
with ThreadPoolExecutor(max_workers=workers) as executor:
futures = []
for i, dev in enumerate(self.devices):
futures.append(executor.submit(_flash_one, i, dev))
for f in futures:
f.result()
self.all_done.emit()

View File

@@ -1,155 +0,0 @@
# Tài liệu Flash Firmware — IoT Firmware Loader
## Tổng quan
Ứng dụng tự động hóa quá trình nạp firmware cho thiết bị **OpenWrt Barrier Breaker 14.07** thông qua giao diện web **LuCI**. Thay vì thao tác thủ công trên trình duyệt, ứng dụng thực hiện 3 bước HTTP tự động cho mỗi thiết bị.
---
## Luồng hoạt động
```mermaid
flowchart TD
A[Chọn Firmware .bin] --> B[Scan LAN]
B --> C[Hiển thị danh sách thiết bị]
C --> D{Chọn thiết bị ☑}
D --> E[Nhấn Flash Selected Devices]
E --> F[FlashThread chạy background]
F --> G["ThreadPoolExecutor\n(max_workers = N)"]
G --> H1[Device 1 → flash_device]
G --> H2[Device 2 → flash_device]
G --> H3[Device N → flash_device]
H1 & H2 & H3 --> I[All Done → Thông báo]
```
### Chi tiết `flash_device()` cho mỗi thiết bị
```mermaid
flowchart TD
S1["STEP 1: Login\nGET /cgi-bin/luci\nPOST username=root, password=empty"] --> C1{Thành công?}
C1 -->|Có cookie sysauth + stok| S2
C1 -->|403 Denied| F1["FAIL: Login denied (403)"]
C1 -->|Không có session| F2["FAIL: Login failed — no session"]
S2["STEP 2: Upload Firmware\nPOST /flashops\nField: image=firmware.bin\nkeep=KHÔNG gửi (bỏ tích)"] --> C2{Response?}
C2 -->|Trang Verify + Proceed| S3
C2 -->|HTTP ≠ 200| F3["FAIL: Upload HTTP xxx"]
C2 -->|invalid image| F4["FAIL: Invalid firmware image"]
C2 -->|unsupported| F5["FAIL: Firmware not compatible"]
C2 -->|Vẫn hiện form upload| F6["FAIL: Upload ignored by server"]
S3["STEP 3: Proceed\nPOST step=2, keep=empty\nXác nhận flash"] --> C3{Response?}
C3 -->|200 Flashing...| R["DONE ✅\nThiết bị đang reboot"]
C3 -->|Connection dropped| R
C3 -->|Read timeout| R
```
---
## Bảng Status
### Status trên cột "Status" trong bảng thiết bị
| Icon | Status | Điều kiện hiển thị |
| ---- | ---------------------------------------- | ------------------------------------------------------------- |
| — | `READY` | Sau khi scan, thiết bị chưa được flash |
| ⏳ | `Logging in...` | Đang POST login vào LuCI |
| ⏳ | `Uploading firmware...` | Đang upload file .bin (~30MB) lên thiết bị |
| ⏳ | `Confirming (Proceed)...` | Đã upload xong, đang gửi lệnh xác nhận flash |
| ⏳ | `Rebooting...` | Thiết bị đang reboot sau khi flash |
| ✅ | `DONE` | Flash thành công, thiết bị đang khởi động lại |
| ✅ | `DONE (rebooting)` | Flash thành công nhưng timeout khi chờ response (bình thường) |
| ❌ | `FAIL: Cannot connect` | Không kết nối được tới thiết bị (sai IP, khác mạng) |
| ❌ | `FAIL: Login denied (403)` | Thiết bị từ chối đăng nhập (sai mật khẩu) |
| ❌ | `FAIL: Login failed — no session` | Login không trả về cookie hoặc token |
| ❌ | `FAIL: Upload HTTP xxx` | Server trả mã lỗi HTTP khi upload |
| ❌ | `FAIL: Invalid firmware image` | File firmware không hợp lệ |
| ❌ | `FAIL: Firmware not compatible` | Firmware không tương thích với thiết bị |
| ❌ | `FAIL: Upload ignored by server` | Server nhận file nhưng không xử lý (sai form field) |
| ❌ | `FAIL: Unexpected response after upload` | Không nhận được trang xác nhận Verify |
---
## Chi tiết kỹ thuật HTTP
### Step 1: Login
```
GET http://{IP}/cgi-bin/luci → Lấy trang login, phát hiện field name
POST http://{IP}/cgi-bin/luci → Gửi username=root&password=
```
| Kết quả | Điều kiện |
| -------------- | ------------------------------------------------------------------ |
| **Thành công** | Response chứa `sysauth` cookie VÀ/HOẶC `stok` token trong URL/body |
| **Thất bại** | HTTP 403, hoặc không có cookie/token |
**Field names tự động phát hiện:**
- OpenWrt Barrier Breaker 14.07: `username` / `password`
- OpenWrt mới hơn: `luci_username` / `luci_password`
### Step 2: Upload Firmware
```
POST http://{IP}/cgi-bin/luci/;stok={TOKEN}/admin/system/flashops
Content-Type: multipart/form-data
Fields:
image = [firmware.bin] (file upload)
keep = (KHÔNG gửi) (bỏ tích Keep Settings)
```
| Kết quả | Điều kiện |
| -------------- | ----------------------------------------------------------------------- |
| **Thành công** | Response chứa "Flash Firmware - Verify" + "Proceed" + checksum |
| **Thất bại** | Response chứa "invalid image", "unsupported", hoặc vẫn hiện form upload |
### Step 3: Proceed (Xác nhận)
```
POST http://{IP}/cgi-bin/luci/;stok={TOKEN}/admin/system/flashops
Fields:
step = 2
keep = (empty string)
```
| Kết quả | Điều kiện |
| -------------- | ------------------------------------------------------------------------------- |
| **Thành công** | Response "The system is flashing now" HOẶC connection bị ngắt (thiết bị reboot) |
| **Thất bại** | Hiếm khi xảy ra — nếu đã qua Step 2 thì Step 3 gần như luôn thành công |
---
## Xử lý song song
```
FlashThread (QThread - background)
└── ThreadPoolExecutor (max_workers = N)
├── Thread 1 → flash_device(IP_1)
├── Thread 2 → flash_device(IP_2)
├── ...
└── Thread N → flash_device(IP_N)
```
| Config | Giá trị | Ý nghĩa |
| ------------------------- | --------- | ------------------------------ |
| Concurrent devices = `10` | Mặc định | Flash 10 thiết bị song song |
| Concurrent devices = `0` | Unlimited | Flash tất cả thiết bị cùng lúc |
| Concurrent devices = `1` | Tuần tự | Flash từng thiết bị một |
**Mỗi thiết bị có session HTTP riêng** → không bị lẫn cookie/token giữa các thiết bị.
---
## Files liên quan
| File | Chức năng |
| --------------- | --------------------------------------------- |
| `flasher.py` | Logic flash 3 bước (login → upload → proceed) |
| `main.py` | UI PyQt6, FlashThread, quản lý song song |
| `debug_full.py` | Script debug — chạy 3 bước với log chi tiết |
| `scanner.py` | Scan mạng LAN tìm thiết bị |
""", "Complexity": 3, "Description": "Created flash documentation file with workflow diagrams, status conditions, HTTP details, and parallel processing explanation.", "EmptyFile": false, "IsArtifact": false, "Overwrite": false, "TargetFile": "/Users/nguyennhatminh/Documents/file code/Smatec/iot_fw_loader/FLASH_DOC.md

123
docs/api_flash_docs.md Normal file
View File

@@ -0,0 +1,123 @@
# Tài liệu Kỹ thuật: Flash Firmware qua LuCI API (`core/api_flash.py`)
Module `api_flash.py` tự động hoá quá trình nạp firmware cho thiết bị **OpenWrt** thông qua giao diện web **LuCI HTTP**. Được dùng trong chế độ **Nạp Mới FW** với method `"api"`.
---
## 1. Kiến Trúc — Vai Trò File
| File | Vai trò |
| -------------------------- | ----------------------------------------------------------------------- |
| `core/api_flash.py` | Logic flash 3 bước (login → upload → proceed) qua LuCI |
| `core/flash_new_worker.py` | `NewFlashThread` — dispatch tới `flash_device_api()` khi `method="api"` |
| `main.py` | UI PyQt6, chọn mode/method, truyền tham số vào worker |
---
## 2. Sơ Đồ Luồng
```mermaid
flowchart TD
A[NewFlashThread - method=api] --> B[flash_device_api]
B --> S1["STEP 1: Login\nGET /cgi-bin/luci — phát hiện field name\nPOST username=root & password="]
S1 --> C1{Thành công?}
C1 -->|sysauth cookie + stok| S2
C1 -->|HTTP 403| F1["FAIL: Login denied (403)"]
C1 -->|Không có session| F2["FAIL: Login failed — no session"]
S2["STEP 2: Upload Firmware\nPOST /flashops\nmultipart: image=firmware.bin"] --> C2{Response?}
C2 -->|Trang Verify + Proceed| S3
C2 -->|HTTP ≠ 200| F3["FAIL: Upload HTTP xxx"]
C2 -->|invalid image| F4["FAIL: Invalid firmware image"]
C2 -->|unsupported| F5["FAIL: Firmware not compatible"]
C2 -->|Vẫn hiện form upload| F6["FAIL: Upload ignored by server"]
S3["STEP 3: Proceed\nPOST step=2 & keep=empty"] --> C3{Response?}
C3 -->|Connection dropped / Timeout| R["DONE ✅ — Device đang reboot"]
C3 -->|200 OK| R
```
---
## 3. Chi Tiết Kỹ Thuật HTTP
### Step 1 — Login
```
GET http://{IP}/cgi-bin/luci → Lấy HTML login, phát hiện field name
POST http://{IP}/cgi-bin/luci → Đăng nhập
```
**Tự động phát hiện field name tương thích:**
| Phiên bản | Field |
| --------------------- | --------------------------------- |
| Barrier Breaker 14.07 | `username` / `password` |
| OpenWrt mới hơn | `luci_username` / `luci_password` |
**Lấy session:** `stok` token được tìm tuần tự trong URL → body HTML → redirect history. Cookie `sysauth` là fallback nếu không có `stok`.
### Step 2 — Upload Firmware
```
POST http://{IP}/cgi-bin/luci/;stok={TOKEN}/admin/system/flashops
Content-Type: multipart/form-data
image = firmware.bin (file upload)
keep = (không gửi) → bỏ tích "Keep Settings" = Clean Flash
```
Thành công khi response trả về trang **"Flash Firmware - Verify"** chứa từ khoá `verify` + `proceed`.
### Step 3 — Confirm (Proceed)
```
POST http://{IP}/cgi-bin/luci/;stok={TOKEN}/admin/system/flashops
step = 2
keep = (empty)
```
**Đứt kết nối = Thành công:** Device bắt đầu flash → SSH/HTTP request bị drop là hành vi mong muốn. `ConnectionError``ReadTimeout` đều được bắt và trả về `"DONE"`.
---
## 4. Bảng Status UI
| Icon | Status | Điều kiện |
| ---- | ---------------------------------------- | ------------------------------------------ |
| ⏳ | `Logging in...` | Đang POST login vào LuCI |
| ⏳ | `Uploading firmware...` | Đang upload file .bin |
| ⏳ | `Confirming (Proceed)...` | Đang gửi lệnh xác nhận flash |
| ⏳ | `Rebooting...` | Chờ device khởi động lại |
| ✅ | `DONE` | Flash thành công |
| ✅ | `DONE (rebooting)` | Flash thành công, timeout khi chờ response |
| ❌ | `FAIL: Cannot connect` | Không kết nối được (sai IP / khác mạng) |
| ❌ | `FAIL: Login denied (403)` | Sai mật khẩu LuCI |
| ❌ | `FAIL: Login failed — no session` | Không có cookie/token sau login |
| ❌ | `FAIL: Upload HTTP xxx` | Server trả lỗi HTTP khi upload |
| ❌ | `FAIL: Invalid firmware image` | File firmware không hợp lệ |
| ❌ | `FAIL: Firmware not compatible` | Firmware không tương thích thiết bị |
| ❌ | `FAIL: Upload ignored by server` | Server không xử lý file (sai form field) |
| ❌ | `FAIL: Unexpected response after upload` | Không nhận được trang Verify |
---
## 5. Xử Lý Song Song
```
NewFlashThread (QThread)
└── ThreadPoolExecutor (max_workers = N)
├── Thread 1 → flash_device_api(IP_1)
├── Thread 2 → flash_device_api(IP_2)
└── Thread N → flash_device_api(IP_N)
```
| Concurrent devices | Ý nghĩa |
| ------------------ | --------------------------------- |
| `10` (mặc định) | Flash 10 thiết bị song song |
| `0` | Unlimited — flash tất cả cùng lúc |
| `1` | Tuần tự — từng thiết bị một |
Mỗi thiết bị có `requests.Session()` riêng — không bị lẫn cookie/token.

271
docs/auto_flash_docs.md Normal file
View 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 (1500)
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ý.

View File

@@ -1,60 +1,166 @@
# Tài liệu Kỹ thuật: Nạp Firmware qua SSH (`core/ssh_flasher.py`)
# Tài liệu Kỹ thuật: Flash Firmware qua SSH
Module `ssh_flasher.py` chịu trách nhiệm nạp Firmware lên các thiết bị OpenWrt nạp mới hoặc nạp lại thông qua hai giao thức Telnet và SSH. Nó được thiết kế với độ an toàn cao để xử lý đa luồng (ví dụ: quét và nạp hàng loạt thiết bị cùng lúc), có cơ chế tự động thử lại (Retry) và cơ chế dự phòng mt khẩu khi thiết bị bị kẹt mật khẩu cũ.
Tài liệu này gộp toàn bộ logic SSH cho cả hai quy trình: **Nạp Mới FW** (Factory Reset)**Update FW** (thiết bị đang chạy). Luồng SSH được tách thành nhiều file với trách nhiệm rõ ràng thay vì gói gọn trong mt module duy nhất.
## 1. Sơ đồ Luồng Hoạt Động (Operational Flow)
---
## 1. Kiến Trúc Tổng Quan — Vai Trò Từng File
```
core/
├── ssh_utils.py ← Transport layer dùng chung (không có business logic)
├── ssh_new_flash.py ← Luồng SSH Nạp Mới FW (Telnet → SSH → sysupgrade)
├── ssh_update_flash.py ← Luồng SSH Update FW (SSH trực tiếp → sysupgrade)
├── flash_new_worker.py ← QThread điều phối Nạp Mới (API LuCI hoặc SSH)
└── flash_update_worker.py← QThread điều phối Update FW (SSH only)
```
### `core/ssh_utils.py` — Transport Layer Dùng Chung
Chứa các helper function cấp thấp, **không mang logic nghiệp vụ**, được import bởi cả 2 luồng SSH:
| Hàm | Mô tả |
| ------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------- |
| `_create_ssh_client(ip, user, password, timeout)` | Tạo SSH client với `AutoAddPolicy`, retry 3 lần. Nếu lỗi `AuthenticationException` thì raise ngay (không retry vô nghĩa). |
| `_upload_firmware(client, firmware_path, status_cb)` | Upload file `.bin` qua SCP lên `/tmp/<filename>`. Retry 3 lần, timeout SCP là 350 giây. Trả về `remote_path`. |
| `_verify_firmware(client, remote_path, status_cb)` | Chạy `test -f && ls -lh` để xác nhận file tồn tại trên device sau khi upload. |
| `_sync_and_sysupgrade(client, remote_path, status_cb)` | Chạy `sync` rồi `sysupgrade -F -v -n`. Đứt kết nối = thành công. Trả về `"DONE"` hoặc `"FAIL: ..."`. |
### `core/ssh_new_flash.py` — Luồng Nạp Mới FW
Xử lý thiết bị **vừa Factory Reset** hoặc **chưa có mật khẩu**. Bao gồm:
- **`_SimpleTelnet`**: Telnet client thủ công bằng raw socket — thay thế `telnetlib` bị xoá khỏi Python 3.13+.
- **`set_device_password()`**: Đặt mật khẩu thiết bị, thử Telnet port 23 trước, fallback sang SSH nếu Telnet đóng.
- **`flash_device_new_ssh()`**: Hàm flash chính — gọi `set_device_password` (nếu `set_passwd=True`) rồi kết nối SSH và gọi pipeline `_upload → _verify → _sync_and_sysupgrade` từ `ssh_utils`.
### `core/ssh_update_flash.py` — Luồng Update FW
Xử lý thiết bị **đang chạy MiraV3**, SSH đã mở sẵn với pass đã biết. Không có Telnet, không có set_passwd:
- **`flash_device_update_ssh()`**: Kết nối SSH (thử `password`, fallback `backup_password` nếu auth fail), rồi gọi thẳng pipeline `_upload → _verify → _sync_and_sysupgrade` từ `ssh_utils`.
### `core/flash_new_worker.py` — QThread Nạp Mới
`NewFlashThread` — điều phối flash song song cho chế độ **Nạp Mới**:
- Nếu `method="ssh"` → gọi `flash_device_new_ssh()` từ `ssh_new_flash`.
- Nếu `method="api"` → gọi `flash_device()` từ `flasher` (LuCI HTTP).
- Nhận đủ credentials từ UI: `ssh_user`, `ssh_password`, `ssh_backup_password`, `set_passwd`.
### `core/flash_update_worker.py` — QThread Update FW
`UpdateFlashThread` — điều phối flash song song cho chế độ **Update FW**:
- Luôn dùng SSH, credentials hardcode an toàn trong module (`root` / `admin123a`, backup `admin`).
- Không nhận credentials từ UI — tránh nhập sai gây flash nhầm.
---
## 2. Sơ Đồ Luồng — Nạp Mới FW (SSH)
```mermaid
graph TD
A[Bắt đầu Flash SSH] -->|Jitter Delay 0.1s - 1.5s| B{Có yêu cầu Set Password?}
B -- Không --> F[Bước 1: Kết nối SSH]
B -- --> C[Thử kết nối Telnet port 23]
A[NewFlashThread.run] --> B{method = ssh?}
B -- api --> API[flash_device - LuCI HTTP]
B -- ssh --> C[flash_device_new_ssh]
C -- Thành công --> C1[Gửi lệnh `passwd` > admin123a] --> F
C -- Lỗi / Timeout / Bị từ chối --> D[Thử SSH với mật khẩu rỗng]
C -->|set_passwd=True| D[set_device_password]
C -->|set_passwd=False| Jitter[Jitter 0.1s - 1.5s]
D -- Thành công --> D1[Gửi lệnh `passwd` > admin123a] --> F
D -- Lỗi 'Authentication Failed' --> E[Thử SSH với Backup Password]
D --> D1[Thử Telnet port 23]
D1 -- Thành công --> D2[passwd > new_password via Telnet]
D2 --> D3[Chờ 3s để Dropbear khởi động]
D1 -- Lỗi / Timeout --> D4[Fallback SSH với old_password rỗng]
D4 -- Auth Fail --> D5[Thử backup_password]
D5 -- Auth Fail --> D6[Thử chính new_password - idempotent]
D6 -- Fail --> Z[DỪNG - Báo FAIL]
D3 --> F
D4 -- OK --> F
D5 -- OK --> F
D6 -- OK --> F
E -- Thành công --> E1[Gửi lệnh `passwd` > admin123a] --> F
E -- Thất bại --> E2[Thử SSH với Password cấu hình]
E2 -- Thành công --> F
E2 -- Thất bại --> Z[DỪNG LẠI - Báo Lỗi]
F -->|Kết nối SSH thành công sau Retry 3 lần| G[Bước 2: Push Firmware SCP vào /tmp/]
G -->|Tối đa 3 lần Retry| H[Bước 3: Xác minh File `test -f`]
H --> I[Bước 4: Đồng bộ bộ nhớ `sync`]
I --> J[Bước 5: Gọi `sysupgrade -F -v -n /tmp/...`]
J --> K{Kết nối bị đứt?}
K -- Có --> L[Khởi động lại thành công - Báo DONE]
K -- Không --> M[Báo lỗi Sysupgrade]
Jitter --> F[_create_ssh_client - retry 3 lần]
F --> G[_upload_firmware via SCP vào /tmp/]
G -->|retry 3 lần| H[_verify_firmware - test -f]
H --> I[_sync_and_sysupgrade]
I --> J[sync rồi sysupgrade -F -v -n]
J --> K{Kết nối đứt?}
K -- Có --> L[DONE - Device đang Reboot]
K -- Không trong 4s --> M[FAIL - Lấy log /tmp/sysup.log]
```
## 2. Phân Tích Logic Cốt Lõi
## 3. Sơ Đồ Luồng — Update FW (SSH)
### 2.1 Cơ Chế Dự Phòng Mật Khẩu Đa Tầng (Fallback Mechanism)
```mermaid
graph TD
A[UpdateFlashThread.run] --> B[flash_device_update_ssh]
B --> C[_create_ssh_client với password chính]
C -- Auth OK --> F
C -- AuthenticationException --> D[Thử backup_password]
D -- Auth OK --> F
D -- Fail --> Z[DỪNG - Báo FAIL]
Điểm mạnh của thuật toán là khả năng "lì lợm" lấy được quyền Root để đặt mật khẩu cho thiết bị. Các lớp dự phòng bao gồm:
F[_upload_firmware via SCP vào /tmp/] -->|retry 3 lần| G[_verify_firmware - test -f]
G --> H[_sync_and_sysupgrade]
H --> I[sync rồi sysupgrade -F -v -n]
I --> J{Kết nối đứt?}
J -- Có --> K[DONE - Device đang Reboot]
J -- Không trong 4s --> L[FAIL - Lấy log /tmp/sysup.log]
```
1. **Telnet (Port 23):** Router OpenWrt khi mới xuất xưởng hoặc ngay sau khi Factory Reset sẽ chặn kết nối SSH bằng tài khoản `root` mất gốc, nhưng lại mở Telnet không có mật khẩu. Script sẽ luôn thử bẻ khóa qua đường này đầu tiên. Bất kỳ ngoại lệ nào phát sinh ở luồng này (Refused, Timeout, Broken Pipe) cũng sẽ kích hoạt fallback.
2. **SSH (Mật khẩu rỗng):** Nếu Telnet bị đóng cửa (chứng tỏ thiết bị đã được kích hoạt sơ), phần mềm thử mở cổng 22 với User `root` và mật khẩu rỗng `""`.
3. **SSH (Backup Password):** Nếu mật khẩu rỗng bị từ chối bằng `AuthenticationException`, script định nhận diện máy đã bị kẹt một pass được cài đặt trước đó và chèn `backup_password` ra thử nghiệm.
4. **SSH (Target Password):** Nếu trượt tới lớp thứ 3, script thử nốt với chính mật khẩu mục tiêu của dự án (mặc định: `admin123a`) để chặn trường hợp thiết bị đã đổi Pass thành công từ lần trước nhưng quá trình nạp Firmware chưa diễn ra.
---
### 2.2 Xử Lý Đa Luồng Chống Nghẽn (Concurrency Limits)
## 4. Phân Tích Logic Cốt Lõi
Thư viện `paramiko` thường dễ đổ vỡ cấu trúc và báo lỗi `[Errno 64] Host is down` hoặc `[Errno 32] Broken Pipe` khi có vài chục luồng (threads) cùng mở port đập vào Network Stack OS (hoặc Switch/Router) ở đúng một chớp mắt. Lỗi này là dạng Transient (có tính tạm thời). Do đó hệ thống được nâng cấp:
### 4.1 Cơ Chế Dự Phòng Mật Khẩu Đa Tầng (Nạp Mới)
- **Jitter (Độ trễ ngẫu nhiên):** Sử dụng `time.sleep(random.uniform(0.1, 1.5))` trước khi chọc vào thiết bị. Sự sai lệch mili-giây này phân tán chùm yêu cầu dội vào mạng, tránh tự gây ra một cuộc tấn công từ chối dịch vụ (DDoS) nội bộ.
- **Vòng lặp (Retry Hooks):** Quá trình đăng nhập (`_create_ssh_client`) cũng như upload payload (`SCPClient.put`) đều được bọc trong bộ đếm thử lại cực đỉnh (thử lại 3 lần sau mỗi 1-2 giây nghỉ). Việc này triệt hạ 99% các lỗi truyền TCP/IP và Timeout.
Thuật toán `set_device_password` có 4 lớp dự phòng để chiếm được quyền Root trên thiết bị dù ở trạng thái nào:
### 2.3 Cơ Chế Khôi Phục File (RAM Disk SCP)
1. **Telnet (Port 23):** OpenWrt ngay sau Factory Reset mở Telnet không cần mật khẩu nhưng chặn SSH. Script luôn thử cổng này đầu tiên. Mọi exception (Refused, Timeout, Broken Pipe) đều kích hoạt fallback.
2. **SSH mật khẩu rỗng `""`:** Nếu Telnet đóng, thử SSH với pass rỗng — trường hợp thiết bị đang ở trạng thái semi-configured.
3. **SSH Backup Password:** Nếu pass rỗng bị `AuthenticationException`, thiết bị đang kẹt một pass cũ — thử `backup_password`.
4. **SSH Target Password (Idempotent):** Thử lại với chính `new_password` để chặn trường hợp device đã đổi pass thành công từ lần flash trước nhưng chưa được flash firmware.
- Firmware được tống thẳng tới phân vùng ảo RAM cục bộ đích (ví dụ: `/tmp/<tên_file>.bin`) thông qua giao thức SCP. Ghi file trực tiếp vào RAM cho phép đạt tốc độ tuyệt đối nhanh và tránh gây hao mòn sinh học lên FlashNOR của router.
- Gửi lệnh `sync` qua kênh Shell để ép thiết bị lưu transaction xuống vùng nhớ cứng, chuẩn bị môi trường ổn định nhất.
### 4.2 Cơ Chế Dự Phòng Mật Khẩu (Update FW)
### 2.4 Thủ Thuật Gọi `sysupgrade -F`
Đơn giản hơn: thử `password` chính → nếu `AuthenticationException` → thử `backup_password`. Không có Telnet, không có set_passwd. Nếu cả hai đều fail thì trả về `FAIL` ngay.
- OpenWrt đời mới khá kén phần Metadata của Firmware Image. Cờ `-F` cởi trói kiểm duyệt đó và ép hệ điều hành nạp đè File bất chấp sự vắng mặt của metadata uImage. Cờ `-n` khiến hệ thống mất sách cấu hình cũ (Clean Flash).
- Lệnh `sysupgrade` sẽ cắn xén cả phần OS dưới lõi máy sau đó Tắt mạng đột ngột. Do đó, script được lập trình để xử lý Exception: Sự kiện **MẤT KẾT NỐI CHỦ ĐỘNG** trong khu vực này là dấu hiệu ăn mừng của việc cài đặt thành công thay vì báo hỏng kết nối.
### 4.3 Xử Lý Đa Luồng Chống Nghẽn
`paramiko` dễ báo lỗi `[Errno 64] Host is down` hoặc `[Errno 32] Broken Pipe` khi hàng chục thread cùng hit network stack cùng một lúc. Hệ thống chống nghẽn bằng 2 cơ chế:
- **Jitter ngẫu nhiên:** `time.sleep(random.uniform(0.1, 1.5))` phân tán chùm request theo thời gian, tránh tự gây DDoS nội bộ.
- **Retry Hooks:** `_create_ssh_client` retry 3 lần (nghỉ 1s giữa mỗi lần). `_upload_firmware` retry 3 lần (nghỉ 1.5s). Triệt 99% lỗi TCP transient.
### 4.4 RAM Disk SCP
Firmware được upload thẳng vào `/tmp/<tên_file>.bin` — phân vùng RAM ảo của OpenWrt. Lợi ích:
- Tốc độ ghi nhanh nhất (RAM vs Flash).
- Không gây hao mòn FlashNOR của router.
- File biến mất sau reboot, không để lại rác.
### 4.5 Thủ Thuật `sysupgrade -F -v -n`
| Cờ | Tác dụng |
| -------------- | ------------------------------------------------------------------------------------------------------------------------ |
| `-F` (Force) | Bỏ qua kiểm tra Image Metadata — bắt buộc với file build Raw / `tim_uImage` để tránh lỗi _"Image metadata not present"_. |
| `-v` (Verbose) | Log chi tiết vào `/tmp/sysup.log` để debug khi cần. |
| `-n` (No-keep) | Clean Flash — không giữ cấu hình cũ, tránh xung đột config giữa các phiên bản. |
**Đứt kết nối = Thành công:** `sysupgrade` phá kernel hiện tại rồi reboot nạp firmware mới → SSH session bị đứt là kết quả tất yếu và mong muốn. Script bẫy exception này và trả về `"DONE"`. Ngược lại, nếu sysupgrade fail sớm (< 4 giây, còn giữ kết nối), script đọc `/tmp/sysup.log` trả về error code đầy đủ.
---
## 5. Trình Gỡ Lỗi Nhanh
Cả hai luồng đều gọi `status_cb(msg)` tại mỗi bước toàn bộ tiến độ như _"Connecting SSH"_, _"Uploading firmware"_, _"Syncing filesystem"_ hiển thị trực tiếp tại cột **Status** trên giao diện chính.
Để debug chi tiết hơn, chạy app qua terminal:
```bash
./run.sh
```
Mọi exception đều bị bắt trả về chuỗi `"FAIL: <lý do>"` hiển thị lên UI không silent failure.

View File

@@ -1,59 +1,120 @@
# Tài liệu Kỹ thuật: Cơ chế Quét IP trên Mạng (Network Scanner)
## 1. Tổng quan
Thành phần quét IP (IP Scanner) trong file `scanner.py` được thiết kế để dò tìm và liệt kê tất cả các thiết bị đang hoạt động trên một dải mạng (ví dụ: `192.168.1.0/24`). Nó theo dõi và trả về danh sách các đối tượng chứa địa chỉ **IP****MAC Address** của từng thiết bị.
Để đảm bảo tỷ lệ phát hiện cao nhất trên các hệ điều hành khác nhau (Windows, macOS, Linux), script sử dụng kết hợp hai phương pháp dò tìm:
1. **Quét mồi bằng Ping (Ping Sweep) kết hợp đọc bảng ARP tĩnh của hệ điều hành.**
2. **Quét ARP trực tiếp ở Tầng 2 (Layer 2) bằng thư viện `scapy`.**
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****MAC Address** của từng thiết bị.
Scanner hoạt động theo cơ chế 2 bước (Ping trước → ARP sau):
1. **ICMP Ping Sweep:** Chỉ những IP có phản hồi ping mới được ghi nhận là online.
2. **ARP Lookup:** Ngay sau khi IP phản hồi ping, OS tự động cập nhật ARP cache cho IP đó. Scanner lập tức query ARP table để lấy MAC chính xác nhất.
> **Giải quyết vấn đề ARP cache cũ (stale entries):** Khác với phương pháp ARP scan truyền thống (`arp -a` liệt kê toàn mạng) thường dính thiết bị đã ngắt kết nối do bị cache, cách làm này **chỉ query ARP cho những IP vừa pass qua bài test ping**. Thiết bị offline sẽ rớt ở bước ping và không bao giờ được đưa vào danh sách.
---
## 2. Luồng hoạt động chính (Hàm `scan_network`)
Đây là hàm được gọi trực tiếp khi muốn quét một mạng. Trình tự rà quét diễn ra qua các bước sau:
```
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]
├── stage_cb("mac") ← Thông báo UI bắt đầu lấy MAC
├── _get_mac_from_arp(ip) × K ← Tra MAC song song cho K IP alive
└── return [{"ip": ..., "mac": "AA:BB:CC:..."}, ...] ← Sorted by IP
```
**Bước 1: "Đánh thức" các thiết bị (Ping Sweep)**
- Hệ thống gọi hàm `_ping_sweep(network)` để gửi gói Ping (ICMP Echo Request) đồng loạt tới tất cả các IP có thể có trong mạng.
- Mục đích của bước này là buộc các thiết bị phản hồi, từ đó hệ điều hành của máy đang chạy lệnh sẽ **tự động ghi nhận địa chỉ MAC** của các thiết bị đó vào bộ nhớ đệm ARP (ARP Cache).
- Hệ thống tạm dừng 1 giây để đảm bảo hệ điều hành kịp lưu thông tin vào ARP Cache.
**Bước 1Ping Sweep**
**Bước 2: Lấy dữ liệu từ bảng ARP của Hệ điều hành (Method 1)**
- Thực thi lệnh hệ thống `arp -a` để đọc ARP Cache.
- Kết quả được phân tích cú pháp (Regex) để trích xuất IP và MAC tương ứng, tương thích linh hoạt với cả đầu ra của Windows lẫn macOS/Linux.
- Các thiết bị đọc được lưu vào danh sách nháp (biến `seen`).
- Gọi `_ping_sweep(network)`: gửi ICMP Echo Request đồng thời tới **toàn bộ host** trong dải mạng.
- Chỉ các IP có `returncode == 0` (phản hồi thành công) mới được đưa vào danh sách `alive_ips`.
**Bước 3: Quét sâu với Scapy (Method 2 - Dự phòng/Bổ sung)**
- Script gọi thêm thư viện `scapy` để phát một thông điệp "ARP Who-has" tới địa chỉ MAC Broadcast (`ff:ff:ff:ff:ff:ff`).
- Phương pháp này giúp phát hiện ra các thiết bị chặn Ping (chặn ICMP) ở Bước 1 nhưng buộc phải phản hồi gói ARP ở tầng Data Link.
- Những thiết bị mới tìm thấy (nếu chưa có trong danh sách `seen` ở Bước 2) sẽ được bổ sung vào danh sách.
**Bước 2 — Địa chỉ MAC (ARP Lookup)**
**Bước 4: Trả về kết quả**
- Danh sách các thiết bị cuối cùng được sắp xếp từ nhỏ đến lớn dựa trên địa chỉ IP và trả về dưới dạng:
`[{"ip": "192.168.1.2", "mac": "aa:bb:cc:dd:ee:ff"}, ...]`
- Từ danh sách `alive_ips`, tạo ThreadPoolExecutor (max_workers=32) để gọi `_get_mac_from_arp(ip)` đồng thời.
- Trích xuất MAC address bằng pattern matching chéo nền tảng.
**Bước 3 — Trả về kết quả**
- Danh sách sort tăng dần theo IP:
`[{"ip": "192.168.1.2", "mac": "AA:BB:CC:DD:EE:FF"}, ...]`
---
## 3. Phân tích chi tiết các hàm hỗ trợ
## 3. Phân tích chi tiết các hàm
### `_ping_sweep(network)` \& `_ping_one(ip, is_win)`
- **Nhiệm vụ:** Quét Ping hàng loạt (Ping Sweep).
- **Cách thức hoạt động:** Sử dụng `ThreadPoolExecutor` để chạy **tối đa 100 luồng (threads) song song**. Điều này giúp việc gửi Ping hàng loạt diễn ra cực kì nhanh chóng tính bằng giây thay vì phải đợi ping từng IP một.
- **Biện pháp an toàn:** Script có cơ chế tự bảo vệ chặn flood mạng: nó sẽ **từ chối chạy Ping Sweep** nếu dải mạng (subnet) lớn hơn 256 địa chỉ IP (tức là chỉ chạy cho dải mạng từ `/24` trở xuống).
### `_ping_one(ip, is_win)`
### `_scan_with_arp_table(network)`
- **Nhiệm vụ:** Hàm chạy độc lập để quét tìm thiết bị mà **không cần thông qua đặc quyền Root/Administrator** (fallback method).
- **Hỗ trợ Đa nền tảng:**
- **Trên Windows (`win32`):** Chuẩn hóa định dạng chuẩn MAC từ gạch ngang sang dấu hai chấm (vd: từ `cc-2d-21...` sang `cc:2d:21...`). Nó dựa vào Regex nhận diện các dòng chuẩn xuất ra có chữ `dynamic` hoặc `static`.
- **Trên macOS / Linux:** Dùng Regex đọc định dạng chuẩn riêng biệt ví dụ: `? (192.168.1.1) at aa:bb:cc:dd:ee:ff on en0`. Loại bỏ những địa chỉ lỗi bị đánh dấu là `(incomplete)`.
Ping một IP đơn lẻ, trả về `True` nếu host phản hồi, `False` nếu không.
### `_scan_with_scapy(network)`
- **Nhiệm vụ:** Công cụ quét cực mạnh ở Tầng 2 (Layer 2) của mô hình mạng OSI.
- **Đặc thù:** Đòi hỏi người dùng phải cấp quyền Sudo/Root trên Linux/macOS hoặc phải cài đặt thư viện phần mềm **Npcap/WinPcap** trên Windows thì mới có thể sử dụng. Gửi gói `srp` bằng `scapy` với timeout là 3 giây để lấy về thông tin phần cứng đích thực.
Timeout tối ưu theo nền tảng:
| 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)`
- 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`).
- 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 độ.
### `_get_mac_from_arp(ip)`
- Gọi lệnh hệ điều hành để đọc ARP cache cho IP cụ thể:
- **Windows**: `arp -a <ip>`
- **macOS / Linux**: `arp -n <ip>`
- Sử dụng Regex để parse MAC address và trả về dưới format chuẩn `AA:BB:CC:DD:EE:FF`.
- Nếu không tìm thấy, fallback thành `"N/A"`.
### `scan_network(network, progress_cb, stage_cb)`
- Entry point chính.
- `stage_cb("ping")``stage_cb("mac")` thông báo UI giai đoạn hiện tại.
- Trả về `list[dict]` với format `{"ip": str, "mac": str}`, sorted theo IP tăng dần.
---
## 4. Tóm tắt Ưu & Nhược điểm của thiết kế này
## 4. Hiệu năng
- **Ưu điểm:** Khả năng dò tìm diện rộng rất cao vì sự kết hợp song song của hai phương pháp. Nếu thiết bị không có quyền Root/Sudo để chạy `scapy`, nó vẫn có thể tìm được ít nhất ~90% thiết bị trong mạng nhờ tính năng Ping Sweeping mạnh mẽ. Tính tương thích chéo OS (Windows/Mac/Linux) được xử lý rất tốt và gọn gàng qua Regex.
- **Nhược điểm:** Tốn thêm 1 giây (hàm `time.sleep(1)`) và thêm vài giây timeout tổng cộng, do cần chờ để làm đầy bộ nhớ đệm ARP trước khi quét sâu. Nếu thiết bị đích cố tình tắt phản hồi ARP, thì vẫn có khả năng bị lọt dán mạng.
| Thông số | Giá trị |
| --------------------------- | ------------------------------------ |
| Ping workers | `len(hosts)` (~254, tất cả cùng lúc) |
| Ping timeout — Windows | 300ms |
| Ping timeout — macOS | 300ms |
| Ping timeout — Linux | 1s |
| Process timeout | 2s |
| **Tổng thời gian scan /24** | **~12s** |
---
## 5. Ưu & Nhược điểm
**Ưu điểm:**
- **Có đầy đủ IP và MAC**, rất hữu ích cho log và tracking lịch sử thiết bị nạp FW.
- **Không bị dính ARP cache cũ**: Do chỉ lấy MAC của các IP vừa ping thành công.
- Không cần quyền Admin/Root.
- Không phụ thuộc thư viện bên ngoài (không cần Npcap / Scapy phức tạp).
- Tương thích đa nền tảng (Windows/macOS/Linux).
- Cực nhanh nhờ cơ chế full-parallel.
**Nhược điểm:**
- Thiết bị cố tình chặn gói tin ICMP (tắt ping) sẽ không bị phát hiện.
- Ping 254 thiết bị cùng lúc bằng tiến trình con (`subprocess`) trên Windows có overhead hệ thống cao hơn Unix.

View File

@@ -1,59 +0,0 @@
# Tài liệu Kỹ thuật: Cập Nhật Firmware (Update FW)
Quy trình `Update FW` là phương thức tối ưu hóa để nâng cấp Firmware mới vào các thiết bị OpenWrt Live (đã và đang chạy sẵn Firmware của hệ sinh thái MiraV3). Cơ chế này dùng chính giao thức SSH thông qua `core/ssh_flasher.py` nhưng lược bỏ hoàn toàn các bước dò tìm và thiết lập mật khẩu thừa thãi.
## 1. Sơ đồ Luồng Hoạt Động (Operational Flow)
```mermaid
graph TD
A[Bắt đầu Update FW] --> B{Kiểm tra danh sách IP}
B -- Có IP lạ != 192.168.11.102 --> C[Bật MessageBox cảnh báo]
C -- Người dùng No --> Z[HỦY BỎ]
C -- Người dùng Yes --> D
B -- Chỉ có 192.168.11.102 --> D
D[Truyền tham số cấu hình tĩnh] --> E[ssh_user: root<br>ssh_password: admin123a<br>set_passwd: False]
E -->|Jitter Delay 0.1s - 1.5s| F[Kết nối thẳng SSH Port 22]
F -- Thành công --> G[Push Firmware SCP vào /tmp/]
F -- Lỗi / Sai Pass --> Y[DỪNG LẠI - Báo Lỗi]
G -->|Tối đa 3 lần Retry| H[Xác minh File `test -f`]
H --> I[Đồng bộ bộ nhớ `sync`]
I --> J[Gọi Sysupgrade: `sysupgrade -F -v -n /tmp/...`]
J --> K{Kết nối bị đứt?}
K -- Có --> L[Khởi động lại thành công - Báo DONE]
K -- Không --> M[Báo lỗi Sysupgrade]
```
## 2. Phân Tích Logic Cốt Lõi
### 2.1 Cơ Chế Bỏ Qua Telnet (Skip Set Password)
Điểm khác biệt lớn nhất của luồng Update FW so với Nạp Mới:
- Thông qua UI `Chế độ Flash > Update Firmware`, hệ thống hiểu rằng thiết bị đích đã từng được cài đặt hệ sinh thái MiraV3 và chắc chắn đã sở hữu lớp bảo mật SSH cơ bản (root/admin123a).
- Module `main.py` tự động kích hoạt cờ tuỳ chọn ngầm `set_passwd = False` khi gọi `FlashThread`.
- **Bỏ Bầu Trời Khởi Động:** Hệ thống ngay lập tức rẽ ngang vào luồng SSH trên Port 22. Tool bỏ qua hoàn toàn quy trình truy vấn cổng Telnet (23) chậm chạp cũng như các lệnh Set Password (vốn mất thêm từ 3-5 giây chờ đợi). Tốc độ tổng thể tăng tốc cực đáng kể, phục vụ trực tiếp cho quá trình Mass-Update.
### 2.2 Ràng Buộc An Toàn (Safety Checks)
Được kiểm soát từ tầng UI (`main.py`) để chống Flash nhầm hàng hoạt:
- Khi người dùng chọn chế độ Update, script quét qua danh sách MAC/IP đang tích chọn. Chỉ duy nhất thiết bị có IP cấu hình cứng `192.168.11.102` mới được âm thầm qua cửa (được xem là IP mặc định an toàn cho việc test Firmware).
- Nếu phát hiện **Bất kỳ IP lạ nào** (ví dụ: 192.168.1.5, 192.168.11.9), hệ thống lập tức chặn quy trình Flasher lại và bật MessageBox cảnh báo màu đỏ hỏi kỹ thuật viên xác nhận lại.
### 2.3 Quá Trình Sysupgrade (Hủy diệt và Tái sinh)
Cũng giống như luồng nạp mới, quá trình xả file dùng chung tệp lệnh: `sysupgrade -F -v -n /tmp/<tên_file>`
- **Cờ `-n`**: Clean Flash sạch trơn, không giữ Configuration rác của bản cũ.
- **Cờ `-F` (Force)**: Ép hệ điều hành OpenWrt bỏ qua bước kiểm tra Image Metadata tại Local. Tuỳ chọn này sống còn đối với các tệp tin Firmware trần trụi (như build Raw `tim_uImage`) để tránh việc sysupgrade từ chối file, văng lỗi "Image metadata not present" và ngắt ngang tiến trình.
- Kịch bản hoàn hảo nhất của quá trình này lại chính là **bị đứt kết nối mạng đột ngột**. Script đã bẫy một khoảng thời gian chờ (time.sleep) để canh việc Server SSH sập nguồn. Đứt mạng nghĩa là Kernel chuẩn bị Reboot nạp File, và hệ thống sẽ bắt Catch đánh giá là Thành Công (`DONE`). Ngược lại, nếu Sysupgrade thất bại, nó sẽ phun ra Log và giữ nguyên kết nối, lúc đó Script sẽ chụp lại Error Code và in ra UI.
---
## 3. Trình Gỡ Lỗi Nhanh (Debugging Update flow)
Lưu ý: Bạn đã xóa các file script debug đơn lẻ khỏi thư mục gốc (`debug_ssh.py`). Hiện tại luồng Update FW nên được gỡ lỗi trực tiếp bằng cách in Print/Log ra terminal của lệnh `./run.sh` lúc chạy App UI. Mọi tiến độ như "Đang kết nối SSH", "Đồng bộ file" được hiển thị minh bạch tại cột **Status** trên giao diện chính.

1198
main.py

File diff suppressed because it is too large Load Diff

View File

@@ -43,6 +43,10 @@ QGroupBox::indicator:checked {
border-color: #3b82f6;
}
QLabel {
background-color: transparent;
}
QLabel#title {
font-size: 16px;
font-weight: bold;
@@ -251,6 +255,7 @@ QScrollBar::sub-page:horizontal {
}
QCheckBox {
background-color: transparent;
spacing: 6px;
color: #94a3b8;
font-size: 12px;
@@ -273,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;
}
"""

View File

@@ -34,3 +34,11 @@ def get_machine_info():
"os": os_info,
"mac": mac_addr,
}
def get_version():
"""Read version from version.txt"""
try:
with open(resource_path('version.txt'), 'r', encoding='utf-8') as f:
return f.read().strip()
except Exception:
return "Unknown"

1
version.txt Normal file
View File

@@ -0,0 +1 @@
1.2.3