Load FW bằng SSH, Thêm tính năng update FW (SSH)

This commit is contained in:
2026-03-08 14:29:35 +07:00
parent 12c65c1948
commit ada3440ebc
8 changed files with 667 additions and 38 deletions

298
main.py
View File

@@ -11,7 +11,8 @@ from PyQt6.QtWidgets import (
QVBoxLayout, QHBoxLayout, QFileDialog, QTableWidget,
QTableWidgetItem, QLabel, QProgressBar,
QMessageBox, QGroupBox, QHeaderView, QLineEdit,
QFrame, QCheckBox, QSpinBox, QScrollArea, QSizePolicy
QFrame, QCheckBox, QSpinBox, QScrollArea, QSizePolicy,
QComboBox
)
from PyQt6.QtCore import (
Qt, QThread, pyqtSignal, QPropertyAnimation, QAbstractAnimation,
@@ -23,6 +24,7 @@ import datetime
from scanner import scan_network
from flasher import flash_device
from ssh_flasher import flash_device_ssh
class CollapsibleGroupBox(QGroupBox):
@@ -475,11 +477,17 @@ class FlashThread(QThread):
device_done = pyqtSignal(int, str) # index, result
all_done = pyqtSignal()
def __init__(self, devices, firmware_path, max_workers=10):
def __init__(self, devices, firmware_path, max_workers=10,
method="api", ssh_user="root", ssh_password="admin123a",
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.set_passwd = set_passwd
def run(self):
def _flash_one(i, dev):
@@ -487,10 +495,19 @@ class FlashThread(QThread):
def on_status(msg):
self.device_status.emit(i, msg)
result = flash_device(
dev["ip"], self.firmware_path,
status_cb=on_status
)
if self.method == "ssh":
result = flash_device_ssh(
dev["ip"], self.firmware_path,
user=self.ssh_user,
password=self.ssh_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}")
@@ -513,9 +530,13 @@ class App(QWidget):
super().__init__()
self.setWindowTitle("MiraV3 Firmware Loader")
self.setWindowIcon(QIcon(resource_path("icon.ico")))
# Data state
self.local_ip = ""
self.gateway_ip = ""
self.firmware = None
self.all_devices = [] # all scan results (unfiltered)
self.devices = [] # currently displayed (filtered)
self.all_devices = [] # raw list from scanner
self.devices = [] # filtered list for table
self.flashed_macs = set() # MAC addresses flashed successfully in session
self.scan_thread = None
info = get_machine_info()
@@ -532,7 +553,7 @@ class App(QWidget):
title.setAlignment(Qt.AlignmentFlag.AlignCenter)
layout.addWidget(title)
copyright_label = QLabel(f"© {datetime.datetime.now().year} Smatec — R&D Software Team. v1.0.0")
copyright_label = QLabel(f"© {datetime.datetime.now().year} Smatec — R&D Software Team. v1.1.0")
copyright_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
copyright_label.setStyleSheet(
"color: #9399b2; font-size: 11px; font-weight: 500; font-family: -apple-system, BlinkMacSystemFont, Segoe UI, Roboto;"
@@ -593,11 +614,11 @@ class App(QWidget):
self.net_input.setPlaceholderText("e.g. 192.168.4.0/24")
net_row.addWidget(self.net_input, 1)
btn_scan = QPushButton("🔍 Scan LAN")
btn_scan.setObjectName("scan")
btn_scan.clicked.connect(self.scan)
btn_scan.setFixedWidth(110)
net_row.addWidget(btn_scan)
self.btn_scan = QPushButton("🔍 Scan LAN")
self.btn_scan.setObjectName("scan")
self.btn_scan.clicked.connect(self.scan)
self.btn_scan.setFixedWidth(110)
net_row.addWidget(self.btn_scan)
scan_layout.addLayout(net_row)
self.scan_progress_bar = QProgressBar()
@@ -621,14 +642,20 @@ class App(QWidget):
dev_layout.setContentsMargins(4, 4, 4, 4)
self.table = QTableWidget()
self.table.setColumnCount(5)
self.table.setHorizontalHeaderLabels(["", "IP", "Name", "MAC", "Status"])
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, 40)
for col in range(1, 5):
header.setSectionResizeMode(col, QHeaderView.ResizeMode.Stretch)
# IP and MAC can be fixed/stretch, Status to stretch to cover full info
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.table.verticalHeader().setVisible(False)
self.table.setSelectionBehavior(
QTableWidget.SelectionBehavior.SelectRows
@@ -641,6 +668,11 @@ class App(QWidget):
filter_row.addWidget(self.device_count_label)
filter_row.addStretch()
btn_history = QPushButton("📋 Flash History")
btn_history.clicked.connect(self._show_history)
btn_history.setStyleSheet("background-color: #313244; color: #cdd6f4;")
filter_row.addWidget(btn_history)
btn_select_all = QPushButton("☑ Select All")
btn_select_all.clicked.connect(self._select_all_devices)
filter_row.addWidget(btn_select_all)
@@ -659,30 +691,137 @@ class App(QWidget):
layout.addWidget(dev_group, stretch=1)
# ── Flash Controls ──
flash_group = QGroupBox("🚀 Flash")
flash_group = QGroupBox("🚀 Flash Controls")
flash_layout = QVBoxLayout()
flash_layout.setSpacing(4)
flash_layout.setContentsMargins(4, 4, 4, 4)
flash_layout.setSpacing(12)
flash_layout.setContentsMargins(10, 15, 10, 12)
self.progress = QProgressBar()
self.progress.setFormat("%v / %m devices (%p%)")
self.progress.setMinimumHeight(22)
flash_layout.addWidget(self.progress)
# Mode selector row (Nạp mới vs Update)
mode_row = QHBoxLayout()
mode_lbl = QLabel("Flash Mode:")
mode_lbl.setStyleSheet("font-size: 14px; font-weight: bold;")
mode_row.addWidget(mode_lbl)
self.mode_combo = QComboBox()
self.mode_combo.addItem("⚡ New Flash (Raw / Factory Reset)", "new")
self.mode_combo.addItem("🔄 Update Firmware (Pre-installed)", "update")
self.mode_combo.setMinimumWidth(380)
self.mode_combo.setMinimumHeight(35)
self.mode_combo.setStyleSheet("""
QComboBox {
background-color: #2d3352; border: 1px solid #3d4a6b; border-radius: 4px;
padding: 4px 10px; color: #ffffff; font-size: 14px; font-weight: bold;
}
""")
self.mode_combo.currentIndexChanged.connect(self._on_mode_changed)
mode_row.addWidget(self.mode_combo)
mode_row.addStretch()
flash_layout.addLayout(mode_row)
# Container cho cấu hình tuỳ chọn "Nạp Mới FW"
self.method_container = QWidget()
method_layout = QVBoxLayout(self.method_container)
method_layout.setContentsMargins(0, 0, 0, 0)
method_layout.setSpacing(10)
# Method selector row
method_row = QHBoxLayout()
method_lbl = QLabel("Method:")
method_lbl.setStyleSheet("font-size: 13px; font-weight: bold;")
method_row.addWidget(method_lbl)
self.method_combo = QComboBox()
self.method_combo.addItem("🌐 API (LuCI - Recommended)", "api")
self.method_combo.addItem("💻 SSH (paramiko/scp)", "ssh")
self.method_combo.setMinimumWidth(220)
self.method_combo.setMinimumHeight(32)
self.method_combo.setStyleSheet("""
QComboBox {
background-color: #1e1e2e; border: 1px solid #3d4a6b; border-radius: 4px;
padding: 4px 10px; color: #ffffff; font-size: 13px; font-weight: bold;
}
""")
self.method_combo.currentIndexChanged.connect(self._on_method_changed)
method_row.addWidget(self.method_combo)
method_row.addStretch()
method_layout.addLayout(method_row)
# SSH credentials (hidden by default)
self.ssh_creds_widget = QWidget()
self.ssh_creds_widget.setStyleSheet("background-color: #11121d; border-radius: 6px; border: 1px dashed #3d4a6b;")
ssh_creds_layout = QVBoxLayout(self.ssh_creds_widget)
ssh_creds_layout.setContentsMargins(15, 12, 15, 12)
ssh_creds_layout.setSpacing(10)
ssh_row1 = QHBoxLayout()
ssh_lbl1 = QLabel("SSH User:")
ssh_lbl1.setStyleSheet("font-size: 12px; font-weight: bold;")
ssh_row1.addWidget(ssh_lbl1)
self.ssh_user_input = QLineEdit("root")
self.ssh_user_input.setFixedWidth(130)
str_qlineedit = "QLineEdit { background-color: #1a1b2e; font-size: 13px; padding: 4px; border: 1px solid #3d4a6b; color: #ffffff; }"
self.ssh_user_input.setStyleSheet(str_qlineedit)
ssh_row1.addWidget(self.ssh_user_input)
ssh_row1.addSpacing(25)
ssh_lbl2 = QLabel("SSH Password:")
ssh_lbl2.setStyleSheet("font-size: 12px; font-weight: bold;")
ssh_row1.addWidget(ssh_lbl2)
self.ssh_pass_input = QLineEdit("admin123a")
self.ssh_pass_input.setEchoMode(QLineEdit.EchoMode.Password)
self.ssh_pass_input.setFixedWidth(130)
self.ssh_pass_input.setStyleSheet(str_qlineedit)
ssh_row1.addWidget(self.ssh_pass_input)
ssh_row1.addStretch()
ssh_creds_layout.addLayout(ssh_row1)
self.set_passwd_cb = QCheckBox("Set password before flash (passwd → admin123a)")
self.set_passwd_cb.setChecked(True)
self.set_passwd_cb.setStyleSheet("color: #94a3b8; font-size: 12px; font-weight: bold;")
ssh_creds_layout.addWidget(self.set_passwd_cb)
self.ssh_creds_widget.setVisible(False)
method_layout.addWidget(self.ssh_creds_widget)
flash_layout.addWidget(self.method_container)
# Warning UI for Update Mode
self.update_warning_lbl = QLabel("⚠️ FW Update Mode: Forced to SSH (root/admin123a). Target IP [192.168.11.102]. Other IPs require confirmation.")
self.update_warning_lbl.setStyleSheet("color: #f38ba8; font-size: 13px; font-weight: bold; padding: 8px; border: 1px dotted #f38ba8;")
self.update_warning_lbl.setWordWrap(True)
self.update_warning_lbl.setVisible(False)
flash_layout.addWidget(self.update_warning_lbl)
# Parallel count row
parallel_row = QHBoxLayout()
parallel_row.addWidget(QLabel("Concurrent devices:"))
parallel_lbl = QLabel("Concurrent devices:")
parallel_lbl.setStyleSheet("font-size: 14px; font-weight: bold;")
parallel_row.addWidget(parallel_lbl)
self.parallel_spin = QSpinBox()
self.parallel_spin.setRange(0, 100)
self.parallel_spin.setValue(10)
self.parallel_spin.setSpecialValueText("Unlimited")
self.parallel_spin.setSpecialValueText("0 (Unlimited)")
self.parallel_spin.setToolTip("0 = unlimited (all devices at once)")
self.parallel_spin.setFixedWidth(80)
self.parallel_spin.setMinimumWidth(160)
self.parallel_spin.setMinimumHeight(35)
self.parallel_spin.setStyleSheet("""
QSpinBox { font-size: 15px; font-weight: bold; text-align: center; }
""")
parallel_row.addWidget(self.parallel_spin)
parallel_row.addStretch()
flash_layout.addLayout(parallel_row)
btn_flash = QPushButton(" Flash Selected Devices")
btn_flash = QPushButton("FLASH SELECTED DEVICES")
btn_flash.setObjectName("flash")
btn_flash.setMinimumHeight(45)
btn_flash.setStyleSheet("QPushButton#flash { font-size: 16px; font-weight: bold; letter-spacing: 1px; }")
btn_flash.clicked.connect(self.flash_all)
flash_layout.addWidget(btn_flash)
@@ -724,27 +863,42 @@ class App(QWidget):
self.table.setRowCount(len(self.devices))
for i, d in enumerate(self.devices):
mac_str = d["mac"].upper()
# Checkbox column
cb_item = QTableWidgetItem()
cb_item.setCheckState(Qt.CheckState.Checked)
# 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"])
name_item = QTableWidgetItem(d.get("name", ""))
mac_item = QTableWidgetItem(d["mac"].upper())
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, name_item, mac_item, status_item]:
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, name_item)
self.table.setItem(i, 3, mac_item)
self.table.setItem(i, 4, status_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
@@ -756,9 +910,12 @@ class App(QWidget):
def _select_all_devices(self):
"""Check all device checkboxes."""
for i in range(self.table.rowCount()):
item = self.table.item(i, 0)
if item:
item.setCheckState(Qt.CheckState.Checked)
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."""
@@ -766,6 +923,15 @@ class App(QWidget):
item = self.table.item(i, 0)
if item:
item.setCheckState(Qt.CheckState.Unchecked)
def _show_history(self):
"""Show list of successfully flashed MACs this session."""
if not self.flashed_macs:
QMessageBox.information(self, "Flash History", "No successful flashes during this session.")
else:
macs = "\n".join(sorted(list(self.flashed_macs)))
msg = f"Successfully flashed devices in this session ({len(self.flashed_macs)}):\n\n{macs}"
QMessageBox.information(self, "Flash History", msg)
# ── Actions ──
@@ -801,6 +967,7 @@ class App(QWidget):
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)
@@ -811,6 +978,7 @@ class App(QWidget):
def _on_scan_done(self, results):
self.scan_progress_bar.setVisible(False)
self.btn_scan.setEnabled(True)
self.all_devices = []
for dev in results:
@@ -853,12 +1021,29 @@ class App(QWidget):
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):
"""Show/hide SSH credentials based on selected method."""
method = self.method_combo.currentData()
self.ssh_creds_widget.setVisible(method == "ssh")
def _get_selected_devices(self):
"""Return list of (table_row_index, device_dict) for checked devices."""
selected = []
@@ -905,10 +1090,41 @@ class App(QWidget):
self._flash_row_map[idx] = row
flash_devices.append(dev)
# Determine flash method and SSH credentials
mode = self.mode_combo.currentData()
if mode == "update":
# Hỏi xác nhận nếu có device nào khác 192.168.11.102
strange_ips = [dev["ip"] for _, dev in selected if dev["ip"] != "192.168.11.102"]
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"
set_passwd = False
else:
method = self.method_combo.currentData()
ssh_user = self.ssh_user_input.text().strip() or "root"
ssh_password = self.ssh_pass_input.text() or "admin123a"
set_passwd = self.set_passwd_cb.isChecked() if method == "ssh" else False
# Run flashing in background thread so UI doesn't freeze
self.flash_thread = FlashThread(
flash_devices, self.firmware,
max_workers=self.parallel_spin.value()
max_workers=self.parallel_spin.value(),
method=method,
ssh_user=ssh_user,
ssh_password=ssh_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)
@@ -918,7 +1134,7 @@ class App(QWidget):
def _on_flash_status(self, index, msg):
"""Update status column while flashing."""
row = self._flash_row_map.get(index, index)
self.table.setItem(row, 4, QTableWidgetItem(f"{msg}"))
self.table.setItem(row, 3, QTableWidgetItem(f"{msg}"))
def _on_flash_done(self, index, result):
"""One device finished flashing."""
@@ -926,6 +1142,12 @@ class App(QWidget):
if result.startswith("DONE"):
item = QTableWidgetItem(f"{result}")
item.setForeground(QColor("#a6e3a1"))
# Save MAC to history
mac_item = self.table.item(row, 2)
if mac_item:
self.flashed_macs.add(mac_item.text().strip())
# Auto-uncheck so it won't be flashed again
cb = self.table.item(row, 0)
if cb:
@@ -933,7 +1155,7 @@ class App(QWidget):
else:
item = QTableWidgetItem(f"{result}")
item.setForeground(QColor("#f38ba8"))
self.table.setItem(row, 4, item)
self.table.setItem(row, 3, item)
self.progress.setValue(self.progress.value() + 1)
def _on_flash_all_done(self):