1465 lines
60 KiB
Python
1465 lines
60 KiB
Python
import sys
|
|
import os
|
|
import socket
|
|
import ipaddress
|
|
import platform
|
|
|
|
from concurrent.futures import ThreadPoolExecutor, as_completed
|
|
|
|
from PyQt6.QtWidgets import (
|
|
QApplication, QWidget, QPushButton,
|
|
QVBoxLayout, QHBoxLayout, QFileDialog, QTableWidget,
|
|
QTableWidgetItem, QLabel, QProgressBar,
|
|
QMessageBox, QGroupBox, QHeaderView, QLineEdit,
|
|
QFrame, QCheckBox, QSpinBox, QScrollArea, QSizePolicy,
|
|
QComboBox
|
|
)
|
|
from PyQt6.QtCore import (
|
|
Qt, QThread, pyqtSignal, QPropertyAnimation, QAbstractAnimation,
|
|
QParallelAnimationGroup, QRect, QTimer
|
|
)
|
|
from PyQt6.QtGui import QFont, QColor, QIcon, QAction
|
|
|
|
import datetime
|
|
|
|
from core.scanner import scan_network
|
|
from core.workers import ScanThread
|
|
from core.flash_new_worker import NewFlashThread
|
|
from core.flash_update_worker import UpdateFlashThread
|
|
from core.auto_flash_worker import AutoFlashWorker
|
|
|
|
from utils.network import _resolve_hostname, get_default_network
|
|
from utils.system import resource_path, get_machine_info, get_version
|
|
|
|
|
|
|
|
from ui.components import CollapsibleGroupBox
|
|
from ui.styles import STYLE
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class App(QWidget):
|
|
|
|
def __init__(self):
|
|
super().__init__()
|
|
self.setWindowTitle(f"Mira Firmware Loader v{get_version()}")
|
|
self.setWindowIcon(QIcon(resource_path("icon.ico")))
|
|
# Data state
|
|
self.local_ip = ""
|
|
self.gateway_ip = ""
|
|
self.firmware = None
|
|
self.all_devices = [] # raw list from scanner
|
|
self.devices = [] # filtered list for table
|
|
self.flashed_macs = {} # MAC -> (ip, mac, result, timestamp)
|
|
self.scan_thread = None
|
|
|
|
info = get_machine_info()
|
|
self.local_ip = info["ip"]
|
|
self.gateway_ip = self._guess_gateway(self.local_ip)
|
|
|
|
# ── Main layout with stacked containers ──
|
|
root_layout = QVBoxLayout()
|
|
root_layout.setSpacing(0)
|
|
root_layout.setContentsMargins(0, 0, 0, 0)
|
|
|
|
# ── MAIN CONTAINER (default view) ──
|
|
self.main_container = QWidget()
|
|
layout = QVBoxLayout(self.main_container)
|
|
layout.setSpacing(3)
|
|
layout.setContentsMargins(8, 6, 8, 6)
|
|
|
|
# ── Title (single line) ──
|
|
title_row = QHBoxLayout()
|
|
title_row.setSpacing(0)
|
|
title = QLabel("⚡ Mira Firmware Loader")
|
|
title.setObjectName("title")
|
|
title_row.addWidget(title)
|
|
title_row.addStretch()
|
|
copyright_label = QLabel(f"© {datetime.datetime.now().year} Smatec — R&D Software Team. v{get_version()}")
|
|
copyright_label.setStyleSheet(
|
|
"color: #9399b2; font-size: 13px; font-weight: 500;"
|
|
)
|
|
title_row.addWidget(copyright_label)
|
|
layout.addLayout(title_row)
|
|
|
|
# ── Machine Info (single compact row) ──
|
|
info_group = CollapsibleGroupBox("🖥 Machine Info")
|
|
info_row = QHBoxLayout()
|
|
info_row.setSpacing(6)
|
|
info_row.setContentsMargins(0, 0, 0, 0)
|
|
for label, value in [("Host:", info["hostname"]), ("IP:", info["ip"]),
|
|
("OS:", info["os"]), ("MAC:", info["mac"])]:
|
|
lbl = QLabel(label)
|
|
lbl.setObjectName("info")
|
|
info_row.addWidget(lbl)
|
|
val = QLabel(value)
|
|
val.setStyleSheet("color: #cdd6f4; font-weight: bold; font-size: 11px;")
|
|
info_row.addWidget(val)
|
|
if label != "MAC:":
|
|
sep = QLabel("│")
|
|
sep.setStyleSheet("color: #3d4a6b;")
|
|
info_row.addWidget(sep)
|
|
info_row.addStretch()
|
|
info_group.set_content_layout(info_row)
|
|
layout.addWidget(info_group)
|
|
|
|
# ── Firmware + Network Scan (combined compact row) ──
|
|
fw_scan_group = CollapsibleGroupBox("📦 FW & 📡 Scan")
|
|
fw_scan_layout = QVBoxLayout()
|
|
fw_scan_layout.setSpacing(6)
|
|
fw_scan_layout.setContentsMargins(4, 2, 4, 4)
|
|
|
|
# Row 1: FW selection (full width, styled card)
|
|
fw_card = QFrame()
|
|
fw_card.setStyleSheet("""
|
|
QFrame {
|
|
background-color: #13141f;
|
|
border: 1px solid #2d3748;
|
|
border-radius: 6px;
|
|
padding: 4px 8px;
|
|
}
|
|
""")
|
|
fw_card_layout = QHBoxLayout(fw_card)
|
|
fw_card_layout.setContentsMargins(8, 4, 8, 4)
|
|
fw_card_layout.setSpacing(8)
|
|
|
|
fw_icon = QLabel("📦")
|
|
fw_icon.setStyleSheet("font-size: 16px; border: none;")
|
|
fw_card_layout.addWidget(fw_icon)
|
|
fw_lbl = QLabel("Firmware:")
|
|
fw_lbl.setStyleSheet("font-weight: bold; font-size: 12px; color: #7eb8f7; border: none;")
|
|
fw_card_layout.addWidget(fw_lbl)
|
|
self.fw_label = QLabel("No firmware selected")
|
|
self.fw_label.setStyleSheet("font-size: 12px; color: #94a3b8; border: none;")
|
|
fw_card_layout.addWidget(self.fw_label, 1)
|
|
btn_fw = QPushButton("📁 Browse FW")
|
|
btn_fw.setFixedHeight(28)
|
|
btn_fw.setStyleSheet("""
|
|
QPushButton {
|
|
background-color: #2d3352; border: 1px solid #3d4a6b;
|
|
border-radius: 5px; padding: 3px 12px;
|
|
font-size: 12px; font-weight: bold; color: #e2e8f0;
|
|
}
|
|
QPushButton:hover {
|
|
background-color: #3d4a6b; border-color: #7eb8f7; color: #ffffff;
|
|
}
|
|
""")
|
|
btn_fw.clicked.connect(self.select_fw)
|
|
fw_card_layout.addWidget(btn_fw)
|
|
fw_scan_layout.addWidget(fw_card)
|
|
|
|
# Row 2: Network + Scan (full width, styled card)
|
|
scan_card = QFrame()
|
|
scan_card.setStyleSheet("""
|
|
QFrame {
|
|
background-color: #13141f;
|
|
border: 1px solid #2d3748;
|
|
border-radius: 6px;
|
|
padding: 4px 8px;
|
|
}
|
|
""")
|
|
scan_card_layout = QHBoxLayout(scan_card)
|
|
scan_card_layout.setContentsMargins(8, 4, 8, 4)
|
|
scan_card_layout.setSpacing(8)
|
|
|
|
net_icon = QLabel("📡")
|
|
net_icon.setStyleSheet("font-size: 16px; border: none;")
|
|
scan_card_layout.addWidget(net_icon)
|
|
net_lbl = QLabel("Network:")
|
|
net_lbl.setStyleSheet("font-weight: bold; font-size: 12px; color: #7eb8f7; border: none;")
|
|
scan_card_layout.addWidget(net_lbl)
|
|
self.net_input = QLineEdit(get_default_network(self.local_ip))
|
|
self.net_input.setPlaceholderText("e.g. 192.168.4.0/24")
|
|
self.net_input.setFixedHeight(28)
|
|
self.net_input.setMinimumWidth(140)
|
|
self.net_input.setMaximumWidth(200)
|
|
self.net_input.setStyleSheet("QLineEdit { font-size: 12px; padding: 2px 8px; border: 1px solid #3d4a6b; border-radius: 5px; background-color: #1a1b2e; }")
|
|
scan_card_layout.addWidget(self.net_input)
|
|
scan_card_layout.addStretch()
|
|
|
|
self.btn_scan = QPushButton("🔍 Scan LAN")
|
|
self.btn_scan.setObjectName("scan")
|
|
self.btn_scan.clicked.connect(self.scan)
|
|
self.btn_scan.setFixedHeight(28)
|
|
self.btn_scan.setFixedWidth(110)
|
|
scan_card_layout.addWidget(self.btn_scan)
|
|
fw_scan_layout.addWidget(scan_card)
|
|
|
|
# Scan progress + status (hidden by default)
|
|
self.scan_progress_bar = QProgressBar()
|
|
self.scan_progress_bar.setObjectName("scan_bar")
|
|
self.scan_progress_bar.setRange(0, 0)
|
|
self.scan_progress_bar.setFormat("")
|
|
self.scan_progress_bar.setFixedHeight(14)
|
|
self.scan_progress_bar.setVisible(False)
|
|
fw_scan_layout.addWidget(self.scan_progress_bar)
|
|
|
|
self.scan_status = QLabel("")
|
|
self.scan_status.setObjectName("info")
|
|
self.scan_status.setStyleSheet("font-size: 10px;")
|
|
fw_scan_layout.addWidget(self.scan_status)
|
|
|
|
fw_scan_group.set_content_layout(fw_scan_layout)
|
|
layout.addWidget(fw_scan_group)
|
|
|
|
# ── Device Table (MAIN area — maximized) ──
|
|
dev_group = QGroupBox("📋 Devices Found")
|
|
dev_layout = QVBoxLayout()
|
|
dev_layout.setSpacing(2)
|
|
dev_layout.setContentsMargins(4, 12, 4, 4)
|
|
|
|
self.table = QTableWidget()
|
|
self.table.setColumnCount(4)
|
|
self.table.setHorizontalHeaderLabels(["", "IP", "MAC", "Status"])
|
|
self.table.setAlternatingRowColors(True)
|
|
header = self.table.horizontalHeader()
|
|
header.setSectionResizeMode(0, QHeaderView.ResizeMode.Fixed)
|
|
header.resizeSection(0, 32)
|
|
header.setSectionResizeMode(1, QHeaderView.ResizeMode.Interactive)
|
|
header.resizeSection(1, 115)
|
|
header.setSectionResizeMode(2, QHeaderView.ResizeMode.Interactive)
|
|
header.resizeSection(2, 135)
|
|
header.setSectionResizeMode(3, QHeaderView.ResizeMode.Stretch)
|
|
self.table.verticalHeader().setVisible(False)
|
|
self.table.setSelectionBehavior(
|
|
QTableWidget.SelectionBehavior.SelectRows
|
|
)
|
|
dev_layout.addWidget(self.table)
|
|
|
|
filter_row = QHBoxLayout()
|
|
filter_row.setSpacing(4)
|
|
self.device_count_label = QLabel("Total: 0 devices")
|
|
self.device_count_label.setObjectName("info")
|
|
filter_row.addWidget(self.device_count_label)
|
|
filter_row.addStretch()
|
|
|
|
btn_history = QPushButton("📋 Flash History")
|
|
btn_history.setFixedHeight(24)
|
|
btn_history.clicked.connect(self._show_history)
|
|
btn_history.setStyleSheet("background-color: #313244; color: #cdd6f4; font-size: 11px; padding: 2px 8px;")
|
|
filter_row.addWidget(btn_history)
|
|
|
|
btn_select_all = QPushButton("☑ All")
|
|
btn_select_all.setFixedHeight(24)
|
|
btn_select_all.setStyleSheet("font-size: 11px; padding: 2px 8px;")
|
|
btn_select_all.clicked.connect(self._select_all_devices)
|
|
filter_row.addWidget(btn_select_all)
|
|
|
|
btn_deselect_all = QPushButton("☐ None")
|
|
btn_deselect_all.setFixedHeight(24)
|
|
btn_deselect_all.setStyleSheet("font-size: 11px; padding: 2px 8px;")
|
|
btn_deselect_all.clicked.connect(self._deselect_all_devices)
|
|
filter_row.addWidget(btn_deselect_all)
|
|
|
|
self.show_all_cb = QCheckBox("Show all")
|
|
self.show_all_cb.setChecked(False)
|
|
self.show_all_cb.setToolTip("Include gateway & self")
|
|
self.show_all_cb.stateChanged.connect(self._refresh_table)
|
|
filter_row.addWidget(self.show_all_cb)
|
|
dev_layout.addLayout(filter_row)
|
|
|
|
dev_group.setLayout(dev_layout)
|
|
layout.addWidget(dev_group, stretch=1)
|
|
|
|
# ── Flash Controls (collapsible, compact) ──
|
|
flash_group = CollapsibleGroupBox("🚀 Flash Controls")
|
|
flash_layout = QVBoxLayout()
|
|
flash_layout.setSpacing(6)
|
|
flash_layout.setContentsMargins(8, 4, 8, 6)
|
|
|
|
self.progress = QProgressBar()
|
|
self.progress.setFormat("%v / %m devices (%p%)")
|
|
self.progress.setFixedHeight(18)
|
|
flash_layout.addWidget(self.progress)
|
|
|
|
# Row 1: Mode + Method
|
|
mode_method_row = QHBoxLayout()
|
|
mode_method_row.setSpacing(10)
|
|
|
|
mode_lbl = QLabel("Mode:")
|
|
mode_lbl.setStyleSheet("font-size: 12px; font-weight: bold;")
|
|
mode_method_row.addWidget(mode_lbl)
|
|
|
|
self.mode_combo = QComboBox()
|
|
self.mode_combo.addItem("⚡ New Flash (Factory Reset)", "new")
|
|
self.mode_combo.addItem("🔄 Update FW (Pre-installed)", "update")
|
|
self.mode_combo.setMinimumWidth(230)
|
|
self.mode_combo.setFixedHeight(28)
|
|
self.mode_combo.setStyleSheet("""
|
|
QComboBox {
|
|
background-color: #2d3352; border: 1px solid #3d4a6b; border-radius: 4px;
|
|
padding: 2px 8px; color: #ffffff; font-size: 12px; font-weight: bold;
|
|
}
|
|
""")
|
|
self.mode_combo.currentIndexChanged.connect(self._on_mode_changed)
|
|
mode_method_row.addWidget(self.mode_combo)
|
|
|
|
sep_m = QLabel("│")
|
|
sep_m.setStyleSheet("color: #3d4a6b;")
|
|
mode_method_row.addWidget(sep_m)
|
|
|
|
# Container cho method (ẩn khi Update mode)
|
|
self.method_container = QWidget()
|
|
mc_layout = QHBoxLayout(self.method_container)
|
|
mc_layout.setContentsMargins(0, 0, 0, 0)
|
|
mc_layout.setSpacing(6)
|
|
|
|
meth_lbl = QLabel("Method:")
|
|
meth_lbl.setStyleSheet("font-size: 12px; font-weight: bold;")
|
|
mc_layout.addWidget(meth_lbl)
|
|
|
|
self.method_combo = QComboBox()
|
|
self.method_combo.addItem("💻 SSH", "ssh")
|
|
self.method_combo.addItem("🌐 API (LuCI)", "api")
|
|
self.method_combo.setMinimumWidth(140)
|
|
self.method_combo.setFixedHeight(28)
|
|
self.method_combo.setStyleSheet("""
|
|
QComboBox {
|
|
background-color: #1e1e2e; border: 1px solid #3d4a6b; border-radius: 4px;
|
|
padding: 2px 8px; color: #ffffff; font-size: 12px; font-weight: bold;
|
|
}
|
|
""")
|
|
self.method_combo.currentIndexChanged.connect(self._on_method_changed)
|
|
mc_layout.addWidget(self.method_combo)
|
|
|
|
mode_method_row.addWidget(self.method_container)
|
|
|
|
# Warning label for Update Mode (inline)
|
|
self.update_warning_lbl = QLabel("⚠️ Update: SSH only, target 192.168.11.102")
|
|
self.update_warning_lbl.setStyleSheet("color: #f38ba8; font-size: 11px; font-weight: bold;")
|
|
self.update_warning_lbl.setVisible(False)
|
|
mode_method_row.addWidget(self.update_warning_lbl)
|
|
|
|
mode_method_row.addStretch()
|
|
flash_layout.addLayout(mode_method_row)
|
|
|
|
# SSH credentials (hidden by default)
|
|
self.ssh_creds_widget = QWidget()
|
|
self.ssh_creds_widget.setStyleSheet("background-color: #11121d; border-radius: 6px; border: 1px dashed #3d4a6b;")
|
|
ssh_creds_layout = QHBoxLayout(self.ssh_creds_widget)
|
|
ssh_creds_layout.setContentsMargins(10, 6, 10, 6)
|
|
ssh_creds_layout.setSpacing(8)
|
|
|
|
ssh_lbl1 = QLabel("User:")
|
|
ssh_lbl1.setStyleSheet("font-size: 11px; font-weight: bold;")
|
|
ssh_creds_layout.addWidget(ssh_lbl1)
|
|
str_qlineedit = "QLineEdit { background-color: #1a1b2e; font-size: 12px; padding: 3px; border: 1px solid #3d4a6b; color: #ffffff; }"
|
|
self.ssh_user_input = QLineEdit("root")
|
|
self.ssh_user_input.setFixedWidth(80)
|
|
self.ssh_user_input.setStyleSheet(str_qlineedit)
|
|
ssh_creds_layout.addWidget(self.ssh_user_input)
|
|
|
|
ssh_lbl2 = QLabel("Pass:")
|
|
ssh_lbl2.setStyleSheet("font-size: 11px; font-weight: bold;")
|
|
ssh_creds_layout.addWidget(ssh_lbl2)
|
|
self.ssh_pass_input = QLineEdit("admin123a")
|
|
self.ssh_pass_input.setEchoMode(QLineEdit.EchoMode.Password)
|
|
self.ssh_pass_input.setFixedWidth(100)
|
|
self.ssh_pass_input.setStyleSheet(str_qlineedit)
|
|
ssh_creds_layout.addWidget(self.ssh_pass_input)
|
|
|
|
self.set_passwd_cb = QCheckBox("Set passwd")
|
|
self.set_passwd_cb.setChecked(True)
|
|
self.set_passwd_cb.setStyleSheet("color: #94a3b8; font-size: 11px;")
|
|
self.set_passwd_cb.setToolTip("Set password before flash (passwd → admin123a)")
|
|
ssh_creds_layout.addWidget(self.set_passwd_cb)
|
|
ssh_creds_layout.addStretch()
|
|
|
|
self.ssh_creds_widget.setVisible(False)
|
|
flash_layout.addWidget(self.ssh_creds_widget)
|
|
|
|
# Row 2: Concurrent + Flash button
|
|
action_row = QHBoxLayout()
|
|
action_row.setSpacing(10)
|
|
|
|
par_lbl = QLabel("Concurrent:")
|
|
par_lbl.setStyleSheet("font-size: 13px; font-weight: bold; color: #e2e8f0;")
|
|
action_row.addWidget(par_lbl)
|
|
|
|
self.parallel_spin = QSpinBox()
|
|
self.parallel_spin.setRange(0, 100)
|
|
self.parallel_spin.setValue(10)
|
|
self.parallel_spin.setSpecialValueText("∞")
|
|
self.parallel_spin.setToolTip("0 = unlimited (all at once)")
|
|
self.parallel_spin.setMinimumWidth(80)
|
|
self.parallel_spin.setFixedHeight(34)
|
|
self.parallel_spin.setStyleSheet(
|
|
"QSpinBox { font-size: 14px; font-weight: bold; color: #e2e8f0; "
|
|
"background-color: #2d3352; border: 1px solid #3d4a6b; border-radius: 6px; padding: 2px 6px; } "
|
|
"QSpinBox::up-button { width: 18px; } QSpinBox::down-button { width: 18px; }"
|
|
)
|
|
action_row.addWidget(self.parallel_spin)
|
|
|
|
self.btn_flash = QPushButton("⚡ FLASH SELECTED DEVICES")
|
|
self.btn_flash.setObjectName("flash")
|
|
self.btn_flash.setFixedHeight(34)
|
|
self.btn_flash.setStyleSheet("QPushButton#flash { font-size: 13px; font-weight: bold; letter-spacing: 1px; }")
|
|
self.btn_flash.clicked.connect(self.flash_all)
|
|
action_row.addWidget(self.btn_flash, 1)
|
|
flash_layout.addLayout(action_row)
|
|
|
|
flash_group.set_content_layout(flash_layout)
|
|
layout.addWidget(flash_group)
|
|
|
|
# ── Automation Button ──
|
|
self.btn_auto = QPushButton("🤖 Auto Firmware Flash")
|
|
self.btn_auto.setObjectName("auto")
|
|
self.btn_auto.setFixedHeight(32)
|
|
self.btn_auto.setStyleSheet("""
|
|
QPushButton#auto {
|
|
background: qlineargradient(x1:0, y1:0, x2:1, y2:0,
|
|
stop:0 #7c3aed, stop:1 #a78bfa);
|
|
border-color: #7c3aed; color: #ffffff;
|
|
font-size: 12px; font-weight: bold; letter-spacing: 1px;
|
|
border-radius: 6px;
|
|
}
|
|
QPushButton#auto:hover {
|
|
background: qlineargradient(x1:0, y1:0, x2:1, y2:0,
|
|
stop:0 #8b5cf6, stop:1 #c4b5fd);
|
|
}
|
|
""")
|
|
self.btn_auto.clicked.connect(self._open_auto_flash)
|
|
layout.addWidget(self.btn_auto)
|
|
|
|
root_layout.addWidget(self.main_container)
|
|
|
|
# ── AUTO CONTAINER (hidden by default) ──
|
|
self.auto_container = QWidget()
|
|
self.auto_container.setVisible(False)
|
|
self._build_auto_ui()
|
|
root_layout.addWidget(self.auto_container)
|
|
|
|
self.setLayout(root_layout)
|
|
|
|
# ── Helpers ──
|
|
|
|
def _guess_gateway(self, ip):
|
|
"""Guess gateway IP (x.x.x.1) from local IP."""
|
|
try:
|
|
parts = ip.split(".")
|
|
return f"{parts[0]}.{parts[1]}.{parts[2]}.1"
|
|
except Exception:
|
|
return ""
|
|
|
|
def _info_label(self, text):
|
|
lbl = QLabel(text)
|
|
lbl.setObjectName("info")
|
|
return lbl
|
|
|
|
def _info_value(self, text):
|
|
lbl = QLabel(text)
|
|
lbl.setStyleSheet("color: #cdd6f4; font-weight: bold;")
|
|
return lbl
|
|
|
|
# IPs permanently hidden from the device list (never shown in UI)
|
|
_HIDDEN_IPS = {"192.168.11.102"}
|
|
|
|
def _get_filtered_devices(self):
|
|
"""Return devices filtered based on show_all checkbox."""
|
|
if self.show_all_cb.isChecked():
|
|
return [d for d in self.all_devices if d["ip"] not in self._HIDDEN_IPS]
|
|
excluded = {self.local_ip, self.gateway_ip} | self._HIDDEN_IPS
|
|
return [d for d in self.all_devices if d["ip"] not in excluded]
|
|
|
|
def _refresh_table(self):
|
|
"""Re-populate table based on current filter state."""
|
|
self.devices = self._get_filtered_devices()
|
|
self.table.setRowCount(len(self.devices))
|
|
|
|
for i, d in enumerate(self.devices):
|
|
mac_str = d["mac"].upper()
|
|
|
|
# Checkbox column
|
|
cb_item = QTableWidgetItem()
|
|
|
|
# If device MAC is already flashed, mark it
|
|
is_already_flashed = mac_str in self.flashed_macs
|
|
if is_already_flashed and d["status"] in ["READY", ""]:
|
|
d["status"] = "Already Flashed"
|
|
|
|
# Checkbox logic
|
|
if is_already_flashed:
|
|
cb_item.setCheckState(Qt.CheckState.Unchecked)
|
|
else:
|
|
cb_item.setCheckState(Qt.CheckState.Checked)
|
|
|
|
cb_item.setFlags(cb_item.flags() | Qt.ItemFlag.ItemIsUserCheckable)
|
|
self.table.setItem(i, 0, cb_item)
|
|
|
|
ip_item = QTableWidgetItem(d["ip"])
|
|
mac_item = QTableWidgetItem(mac_str)
|
|
status_item = QTableWidgetItem(d["status"])
|
|
|
|
if is_already_flashed:
|
|
status_item.setForeground(QColor("#a6e3a1"))
|
|
ip_item.setForeground(QColor("#a6e3a1"))
|
|
|
|
# Dim gateway & self if showing all
|
|
if d["ip"] in {self.local_ip, self.gateway_ip}:
|
|
for item in [ip_item, mac_item, status_item]:
|
|
item.setForeground(QColor("#4a5568"))
|
|
cb_item.setCheckState(Qt.CheckState.Unchecked)
|
|
|
|
self.table.setItem(i, 1, ip_item)
|
|
self.table.setItem(i, 2, mac_item)
|
|
self.table.setItem(i, 3, status_item)
|
|
|
|
total = len(self.devices)
|
|
hidden = len(self.all_devices) - total
|
|
label = f"Total: {total} devices"
|
|
if hidden > 0:
|
|
label += f" (hiding {hidden})"
|
|
self.device_count_label.setText(label)
|
|
|
|
def _select_all_devices(self):
|
|
"""Check all device checkboxes."""
|
|
for i in range(self.table.rowCount()):
|
|
mac = self.table.item(i, 2).text().strip()
|
|
# Prevent checking if already flashed or is a gateway (optional but good UI logic)
|
|
if mac not in self.flashed_macs:
|
|
item = self.table.item(i, 0)
|
|
if item:
|
|
item.setCheckState(Qt.CheckState.Checked)
|
|
|
|
def _deselect_all_devices(self):
|
|
"""Uncheck all device checkboxes."""
|
|
for i in range(self.table.rowCount()):
|
|
item = self.table.item(i, 0)
|
|
if item:
|
|
item.setCheckState(Qt.CheckState.Unchecked)
|
|
|
|
def _show_history(self):
|
|
"""Show history of flashed devices in this session using a table dialog."""
|
|
if not self.flashed_macs:
|
|
QMessageBox.information(self, "Flash History", "No devices have been flashed in this session.")
|
|
return
|
|
|
|
from PyQt6.QtWidgets import QDialog
|
|
dialog = QDialog(self)
|
|
dialog.setWindowTitle("Flash History")
|
|
dialog.resize(600, 400)
|
|
dialog.setStyleSheet("QDialog { background-color: #1a1b2e; } QTableWidget { font-size: 12px; }")
|
|
|
|
layout = QVBoxLayout(dialog)
|
|
layout.setContentsMargins(10, 10, 10, 10)
|
|
|
|
table = QTableWidget()
|
|
table.setColumnCount(4)
|
|
table.setHorizontalHeaderLabels(["Time", "IP", "MAC", "Result"])
|
|
table.setAlternatingRowColors(True)
|
|
table.setEditTriggers(QTableWidget.EditTrigger.NoEditTriggers)
|
|
table.setSelectionBehavior(QTableWidget.SelectionBehavior.SelectRows)
|
|
|
|
header = table.horizontalHeader()
|
|
header.setSectionResizeMode(0, QHeaderView.ResizeMode.Fixed)
|
|
header.resizeSection(0, 80)
|
|
header.setSectionResizeMode(1, QHeaderView.ResizeMode.Fixed)
|
|
header.resizeSection(1, 120)
|
|
header.setSectionResizeMode(2, QHeaderView.ResizeMode.Fixed)
|
|
header.resizeSection(2, 140)
|
|
header.setSectionResizeMode(3, QHeaderView.ResizeMode.Stretch)
|
|
table.verticalHeader().setVisible(False)
|
|
|
|
table.setRowCount(len(self.flashed_macs))
|
|
|
|
success_count = 0
|
|
fail_count = 0
|
|
|
|
for row, mac in enumerate(sorted(self.flashed_macs.keys())):
|
|
ip, _, result, ts = self.flashed_macs[mac]
|
|
ok = result.startswith("DONE")
|
|
if ok:
|
|
success_count += 1
|
|
else:
|
|
fail_count += 1
|
|
|
|
time_item = QTableWidgetItem(ts)
|
|
time_item.setTextAlignment(Qt.AlignmentFlag.AlignCenter)
|
|
table.setItem(row, 0, time_item)
|
|
table.setItem(row, 1, QTableWidgetItem(ip))
|
|
table.setItem(row, 2, QTableWidgetItem(mac))
|
|
|
|
res_item = QTableWidgetItem(f"✅ {result}" if ok else f"❌ {result}")
|
|
res_item.setForeground(QColor("#a6e3a1") if ok else QColor("#f38ba8"))
|
|
table.setItem(row, 3, res_item)
|
|
|
|
layout.addWidget(table)
|
|
|
|
summary_lbl = QLabel(f"Total: {len(self.flashed_macs)} | ✅ {success_count} | ❌ {fail_count}")
|
|
summary_lbl.setStyleSheet("color: #cdd6f4; font-size: 13px; font-weight: bold;")
|
|
layout.addWidget(summary_lbl)
|
|
|
|
btn_close = QPushButton("Close")
|
|
btn_close.setFixedHeight(30)
|
|
btn_close.setFixedWidth(100)
|
|
btn_close.clicked.connect(dialog.accept)
|
|
|
|
btn_layout = QHBoxLayout()
|
|
btn_layout.addStretch()
|
|
btn_layout.addWidget(btn_close)
|
|
layout.addLayout(btn_layout)
|
|
|
|
dialog.exec()
|
|
|
|
# ── Actions ──
|
|
|
|
def select_fw(self):
|
|
file, _ = QFileDialog.getOpenFileName(
|
|
self, "Select Firmware", "",
|
|
"Firmware Files (*.bin *.hex *.uf2);;All Files (*)"
|
|
)
|
|
if file:
|
|
self.firmware = file
|
|
name = file.split("/")[-1]
|
|
self.fw_label.setText(f"✅ {name}")
|
|
self.fw_label.setStyleSheet("color: #a6e3a1; font-weight: bold; font-size: 12px; border: none;")
|
|
|
|
def scan(self):
|
|
network_str = self.net_input.text().strip()
|
|
|
|
try:
|
|
network = ipaddress.ip_network(network_str, strict=False)
|
|
except ValueError:
|
|
QMessageBox.warning(
|
|
self, "Invalid Network",
|
|
f"'{network_str}' is not a valid network.\n"
|
|
"Example: 192.168.4.0/24"
|
|
)
|
|
return
|
|
|
|
self.scan_status.setText("⏳ Preparing scan...")
|
|
self.scan_status.setStyleSheet("color: #f9e2af;")
|
|
self.table.setRowCount(0)
|
|
self.devices = []
|
|
|
|
self.scan_progress_bar.setRange(0, 0)
|
|
self.scan_progress_bar.setFormat(" Starting...")
|
|
self.scan_progress_bar.setVisible(True)
|
|
self.btn_scan.setEnabled(False)
|
|
|
|
self.scan_thread = ScanThread(network)
|
|
self.scan_thread.finished.connect(self._on_scan_done)
|
|
self.scan_thread.error.connect(self._on_scan_error)
|
|
self.scan_thread.scan_progress.connect(self._on_scan_progress)
|
|
self.scan_thread.stage.connect(self._on_scan_stage)
|
|
self.scan_thread.start()
|
|
|
|
def _on_scan_done(self, results):
|
|
self.scan_progress_bar.setVisible(False)
|
|
self.btn_scan.setEnabled(True)
|
|
self.all_devices = []
|
|
|
|
for dev in results:
|
|
dev["status"] = "READY"
|
|
self.all_devices.append(dev)
|
|
|
|
self.all_devices.sort(key=lambda d: ipaddress.ip_address(d["ip"]))
|
|
|
|
# Apply filter and populate table
|
|
self._refresh_table()
|
|
|
|
total_all = len(self.all_devices)
|
|
total_shown = len(self.devices)
|
|
self.scan_status.setText(f"✅ Scan complete — {total_all} found, {total_shown} shown")
|
|
self.scan_status.setStyleSheet("color: #a6e3a1;")
|
|
|
|
def _on_scan_stage(self, stage):
|
|
if stage == "ping":
|
|
self.scan_progress_bar.setFormat("⚡ Pinging hosts... %v / %m")
|
|
self.scan_status.setText("⏳ Pinging all hosts...")
|
|
self.scan_status.setStyleSheet("color: #f9e2af;")
|
|
elif stage == "mac":
|
|
self.scan_progress_bar.setRange(0, 0)
|
|
self.scan_progress_bar.setFormat(" Reading MAC addresses...")
|
|
self.scan_status.setText("⏳ Reading MAC addresses from ARP...")
|
|
self.scan_status.setStyleSheet("color: #f9e2af;")
|
|
elif stage == "arp":
|
|
self.scan_progress_bar.setRange(0, 0)
|
|
self.scan_progress_bar.setFormat(" Reading ARP cache...")
|
|
self.scan_status.setText("⏳ Reading ARP cache...")
|
|
elif stage == "scapy":
|
|
self.scan_progress_bar.setRange(0, 0)
|
|
self.scan_progress_bar.setFormat(" ARP broadcast scan...")
|
|
self.scan_status.setText("⏳ Running ARP broadcast scan...")
|
|
|
|
|
|
def _on_scan_progress(self, done, total):
|
|
self.scan_progress_bar.setRange(0, total)
|
|
self.scan_progress_bar.setValue(done)
|
|
self.scan_status.setText(f"⏳ Pinging hosts... {done} / {total}")
|
|
self.scan_status.setStyleSheet("color: #f9e2af;")
|
|
|
|
def _on_scan_error(self, error_msg):
|
|
self.scan_progress_bar.setVisible(False)
|
|
self.btn_scan.setEnabled(True)
|
|
self.scan_status.setText("❌ Scan failed")
|
|
self.scan_status.setStyleSheet("color: #f38ba8;")
|
|
QMessageBox.critical(
|
|
self, "Scan Error", f"Failed to scan network:\n{error_msg}"
|
|
)
|
|
|
|
def _on_mode_changed(self, index):
|
|
"""Show/hide method based on selected mode."""
|
|
mode = self.mode_combo.currentData()
|
|
if mode == "update":
|
|
self.method_container.setVisible(False)
|
|
self.update_warning_lbl.setVisible(True)
|
|
else:
|
|
self.method_container.setVisible(True)
|
|
self.update_warning_lbl.setVisible(False)
|
|
self._on_method_changed(self.method_combo.currentIndex())
|
|
|
|
def _on_method_changed(self, index):
|
|
"""SSH credentials are always hidden; credentials are hardcoded."""
|
|
self.ssh_creds_widget.setVisible(False)
|
|
|
|
def _get_selected_devices(self):
|
|
"""Return list of (table_row_index, device_dict) for checked devices."""
|
|
selected = []
|
|
for i in range(self.table.rowCount()):
|
|
item = self.table.item(i, 0)
|
|
if item and item.checkState() == Qt.CheckState.Checked:
|
|
if i < len(self.devices):
|
|
selected.append((i, self.devices[i]))
|
|
return selected
|
|
|
|
def flash_all(self):
|
|
if not self.firmware:
|
|
QMessageBox.warning(
|
|
self, "No Firmware",
|
|
"Please select a firmware file first."
|
|
)
|
|
return
|
|
|
|
if not self.devices:
|
|
QMessageBox.information(
|
|
self, "No Devices",
|
|
"No devices found to flash.\n"
|
|
"Please scan the network first."
|
|
)
|
|
return
|
|
|
|
selected = self._get_selected_devices()
|
|
if not selected:
|
|
QMessageBox.information(
|
|
self, "No Selection",
|
|
"No devices selected.\n"
|
|
"Check the boxes next to the devices you want to flash."
|
|
)
|
|
return
|
|
|
|
total = len(selected)
|
|
self.progress.setMaximum(total)
|
|
self.progress.setValue(0)
|
|
|
|
# Determine flash method and SSH credentials
|
|
mode = self.mode_combo.currentData()
|
|
|
|
# Validate: IP 192.168.11.102 chỉ được nạp ở chế độ Update FW
|
|
if mode != "update":
|
|
blocked = [dev["ip"] for _, dev in selected if dev["ip"] == "192.168.11.102"]
|
|
if blocked:
|
|
QMessageBox.warning(
|
|
self, "Not Allowed",
|
|
"⚠️ Device 192.168.11.102 can only be flashed in Update FW mode.\n"
|
|
"Please deselect this device or switch to Update FW mode."
|
|
)
|
|
return
|
|
|
|
# Build list with row indices for UI updates
|
|
self._flash_row_map = {}
|
|
flash_devices = []
|
|
for idx, (row, dev) in enumerate(selected):
|
|
self._flash_row_map[idx] = row
|
|
flash_devices.append(dev)
|
|
|
|
if mode == "update":
|
|
# Hỏi xác nhận nếu có device nào khác 192.168.11.102
|
|
strange_ips = [dev["ip"] for _, dev in selected if dev["ip"] != "192.168.11.102"]
|
|
if strange_ips:
|
|
msg = (f"⚠️ Detected IP(s) other than [192.168.11.102] in your selection:\n"
|
|
f"{', '.join(strange_ips[:3])}{'...' if len(strange_ips) > 3 else ''}\n\n"
|
|
f"Are you SURE you want to update firmware on these devices?")
|
|
reply = QMessageBox.question(
|
|
self, "Confirm Update Target", msg,
|
|
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
|
|
QMessageBox.StandardButton.No
|
|
)
|
|
if reply == QMessageBox.StandardButton.No:
|
|
return
|
|
method = "ssh"
|
|
ssh_user = "root"
|
|
ssh_password = "admin123a"
|
|
ssh_backup_password = "admin"
|
|
set_passwd = False
|
|
else:
|
|
method = self.method_combo.currentData()
|
|
ssh_user = "root"
|
|
ssh_password = "admin123a"
|
|
ssh_backup_password = "admin123a"
|
|
set_passwd = True if method == "ssh" else False
|
|
|
|
# Chọn đúng worker theo mode và chạy trong background thread
|
|
max_w = self.parallel_spin.value()
|
|
if mode == "update":
|
|
self.flash_thread = UpdateFlashThread(
|
|
flash_devices, self.firmware,
|
|
max_workers=max_w,
|
|
)
|
|
else:
|
|
self.flash_thread = NewFlashThread(
|
|
flash_devices, self.firmware,
|
|
max_workers=max_w,
|
|
method=method,
|
|
ssh_user=ssh_user,
|
|
ssh_password=ssh_password,
|
|
ssh_backup_password=ssh_backup_password,
|
|
set_passwd=set_passwd,
|
|
)
|
|
self.flash_thread.device_status.connect(self._on_flash_status)
|
|
self.flash_thread.device_done.connect(self._on_flash_done)
|
|
self.flash_thread.all_done.connect(self._on_flash_all_done)
|
|
|
|
# Disable flash button trong khi đang flash
|
|
self.btn_flash.setEnabled(False)
|
|
self.flash_thread.start()
|
|
|
|
def _on_flash_status(self, index, msg):
|
|
"""Update status column while flashing."""
|
|
row = self._flash_row_map.get(index, index)
|
|
self.table.setItem(row, 3, QTableWidgetItem(f"⏳ {msg}"))
|
|
|
|
def _on_flash_done(self, index, result):
|
|
"""One device finished flashing."""
|
|
row = self._flash_row_map.get(index, index)
|
|
mac_item = self.table.item(row, 2)
|
|
ip_item = self.table.item(row, 1)
|
|
now_str = datetime.datetime.now().strftime("%H:%M:%S")
|
|
mac_str = mac_item.text().strip() if mac_item else ""
|
|
ip_str = ip_item.text().strip() if ip_item else ""
|
|
|
|
if result.startswith("DONE"):
|
|
item = QTableWidgetItem(f"✅ {result}")
|
|
item.setForeground(QColor("#a6e3a1"))
|
|
|
|
# Auto-uncheck so it won't be flashed again
|
|
cb = self.table.item(row, 0)
|
|
if cb:
|
|
cb.setCheckState(Qt.CheckState.Unchecked)
|
|
else:
|
|
item = QTableWidgetItem(f"❌ {result}")
|
|
item.setForeground(QColor("#f38ba8"))
|
|
|
|
# Lưu vào lịch sử nạp
|
|
if mac_str:
|
|
self.flashed_macs[mac_str] = (ip_str, mac_str, result, now_str)
|
|
|
|
self.table.setItem(row, 3, item)
|
|
self.progress.setValue(self.progress.value() + 1)
|
|
|
|
def _on_flash_all_done(self):
|
|
"""All flashing complete."""
|
|
self.btn_flash.setEnabled(True)
|
|
QMessageBox.information(self, "Flash Complete", "All devices have been processed.")
|
|
|
|
def _open_auto_flash(self):
|
|
"""Switch to Auto Flash UI."""
|
|
# Sync firmware & network from main to auto
|
|
self.auto_fw_label.setText(
|
|
os.path.basename(self.firmware) if self.firmware else "Not selected"
|
|
)
|
|
self.auto_fw_label.setStyleSheet(
|
|
"color: #a6e3a1; font-weight: bold; font-size: 12px;" if self.firmware
|
|
else "color: #f38ba8; font-size: 12px;"
|
|
)
|
|
self.auto_net_input.setText(self.net_input.text().strip())
|
|
self.auto_firmware = self.firmware
|
|
|
|
self.main_container.setVisible(False)
|
|
self.auto_container.setVisible(True)
|
|
|
|
def _back_to_main(self):
|
|
"""Return to main view."""
|
|
self.auto_container.setVisible(False)
|
|
self.main_container.setVisible(True)
|
|
|
|
# ── Auto Flash UI Builder ──
|
|
|
|
def _build_auto_ui(self):
|
|
"""Build the Auto Firmware Flash UI inside auto_container."""
|
|
self.auto_firmware = self.firmware
|
|
self._auto_worker = None
|
|
self._auto_device_rows = {}
|
|
self._auto_log_lines = []
|
|
self._auto_success_count = 0
|
|
self._auto_fail_count = 0
|
|
self._auto_history = []
|
|
|
|
auto_layout = QVBoxLayout(self.auto_container)
|
|
auto_layout.setSpacing(4)
|
|
auto_layout.setContentsMargins(10, 8, 10, 8)
|
|
|
|
# ── Back button + Title row ──
|
|
top_row = QHBoxLayout()
|
|
top_row.setSpacing(8)
|
|
self.btn_back = QPushButton("⬅ Back")
|
|
self.btn_back.setFixedHeight(32)
|
|
self.btn_back.setStyleSheet("""
|
|
QPushButton {
|
|
background: qlineargradient(x1:0, y1:0, x2:1, y2:0,
|
|
stop:0 #374151, stop:1 #4b5563);
|
|
border-color: #4b5563; color: #ffffff;
|
|
font-size: 12px; font-weight: bold;
|
|
border-radius: 6px; padding: 4px 16px;
|
|
}
|
|
QPushButton:hover {
|
|
background: qlineargradient(x1:0, y1:0, x2:1, y2:0,
|
|
stop:0 #4b5563, stop:1 #6b7280);
|
|
}
|
|
""")
|
|
self.btn_back.clicked.connect(self._back_to_main)
|
|
top_row.addWidget(self.btn_back)
|
|
|
|
auto_title = QLabel("🤖 Auto Firmware Flash")
|
|
auto_title.setStyleSheet("font-size: 16px; font-weight: bold; color: #c4b5fd; letter-spacing: 1px;")
|
|
auto_title.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
|
top_row.addWidget(auto_title, 1)
|
|
auto_layout.addLayout(top_row)
|
|
|
|
# ── Config group ──
|
|
config_group = CollapsibleGroupBox("⚙️ Flash Configuration")
|
|
config_layout = QVBoxLayout()
|
|
config_layout.setSpacing(6)
|
|
config_layout.setContentsMargins(8, 4, 8, 4)
|
|
|
|
# Row 1: Firmware + Network
|
|
row1 = QHBoxLayout()
|
|
row1.setSpacing(12)
|
|
fw_lbl = QLabel("FW:")
|
|
fw_lbl.setStyleSheet("font-weight: bold; font-size: 12px;")
|
|
row1.addWidget(fw_lbl)
|
|
self.auto_fw_label = QLabel(os.path.basename(self.firmware) if self.firmware else "Not selected")
|
|
self.auto_fw_label.setStyleSheet(
|
|
"color: #a6e3a1; font-weight: bold; font-size: 12px;" if self.firmware
|
|
else "color: #f38ba8; font-size: 12px;"
|
|
)
|
|
row1.addWidget(self.auto_fw_label)
|
|
btn_fw = QPushButton("📁")
|
|
btn_fw.setFixedSize(40, 30)
|
|
btn_fw.setStyleSheet("font-size: 16px;")
|
|
btn_fw.setToolTip("Select firmware")
|
|
btn_fw.clicked.connect(self._auto_select_firmware)
|
|
row1.addWidget(btn_fw)
|
|
|
|
sep1 = QLabel("│")
|
|
sep1.setStyleSheet("color: #3d4a6b; font-size: 14px;")
|
|
row1.addWidget(sep1)
|
|
|
|
net_lbl = QLabel("Network:")
|
|
net_lbl.setStyleSheet("font-weight: bold; font-size: 12px;")
|
|
row1.addWidget(net_lbl)
|
|
self.auto_net_input = QLineEdit(get_default_network(self.local_ip))
|
|
self.auto_net_input.setPlaceholderText("e.g. 192.168.4.0/24")
|
|
self.auto_net_input.setMaximumWidth(180)
|
|
self.auto_net_input.setFixedHeight(28)
|
|
self.auto_net_input.setStyleSheet("QLineEdit { font-size: 12px; padding: 3px 8px; }")
|
|
row1.addWidget(self.auto_net_input)
|
|
row1.addStretch()
|
|
config_layout.addLayout(row1)
|
|
|
|
# Row 2: Target count + Method + Concurrent
|
|
row2 = QHBoxLayout()
|
|
row2.setSpacing(12)
|
|
|
|
cnt_lbl = QLabel("Target Count:")
|
|
cnt_lbl.setStyleSheet("font-weight: bold; font-size: 13px; color: #e2e8f0;")
|
|
row2.addWidget(cnt_lbl)
|
|
self.auto_target_spin = QSpinBox()
|
|
self.auto_target_spin.setRange(1, 500)
|
|
self.auto_target_spin.setValue(5)
|
|
self.auto_target_spin.setMinimumWidth(80)
|
|
self.auto_target_spin.setFixedHeight(34)
|
|
self.auto_target_spin.setStyleSheet(
|
|
"QSpinBox { font-size: 14px; font-weight: bold; color: #e2e8f0; "
|
|
"background-color: #2d3352; border: 1px solid #3d4a6b; border-radius: 6px; padding: 2px 6px; } "
|
|
"QSpinBox::up-button { width: 18px; } QSpinBox::down-button { width: 18px; }"
|
|
)
|
|
row2.addWidget(self.auto_target_spin)
|
|
|
|
sep2 = QLabel("│")
|
|
sep2.setStyleSheet("color: #3d4a6b; font-size: 14px;")
|
|
row2.addWidget(sep2)
|
|
|
|
meth_lbl = QLabel("Method:")
|
|
meth_lbl.setStyleSheet("font-weight: bold; font-size: 12px;")
|
|
row2.addWidget(meth_lbl)
|
|
self.auto_method_combo = QComboBox()
|
|
self.auto_method_combo.addItem("💻 SSH", "ssh")
|
|
self.auto_method_combo.addItem("🌐 API (LuCI)", "api")
|
|
self.auto_method_combo.setFixedHeight(34)
|
|
self.auto_method_combo.setMinimumWidth(140)
|
|
self.auto_method_combo.setStyleSheet("""
|
|
QComboBox {
|
|
background-color: #2d3352; border: 1px solid #3d4a6b; border-radius: 6px;
|
|
padding: 2px 8px; color: #e2e8f0; font-size: 13px; font-weight: bold;
|
|
}
|
|
QComboBox:hover { border-color: #7eb8f7; }
|
|
QComboBox::drop-down { border: none; width: 20px; }
|
|
QComboBox QAbstractItemView {
|
|
background-color: #2d3352; color: #e2e8f0;
|
|
border: 1px solid #3d4a6b; selection-background-color: #3d4a6b;
|
|
}
|
|
""")
|
|
row2.addWidget(self.auto_method_combo)
|
|
|
|
sep3 = QLabel("│")
|
|
sep3.setStyleSheet("color: #3d4a6b; font-size: 14px;")
|
|
row2.addWidget(sep3)
|
|
|
|
par_lbl = QLabel("Concurrent:")
|
|
par_lbl.setStyleSheet("font-weight: bold; font-size: 13px; color: #e2e8f0;")
|
|
row2.addWidget(par_lbl)
|
|
self.auto_parallel_spin = QSpinBox()
|
|
self.auto_parallel_spin.setRange(0, 100)
|
|
self.auto_parallel_spin.setValue(10)
|
|
self.auto_parallel_spin.setSpecialValueText("∞")
|
|
self.auto_parallel_spin.setToolTip("0 = unlimited")
|
|
self.auto_parallel_spin.setMinimumWidth(80)
|
|
self.auto_parallel_spin.setFixedHeight(34)
|
|
self.auto_parallel_spin.setStyleSheet(
|
|
"QSpinBox { font-size: 14px; font-weight: bold; color: #e2e8f0; "
|
|
"background-color: #2d3352; border: 1px solid #3d4a6b; border-radius: 6px; padding: 2px 6px; } "
|
|
"QSpinBox::up-button { width: 18px; } QSpinBox::down-button { width: 18px; }"
|
|
)
|
|
row2.addWidget(self.auto_parallel_spin)
|
|
row2.addStretch()
|
|
config_layout.addLayout(row2)
|
|
|
|
config_group.set_content_layout(config_layout)
|
|
auto_layout.addWidget(config_group)
|
|
|
|
# ── Control Buttons ──
|
|
btn_row = QHBoxLayout()
|
|
btn_row.setSpacing(8)
|
|
self.auto_btn_start = QPushButton("▶ CONFIRM & START")
|
|
self.auto_btn_start.setObjectName("start_btn")
|
|
self.auto_btn_start.setFixedHeight(36)
|
|
self.auto_btn_start.setStyleSheet("""
|
|
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; }
|
|
""")
|
|
self.auto_btn_start.clicked.connect(self._auto_on_start)
|
|
btn_row.addWidget(self.auto_btn_start)
|
|
|
|
self.auto_btn_stop = QPushButton("⏹ STOP")
|
|
self.auto_btn_stop.setObjectName("stop_btn")
|
|
self.auto_btn_stop.setFixedHeight(36)
|
|
self.auto_btn_stop.setEnabled(False)
|
|
self.auto_btn_stop.setStyleSheet("""
|
|
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; }
|
|
""")
|
|
self.auto_btn_stop.clicked.connect(self._auto_on_stop)
|
|
btn_row.addWidget(self.auto_btn_stop)
|
|
auto_layout.addLayout(btn_row)
|
|
|
|
# ── Status + Progress ──
|
|
status_row = QHBoxLayout()
|
|
status_row.setSpacing(8)
|
|
self.auto_status_label = QLabel("⏸ Waiting to start...")
|
|
self.auto_status_label.setStyleSheet("font-size: 12px; font-weight: bold; color: #94a3b8;")
|
|
status_row.addWidget(self.auto_status_label, 1)
|
|
|
|
self.auto_progress_bar = QProgressBar()
|
|
self.auto_progress_bar.setFormat("%v / %m (%p%)")
|
|
self.auto_progress_bar.setFixedHeight(18)
|
|
self.auto_progress_bar.setFixedWidth(250)
|
|
self.auto_progress_bar.setVisible(False)
|
|
status_row.addWidget(self.auto_progress_bar)
|
|
auto_layout.addLayout(status_row)
|
|
|
|
# ── Device Table ──
|
|
dev_group = QGroupBox("📋 Device List")
|
|
dev_layout = QVBoxLayout()
|
|
dev_layout.setSpacing(2)
|
|
dev_layout.setContentsMargins(4, 12, 4, 4)
|
|
|
|
self.auto_result_table = QTableWidget()
|
|
self.auto_result_table.setColumnCount(4)
|
|
self.auto_result_table.setHorizontalHeaderLabels(["#", "IP", "MAC", "Result"])
|
|
self.auto_result_table.setAlternatingRowColors(True)
|
|
header = self.auto_result_table.horizontalHeader()
|
|
header.setSectionResizeMode(0, QHeaderView.ResizeMode.Fixed)
|
|
header.resizeSection(0, 35)
|
|
header.setSectionResizeMode(1, QHeaderView.ResizeMode.Interactive)
|
|
header.resizeSection(1, 120)
|
|
header.setSectionResizeMode(2, QHeaderView.ResizeMode.Interactive)
|
|
header.resizeSection(2, 140)
|
|
header.setSectionResizeMode(3, QHeaderView.ResizeMode.Stretch)
|
|
self.auto_result_table.verticalHeader().setVisible(False)
|
|
self.auto_result_table.setStyleSheet("QTableWidget { font-size: 12px; }")
|
|
dev_layout.addWidget(self.auto_result_table)
|
|
|
|
# Summary row
|
|
summary_row = QHBoxLayout()
|
|
summary_row.setSpacing(6)
|
|
self.auto_summary_label = QLabel("")
|
|
self.auto_summary_label.setStyleSheet("color: #94a3b8; font-size: 11px; padding: 2px;")
|
|
summary_row.addWidget(self.auto_summary_label, 1)
|
|
|
|
btn_auto_history = QPushButton("📋 Flash History")
|
|
btn_auto_history.setFixedHeight(24)
|
|
btn_auto_history.setStyleSheet("background-color: #313244; color: #cdd6f4; font-size: 11px; padding: 2px 8px;")
|
|
btn_auto_history.clicked.connect(self._show_auto_history)
|
|
summary_row.addWidget(btn_auto_history)
|
|
dev_layout.addLayout(summary_row)
|
|
|
|
dev_group.setLayout(dev_layout)
|
|
auto_layout.addWidget(dev_group, stretch=3)
|
|
|
|
# ── Log ──
|
|
log_group = CollapsibleGroupBox("📝 Log")
|
|
log_layout = QVBoxLayout()
|
|
log_layout.setContentsMargins(4, 2, 4, 2)
|
|
|
|
self.auto_log_area = QScrollArea()
|
|
self.auto_log_content = QLabel("")
|
|
self.auto_log_content.setWordWrap(True)
|
|
self.auto_log_content.setAlignment(Qt.AlignmentFlag.AlignTop)
|
|
self.auto_log_content.setStyleSheet(
|
|
"color: #cdd6f4; font-size: 12px; font-family: 'Consolas', 'Courier New', monospace;"
|
|
"padding: 6px; background-color: #11121d; border-radius: 4px;"
|
|
)
|
|
self.auto_log_content.setTextFormat(Qt.TextFormat.PlainText)
|
|
self.auto_log_area.setWidget(self.auto_log_content)
|
|
self.auto_log_area.setWidgetResizable(True)
|
|
self.auto_log_area.setMinimumHeight(150)
|
|
self.auto_log_area.setStyleSheet("QScrollArea { border: 1px solid #2d3748; border-radius: 4px; background-color: #11121d; }")
|
|
log_layout.addWidget(self.auto_log_area)
|
|
|
|
log_group.set_content_layout(log_layout)
|
|
auto_layout.addWidget(log_group, stretch=1)
|
|
|
|
# ── Auto Flash Actions ──
|
|
|
|
def _show_auto_history(self):
|
|
"""Show history of auto-flashed devices using a table dialog."""
|
|
if not self._auto_history:
|
|
QMessageBox.information(self, "Flash History", "No devices have been flashed in this session.")
|
|
return
|
|
|
|
from PyQt6.QtWidgets import QDialog
|
|
dialog = QDialog(self)
|
|
dialog.setWindowTitle("Auto Flash History")
|
|
dialog.resize(600, 400)
|
|
dialog.setStyleSheet("QDialog { background-color: #1a1b2e; } QTableWidget { font-size: 12px; }")
|
|
|
|
layout = QVBoxLayout(dialog)
|
|
layout.setContentsMargins(10, 10, 10, 10)
|
|
|
|
table = QTableWidget()
|
|
table.setColumnCount(4)
|
|
table.setHorizontalHeaderLabels(["Time", "IP", "MAC", "Result"])
|
|
table.setAlternatingRowColors(True)
|
|
table.setEditTriggers(QTableWidget.EditTrigger.NoEditTriggers)
|
|
table.setSelectionBehavior(QTableWidget.SelectionBehavior.SelectRows)
|
|
|
|
header = table.horizontalHeader()
|
|
header.setSectionResizeMode(0, QHeaderView.ResizeMode.Fixed)
|
|
header.resizeSection(0, 80)
|
|
header.setSectionResizeMode(1, QHeaderView.ResizeMode.Fixed)
|
|
header.resizeSection(1, 120)
|
|
header.setSectionResizeMode(2, QHeaderView.ResizeMode.Fixed)
|
|
header.resizeSection(2, 140)
|
|
header.setSectionResizeMode(3, QHeaderView.ResizeMode.Stretch)
|
|
table.verticalHeader().setVisible(False)
|
|
|
|
table.setRowCount(len(self._auto_history))
|
|
|
|
success_count = 0
|
|
fail_count = 0
|
|
|
|
for row, (ip, mac, result, ts) in enumerate(self._auto_history):
|
|
ok = result.startswith("DONE")
|
|
if ok:
|
|
success_count += 1
|
|
else:
|
|
fail_count += 1
|
|
|
|
time_item = QTableWidgetItem(ts)
|
|
time_item.setTextAlignment(Qt.AlignmentFlag.AlignCenter)
|
|
table.setItem(row, 0, time_item)
|
|
table.setItem(row, 1, QTableWidgetItem(ip))
|
|
table.setItem(row, 2, QTableWidgetItem(mac))
|
|
|
|
res_item = QTableWidgetItem(f"✅ {result}" if ok else f"❌ {result}")
|
|
res_item.setForeground(QColor("#a6e3a1") if ok else QColor("#f38ba8"))
|
|
table.setItem(row, 3, res_item)
|
|
|
|
layout.addWidget(table)
|
|
|
|
summary_lbl = QLabel(f"Total: {len(self._auto_history)} | ✅ {success_count} | ❌ {fail_count}")
|
|
summary_lbl.setStyleSheet("color: #cdd6f4; font-size: 13px; font-weight: bold;")
|
|
layout.addWidget(summary_lbl)
|
|
|
|
btn_close = QPushButton("Close")
|
|
btn_close.setFixedHeight(30)
|
|
btn_close.setFixedWidth(100)
|
|
btn_close.clicked.connect(dialog.accept)
|
|
|
|
btn_layout = QHBoxLayout()
|
|
btn_layout.addStretch()
|
|
btn_layout.addWidget(btn_close)
|
|
layout.addLayout(btn_layout)
|
|
|
|
dialog.exec()
|
|
|
|
def _auto_select_firmware(self):
|
|
file, _ = QFileDialog.getOpenFileName(
|
|
self, "Select Firmware", "",
|
|
"Firmware Files (*.bin *.hex *.uf2);;All Files (*)"
|
|
)
|
|
if file:
|
|
self.auto_firmware = file
|
|
self.firmware = file # sync back to main
|
|
self.auto_fw_label.setText(os.path.basename(file))
|
|
self.auto_fw_label.setStyleSheet("color: #a6e3a1; font-weight: bold; font-size: 12px;")
|
|
# Also update main UI
|
|
name = file.split("/")[-1]
|
|
self.fw_label.setText(f"✅ {name}")
|
|
self.fw_label.setStyleSheet("color: #a6e3a1; font-weight: bold; font-size: 11px;")
|
|
|
|
def _auto_append_log(self, msg):
|
|
self._auto_log_lines.append(msg)
|
|
if len(self._auto_log_lines) > 500:
|
|
self._auto_log_lines = self._auto_log_lines[-500:]
|
|
self.auto_log_content.setText("\n".join(self._auto_log_lines))
|
|
QTimer.singleShot(0, lambda: self.auto_log_area.verticalScrollBar().setValue(
|
|
self.auto_log_area.verticalScrollBar().maximum()
|
|
))
|
|
|
|
def _auto_on_start(self):
|
|
if not self.auto_firmware:
|
|
QMessageBox.warning(self, "No Firmware Selected", "Please select a firmware file first.")
|
|
return
|
|
|
|
network_str = self.auto_net_input.text().strip()
|
|
try:
|
|
ipaddress.ip_network(network_str, strict=False)
|
|
except ValueError:
|
|
QMessageBox.warning(self, "Invalid Network", f"'{network_str}' is not a valid network.\nExample: 192.168.4.0/24")
|
|
return
|
|
|
|
target = self.auto_target_spin.value()
|
|
method = self.auto_method_combo.currentData()
|
|
max_workers = self.auto_parallel_spin.value()
|
|
|
|
reply = QMessageBox.question(
|
|
self, "Confirm",
|
|
f"Start auto firmware flash?\n\n"
|
|
f" Firmware: {os.path.basename(self.auto_firmware)}\n"
|
|
f" Network: {network_str}\n"
|
|
f" Target count: {target} device(s)\n"
|
|
f" Method: {method.upper()}\n"
|
|
f" Concurrent: {max_workers if max_workers > 0 else 'Unlimited'}\n",
|
|
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
|
|
QMessageBox.StandardButton.Yes,
|
|
)
|
|
if reply != QMessageBox.StandardButton.Yes:
|
|
return
|
|
|
|
# Reset state
|
|
self._auto_log_lines = []
|
|
self.auto_log_content.setText("")
|
|
self.auto_result_table.setRowCount(0)
|
|
self._auto_device_rows = {}
|
|
self._auto_success_count = 0
|
|
self._auto_fail_count = 0
|
|
self._auto_history.clear()
|
|
self.auto_summary_label.setText("")
|
|
self.auto_progress_bar.setVisible(False)
|
|
|
|
self.auto_btn_start.setEnabled(False)
|
|
self.auto_btn_stop.setEnabled(True)
|
|
self.auto_net_input.setEnabled(False)
|
|
self.auto_target_spin.setEnabled(False)
|
|
self.auto_method_combo.setEnabled(False)
|
|
self.auto_parallel_spin.setEnabled(False)
|
|
self.btn_back.setEnabled(False)
|
|
|
|
self.auto_status_label.setText("🔍 Scanning LAN...")
|
|
self.auto_status_label.setStyleSheet("font-size: 12px; font-weight: bold; color: #f9e2af;")
|
|
|
|
self._auto_worker = AutoFlashWorker(
|
|
network=network_str,
|
|
target_count=target,
|
|
method=method,
|
|
max_workers=max_workers,
|
|
firmware_path=self.auto_firmware,
|
|
local_ip=self.local_ip,
|
|
gateway_ip=self.gateway_ip,
|
|
)
|
|
self._auto_worker.log_message.connect(self._auto_append_log)
|
|
self._auto_worker.scan_found.connect(self._auto_on_scan_found)
|
|
self._auto_worker.devices_ready.connect(self._auto_on_devices_ready)
|
|
self._auto_worker.device_status.connect(self._auto_on_device_status)
|
|
self._auto_worker.device_done.connect(self._auto_on_device_done)
|
|
self._auto_worker.flash_progress.connect(self._auto_on_flash_progress)
|
|
self._auto_worker.all_done.connect(self._auto_on_all_done)
|
|
self._auto_worker.scan_timeout.connect(self._auto_on_scan_timeout)
|
|
self._auto_worker.stopped.connect(self._auto_on_stopped)
|
|
self._auto_worker.start()
|
|
|
|
def _auto_on_stop(self):
|
|
if self._auto_worker:
|
|
self._auto_worker.stop()
|
|
self.auto_btn_stop.setEnabled(False)
|
|
self.auto_status_label.setText("⏳ Stopping...")
|
|
|
|
def _auto_on_scan_found(self, count):
|
|
target = self.auto_target_spin.value()
|
|
self.auto_status_label.setText(f"🔍 Scan: found {count}/{target} device(s)...")
|
|
if count >= target:
|
|
self.auto_status_label.setText(f"⚡ {target} device(s) found — flashing firmware...")
|
|
self.auto_status_label.setStyleSheet("font-size: 12px; font-weight: bold; color: #a6e3a1;")
|
|
|
|
def _auto_on_devices_ready(self, devices):
|
|
self.auto_result_table.setRowCount(0)
|
|
self._auto_device_rows = {}
|
|
for i, dev in enumerate(devices):
|
|
self.auto_result_table.insertRow(i)
|
|
num_item = QTableWidgetItem(str(i + 1))
|
|
num_item.setTextAlignment(Qt.AlignmentFlag.AlignCenter)
|
|
self.auto_result_table.setItem(i, 0, num_item)
|
|
self.auto_result_table.setItem(i, 1, QTableWidgetItem(dev["ip"]))
|
|
self.auto_result_table.setItem(i, 2, QTableWidgetItem(dev.get("mac", "N/A").upper()))
|
|
waiting_item = QTableWidgetItem("⏳ Waiting...")
|
|
waiting_item.setForeground(QColor("#94a3b8"))
|
|
self.auto_result_table.setItem(i, 3, waiting_item)
|
|
self._auto_device_rows[dev["ip"]] = i
|
|
self.auto_summary_label.setText(f"Total: {len(devices)} device(s)")
|
|
|
|
def _auto_on_device_status(self, ip, msg):
|
|
row = self._auto_device_rows.get(ip)
|
|
if row is not None:
|
|
item = QTableWidgetItem(f"⏳ {msg}")
|
|
item.setForeground(QColor("#f9e2af"))
|
|
self.auto_result_table.setItem(row, 3, item)
|
|
|
|
def _auto_on_device_done(self, ip, mac, result):
|
|
if ip not in self._auto_device_rows:
|
|
row = self.auto_result_table.rowCount()
|
|
self.auto_result_table.insertRow(row)
|
|
num_item = QTableWidgetItem(str(row + 1))
|
|
num_item.setTextAlignment(Qt.AlignmentFlag.AlignCenter)
|
|
self.auto_result_table.setItem(row, 0, num_item)
|
|
self.auto_result_table.setItem(row, 1, QTableWidgetItem(ip))
|
|
self.auto_result_table.setItem(row, 2, QTableWidgetItem(mac.upper()))
|
|
self._auto_device_rows[ip] = row
|
|
|
|
row = self._auto_device_rows[ip]
|
|
now_str = datetime.datetime.now().strftime("%H:%M:%S")
|
|
if result.startswith("DONE"):
|
|
item = QTableWidgetItem(f"✅ {result}")
|
|
item.setForeground(QColor("#a6e3a1"))
|
|
self._auto_success_count += 1
|
|
self.flashed_macs[mac.upper()] = (ip, mac.upper(), result, now_str)
|
|
else:
|
|
item = QTableWidgetItem(f"❌ {result}")
|
|
item.setForeground(QColor("#f38ba8"))
|
|
self._auto_fail_count += 1
|
|
self.auto_result_table.setItem(row, 3, item)
|
|
self._auto_history.append((ip, mac.upper(), result, now_str))
|
|
|
|
total = len(self._auto_device_rows)
|
|
done = self._auto_success_count + self._auto_fail_count
|
|
self.auto_summary_label.setText(
|
|
f"Total: {total} | Done: {done} | ✅ {self._auto_success_count} | ❌ {self._auto_fail_count}"
|
|
)
|
|
|
|
def _auto_on_flash_progress(self, done, total):
|
|
self.auto_progress_bar.setVisible(True)
|
|
self.auto_progress_bar.setMaximum(total)
|
|
self.auto_progress_bar.setValue(done)
|
|
self.auto_status_label.setText(f"⚡ Flashing: {done}/{total} device(s)...")
|
|
|
|
def _auto_on_all_done(self, success, fail):
|
|
self._auto_reset_controls()
|
|
self.auto_status_label.setText(f"🏁 Complete! ✅ {success} | ❌ {fail}")
|
|
self.auto_status_label.setStyleSheet("font-size: 12px; font-weight: bold; color: #a6e3a1;")
|
|
QMessageBox.information(
|
|
self, "Complete",
|
|
f"Auto firmware flash complete!\n\n"
|
|
f"✅ Success: {success}\n"
|
|
f"❌ Failed: {fail}",
|
|
)
|
|
|
|
def _auto_on_stopped(self):
|
|
self._auto_reset_controls()
|
|
self.auto_status_label.setText("⛔ Stopped by user")
|
|
self.auto_status_label.setStyleSheet("font-size: 12px; font-weight: bold; color: #f38ba8;")
|
|
|
|
def _auto_on_scan_timeout(self, found, target):
|
|
self._auto_reset_controls()
|
|
self.auto_status_label.setText(f"⚠️ Scan timed out: only found {found}/{target} device(s)")
|
|
self.auto_status_label.setStyleSheet("font-size: 12px; font-weight: bold; color: #fab387;")
|
|
QMessageBox.warning(
|
|
self, "Not Enough Devices",
|
|
f"Scan reached maximum attempts but only found {found}/{target} device(s).\n\n"
|
|
f"Please check:\n"
|
|
f" • Devices are powered on and connected to the network\n"
|
|
f" • Network range ({self.auto_net_input.text()}) is correct\n"
|
|
f" • Try again after verifying",
|
|
)
|
|
|
|
def _auto_reset_controls(self):
|
|
self.auto_btn_start.setEnabled(True)
|
|
self.auto_btn_stop.setEnabled(False)
|
|
self.auto_net_input.setEnabled(True)
|
|
self.auto_target_spin.setEnabled(True)
|
|
self.auto_method_combo.setEnabled(True)
|
|
self.auto_parallel_spin.setEnabled(True)
|
|
self.btn_back.setEnabled(True)
|
|
self._auto_worker = None
|
|
|
|
|
|
if __name__ == "__main__":
|
|
app = QApplication(sys.argv)
|
|
app.setStyleSheet(STYLE)
|
|
|
|
window = App()
|
|
|
|
# 60% height, limited width (750px), centered
|
|
screen = app.primaryScreen().availableGeometry()
|
|
w = min(750, screen.width())
|
|
h = int(screen.height() * 0.6)
|
|
x = (screen.width() - w) // 2
|
|
y = screen.y() + (screen.height() - h) // 2
|
|
window.setGeometry(x, y, w, h)
|
|
window.show()
|
|
|
|
sys.exit(app.exec()) |