gcode预览测试

This commit is contained in:
2026-05-14 20:21:16 +08:00
parent 65f221a5d8
commit 837996c436
17 changed files with 1363 additions and 296 deletions

Binary file not shown.

BIN
assets/img/logo.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

71
main.py
View File

@@ -31,6 +31,7 @@ class MainWindow(QWidget):
self.wifi_manager = WifiManager() self.wifi_manager = WifiManager()
self._last_network_check = 0.0 self._last_network_check = 0.0
self._is_network_connected = False self._is_network_connected = False
self._clock_has_synced = False # 是否曾成功获取到时间(断网后继续显示)
self.init_ui() self.init_ui()
# 定时刷新风扇状态显示 # 定时刷新风扇状态显示
@@ -58,8 +59,35 @@ class MainWindow(QWidget):
api_key=self.config_parser.api_key api_key=self.config_parser.api_key
) )
def _signal_to_bars(self, signal_val):
"""将信号强度转换为条形图标字符串
支持 dBm负值如 -45和百分比0-100nmcli 格式)
"""
if signal_val is None:
return "⬜⬜⬜"
if signal_val < 0:
# dBm 格式
if signal_val >= -50:
return "■■■"
elif signal_val >= -60:
return "■■□"
elif signal_val >= -70:
return "■□□"
else:
return "□□□"
else:
# 百分比格式 (0-100)
if signal_val >= 75:
return "■■■"
elif signal_val >= 50:
return "■■□"
elif signal_val >= 25:
return "■□□"
else:
return "□□□"
def _update_top_bar(self): def _update_top_bar(self):
"""更新风扇状态横条显示""" """更新风扇/网络状态横条显示"""
s = self.auto_fan_status s = self.auto_fan_status
temp = f"{s.cpu_temp:.1f}°C" if s.is_auto_fan_service_running else "--.-°C" temp = f"{s.cpu_temp:.1f}°C" if s.is_auto_fan_service_running else "--.-°C"
speed_pct = min(s.fan_speed / 255 * 100, 100) speed_pct = min(s.fan_speed / 255 * 100, 100)
@@ -79,17 +107,42 @@ class MainWindow(QWidget):
f"background-color: #2a2a2a; color: {color}; " f"background-color: #2a2a2a; color: {color}; "
f"font-size: 18px; font-weight: 600; padding: 4px 16px;" f"font-size: 18px; font-weight: 600; padding: 4px 16px;"
) )
load_color = "#a0d8a0" if s.cpu_load < 1.0 else ("#e8a060" if s.cpu_load < 2.0 else "#e86c60")
cpu_load_str = f"<span style='color:{load_color}'>{s.cpu_load:.2f}</span><span>/4.0</span>"
self._fan_label.setText( self._fan_label.setText(
f"🌡 {temp} {state} 𖣘 {speed} {rpm}" f"🌡 {temp} {state} 𖣘 {speed} {rpm} 🖥 {cpu_load_str}"
) )
self._fan_label.setTextFormat(Qt.TextFormat.RichText)
# 更新时钟(有网络时显示) # --- WiFi 状态指示 ---
if self._check_network(): is_connected = self._check_network()
if is_connected:
try:
status = self.wifi_manager.get_current_status()
raw_signal = status.get("signal_level")
signal_dbm = int(raw_signal) if raw_signal else None
except Exception:
signal_dbm = None
bars = self._signal_to_bars(signal_dbm)
self._wifi_label.setText(f"Signal: {bars}")
self._wifi_label.setStyleSheet("color: #a0d8a0; font-size: 18px; font-weight: 600;")
else:
self._wifi_label.setText("No Signal")
self._wifi_label.setStyleSheet("color: #e86c60; font-size: 18px; font-weight: 600;")
# --- 时钟(有网络时更新;断网后保留最后一次的时间) ---
if is_connected:
now = datetime.now() now = datetime.now()
self._clock_label.setText(now.strftime("%H:%M:%S")) self._clock_label.setText(now.strftime("%H:%M:%S"))
if not self._clock_has_synced:
self._clock_has_synced = True
self._clock_label.show() self._clock_label.show()
else: else:
self._clock_label.hide() # 从未同步过则隐藏,否则保留上次时间
if self._clock_has_synced:
self._clock_label.show()
else:
self._clock_label.hide()
def init_ui(self): def init_ui(self):
# 整体布局 # 整体布局
@@ -124,7 +177,13 @@ class MainWindow(QWidget):
top_layout.addWidget(self._fan_label) top_layout.addWidget(self._fan_label)
top_layout.addStretch() top_layout.addStretch()
# 时钟标签(有网络时显示) # WiFi 状态指示
self._wifi_label = QLabel("📶 --")
self._wifi_label.setAlignment(Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter)
self._wifi_label.setStyleSheet("color: #a0d8a0; font-size: 18px; font-weight: 600;")
top_layout.addWidget(self._wifi_label)
# 时钟标签(有网络时显示,获取过一次后断网也不隐藏)
self._clock_label = QLabel("--:--:--") self._clock_label = QLabel("--:--:--")
self._clock_label.setAlignment(Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter) self._clock_label.setAlignment(Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter)
self._clock_label.setStyleSheet("color: #a0d8a0; font-size: 18px; font-weight: 600;") self._clock_label.setStyleSheet("color: #a0d8a0; font-size: 18px; font-weight: 600;")

View File

@@ -1,10 +1,11 @@
from PyQt6.QtWidgets import ( from PyQt6.QtWidgets import (
QWidget, QVBoxLayout, QHBoxLayout, QPushButton, QLabel, QWidget, QVBoxLayout, QHBoxLayout, QPushButton, QLabel,
QFrame, QGridLayout, QSizePolicy, QLineEdit, QFrame, QGridLayout, QSizePolicy, QLineEdit, QApplication,
) )
from PyQt6.QtCore import Qt, QTimer, QPointF, QRectF from PyQt6.QtCore import Qt, QTimer, QPointF, QRectF, QEvent
from PyQt6.QtGui import QFont, QPainter, QColor, QBrush, QPen, QDoubleValidator from PyQt6.QtGui import QFont, QPainter, QColor, QBrush, QPen, QDoubleValidator
from utils.config_parse import ConfigParse from utils.config_parse import ConfigParse
from utils.floating_keyboard import FloatingKeyboard
MOVE_STEP = 10 # 每次点击移动 mm (保留备用) MOVE_STEP = 10 # 每次点击移动 mm (保留备用)
@@ -222,6 +223,16 @@ class ControlPage(QWidget):
self._homed = False # 是否已轴回零 self._homed = False # 是否已轴回零
self._motor_on = True # 电机是否已使能默认True关电机后置False self._motor_on = True # 电机是否已使能默认True关电机后置False
# 归零后的停留位置
hp = self.config_parser.home_positions or {}
self._home_x = hp.get("x", 0.0)
self._home_y = hp.get("y", 0.0)
self._home_z = hp.get("z", 0.0)
# 悬浮键盘
self._keyboard = FloatingKeyboard()
self._keyboard_attached = False
self.init_ui() self.init_ui()
self._sync_inputs() self._sync_inputs()
self._apply_state() self._apply_state()
@@ -371,7 +382,9 @@ class ControlPage(QWidget):
def _cmd_home(self): def _cmd_home(self):
self.api_client.home_axes(["x", "y", "z"]) self.api_client.home_axes(["x", "y", "z"])
self.pos_x = self.pos_y = self.pos_z = 0.0 self.pos_x = self._home_x
self.pos_y = self._home_y
self.pos_z = self._home_z
self._homed = True self._homed = True
self._motor_on = True self._motor_on = True
self._sync_inputs() self._sync_inputs()
@@ -385,6 +398,45 @@ class ControlPage(QWidget):
self._motor_on = False self._motor_on = False
self._apply_state() self._apply_state()
# ── 悬浮键盘 ─────────────────────────────────────────────
def _attach_keyboard(self, widget):
"""将悬浮键盘绑定到指定输入框并显示"""
self._keyboard.attach(widget)
self._keyboard.show_below(widget)
self._keyboard_attached = True
def _dismiss_keyboard(self):
"""关闭悬浮键盘"""
self._keyboard.hide()
self._keyboard.detach()
self._keyboard_attached = False
def _on_input_focus_in(self, widget):
"""输入框获得焦点时的处理"""
if self._keyboard_attached:
self._keyboard.attach(widget)
self._keyboard.show_below(widget)
else:
self._attach_keyboard(widget)
def eventFilter(self, obj, event):
if event.type() == QEvent.Type.FocusIn:
if obj in (self.input_x, self.input_y, self.input_z):
self._on_input_focus_in(obj)
elif self._keyboard_attached and not isinstance(obj, QLineEdit):
self._dismiss_keyboard()
elif event.type() == QEvent.Type.FocusOut:
if obj in (self.input_x, self.input_y, self.input_z):
QTimer.singleShot(100, self._check_dismiss_keyboard)
return super().eventFilter(obj, event)
def _check_dismiss_keyboard(self):
"""检查当前焦点是否还在坐标输入框上,不在则关闭键盘"""
w = self.focusWidget()
if w not in (self.input_x, self.input_y, self.input_z):
self._dismiss_keyboard()
# ── UI 构建 ────────────────────────────────────────────── # ── UI 构建 ──────────────────────────────────────────────
def init_ui(self): def init_ui(self):
@@ -511,6 +563,7 @@ class ControlPage(QWidget):
inp.setAlignment(Qt.AlignmentFlag.AlignCenter) inp.setAlignment(Qt.AlignmentFlag.AlignCenter)
inp.setValidator(QDoubleValidator(-9999, 9999, 1)) inp.setValidator(QDoubleValidator(-9999, 9999, 1))
inp.returnPressed.connect(self._on_coord_changed) inp.returnPressed.connect(self._on_coord_changed)
inp.installEventFilter(self)
setattr(self, f"input_{axis.lower()}", inp) setattr(self, f"input_{axis.lower()}", inp)
coord_row.addWidget(inp) coord_row.addWidget(inp)

View File

@@ -132,6 +132,94 @@ class WifiScanWorker(QObject):
self.scan_error.emit(str(e)) self.scan_error.emit(str(e))
class WifiConnectWorker(QObject):
"""在后台线程中连接新WiFi"""
finished = pyqtSignal(bool, str) # (success, ssid)
error = pyqtSignal(str)
def __init__(self, wifi_manager, auth_mode, ssid, password=None, identity=None):
super().__init__()
self.wifi_manager = wifi_manager
self.auth_mode = auth_mode
self.ssid = ssid
self.password = password
self.identity = identity
def run(self):
try:
if self.auth_mode == "open":
ok = self.wifi_manager.connect_wifi(self.ssid, None)
elif self.auth_mode == "psk":
ok = self.wifi_manager.connect_wifi(self.ssid, self.password)
else:
ok = self.wifi_manager.connect_eap(self.ssid, self.identity, self.password)
self.finished.emit(ok, self.ssid)
except Exception as e:
self.error.emit(str(e))
class WifiConnectSavedWorker(QObject):
"""在后台线程中连接已保存的网络"""
finished = pyqtSignal(bool, str)
error = pyqtSignal(str)
def __init__(self, wifi_manager, network_id, ssid):
super().__init__()
self.wifi_manager = wifi_manager
self.network_id = network_id
self.ssid = ssid
def run(self):
try:
ok = self.wifi_manager.connect_network_id(self.network_id)
self.finished.emit(ok, self.ssid)
except Exception as e:
self.error.emit(str(e))
class WifiRemoveWorker(QObject):
"""在后台线程中删除已保存的网络"""
finished = pyqtSignal(bool, str)
error = pyqtSignal(str)
def __init__(self, wifi_manager, network_id, ssid):
super().__init__()
self.wifi_manager = wifi_manager
self.network_id = network_id
self.ssid = ssid
def run(self):
try:
self.wifi_manager.remove_network(self.network_id)
self.finished.emit(True, self.ssid)
except Exception as e:
self.error.emit(str(e))
class WifiHotspotWorker(QObject):
"""在后台线程中开启/关闭热点"""
finished = pyqtSignal(bool, str)
error = pyqtSignal(str)
def __init__(self, wifi_manager, action, ssid=None, password=None):
super().__init__()
self.wifi_manager = wifi_manager
self.action = action
self.ssid = ssid
self.password = password
def run(self):
try:
if self.action == "open":
ret = self.wifi_manager.open_hotspot(self.ssid, self.password)
self.finished.emit(bool(ret), self.ssid or "")
else:
self.wifi_manager.close_hotspot()
self.finished.emit(True, "")
except Exception as e:
self.error.emit(str(e))
class SettingPage(QWidget): class SettingPage(QWidget):
def __init__(self, api_client, parent=None): def __init__(self, api_client, parent=None):
super().__init__(parent) super().__init__(parent)
@@ -451,13 +539,13 @@ class SettingPage(QWidget):
saved_buttons_layout = QHBoxLayout() saved_buttons_layout = QHBoxLayout()
saved_buttons_layout.setSpacing(12) saved_buttons_layout.setSpacing(12)
connect_saved_button = QPushButton("连接到此网络") self.connect_saved_button = QPushButton("连接到此网络")
connect_saved_button.clicked.connect(self.connect_to_saved_wifi) self.connect_saved_button.clicked.connect(self.connect_to_saved_wifi)
saved_buttons_layout.addWidget(connect_saved_button) saved_buttons_layout.addWidget(self.connect_saved_button)
remove_saved_button = QPushButton("删除选中") self.remove_saved_button = QPushButton("删除选中")
remove_saved_button.clicked.connect(self.remove_selected_saved_wifi) self.remove_saved_button.clicked.connect(self.remove_selected_saved_wifi)
saved_buttons_layout.addWidget(remove_saved_button) saved_buttons_layout.addWidget(self.remove_saved_button)
wifi_layout.addLayout(saved_buttons_layout) wifi_layout.addLayout(saved_buttons_layout)
nearby_title = QLabel("附近网络") nearby_title = QLabel("附近网络")
@@ -547,8 +635,8 @@ class SettingPage(QWidget):
wifi_layout.addLayout(form) wifi_layout.addLayout(form)
# 连接按钮使用醒目的强调色 # 连接按钮使用醒目的强调色
connect_button = QPushButton("连接") self.connect_button = QPushButton("连接")
connect_button.setStyleSheet( self.connect_button.setStyleSheet(
""" """
QPushButton { QPushButton {
min-height: 52px; min-height: 52px;
@@ -570,8 +658,8 @@ class SettingPage(QWidget):
} }
""" """
) )
connect_button.clicked.connect(self.connect_to_wifi) self.connect_button.clicked.connect(self.connect_to_wifi)
wifi_layout.addWidget(connect_button) wifi_layout.addWidget(self.connect_button)
wifi_layout.addStretch() wifi_layout.addStretch()
self.settings_stack.addWidget(self._wrap_scroll(wifi_widget)) self.settings_stack.addWidget(self._wrap_scroll(wifi_widget))
@@ -722,47 +810,101 @@ class SettingPage(QWidget):
self.hotspot_toggle.setText("OFF") self.hotspot_toggle.setText("OFF")
def _on_hotspot_toggled(self, checked): def _on_hotspot_toggled(self, checked):
# 立即阻塞信号,防止递归触发
self.hotspot_toggle.blockSignals(True)
if checked: if checked:
ssid = self.hotspot_ssid.text().strip() ssid = self.hotspot_ssid.text().strip()
password = self.hotspot_password.text().strip() password = self.hotspot_password.text().strip()
if not ssid: if not ssid:
self._styled_message(QMessageBox.Icon.Warning, self, "提示", "请输入热点名称")
self.hotspot_toggle.blockSignals(True)
self.hotspot_toggle.setChecked(False) self.hotspot_toggle.setChecked(False)
self.hotspot_toggle.blockSignals(False) self.hotspot_toggle.blockSignals(False)
self._styled_message(QMessageBox.Icon.Warning, self, "提示", "请输入热点名称")
return return
if len(password) < 8: if len(password) < 8:
self.hotspot_toggle.setChecked(False)
self.hotspot_toggle.blockSignals(False)
self._styled_message(QMessageBox.Icon.Warning, self, "提示", "密码至少需要8位") self._styled_message(QMessageBox.Icon.Warning, self, "提示", "密码至少需要8位")
self.hotspot_toggle.blockSignals(True)
self.hotspot_toggle.setChecked(False)
self.hotspot_toggle.blockSignals(False)
return return
try:
ret = self.wifi_manager.open_hotspot(ssid, password) # 按钮UI反馈 → 显示"开启中……"
if ret: self.hotspot_toggle.setEnabled(False)
self._apply_toggle_style(True) self.hotspot_toggle.setText("开启中……")
self.hotspot_status.setText(f"热点状态:已开启 ({ssid})") self.hotspot_toggle.blockSignals(False)
self.hotspot_ssid.setEnabled(False)
self.hotspot_password.setEnabled(False) self._hotspot_thread = QThread()
self._generate_qr_code(ssid, password) self._hotspot_worker = WifiHotspotWorker(self.wifi_manager, "open", ssid, password)
else: self._hotspot_worker.moveToThread(self._hotspot_thread)
raise RuntimeError("wpa_cli 返回失败") self._hotspot_thread.started.connect(self._hotspot_worker.run)
except Exception as e: self._hotspot_worker.finished.connect(self._on_hotspot_open_finished)
self._styled_message(QMessageBox.Icon.Critical, self, "错误", f"开启热点失败: {str(e)}") self._hotspot_worker.error.connect(self._on_hotspot_open_error)
self.hotspot_toggle.blockSignals(True) self._hotspot_worker.finished.connect(self._hotspot_thread.quit)
self.hotspot_toggle.setChecked(False) self._hotspot_worker.error.connect(self._hotspot_thread.quit)
self.hotspot_toggle.blockSignals(False) self._hotspot_worker.finished.connect(self._hotspot_worker.deleteLater)
self._hotspot_worker.error.connect(self._hotspot_worker.deleteLater)
self._hotspot_thread.finished.connect(self._hotspot_thread.deleteLater)
self._hotspot_thread.start()
else: else:
try: # 关闭热点
self.wifi_manager.close_hotspot() self.hotspot_toggle.setEnabled(False)
except Exception: self.hotspot_toggle.setText("关闭中……")
pass self.hotspot_toggle.blockSignals(False)
self._hotspot_thread = QThread()
self._hotspot_worker = WifiHotspotWorker(self.wifi_manager, "close")
self._hotspot_worker.moveToThread(self._hotspot_thread)
self._hotspot_thread.started.connect(self._hotspot_worker.run)
self._hotspot_worker.finished.connect(self._on_hotspot_close_finished)
self._hotspot_worker.error.connect(self._on_hotspot_close_error)
self._hotspot_worker.finished.connect(self._hotspot_thread.quit)
self._hotspot_worker.error.connect(self._hotspot_thread.quit)
self._hotspot_worker.finished.connect(self._hotspot_worker.deleteLater)
self._hotspot_worker.error.connect(self._hotspot_worker.deleteLater)
self._hotspot_thread.finished.connect(self._hotspot_thread.deleteLater)
self._hotspot_thread.start()
def _on_hotspot_open_finished(self, ok, ssid):
if ok:
self._apply_toggle_style(True)
self.hotspot_status.setText(f"热点状态:已开启 ({ssid})")
self.hotspot_ssid.setEnabled(False)
self.hotspot_password.setEnabled(False)
self._generate_qr_code(ssid, self.hotspot_password.text().strip())
else:
self._styled_message(QMessageBox.Icon.Critical, self, "错误", "开启热点失败: wpa_cli 返回失败")
self.hotspot_toggle.blockSignals(True)
self.hotspot_toggle.setChecked(False)
self.hotspot_toggle.blockSignals(False)
self._apply_toggle_style(False) self._apply_toggle_style(False)
self.hotspot_status.setText("热点状态:关闭") self.hotspot_toggle.setEnabled(True)
self.hotspot_ssid.setEnabled(True)
self.hotspot_password.setEnabled(True) def _on_hotspot_open_error(self, err_msg):
self.qr_label.clear() self._styled_message(QMessageBox.Icon.Critical, self, "错误", f"开启热点失败: {err_msg}")
self.qr_hint.setText("开启热点后自动生成二维码") self.hotspot_toggle.blockSignals(True)
self.hotspot_toggle.setChecked(False)
self.hotspot_toggle.blockSignals(False)
self._apply_toggle_style(False)
self.hotspot_toggle.setEnabled(True)
def _on_hotspot_close_finished(self, ok, _msg):
self._apply_toggle_style(False)
self.hotspot_status.setText("热点状态:关闭")
self.hotspot_ssid.setEnabled(True)
self.hotspot_password.setEnabled(True)
self.qr_label.clear()
self.qr_hint.setText("开启热点后自动生成二维码")
self.hotspot_toggle.setEnabled(True)
def _on_hotspot_close_error(self, err_msg):
# 关闭失败仍尝试恢复UI
self._apply_toggle_style(False)
self.hotspot_status.setText("热点状态:关闭")
self.hotspot_ssid.setEnabled(True)
self.hotspot_password.setEnabled(True)
self.qr_label.clear()
self.qr_hint.setText("开启热点后自动生成二维码")
self.hotspot_toggle.setEnabled(True)
self._styled_message(QMessageBox.Icon.Warning, self, "提示", f"关闭热点时出现异常: {err_msg}")
def _generate_qr_code(self, ssid, password): def _generate_qr_code(self, ssid, password):
"""生成 WiFi 二维码并显示""" """生成 WiFi 二维码并显示"""
@@ -943,7 +1085,7 @@ class SettingPage(QWidget):
self.saved_wifi_list.clear() self.saved_wifi_list.clear()
for network in saved_networks: for network in saved_networks:
item_text = f"[{network.get('network_id', '-')}] {network.get('ssid', '<hidden>')} {network.get('flags', '')}" item_text = f"{network.get('ssid', '<hidden>')}"
item = QListWidgetItem(item_text) item = QListWidgetItem(item_text)
item.setData(Qt.ItemDataRole.UserRole, network) item.setData(Qt.ItemDataRole.UserRole, network)
self.saved_wifi_list.addItem(item) self.saved_wifi_list.addItem(item)
@@ -1016,7 +1158,11 @@ class SettingPage(QWidget):
ssid_label = QLabel(decoded_ssid) ssid_label = QLabel(decoded_ssid)
ssid_label.setStyleSheet("background: transparent; color: #f2f2f2; font-size: 18px;") ssid_label.setStyleSheet("background: transparent; color: #f2f2f2; font-size: 18px;")
signal_label = QLabel(f"{signal} dBm" if signal else "") try:
signal = int(signal)
except (ValueError, TypeError):
signal = 0
signal_label = QLabel(f"{signal} dBm" if signal < 0 else f"{signal}%")
signal_label.setStyleSheet("background: transparent; color: #aaaaaa; font-size: 16px;") signal_label.setStyleSheet("background: transparent; color: #aaaaaa; font-size: 16px;")
item_layout.addWidget(ssid_label) item_layout.addWidget(ssid_label)
@@ -1165,7 +1311,7 @@ class SettingPage(QWidget):
self.refresh_current_status() self.refresh_current_status()
def connect_to_saved_wifi(self): def connect_to_saved_wifi(self):
"""连接已保存列表中选中的网络""" """连接已保存列表中选中的网络(后台线程)"""
item = self.saved_wifi_list.currentItem() item = self.saved_wifi_list.currentItem()
if item is None: if item is None:
self._styled_message(QMessageBox.Icon.Warning, self, "提示", "请先选择一个已保存网络") self._styled_message(QMessageBox.Icon.Warning, self, "提示", "请先选择一个已保存网络")
@@ -1176,14 +1322,38 @@ class SettingPage(QWidget):
if network_id is None: if network_id is None:
self._styled_message(QMessageBox.Icon.Warning, self, "提示", "选中网络无效") self._styled_message(QMessageBox.Icon.Warning, self, "提示", "选中网络无效")
return return
try:
ok = self.wifi_manager.connect_network_id(network_id) # 按钮UI反馈
if not ok: self.connect_saved_button.setEnabled(False)
self._styled_message(QMessageBox.Icon.Critical, self, "错误", "连接请求下发失败") self.connect_saved_button.setText("连接中……")
return
self._styled_message(QMessageBox.Icon.Information, self, "成功", f"已发起连接: {ssid}") self._saved_connect_thread = QThread()
except Exception as e: self._saved_connect_worker = WifiConnectSavedWorker(self.wifi_manager, network_id, ssid)
self._styled_message(QMessageBox.Icon.Critical, self, "错误", f"连接失败: {str(e)}") self._saved_connect_worker.moveToThread(self._saved_connect_thread)
self._saved_connect_thread.started.connect(self._saved_connect_worker.run)
self._saved_connect_worker.finished.connect(self._on_saved_connect_finished)
self._saved_connect_worker.error.connect(self._on_saved_connect_error)
self._saved_connect_worker.finished.connect(self._saved_connect_thread.quit)
self._saved_connect_worker.error.connect(self._saved_connect_thread.quit)
self._saved_connect_worker.finished.connect(self._saved_connect_worker.deleteLater)
self._saved_connect_worker.error.connect(self._saved_connect_worker.deleteLater)
self._saved_connect_thread.finished.connect(self._saved_connect_thread.deleteLater)
self._saved_connect_thread.start()
def _on_saved_connect_finished(self, ok, ssid):
self.connect_saved_button.setEnabled(True)
self.connect_saved_button.setText("连接到此网络")
if ok:
self._styled_message(QMessageBox.Icon.Information, self, "成功", f"已连接: {ssid}")
else:
self._styled_message(QMessageBox.Icon.Critical, self, "错误", "连接失败")
self.refresh_saved_wifi()
self.refresh_current_status()
def _on_saved_connect_error(self, err_msg):
self.connect_saved_button.setEnabled(True)
self.connect_saved_button.setText("连接到此网络")
self._styled_message(QMessageBox.Icon.Critical, self, "错误", f"连接失败: {err_msg}")
def remove_selected_saved_wifi(self): def remove_selected_saved_wifi(self):
item = self.saved_wifi_list.currentItem() item = self.saved_wifi_list.currentItem()
@@ -1196,13 +1366,36 @@ class SettingPage(QWidget):
if network_id is None: if network_id is None:
self._styled_message(QMessageBox.Icon.Warning, self, "提示", "选中网络无效,无法删除") self._styled_message(QMessageBox.Icon.Warning, self, "提示", "选中网络无效,无法删除")
return return
try:
self.wifi_manager.remove_network(network_id) # 按钮UI反馈
self.remove_saved_button.setEnabled(False)
self.remove_saved_button.setText("删除中……")
self._remove_thread = QThread()
self._remove_worker = WifiRemoveWorker(self.wifi_manager, network_id, ssid)
self._remove_worker.moveToThread(self._remove_thread)
self._remove_thread.started.connect(self._remove_worker.run)
self._remove_worker.finished.connect(self._on_remove_finished)
self._remove_worker.error.connect(self._on_remove_error)
self._remove_worker.finished.connect(self._remove_thread.quit)
self._remove_worker.error.connect(self._remove_thread.quit)
self._remove_worker.finished.connect(self._remove_worker.deleteLater)
self._remove_worker.error.connect(self._remove_worker.deleteLater)
self._remove_thread.finished.connect(self._remove_thread.deleteLater)
self._remove_thread.start()
def _on_remove_finished(self, ok, ssid):
self.remove_saved_button.setEnabled(True)
self.remove_saved_button.setText("删除选中")
if ok:
self._styled_message(QMessageBox.Icon.Information, self, "成功", f"已删除网络: {ssid}") self._styled_message(QMessageBox.Icon.Information, self, "成功", f"已删除网络: {ssid}")
self.refresh_saved_wifi() self.refresh_saved_wifi()
self.refresh_current_status() self.refresh_current_status()
except Exception as e:
self._styled_message(QMessageBox.Icon.Critical, self, "错误", f"删除失败: {str(e)}") def _on_remove_error(self, err_msg):
self.remove_saved_button.setEnabled(True)
self.remove_saved_button.setText("删除选中")
self._styled_message(QMessageBox.Icon.Critical, self, "错误", f"删除失败: {err_msg}")
def connect_to_wifi(self): def connect_to_wifi(self):
ssid = self.ssid_input.text().strip() ssid = self.ssid_input.text().strip()
@@ -1222,23 +1415,37 @@ class SettingPage(QWidget):
self._styled_message(QMessageBox.Icon.Warning, self, "警告", "WPA-EAP 认证需要身份和密码") self._styled_message(QMessageBox.Icon.Warning, self, "警告", "WPA-EAP 认证需要身份和密码")
return return
try: # 按钮UI反馈
if auth_mode == "open": self.connect_button.setEnabled(False)
ok = self.wifi_manager.connect_wifi(ssid, None) self.connect_button.setText("连接中……")
elif auth_mode == "psk":
ok = self.wifi_manager.connect_wifi(ssid, password)
else:
ok = self.wifi_manager.connect_eap(ssid, identity, password)
if not ok: self._connect_thread = QThread()
self._styled_message(QMessageBox.Icon.Critical, self, "错误", "连接请求下发失败,请检查系统日志") self._connect_worker = WifiConnectWorker(self.wifi_manager, auth_mode, ssid, password, identity)
return self._connect_worker.moveToThread(self._connect_thread)
self._connect_thread.started.connect(self._connect_worker.run)
self._connect_worker.finished.connect(self._on_connect_finished)
self._connect_worker.error.connect(self._on_connect_error)
self._connect_worker.finished.connect(self._connect_thread.quit)
self._connect_worker.error.connect(self._connect_thread.quit)
self._connect_worker.finished.connect(self._connect_worker.deleteLater)
self._connect_worker.error.connect(self._connect_worker.deleteLater)
self._connect_thread.finished.connect(self._connect_thread.deleteLater)
self._connect_thread.start()
self._styled_message(QMessageBox.Icon.Information, self, "成功", f"已发起连接: {ssid}") def _on_connect_finished(self, ok, ssid):
self.refresh_saved_wifi() self.connect_button.setEnabled(True)
self.refresh_current_status() self.connect_button.setText("连接")
except Exception as e: if ok:
self._styled_message(QMessageBox.Icon.Critical, self, "错误", f"连接WiFi失败: {str(e)}") self._styled_message(QMessageBox.Icon.Information, self, "成功", f"连接: {ssid}")
else:
self._styled_message(QMessageBox.Icon.Critical, self, "错误", "连接失败")
self.refresh_saved_wifi()
self.refresh_current_status()
def _on_connect_error(self, err_msg):
self.connect_button.setEnabled(True)
self.connect_button.setText("连接")
self._styled_message(QMessageBox.Icon.Critical, self, "错误", f"连接WiFi失败: {err_msg}")
def display_setting(self, index): def display_setting(self, index):
if index < 0: if index < 0:

View File

@@ -7,18 +7,11 @@ from PyQt6.QtWidgets import (QWidget, QHBoxLayout, QVBoxLayout,
from PyQt6.QtCore import Qt, QTimer, QThread, pyqtSignal, QUrl, QObject, pyqtProperty, QRectF, QSize from PyQt6.QtCore import Qt, QTimer, QThread, pyqtSignal, QUrl, QObject, pyqtProperty, QRectF, QSize
from PyQt6.QtGui import QColor, QPen, QPainter, QPainterPath, QFont, QLinearGradient, QBrush from PyQt6.QtGui import QColor, QPen, QPainter, QPainterPath, QFont, QLinearGradient, QBrush
from utils.config_parse import ConfigParse from utils.config_parse import ConfigParse
import sys
import os
from utils.gcode_viewer import GCodeViewerWidget
def get_gcode_dir():
config_path = os.path.join(os.path.dirname(os.path.dirname(__file__)), "config.json")
try:
with open(config_path, "r", encoding="utf-8") as f:
config = json.load(f)
return config.get("GCODE_DIR", "/home/lhye200/.octoprint/uploads")
except:
return "/home/lhye200/.octoprint/uploads"
GCODE_DIR = get_gcode_dir()
# ── 状态主题色 ────────────────────────────────────────── # ── 状态主题色 ──────────────────────────────────────────
STATUS_COLORS = { STATUS_COLORS = {
@@ -138,52 +131,6 @@ class TempGauge(QWidget):
p.drawText(0, h - 20, w, 20, Qt.AlignmentFlag.AlignCenter, self._label) p.drawText(0, h - 20, w, 20, Qt.AlignmentFlag.AlignCenter, self._label)
# ── GCode 2D 预览(暂注释,待开发)─────────────────────
# class GCode2DPreviewWidget(QGraphicsView):
# def __init__(self, parent=None):
# super().__init__(parent)
# self.scene = QGraphicsScene(self)
# self.setScene(self.scene)
# self.setStyleSheet("background-color: #111111; border-radius: 5px; border: 1px solid #666;")
# self.setRenderHint(QPainter.RenderHint.Antialiasing)
# self.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
# self.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
# self.scale(1, -1)
#
# def draw_paths(self, lines_data):
# self.scene.clear()
# bounding_rect = QRectF()
# for color_str, data in lines_data.items():
# points = data.get("points", [])
# line_width = data.get("line_width", 2)
# if not points:
# continue
# path = QPainterPath()
# if isinstance(points[0][0], (int, float)):
# path.moveTo(float(points[0][0]), float(points[0][1]))
# for pt in points[1:]:
# path.lineTo(float(pt[0]), float(pt[1]))
# else:
# for line_pts in points:
# if not line_pts:
# continue
# path.moveTo(float(line_pts[0][0]), float(line_pts[0][1]))
# for pt in line_pts[1:]:
# path.lineTo(float(pt[0]), float(pt[1]))
# path_item = QGraphicsPathItem(path)
# pen_color = QColor(color_str) if QColor.isValidColor(color_str) else QColor("white")
# pen = QPen(pen_color)
# pen.setWidth(int(line_width))
# pen.setCosmetic(True)
# path_item.setPen(pen)
# self.scene.addItem(path_item)
# bounding_rect = bounding_rect.united(path.boundingRect())
# if bounding_rect.width() < 1 or bounding_rect.height() < 1:
# bounding_rect = QRectF(0, 0, 220, 220)
# bounding_rect.adjust(-10, -10, 10, 10)
# self.scene.setSceneRect(bounding_rect)
# self.fitInView(self.scene.sceneRect(), Qt.AspectRatioMode.KeepAspectRatio)
# ── 状态页面 ──────────────────────────────────────────── # ── 状态页面 ────────────────────────────────────────────
class StatusPage(QWidget): class StatusPage(QWidget):
@@ -193,6 +140,7 @@ class StatusPage(QWidget):
self.file_name = "None" self.file_name = "None"
self.progress = 0.0 self.progress = 0.0
self.filepos = 0
self.display_name = "None" self.display_name = "None"
self.state = "Unknown" self.state = "Unknown"
self.print_time = 0 self.print_time = 0
@@ -205,6 +153,7 @@ class StatusPage(QWidget):
self.config_parser = ConfigParse() self.config_parser = ConfigParse()
self.config_parser.config_changed.connect(self._on_config_changed) self.config_parser.config_changed.connect(self._on_config_changed)
self.gcode_dir = self.config_parser.gcode_dir self.gcode_dir = self.config_parser.gcode_dir
self._loaded_file = None
self.init_ui() self.init_ui()
self.timer = QTimer(self) self.timer = QTimer(self)
@@ -222,6 +171,7 @@ class StatusPage(QWidget):
job = data.get("job", {}) job = data.get("job", {})
self.file_name = job.get("job", {}).get("file", {}).get("name", "None") self.file_name = job.get("job", {}).get("file", {}).get("name", "None")
self.progress = job.get("progress", {}).get("completion", 0) or 0 self.progress = job.get("progress", {}).get("completion", 0) or 0
self.filepos = job.get("progress", {}).get("filepos", 0) or 0
self.display_name = job.get("job", {}).get("file", {}).get("display_name", "None") self.display_name = job.get("job", {}).get("file", {}).get("display_name", "None")
self.state = status.get("state", {}).get("text", "Offline") self.state = status.get("state", {}).get("text", "Offline")
self.print_time = job.get("progress", {}).get("printTime", 0) or 0 self.print_time = job.get("progress", {}).get("printTime", 0) or 0
@@ -336,10 +286,8 @@ class StatusPage(QWidget):
right_layout = QVBoxLayout(right_frame) right_layout = QVBoxLayout(right_frame)
right_layout.setContentsMargins(6, 6, 6, 6) right_layout.setContentsMargins(6, 6, 6, 6)
placeholder = QLabel("GCode 预览\n⚙ 待开发") self.gcode_viewer = GCodeViewerWidget()
placeholder.setAlignment(Qt.AlignmentFlag.AlignCenter) right_layout.addWidget(self.gcode_viewer)
placeholder.setStyleSheet("color: #666666; font-size: 24px; font-weight: 600; border: none;")
right_layout.addWidget(placeholder)
main_layout.addWidget(left_frame, 2) main_layout.addWidget(left_frame, 2)
main_layout.addWidget(right_frame, 3) main_layout.addWidget(right_frame, 3)
@@ -405,6 +353,22 @@ class StatusPage(QWidget):
self._tool_gauge.set_value(self.tool_temp_actual, self.tool_temp_target) self._tool_gauge.set_value(self.tool_temp_actual, self.tool_temp_target)
self._bed_gauge.set_value(self.bed_temp_actual, self.bed_temp_target) self._bed_gauge.set_value(self.bed_temp_actual, self.bed_temp_target)
# G-code 模型加载与进度更新
if self.file_name and self.file_name != "None":
if self.file_name != self._loaded_file:
gcode_path = os.path.join(self.gcode_dir, self.file_name)
if os.path.exists(gcode_path):
try:
self.gcode_viewer.load_gcode(gcode_path)
self._loaded_file = self.file_name
except Exception as e:
print("Failed to load G-code:", e)
# 使用 filepos 替代进度百分比进行精准的偏移量层级更新
if self._loaded_file == self.file_name:
is_printing = self.state.startswith("Printing") or self.state.startswith("Paused")
self.gcode_viewer.update_by_filepos(self.filepos, is_printing)
#TODO: Better Gcode Parser, this one is too slow for large files, need to optimize or use a separate thread to load #TODO: Better Gcode Parser, this one is too slow for large files, need to optimize or use a separate thread to load

53
refer/test.py Normal file
View File

@@ -0,0 +1,53 @@
import sys
from PyQt6.QtWidgets import QApplication, QMainWindow, QVBoxLayout, QWidget, QCheckBox, QSlider
from PyQt6.QtCore import Qt
from gcode_viewer import GCodeViewerWidget
class MainWindow(QMainWindow):
def __init__(self):
super().__init__()
self.setWindowTitle("G-code 预览")
self.viewer = GCodeViewerWidget()
# 加载文件(请替换为你的 G-code 路径)
self.viewer.load_gcode("/home/lhye200/.octoprint/uploads/20260508141659_085359c9908947bebcaa0fe7490641e8.gcode")
# 进度滑块0~100%
self.slider = QSlider(Qt.Orientation.Horizontal)
self.slider.setRange(0, 100)
self.slider.setValue(100)
self.slider.valueChanged.connect(
lambda v: self.viewer.update_processes(v / 100.0))
# 类型开关
self.chk_support = QCheckBox("显示支撑")
self.chk_support.setChecked(True)
self.chk_support.toggled.connect(
lambda v: self.viewer.update_switch('SUPPORT', v))
self.chk_infill = QCheckBox("显示填充")
self.chk_infill.setChecked(True)
self.chk_infill.toggled.connect(
lambda v: self.viewer.update_switch('FILL', v))
self.chk_perimeter = QCheckBox("显示外壳")
self.chk_perimeter.setChecked(True)
self.chk_perimeter.toggled.connect(
lambda v: self.viewer.update_switch('WALL-OUTER', v))
self.chk_perimeter.toggled.connect(
lambda v: self.viewer.update_switch('WALL-INNER', v))
central = QWidget()
layout = QVBoxLayout(central)
layout.addWidget(self.viewer, 1)
layout.addWidget(self.slider)
layout.addWidget(self.chk_support)
layout.addWidget(self.chk_infill)
layout.addWidget(self.chk_perimeter)
self.setCentralWidget(central)
if __name__ == "__main__":
app = QApplication(sys.argv)
win = MainWindow()
win.show()
sys.exit(app.exec())

14
refer/test_line_width.py Normal file
View File

@@ -0,0 +1,14 @@
import OpenGL.GL as gl
from PyQt6.QtWidgets import QApplication
from PyQt6.QtOpenGLWidgets import QOpenGLWidget
class View(QOpenGLWidget):
def initializeGL(self):
v = gl.glGetFloatv(gl.GL_ALIASED_LINE_WIDTH_RANGE)
print("Line width range:", v)
QApplication.instance().quit()
app = QApplication([])
v = View()
v.show()
app.exec()

13
refer/test_linewidth.py Normal file
View File

@@ -0,0 +1,13 @@
import sys
from PyQt6.QtWidgets import QApplication
from PyQt6.QtOpenGLWidgets import QOpenGLWidget
class W(QOpenGLWidget):
def initializeGL(self):
import OpenGL.GL as gl
bounds = gl.glGetFloatv(gl.GL_ALIASED_LINE_WIDTH_RANGE)
print("Line width range:", bounds)
sys.exit(0)
app = QApplication([])
w = W()
w.show()
app.exec()

15
refer/test_qmat.py Normal file
View File

@@ -0,0 +1,15 @@
from PyQt6.QtGui import QMatrix4x4
import numpy as np
mat = QMatrix4x4()
mat.translate(0.0, 0.0, -250.0)
mat.rotate(30.0, 1.0, 0.0, 0.0)
mat.rotate(45.0, 0.0, 0.0, 1.0)
# If QMatrix4x4 passes list to uniformMatrix4fv, does it need column-major or row-major?
# When I used numpy: ``mvp.flatten().tolist()`` -> it gave row-major. And it WORKED.
# Let's check QMatrix4x4 data.
data = mat.data()
print("QMatrix4x4 data (length %d):" % len(data))
print(data)

25
refer/test_rot.py Normal file
View File

@@ -0,0 +1,25 @@
import numpy as np
import math
rx = math.radians(-60.0)
rz = math.radians(45.0)
cosX, sinX = math.cos(rx), math.sin(rx)
cosZ, sinZ = math.cos(rz), math.sin(rz)
# Original code rotation matrix
rot = np.array([
[cosZ, -sinZ*cosX, sinZ*sinX, 0],
[sinZ, cosZ*cosX, -cosZ*sinX, 0],
[0, sinX, cosX, 0],
[0, 0, 0, 1]
])
print("Original rot:\\n", rot)
# Correct orbit Rx @ Rz
rot_correct = np.array([
[cosZ, -sinZ, 0, 0],
[cosX*sinZ, cosX*cosZ, -sinX, 0],
[sinX*sinZ, sinX*cosZ, cosX, 0],
[0, 0, 0, 1]
])
print("Rx @ Rz:\\n", rot_correct)

4
run.sh
View File

@@ -20,6 +20,6 @@ export QT_QPA_PLATFORM=eglfs
# echo "Starting Printer Screen Menu..." # echo "Starting Printer Screen Menu..."
# 启动界面 # 启动界面
python "$(dirname "$0")/main.py" > /dev/null 2>&1 # python "$(dirname "$0")/main.py" > /dev/null 2>&1
# python "$(dirname "$0")/main.py" python "$(dirname "$0")/main.py"
# .venv/bin/python main.py # .venv/bin/python main.py

View File

@@ -17,58 +17,58 @@ class AIOPrrintSystemAPI:
def get_status(self): def get_status(self):
# test_data = { test_data = {
# 'job': { 'job': {
# 'job': { 'job': {
# 'estimatedPrintTime': 1234, 'estimatedPrintTime': 1234,
# 'filament': {'length': 765, 'volume': 24356}, 'filament': {'length': 765, 'volume': 24356},
# 'file': {'display_name': 'Test File','date': None, 'name': '20260414135441_42bff5215c6148b8b5f4d8c4f15d5ddc.gcode', 'origin': 'local', 'path': None, 'size': 1468987}, 'file': {'display_name': 'Test File','date': None, 'name': '20260508141659_085359c9908947bebcaa0fe7490641e8.gcode', 'origin': 'local', 'path': None, 'size': 1468987},
# 'lastPrintTime': None, 'lastPrintTime': None,
# 'user': None 'user': None
# }, },
# 'progress': { 'progress': {
# 'completion': 74.8, 'completion': 74.8,
# 'filepos': 1234, 'filepos': 1234,
# 'printTime': 1235, 'printTime': 1235,
# 'printTimeLeft': 6353, 'printTimeLeft': 6353,
# 'printTimeLeftOrigin': 5366 'printTimeLeftOrigin': 5366
# }, },
# 'state': 'Operational' 'state': 'Operational'
# }, },
# 'status': { 'status': {
# 'sd': {'ready': False}, 'sd': {'ready': False},
# 'state': { 'state': {
# 'error': '', 'error': '',
# 'flags': { 'flags': {
# 'cancelling': False, 'cancelling': False,
# 'closedOrError': False, 'closedOrError': False,
# 'error': False, 'error': False,
# 'finishing': False, 'finishing': False,
# 'operational': True, 'operational': True,
# 'paused': False, 'paused': False,
# 'pausing': False, 'pausing': False,
# 'printing': False, 'printing': False,
# 'ready': True, 'ready': True,
# 'resuming': False, 'resuming': False,
# 'sdReady': False 'sdReady': False
# }, },
# 'text': 'Operational test' 'text': 'Operational test'
# }, },
# 'temperature': { 'temperature': {
# 'bed': {'actual': 85, 'offset': 0, 'target': 56}, 'bed': {'actual': 85, 'offset': 0, 'target': 56},
# 'tool0': {'actual': 0.0, 'offset': 0, 'target': 340} 'tool0': {'actual': 0.0, 'offset': 0, 'target': 340}
# } }
# } }
# } }
# return test_data return test_data
url = f"{self.api_url}/status" # url = f"{self.api_url}/status"
try: # try:
r = requests.get(url, headers=self.headers, timeout=5) # r = requests.get(url, headers=self.headers, timeout=5)
r.raise_for_status() # r.raise_for_status()
return r.json() # return r.json()
except: # except:
return {"status": {}, "job": {}} # return {"status": {}, "job": {}}
def pause_print(self): def pause_print(self):
return self._post_action("pause_print", action="pause") return self._post_action("pause_print", action="pause")
@@ -83,7 +83,7 @@ class AIOPrrintSystemAPI:
return self._post_action("auto_leveling") return self._post_action("auto_leveling")
def send_gcode(self, gcode): def send_gcode(self, gcode):
return self._post_action("send_gcode", gcode=gcode) return self._post_action("send_gcode", commands=gcode)
def off_motors(self): def off_motors(self):
return self.send_gcode("M84") return self.send_gcode("M84")

View File

@@ -5,6 +5,7 @@ from PyQt6.QtCore import QTimer
class AutoFanStatus: class AutoFanStatus:
def __init__(self, update_interval_ms=1000): def __init__(self, update_interval_ms=1000):
self.cpu_temp = 0.0 self.cpu_temp = 0.0
self.cpu_load = 0.0 # 1 分钟 CPU 负载
self.fan_speed = 0 self.fan_speed = 0
self.fan_state = "Unknown" self.fan_state = "Unknown"
self.fan_rpm = 0 self.fan_rpm = 0
@@ -38,3 +39,11 @@ class AutoFanStatus:
self.fan_state = "Unknown" self.fan_state = "Unknown"
self.fan_rpm = 0 self.fan_rpm = 0
self.is_auto_fan_service_running = False self.is_auto_fan_service_running = False
# 读取 CPU 负载(始终执行)
try:
with open("/proc/loadavg", "r") as f:
fields = f.read().split()
self.cpu_load = float(fields[0]) # 1 分钟平均负载
except (OSError, IndexError, ValueError):
self.cpu_load = 0.0

View File

@@ -43,8 +43,6 @@ class ConfigParse(QObject):
except OSError: except OSError:
return return
print("Config changed")
old_config = self.config old_config = self.config
new_config = self._load_config() new_config = self._load_config()
if new_config == old_config: if new_config == old_config:
@@ -68,4 +66,5 @@ class ConfigParse(QObject):
self.gcode_dir = self.config.get("gcode_dir", None) self.gcode_dir = self.config.get("gcode_dir", None)
self.move_axis_area = self.config.get("move_axis_area", None) self.move_axis_area = self.config.get("move_axis_area", None)
self.move_max_speed = self.config.get("move_max_speed", {"x": 3000, "y": 3000, "z": 200}) self.move_max_speed = self.config.get("move_max_speed", {"x": 3000, "y": 3000, "z": 200})
self.home_positions = self.config.get("home_positions", {"x": 134, "y": 123, "z": 10})
self.move_max_speed = self.config.get("move_max_speed", None) self.move_max_speed = self.config.get("move_max_speed", None)

574
utils/gcode_viewer.py Normal file
View File

@@ -0,0 +1,574 @@
import numpy as np
from PyQt6.QtOpenGLWidgets import QOpenGLWidget
from PyQt6.QtCore import Qt, QEvent, QThread, pyqtSignal
from PyQt6.QtGui import QTouchEvent, QSurfaceFormat
from PyQt6.QtOpenGL import QOpenGLShaderProgram, QOpenGLShader, QOpenGLBuffer
class GCodeParseWorker(QThread):
finished = pyqtSignal(dict)
def __init__(self, filepath, type_map, default_colors, parent=None):
super().__init__(parent)
self.filepath = filepath
self.TYPE_MAP = type_map
self.DEFAULT_COLORS = default_colors
def run(self):
points = []
colors = []
type_segments = {}
type_visibility = {}
x = y = z = e = 0.0
vertex_idx = 0
feature_type = 'OTHER'
current_segment_type = 'OTHER'
segment_start = 0
type_visibility['TRAVEL'] = False
relative_e = False
min_x = min_y = min_z = float('inf')
max_x = max_y = max_z = float('-inf')
current_offset = 0
max_z_seen = -999.0
layer_map = [(0, 0)]
def add_segment(t_name, start, length):
if length > 0:
type_segments.setdefault(t_name, []).append((start, length))
try:
with open(self.filepath, 'rb') as f:
for line_bytes in f:
current_offset += len(line_bytes)
line = line_bytes.decode('utf-8', errors='ignore').strip()
if not line:
continue
if line.startswith('M82'):
relative_e = False
continue
if line.startswith('M83'):
relative_e = True
continue
if line.startswith(';'):
if line.startswith(';TYPE:'):
raw_type = line.split(':', 1)[1].strip()
else:
raw_type = line[1:].strip()
if raw_type not in self.TYPE_MAP and not any(k in raw_type.lower() for k in ('perimeter', 'infill', 'material', 'skirt/brim')):
continue
if 'Skirt/Brim' in raw_type:
raw_type = 'Skirt'
new_type = self.TYPE_MAP.get(raw_type, 'OTHER')
if new_type != feature_type:
feature_type = new_type
if feature_type not in type_visibility:
type_visibility[feature_type] = True
continue
if line.startswith(('G0', 'G1')):
new_x, new_y, new_z = x, y, z
e_val = 0.0
has_e = False
parts = line.split(';')[0].split()
for p in parts[1:]:
try:
if p.startswith('X'): new_x = float(p[1:])
elif p.startswith('Y'): new_y = float(p[1:])
elif p.startswith('Z'): new_z = float(p[1:])
elif p.startswith('E'):
e_val = float(p[1:])
has_e = True
except ValueError:
pass
if new_x == x and new_y == y and new_z == z:
if has_e:
if relative_e: e += e_val
else: e = e_val
continue
is_extrusion = False
if has_e:
if relative_e:
is_extrusion = e_val > 0
new_e = e + e_val
else:
is_extrusion = e_val > e
new_e = e_val
else:
new_e = e
if is_extrusion:
seg_type = feature_type
c = self.DEFAULT_COLORS.get(feature_type, self.DEFAULT_COLORS['OTHER'])
else:
seg_type = 'TRAVEL'
c = self.DEFAULT_COLORS['TRAVEL']
if 'TRAVEL' not in type_visibility:
type_visibility['TRAVEL'] = False
if seg_type != current_segment_type:
if vertex_idx > segment_start:
add_segment(current_segment_type, segment_start, vertex_idx - segment_start)
current_segment_type = seg_type
segment_start = vertex_idx
if new_x < min_x: min_x = new_x
if new_x > max_x: max_x = new_x
if new_y < min_y: min_y = new_y
if new_y > max_y: max_y = new_y
if new_z < min_z: min_z = new_z
if new_z > max_z: max_z = new_z
if new_z > max_z_seen and is_extrusion:
max_z_seen = new_z
layer_map.append((current_offset, vertex_idx))
points.extend([x, y, z, new_x, new_y, new_z])
colors.extend([*c, *c])
vertex_idx += 2
x, y, z, e = new_x, new_y, new_z, new_e
elif line.startswith('G92'):
parts = line.split(';')[0].split()
for p in parts[1:]:
try:
if p.startswith('E'): e = float(p[1:])
elif p.startswith('X'): x = float(p[1:])
elif p.startswith('Y'): y = float(p[1:])
elif p.startswith('Z'): z = float(p[1:])
except ValueError:
pass
if vertex_idx > segment_start:
add_segment(current_segment_type, segment_start, vertex_idx - segment_start)
cx = (min_x + max_x) / 2.0 if max_x >= min_x else 110.0
cy = (min_y + max_y) / 2.0 if max_y >= min_y else 110.0
cz = (min_z + max_z) / 2.0 if max_z >= min_z else 0.0
result = {
'vertices': np.array(points, dtype=np.float32) if vertex_idx > 0 else np.zeros((0,), dtype=np.float32),
'colors': np.array(colors, dtype=np.float32) if vertex_idx > 0 else np.zeros((0,), dtype=np.float32),
'vertex_count': vertex_idx,
'center_x': cx,
'center_y': cy,
'center_z': cz,
'type_segments': type_segments,
'type_visibility': type_visibility,
'layer_map': layer_map
}
self.finished.emit(result)
except Exception as e:
print("ParseGCode Error:", e)
self.finished.emit({})
class GCodeViewerWidget(QOpenGLWidget):
"""
3D G-code 预览控件OpenGL ES / eglfs 兼容版)
使用可编程管线替代固定管线,适配树莓派 4B
"""
# PrusaSlicer 类型映射、颜色定义等保持不变
TYPE_MAP = {
'External perimeter': 'WALL-OUTER',
'Perimeter': 'WALL-INNER',
'Overhang perimeter': 'WALL-OUTER',
'Solid infill': 'SKIN',
'Top solid infill': 'SKIN',
'Bridge infill': 'SKIN',
'Internal infill': 'FILL',
'Support material': 'SUPPORT',
'Support material interface': 'SUPPORT-INTERFACE',
'Skirt': 'SKIRT',
'Brim': 'SKIRT',
'Custom': 'OTHER',
}
DEFAULT_COLORS = {
'WALL-OUTER': (0.92, 0.55, 0.22), # 0xeb8b38
'WALL-INNER': (0.25, 0.50, 0.81), # 0x4080cf
'FILL': (0.80, 0.75, 0.29), # 0xccc04b
'SKIN': (0.62, 0.38, 0.70), # 0x9e60b3
'SUPPORT': (0.34, 0.70, 0.34), # 0x57b357
'SUPPORT-INTERFACE': (0.17, 0.42, 0.17), # 0x2b6b2b
'SKIRT': (0.00, 1.00, 1.00), # 0x00ffff
'OTHER': (0.67, 0.67, 0.67), # 0xaaaaaa
'TRAVEL': (0.25, 0.31, 0.38), # 0x405060
}
# 顶点着色器GLSL ES 1.00
VERTEX_SHADER = """
attribute vec3 aPos;
attribute vec3 aColor;
varying vec3 vColor;
uniform mat4 uMVP;
void main() {
gl_Position = uMVP * vec4(aPos, 1.0);
vColor = aColor;
}
"""
# 片段着色器(添加可调节颜色深度的 uniform 以实现边界加深)
FRAGMENT_SHADER = """
precision mediump float;
varying vec3 vColor;
uniform float uDarken;
void main() {
gl_FragColor = vec4(vColor * uDarken, 1.0);
}
"""
def __init__(self, parent=None):
# 请求 OpenGL ES 2.0 上下文
fmt = QSurfaceFormat()
fmt.setRenderableType(QSurfaceFormat.RenderableType.OpenGLES)
fmt.setVersion(2, 0)
super().__init__(parent)
self.setMinimumSize(400, 300)
self.setAttribute(Qt.WidgetAttribute.WA_AcceptTouchEvents, True)
self.setFormat(fmt)
# 数据缓冲区
self.vertices = None
self.colors = None
self.vertex_count = 0
self.vbo_vertices = None
self.vbo_colors = None
self.vbo_ready = False
# 类型分段
self.type_segments = {}
self.type_visibility = {}
self.current_type = 'OTHER'
# 视角参数
self.view_rot_x = -60.0
self.view_rot_z = 45.0
self.view_zoom = -250.0 # 稍微退后一点以便看到整个打印平台
self.view_trans_x = 0.0
self.view_trans_y = 0.0
# ...
self.progress_ratio = 1.0
self.progress_vertices = 0
# 按需渲染:缓存当前的 filepos 对应的顶点数
self.layer_map = [(0, 0)] # (offset_bytes, vertex_idx)
self.last_reported_filepos = -1
# 模型中心点
self.center_x = 110.0
self.center_y = 110.0
self.center_z = 0.0
# 触摸状态
self.last_mouse_pos = None
self._touch_points = {}
self._pinch_start_dist = 0.0
self._pinch_start_zoom = 0.0
self._pinch_start_center = None
self._pinch_start_trans = (0.0, 0.0)
self._ignore_wheel = False
# 着色器程序
self.shader_program = None
self.aPos_location = None
self.aColor_location = None
self.uMVP_location = None
# ── 公开接口 ──
def load_gcode(self, filepath: str):
if hasattr(self, '_worker') and self._worker.isRunning():
self._worker.terminate()
self._worker.wait()
self._worker = GCodeParseWorker(filepath, self.TYPE_MAP, self.DEFAULT_COLORS)
self._worker.finished.connect(self._on_parse_finished)
self._worker.start()
def _on_parse_finished(self, result: dict):
if not result:
return
self.vertices = result['vertices']
self.colors = result['colors']
self.vertex_count = result['vertex_count']
self.center_x = result['center_x']
self.center_y = result['center_y']
self.center_z = result['center_z']
self.type_segments = result['type_segments']
self.type_visibility = result['type_visibility']
self.layer_map = result['layer_map']
self.vbo_ready = False
# 初始时先不显示,让 update_by_filepos 决定或者如果是0则自动更新
# 这里把 progress_vertices 赋予当前的 target
target = 0
if self.layer_map:
target = self.layer_map[-1][1] # 全显
self.progress_vertices = target
self.last_reported_filepos = -1
self.update()
def update_processes(self, progress: float):
pass
def update_by_filepos(self, filepos: int, is_printing: bool = True):
import bisect
if not hasattr(self, 'layer_map') or not self.layer_map:
return
if not is_printing:
# 不在打印途中时,渲染包含所有顶点(完整模型)
target_vertices = self.vertex_count
else:
# 使用二分查找快速定位当前 filepos 对应的最多展示顶点数
keys = [item[0] for item in self.layer_map]
idx = bisect.bisect_right(keys, filepos)
target_vertices = 0 if idx == 0 else self.layer_map[idx-1][1]
# 按需渲染:只有当层级(计算出的可见顶点数)真正发生跳变时才调用 update()
if target_vertices != getattr(self, 'progress_vertices', -1):
self.progress_vertices = target_vertices
self.update()
def update_switch(self, type_name: str, visible: bool):
if type_name in self.type_visibility:
self.type_visibility[type_name] = visible
self.update()
def set_view_angles(self, rot_x: float, rot_z: float, zoom: float = None):
self.view_rot_x = max(-90.0, min(0.0, rot_x))
self.view_rot_z = rot_z
if zoom is not None:
self.view_zoom = zoom
self.update()
if length == 0:
return
self.type_segments.setdefault(type_name, []).append((start, length))
# ── OpenGL 可编程管线初始化 ──
def initializeGL(self):
import OpenGL.GL as gl
gl.glClearColor(0.15, 0.15, 0.15, 1.0)
gl.glEnable(gl.GL_DEPTH_TEST)
gl.glLineWidth(1.0)
# 编译着色器
self.shader_program = QOpenGLShaderProgram()
self.shader_program.addShaderFromSourceCode(QOpenGLShader.ShaderTypeBit.Vertex, self.VERTEX_SHADER)
self.shader_program.addShaderFromSourceCode(QOpenGLShader.ShaderTypeBit.Fragment, self.FRAGMENT_SHADER)
self.shader_program.link()
# 获取属性和 uniform 位置
self.aPos_location = self.shader_program.attributeLocation("aPos")
self.aColor_location = self.shader_program.attributeLocation("aColor")
self.uMVP_location = self.shader_program.uniformLocation("uMVP")
self.uDarken_location = self.shader_program.uniformLocation("uDarken")
# 创建缓冲对象
self.vbo_vertices = QOpenGLBuffer(QOpenGLBuffer.Type.VertexBuffer)
self.vbo_colors = QOpenGLBuffer(QOpenGLBuffer.Type.VertexBuffer)
def resizeGL(self, w, h):
import OpenGL.GL as gl
gl.glViewport(0, 0, w, h)
# ── 构建 MVP 矩阵(替代 glOrtho/glRotate ──
def _build_mvp(self):
from PyQt6.QtGui import QMatrix4x4
mat = QMatrix4x4()
aspect = self.width() / self.height() if self.height() else 1
mat.perspective(45.0, aspect, 1.0, 1000.0)
mat.translate(self.view_trans_x, self.view_trans_y, self.view_zoom)
mat.rotate(self.view_rot_x, 1.0, 0.0, 0.0)
mat.rotate(self.view_rot_z, 0.0, 0.0, 1.0)
mat.translate(-self.center_x, -self.center_y, -self.center_z)
return mat
# ── 渲染 ──
def paintGL(self):
import OpenGL.GL as gl
gl.glClear(gl.GL_COLOR_BUFFER_BIT | gl.GL_DEPTH_BUFFER_BIT)
if self.vertices is None or self.vertex_count == 0:
return
if not self.vbo_ready:
self._create_vbos()
self.vbo_ready = True
# 使用着色器程序
self.shader_program.bind()
# 设置 MVP 矩阵
import math
from PyQt6.QtGui import QMatrix4x4
mvp_mat = self._build_mvp()
self.shader_program.setUniformValue(self.uMVP_location, mvp_mat)
# 绑定 VBO
self.vbo_vertices.bind()
self.shader_program.setAttributeBuffer(self.aPos_location, gl.GL_FLOAT, 0, 3, 0)
self.shader_program.enableAttributeArray(self.aPos_location)
self.vbo_colors.bind()
self.shader_program.setAttributeBuffer(self.aColor_location, gl.GL_FLOAT, 0, 3, 0)
self.shader_program.enableAttributeArray(self.aColor_location)
# 允许 z-fighting 覆盖,用于同一位置多次渲染线条
gl.glDepthFunc(gl.GL_LEQUAL)
# 渲染两次:一次绘制加粗加深的边界底线,一次绘制正常宽度的原色骨架线
# 在树莓派等性能有限的平台上使用真实的3D圆柱/方块代替线条会导致顶点数暴增十几倍直接卡顿,
# 因此通过动态加粗像素级线宽来性能无损地模拟出“体积感”)
for pass_idx in range(2):
if pass_idx == 0:
gl.glLineWidth(6.0) # 底线宽度(加大以模拟体积轮廓)
self.shader_program.setUniformValue(self.uDarken_location, 0.8) # 加深颜色至 40% 亮度
else:
gl.glLineWidth(3.0) # 主体宽度(加大以模拟线条厚度)
self.shader_program.setUniformValue(self.uDarken_location, 1.0) # 保持原色
# 按类型分段绘制
for type_name, segments in self.type_segments.items():
if not self.type_visibility.get(type_name, True):
continue
for start, length in segments:
if start >= self.progress_vertices:
continue
end = start + length
visible_start = start
visible_count = length
if end > self.progress_vertices:
visible_count = self.progress_vertices - start
if visible_count > 0:
gl.glDrawArrays(gl.GL_LINES, visible_start, visible_count)
# 恢复默认深度测试模式
gl.glDepthFunc(gl.GL_LESS)
self.shader_program.disableAttributeArray(self.aPos_location)
self.shader_program.disableAttributeArray(self.aColor_location)
self.vbo_vertices.release()
self.vbo_colors.release()
self.shader_program.release()
def _create_vbos(self):
if self.vbo_vertices.isCreated():
self.vbo_vertices.destroy()
if self.vbo_colors.isCreated():
self.vbo_colors.destroy()
self.vbo_vertices.create()
self.vbo_vertices.bind()
self.vbo_vertices.allocate(self.vertices.tobytes(), self.vertices.nbytes)
self.vbo_vertices.release()
self.vbo_colors.create()
self.vbo_colors.bind()
self.vbo_colors.allocate(self.colors.tobytes(), self.colors.nbytes)
self.vbo_colors.release()
# ── 触摸/鼠标交互(完全不变) ──
def mousePressEvent(self, event):
self.last_mouse_pos = event.position()
def mouseMoveEvent(self, event):
if self.last_mouse_pos is None:
return
dx = event.position().x() - self.last_mouse_pos.x()
dy = event.position().y() - self.last_mouse_pos.y()
if event.buttons() & Qt.MouseButton.LeftButton:
self.view_rot_x += dy * 0.5
self.view_rot_x = max(-90.0, min(0.0, self.view_rot_x)) # 限制垂直视角的翻转
self.view_rot_z += dx * 0.5
self.last_mouse_pos = event.position()
self.update()
def wheelEvent(self, event):
delta = event.angleDelta().y() / 120
self.view_zoom += delta * 10
self.update()
def event(self, e):
if e.type() in (QEvent.Type.TouchBegin, QEvent.Type.TouchUpdate, QEvent.Type.TouchEnd):
self._ignore_wheel = True
self.touchEvent(e)
return True
elif e.type() == QEvent.Type.Wheel:
if self._ignore_wheel:
self._ignore_wheel = False
return True
return super().event(e)
def touchEvent(self, event: QTouchEvent):
points = event.points()
if not points:
return
if event.type() == QEvent.Type.TouchEnd:
self._touch_points.clear()
self._pinch_start_center = None
self._pinch_start_dist = 0.0
event.accept()
return
if len(points) == 1:
p = points[0]
# 如果是刚检测到单指,或者从双指变回单指
if p.id() not in self._touch_points or self._pinch_start_center is not None:
self._touch_points.clear()
self._touch_points[p.id()] = p.position()
self._pinch_start_center = None
else:
last = self._touch_points[p.id()]
dx = p.position().x() - last.x()
dy = p.position().y() - last.y()
self.view_rot_x += dy * 0.5
self.view_rot_x = max(-90.0, min(0.0, self.view_rot_x)) # 限制垂直视角的翻转
self.view_rot_z += dx * 0.5
self._touch_points[p.id()] = p.position()
self.update()
elif len(points) == 2:
p1, p2 = points[0], points[1]
dx_p = p1.position().x() - p2.position().x()
dy_p = p1.position().y() - p2.position().y()
dist = (dx_p**2 + dy_p**2)**0.5
center_x = (p1.position().x() + p2.position().x()) / 2.0
center_y = (p1.position().y() + p2.position().y()) / 2.0
# 初始化双指状态
if self._pinch_start_center is None:
self._pinch_start_dist = dist
self._pinch_start_zoom = self.view_zoom
self._pinch_start_center = (center_x, center_y)
self._pinch_start_trans = (self.view_trans_x, self.view_trans_y)
self._touch_points.clear() # 清除单指记录
else:
# 缩放 (双指捏合)
if self._pinch_start_dist > 0:
scale = dist / self._pinch_start_dist
self.view_zoom = self._pinch_start_zoom * (1 / scale)
# 平移 (双指并行移动)
dcx = center_x - self._pinch_start_center[0]
dcy = center_y - self._pinch_start_center[1]
pan_speed = abs(self.view_zoom) * 0.002
self.view_trans_x = self._pinch_start_trans[0] + dcx * pan_speed
self.view_trans_y = self._pinch_start_trans[1] - dcy * pan_speed
self.update()
event.accept()

View File

@@ -3,8 +3,9 @@ import time
import re import re
class WifiManager: class WifiManager:
def __init__(self, interface="wlan0"): def __init__(self, interface="wlan0", backend="nmcli"):
self.interface = interface self.interface = interface
self.backend = backend # "nmcli" or "wpa_cli"
def _run_wpa_cli(self, *args): def _run_wpa_cli(self, *args):
"""执行 wpa_cli 命令""" """执行 wpa_cli 命令"""
@@ -16,128 +17,209 @@ class WifiManager:
print(f"执行 wpa_cli {args} 失败: {e.stderr}") print(f"执行 wpa_cli {args} 失败: {e.stderr}")
return "" return ""
def _run_nmcli(self, *args):
"""执行 nmcli 命令"""
cmd = ["nmcli"] + list(args)
try:
result = subprocess.run(cmd, capture_output=True, text=True, check=True)
return result.stdout.strip()
except subprocess.CalledProcessError as e:
print(f"执行 nmcli {args} 失败: {e.stderr}")
return ""
def list_saved_networks(self): def list_saved_networks(self):
"""列出已保存的 wifiwpa_cli list_networks 以 Tab 分隔)""" if self.backend == "wpa_cli":
output = self._run_wpa_cli("list_networks") output = self._run_wpa_cli("list_networks")
networks = [] networks = []
lines = output.splitlines() lines = output.splitlines()
if len(lines) > 1: if len(lines) > 1:
for line in lines[1:]: # skip header for line in lines[1:]:
parts = line.split('\t') parts = line.split('\t')
if len(parts) >= 4: if len(parts) >= 4:
networks.append({
"network_id": parts[0],
"ssid": parts[1],
"bssid": parts[2],
"flags": parts[3]
})
return networks
else:
output = self._run_nmcli("-t", "-f", "UUID,NAME,TYPE", "connection", "show")
networks = []
for line in output.splitlines():
parts = line.split(':')
# UUID:NAME:TYPE
if len(parts) >= 3 and "wireless" in parts[-1]:
networks.append({ networks.append({
"network_id": parts[0], "network_id": parts[0],
"ssid": parts[1], "ssid": parts[1],
"bssid": parts[2], "bssid": "",
"flags": parts[3] "flags": parts[-1]
}) })
return networks return networks
def scan_networks(self): def scan_networks(self):
"""扫描范围内的 wifi""" if self.backend == "wpa_cli":
self._run_wpa_cli("scan") self._run_wpa_cli("scan")
time.sleep(3) # 等待扫描完成 time.sleep(3)
output = self._run_wpa_cli("scan_results") output = self._run_wpa_cli("scan_results")
networks = [] networks = []
lines = output.splitlines() lines = output.splitlines()
if len(lines) > 1: if len(lines) > 1:
for line in lines[1:]: for line in lines[1:]:
# bssid / frequency / signal level / flags / ssid parts = line.split('\t')
parts = line.split('\t') if len(parts) >= 5:
if len(parts) >= 5: networks.append({
networks.append({ "bssid": parts[0],
"bssid": parts[0], "frequency": parts[1],
"frequency": parts[1], "signal_level": parts[2],
"signal_level": parts[2], "flags": parts[3],
"flags": parts[3], "ssid": parts[4]
"ssid": parts[4] })
}) return networks
return networks else:
output = self._run_nmcli("-t", "-m", "multiline", "-f", "BSSID,FREQ,SIGNAL,SECURITY,SSID", "device", "wifi", "list", "--rescan", "yes")
networks = []
current = {}
for line in output.splitlines():
if ':' in line:
k, v = line.split(':', 1)
current[k] = v
if k == "SSID":
networks.append({
"bssid": current.get("BSSID", ""),
"frequency": current.get("FREQ", "").replace(" MHz", ""),
"signal_level": current.get("SIGNAL", ""),
"flags": current.get("SECURITY", ""),
"ssid": current.get("SSID", "")
})
current = {}
return networks
def connect_wifi(self, ssid, password=None): def connect_wifi(self, ssid, password=None):
"""连接普通 Wi-Fi (WPA2-PSK)""" if self.backend == "wpa_cli":
network_id = self._run_wpa_cli("add_network") network_id = self._run_wpa_cli("add_network")
if not network_id.isdigit(): if not network_id.isdigit(): return False
return False self._run_wpa_cli("set_network", network_id, "ssid", f'"{ssid}"')
if password: self._run_wpa_cli("set_network", network_id, "psk", f'"{password}"')
self._run_wpa_cli("set_network", network_id, "ssid", f'"{ssid}"') else: self._run_wpa_cli("set_network", network_id, "key_mgmt", "NONE")
if password: self._run_wpa_cli("enable_network", network_id)
self._run_wpa_cli("set_network", network_id, "psk", f'"{password}"') self._run_wpa_cli("select_network", network_id)
self._run_wpa_cli("save_config")
return True
else: else:
self._run_wpa_cli("set_network", network_id, "key_mgmt", "NONE") args = ["device", "wifi", "connect", ssid, "ifname", self.interface]
if password:
self._run_wpa_cli("enable_network", network_id) args.extend(["password", password])
self._run_wpa_cli("select_network", network_id) res = self._run_nmcli(*args)
self._run_wpa_cli("save_config") return "successfully" in res.lower() or "成功" in res
return True
def connect_eap(self, ssid, identity, password): def connect_eap(self, ssid, identity, password):
"""连接企业级 Wi-Fi (WPA-EAP PEAP/MSCHAPv2)""" if self.backend == "wpa_cli":
network_id = self._run_wpa_cli("add_network") network_id = self._run_wpa_cli("add_network")
if not network_id.isdigit(): if not network_id.isdigit(): return False
self._run_wpa_cli("set_network", network_id, "ssid", f'"{ssid}"')
self._run_wpa_cli("set_network", network_id, "key_mgmt", "WPA-EAP")
self._run_wpa_cli("set_network", network_id, "eap", "PEAP")
self._run_wpa_cli("set_network", network_id, "phase2", '"auth=MSCHAPV2"')
self._run_wpa_cli("set_network", network_id, "identity", f'"{identity}"')
self._run_wpa_cli("set_network", network_id, "password", f'"{password}"')
self._run_wpa_cli("enable_network", network_id)
self._run_wpa_cli("select_network", network_id)
self._run_wpa_cli("save_config")
return True
else:
res = self._run_nmcli("con", "add", "type", "wifi", "con-name", ssid, "ifname", self.interface, "ssid", ssid,
"--", "802-11-wireless-security.key-mgmt", "wpa-eap", "802-1x.eap", "peap",
"802-1x.phase2-auth", "mschapv2", "802-1x.identity", identity, "802-1x.password", password)
if "successfully" in res.lower() or "成功" in res:
self._run_nmcli("con", "up", ssid)
return True
return False return False
self._run_wpa_cli("set_network", network_id, "ssid", f'"{ssid}"')
self._run_wpa_cli("set_network", network_id, "key_mgmt", "WPA-EAP")
self._run_wpa_cli("set_network", network_id, "eap", "PEAP")
self._run_wpa_cli("set_network", network_id, "phase2", '"auth=MSCHAPV2"')
self._run_wpa_cli("set_network", network_id, "identity", f'"{identity}"')
self._run_wpa_cli("set_network", network_id, "password", f'"{password}"')
self._run_wpa_cli("enable_network", network_id)
self._run_wpa_cli("select_network", network_id)
self._run_wpa_cli("save_config")
return True
def connect_network_id(self, network_id): def connect_network_id(self, network_id):
"""通过 network_id 连接已保存的网络""" if self.backend == "wpa_cli":
ret1 = self._run_wpa_cli("select_network", str(network_id)) ret1 = self._run_wpa_cli("select_network", str(network_id))
self._run_wpa_cli("save_config") self._run_wpa_cli("save_config")
return bool(ret1) return bool(ret1)
else:
res = self._run_nmcli("connection", "up", "uuid", str(network_id))
return "successfully" in res.lower() or "成功" in res
def remove_network(self, network_id): def remove_network(self, network_id):
"""删除某个已保存的网络""" if self.backend == "wpa_cli":
self._run_wpa_cli("remove_network", str(network_id)) self._run_wpa_cli("remove_network", str(network_id))
self._run_wpa_cli("save_config") self._run_wpa_cli("save_config")
else:
self._run_nmcli("connection", "delete", "uuid", str(network_id))
def get_current_status(self): def get_current_status(self):
"""获取当前网络状态""" if self.backend == "wpa_cli":
output = self._run_wpa_cli("status") output = self._run_wpa_cli("status")
status = {} status = {}
for line in output.splitlines(): for line in output.splitlines():
if '=' in line: if '=' in line:
key, val = line.split('=', 1) key, val = line.split('=', 1)
status[key] = val status[key] = val
return status return status
else:
output = self._run_nmcli("-t", "-m", "multiline", "-f", "GENERAL.STATE,GENERAL.CONNECTION,IP4.ADDRESS", "device", "show", self.interface)
status = {"wpa_state": "DISCONNECTED", "ssid": "", "signal_level": None}
for line in output.splitlines():
if line.startswith("GENERAL.STATE:"):
state_val = line.split(':', 1)[1]
if "connected" in state_val.lower():
status["wpa_state"] = "COMPLETED"
elif line.startswith("GENERAL.CONNECTION:"):
conn_val = line.split(':', 1)[1]
if conn_val:
status["ssid"] = conn_val
elif line.startswith("IP4.ADDRESS[1]:"):
ip_val = line.split(':', 1)[1]
status["ip_address"] = ip_val.split('/')[0]
# 额外获取当前连接网络的信号强度nmcli device wifi 才有 SIGNAL 字段)
if status["wpa_state"] == "COMPLETED":
wifi_out = self._run_nmcli("-t", "-f", "SSID,SIGNAL,IN-USE", "device", "wifi")
for line in wifi_out.splitlines():
parts = line.split(':')
if len(parts) >= 3 and parts[2] == '*':
status["signal_level"] = parts[1] # SIGNAL 字段(百分比 0-100
break
return status
def open_hotspot(self, ssid, password, channel=6): def open_hotspot(self, ssid, password, channel=6):
""" if self.backend == "wpa_cli":
开启热点 (AP 模式) network_id = self._run_wpa_cli("add_network")
注意:要求网卡和 wpa_supplicant 均支持 AP 模式 (mode=2) if not network_id.isdigit(): return False
""" self._run_wpa_cli("set_network", network_id, "ssid", f'"{ssid}"')
network_id = self._run_wpa_cli("add_network") self._run_wpa_cli("set_network", network_id, "mode", "2")
if not network_id.isdigit(): self._run_wpa_cli("set_network", network_id, "key_mgmt", "WPA-PSK")
return False self._run_wpa_cli("set_network", network_id, "psk", f'"{password}"')
self._run_wpa_cli("set_network", network_id, "frequency", str(2412 + (channel - 1) * 5))
self._run_wpa_cli("set_network", network_id, "ssid", f'"{ssid}"') self._run_wpa_cli("select_network", network_id)
self._run_wpa_cli("set_network", network_id, "mode", "2") self._run_wpa_cli("save_config")
self._run_wpa_cli("set_network", network_id, "key_mgmt", "WPA-PSK") return network_id
self._run_wpa_cli("set_network", network_id, "psk", f'"{password}"') else:
self._run_wpa_cli("set_network", network_id, "frequency", str(2412 + (channel - 1) * 5)) self._run_nmcli("device", "wifi", "hotspot", "ifname", self.interface, "con-name", ssid, "ssid", ssid, "band", "bg", "channel", str(channel), "password", password)
networks = self.list_saved_networks()
self._run_wpa_cli("select_network", network_id) for net in networks:
self._run_wpa_cli("save_config") if net.get("ssid") == ssid:
return network_id return net.get("network_id")
return ssid
def close_hotspot(self, network_id=None): def close_hotspot(self, network_id=None):
"""关闭热点""" if self.backend == "wpa_cli":
if network_id is not None: if network_id is not None:
self._run_wpa_cli("remove_network", str(network_id)) self._run_wpa_cli("remove_network", str(network_id))
self._run_wpa_cli("reconfigure") self._run_wpa_cli("reconfigure")
self._run_wpa_cli("save_config") self._run_wpa_cli("save_config")
else:
if network_id is not None:
self._run_nmcli("connection", "down", str(network_id))
self._run_nmcli("connection", "delete", str(network_id))
if __name__ == "__main__": if __name__ == "__main__":
# Example Usage wifi = WifiManager("wlan0", backend="nmcli")
wifi = WifiManager("wlan0")
print("Current status:", wifi.get_current_status()) print("Current status:", wifi.get_current_status())
print("Saved networks:", wifi.list_saved_networks()) print("Saved networks:", wifi.list_saved_networks())