diff --git a/__pycache__/gcode_viewer.cpython-311.pyc b/__pycache__/gcode_viewer.cpython-311.pyc new file mode 100644 index 0000000..1ff0fa7 Binary files /dev/null and b/__pycache__/gcode_viewer.cpython-311.pyc differ diff --git a/assets/img/logo.jpg b/assets/img/logo.jpg new file mode 100644 index 0000000..92ef0cf Binary files /dev/null and b/assets/img/logo.jpg differ diff --git a/main.py b/main.py index 529394a..88e04b6 100644 --- a/main.py +++ b/main.py @@ -31,6 +31,7 @@ class MainWindow(QWidget): self.wifi_manager = WifiManager() self._last_network_check = 0.0 self._is_network_connected = False + self._clock_has_synced = False # 是否曾成功获取到时间(断网后继续显示) self.init_ui() # 定时刷新风扇状态显示 @@ -58,8 +59,35 @@ class MainWindow(QWidget): api_key=self.config_parser.api_key ) + def _signal_to_bars(self, signal_val): + """将信号强度转换为条形图标字符串 + 支持 dBm(负值,如 -45)和百分比(0-100,nmcli 格式) + """ + 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): - """更新风扇状态横条显示""" + """更新风扇/网络状态横条显示""" s = self.auto_fan_status 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) @@ -79,17 +107,42 @@ class MainWindow(QWidget): f"background-color: #2a2a2a; color: {color}; " 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"{s.cpu_load:.2f}/4.0" self._fan_label.setText( - f"🌡 {temp} {state} 𖣘 {speed} {rpm}" + f"🌡 {temp} {state} 𖣘 {speed} {rpm} 🖥 {cpu_load_str}" ) + self._fan_label.setTextFormat(Qt.TextFormat.RichText) - # 更新时钟(有网络时显示) - if self._check_network(): + # --- WiFi 状态指示 --- + 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() self._clock_label.setText(now.strftime("%H:%M:%S")) + if not self._clock_has_synced: + self._clock_has_synced = True self._clock_label.show() else: - self._clock_label.hide() + # 从未同步过则隐藏,否则保留上次时间 + if self._clock_has_synced: + self._clock_label.show() + else: + self._clock_label.hide() def init_ui(self): # 整体布局 @@ -124,7 +177,13 @@ class MainWindow(QWidget): top_layout.addWidget(self._fan_label) 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.setAlignment(Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter) self._clock_label.setStyleSheet("color: #a0d8a0; font-size: 18px; font-weight: 600;") diff --git a/pages/control_page.py b/pages/control_page.py index 831a706..8e82ecf 100644 --- a/pages/control_page.py +++ b/pages/control_page.py @@ -1,10 +1,11 @@ from PyQt6.QtWidgets import ( 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 utils.config_parse import ConfigParse +from utils.floating_keyboard import FloatingKeyboard MOVE_STEP = 10 # 每次点击移动 mm (保留备用) @@ -222,6 +223,16 @@ class ControlPage(QWidget): self._homed = 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._sync_inputs() self._apply_state() @@ -371,7 +382,9 @@ class ControlPage(QWidget): def _cmd_home(self): 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._motor_on = True self._sync_inputs() @@ -385,6 +398,45 @@ class ControlPage(QWidget): self._motor_on = False 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 构建 ────────────────────────────────────────────── def init_ui(self): @@ -511,6 +563,7 @@ class ControlPage(QWidget): inp.setAlignment(Qt.AlignmentFlag.AlignCenter) inp.setValidator(QDoubleValidator(-9999, 9999, 1)) inp.returnPressed.connect(self._on_coord_changed) + inp.installEventFilter(self) setattr(self, f"input_{axis.lower()}", inp) coord_row.addWidget(inp) diff --git a/pages/setting_page.py b/pages/setting_page.py index f9f8ba0..ae833c5 100644 --- a/pages/setting_page.py +++ b/pages/setting_page.py @@ -132,6 +132,94 @@ class WifiScanWorker(QObject): 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): def __init__(self, api_client, parent=None): super().__init__(parent) @@ -451,13 +539,13 @@ class SettingPage(QWidget): saved_buttons_layout = QHBoxLayout() saved_buttons_layout.setSpacing(12) - connect_saved_button = QPushButton("连接到此网络") - connect_saved_button.clicked.connect(self.connect_to_saved_wifi) - saved_buttons_layout.addWidget(connect_saved_button) + self.connect_saved_button = QPushButton("连接到此网络") + self.connect_saved_button.clicked.connect(self.connect_to_saved_wifi) + saved_buttons_layout.addWidget(self.connect_saved_button) - remove_saved_button = QPushButton("删除选中") - remove_saved_button.clicked.connect(self.remove_selected_saved_wifi) - saved_buttons_layout.addWidget(remove_saved_button) + self.remove_saved_button = QPushButton("删除选中") + self.remove_saved_button.clicked.connect(self.remove_selected_saved_wifi) + saved_buttons_layout.addWidget(self.remove_saved_button) wifi_layout.addLayout(saved_buttons_layout) nearby_title = QLabel("附近网络") @@ -547,8 +635,8 @@ class SettingPage(QWidget): wifi_layout.addLayout(form) # 连接按钮使用醒目的强调色 - connect_button = QPushButton("连接") - connect_button.setStyleSheet( + self.connect_button = QPushButton("连接") + self.connect_button.setStyleSheet( """ QPushButton { min-height: 52px; @@ -570,8 +658,8 @@ class SettingPage(QWidget): } """ ) - connect_button.clicked.connect(self.connect_to_wifi) - wifi_layout.addWidget(connect_button) + self.connect_button.clicked.connect(self.connect_to_wifi) + wifi_layout.addWidget(self.connect_button) wifi_layout.addStretch() self.settings_stack.addWidget(self._wrap_scroll(wifi_widget)) @@ -722,47 +810,101 @@ class SettingPage(QWidget): self.hotspot_toggle.setText("OFF") def _on_hotspot_toggled(self, checked): + # 立即阻塞信号,防止递归触发 + self.hotspot_toggle.blockSignals(True) + if checked: ssid = self.hotspot_ssid.text().strip() password = self.hotspot_password.text().strip() if not ssid: - self._styled_message(QMessageBox.Icon.Warning, self, "提示", "请输入热点名称") - self.hotspot_toggle.blockSignals(True) self.hotspot_toggle.setChecked(False) self.hotspot_toggle.blockSignals(False) + self._styled_message(QMessageBox.Icon.Warning, self, "提示", "请输入热点名称") return if len(password) < 8: + self.hotspot_toggle.setChecked(False) + self.hotspot_toggle.blockSignals(False) self._styled_message(QMessageBox.Icon.Warning, self, "提示", "密码至少需要8位") - self.hotspot_toggle.blockSignals(True) - self.hotspot_toggle.setChecked(False) - self.hotspot_toggle.blockSignals(False) return - try: - ret = self.wifi_manager.open_hotspot(ssid, password) - if ret: - 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, password) - else: - raise RuntimeError("wpa_cli 返回失败") - except Exception as e: - self._styled_message(QMessageBox.Icon.Critical, self, "错误", f"开启热点失败: {str(e)}") - self.hotspot_toggle.blockSignals(True) - self.hotspot_toggle.setChecked(False) - self.hotspot_toggle.blockSignals(False) + + # 按钮UI反馈 → 显示"开启中……" + self.hotspot_toggle.setEnabled(False) + self.hotspot_toggle.setText("开启中……") + self.hotspot_toggle.blockSignals(False) + + self._hotspot_thread = QThread() + self._hotspot_worker = WifiHotspotWorker(self.wifi_manager, "open", ssid, password) + self._hotspot_worker.moveToThread(self._hotspot_thread) + self._hotspot_thread.started.connect(self._hotspot_worker.run) + self._hotspot_worker.finished.connect(self._on_hotspot_open_finished) + self._hotspot_worker.error.connect(self._on_hotspot_open_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() else: - try: - self.wifi_manager.close_hotspot() - except Exception: - pass + # 关闭热点 + self.hotspot_toggle.setEnabled(False) + self.hotspot_toggle.setText("关闭中……") + 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.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_open_error(self, err_msg): + self._styled_message(QMessageBox.Icon.Critical, self, "错误", f"开启热点失败: {err_msg}") + 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): """生成 WiFi 二维码并显示""" @@ -943,7 +1085,7 @@ class SettingPage(QWidget): self.saved_wifi_list.clear() for network in saved_networks: - item_text = f"[{network.get('network_id', '-')}] {network.get('ssid', '')} {network.get('flags', '')}" + item_text = f"{network.get('ssid', '')}" item = QListWidgetItem(item_text) item.setData(Qt.ItemDataRole.UserRole, network) self.saved_wifi_list.addItem(item) @@ -1016,7 +1158,11 @@ class SettingPage(QWidget): ssid_label = QLabel(decoded_ssid) 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;") item_layout.addWidget(ssid_label) @@ -1165,7 +1311,7 @@ class SettingPage(QWidget): self.refresh_current_status() def connect_to_saved_wifi(self): - """连接已保存列表中选中的网络""" + """连接已保存列表中选中的网络(后台线程)""" item = self.saved_wifi_list.currentItem() if item is None: self._styled_message(QMessageBox.Icon.Warning, self, "提示", "请先选择一个已保存网络") @@ -1176,14 +1322,38 @@ class SettingPage(QWidget): if network_id is None: self._styled_message(QMessageBox.Icon.Warning, self, "提示", "选中网络无效") return - try: - ok = self.wifi_manager.connect_network_id(network_id) - if not ok: - self._styled_message(QMessageBox.Icon.Critical, self, "错误", "连接请求下发失败") - return - self._styled_message(QMessageBox.Icon.Information, self, "成功", f"已发起连接: {ssid}") - except Exception as e: - self._styled_message(QMessageBox.Icon.Critical, self, "错误", f"连接失败: {str(e)}") + + # 按钮UI反馈 + self.connect_saved_button.setEnabled(False) + self.connect_saved_button.setText("连接中……") + + self._saved_connect_thread = QThread() + self._saved_connect_worker = WifiConnectSavedWorker(self.wifi_manager, network_id, ssid) + 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): item = self.saved_wifi_list.currentItem() @@ -1196,13 +1366,36 @@ class SettingPage(QWidget): if network_id is None: self._styled_message(QMessageBox.Icon.Warning, self, "提示", "选中网络无效,无法删除") 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.refresh_saved_wifi() - self.refresh_current_status() - except Exception as e: - self._styled_message(QMessageBox.Icon.Critical, self, "错误", f"删除失败: {str(e)}") + self.refresh_saved_wifi() + self.refresh_current_status() + + 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): ssid = self.ssid_input.text().strip() @@ -1222,23 +1415,37 @@ class SettingPage(QWidget): self._styled_message(QMessageBox.Icon.Warning, self, "警告", "WPA-EAP 认证需要身份和密码") return - try: - if auth_mode == "open": - ok = self.wifi_manager.connect_wifi(ssid, None) - elif auth_mode == "psk": - ok = self.wifi_manager.connect_wifi(ssid, password) - else: - ok = self.wifi_manager.connect_eap(ssid, identity, password) + # 按钮UI反馈 + self.connect_button.setEnabled(False) + self.connect_button.setText("连接中……") - if not ok: - self._styled_message(QMessageBox.Icon.Critical, self, "错误", "连接请求下发失败,请检查系统日志") - return + self._connect_thread = QThread() + self._connect_worker = WifiConnectWorker(self.wifi_manager, auth_mode, ssid, password, identity) + 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}") - self.refresh_saved_wifi() - self.refresh_current_status() - except Exception as e: - self._styled_message(QMessageBox.Icon.Critical, self, "错误", f"连接WiFi失败: {str(e)}") + def _on_connect_finished(self, ok, ssid): + self.connect_button.setEnabled(True) + self.connect_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_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): if index < 0: diff --git a/pages/status_page.py b/pages/status_page.py index 4697899..3247edb 100644 --- a/pages/status_page.py +++ b/pages/status_page.py @@ -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.QtGui import QColor, QPen, QPainter, QPainterPath, QFont, QLinearGradient, QBrush 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 = { @@ -138,52 +131,6 @@ class TempGauge(QWidget): 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): @@ -193,6 +140,7 @@ class StatusPage(QWidget): self.file_name = "None" self.progress = 0.0 + self.filepos = 0 self.display_name = "None" self.state = "Unknown" self.print_time = 0 @@ -205,6 +153,7 @@ class StatusPage(QWidget): self.config_parser = ConfigParse() self.config_parser.config_changed.connect(self._on_config_changed) self.gcode_dir = self.config_parser.gcode_dir + self._loaded_file = None self.init_ui() self.timer = QTimer(self) @@ -222,6 +171,7 @@ class StatusPage(QWidget): job = data.get("job", {}) self.file_name = job.get("job", {}).get("file", {}).get("name", "None") 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.state = status.get("state", {}).get("text", "Offline") 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.setContentsMargins(6, 6, 6, 6) - placeholder = QLabel("GCode 预览\n⚙ 待开发") - placeholder.setAlignment(Qt.AlignmentFlag.AlignCenter) - placeholder.setStyleSheet("color: #666666; font-size: 24px; font-weight: 600; border: none;") - right_layout.addWidget(placeholder) + self.gcode_viewer = GCodeViewerWidget() + right_layout.addWidget(self.gcode_viewer) main_layout.addWidget(left_frame, 2) 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._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 diff --git a/refer/test.py b/refer/test.py new file mode 100644 index 0000000..76598c0 --- /dev/null +++ b/refer/test.py @@ -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()) \ No newline at end of file diff --git a/refer/test_line_width.py b/refer/test_line_width.py new file mode 100644 index 0000000..308de68 --- /dev/null +++ b/refer/test_line_width.py @@ -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() diff --git a/refer/test_linewidth.py b/refer/test_linewidth.py new file mode 100644 index 0000000..a44d97b --- /dev/null +++ b/refer/test_linewidth.py @@ -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() diff --git a/refer/test_qmat.py b/refer/test_qmat.py new file mode 100644 index 0000000..ca48913 --- /dev/null +++ b/refer/test_qmat.py @@ -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) + diff --git a/refer/test_rot.py b/refer/test_rot.py new file mode 100644 index 0000000..15514d0 --- /dev/null +++ b/refer/test_rot.py @@ -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) + diff --git a/run.sh b/run.sh index 7fa4b19..8b9c8be 100755 --- a/run.sh +++ b/run.sh @@ -20,6 +20,6 @@ export QT_QPA_PLATFORM=eglfs # echo "Starting Printer Screen Menu..." # 启动界面 -python "$(dirname "$0")/main.py" > /dev/null 2>&1 -# python "$(dirname "$0")/main.py" +# python "$(dirname "$0")/main.py" > /dev/null 2>&1 +python "$(dirname "$0")/main.py" # .venv/bin/python main.py diff --git a/utils/aio_print_api.py b/utils/aio_print_api.py index 39e19c5..ac5a1b3 100644 --- a/utils/aio_print_api.py +++ b/utils/aio_print_api.py @@ -17,58 +17,58 @@ class AIOPrrintSystemAPI: def get_status(self): - # test_data = { - # 'job': { - # 'job': { - # 'estimatedPrintTime': 1234, - # 'filament': {'length': 765, 'volume': 24356}, - # 'file': {'display_name': 'Test File','date': None, 'name': '20260414135441_42bff5215c6148b8b5f4d8c4f15d5ddc.gcode', 'origin': 'local', 'path': None, 'size': 1468987}, - # 'lastPrintTime': None, - # 'user': None - # }, - # 'progress': { - # 'completion': 74.8, - # 'filepos': 1234, - # 'printTime': 1235, - # 'printTimeLeft': 6353, - # 'printTimeLeftOrigin': 5366 - # }, - # 'state': 'Operational' - # }, - # 'status': { - # 'sd': {'ready': False}, - # 'state': { - # 'error': '', - # 'flags': { - # 'cancelling': False, - # 'closedOrError': False, - # 'error': False, - # 'finishing': False, - # 'operational': True, - # 'paused': False, - # 'pausing': False, - # 'printing': False, - # 'ready': True, - # 'resuming': False, - # 'sdReady': False - # }, - # 'text': 'Operational test' - # }, - # 'temperature': { - # 'bed': {'actual': 85, 'offset': 0, 'target': 56}, - # 'tool0': {'actual': 0.0, 'offset': 0, 'target': 340} - # } - # } - # } - # return test_data + test_data = { + 'job': { + 'job': { + 'estimatedPrintTime': 1234, + 'filament': {'length': 765, 'volume': 24356}, + 'file': {'display_name': 'Test File','date': None, 'name': '20260508141659_085359c9908947bebcaa0fe7490641e8.gcode', 'origin': 'local', 'path': None, 'size': 1468987}, + 'lastPrintTime': None, + 'user': None + }, + 'progress': { + 'completion': 74.8, + 'filepos': 1234, + 'printTime': 1235, + 'printTimeLeft': 6353, + 'printTimeLeftOrigin': 5366 + }, + 'state': 'Operational' + }, + 'status': { + 'sd': {'ready': False}, + 'state': { + 'error': '', + 'flags': { + 'cancelling': False, + 'closedOrError': False, + 'error': False, + 'finishing': False, + 'operational': True, + 'paused': False, + 'pausing': False, + 'printing': False, + 'ready': True, + 'resuming': False, + 'sdReady': False + }, + 'text': 'Operational test' + }, + 'temperature': { + 'bed': {'actual': 85, 'offset': 0, 'target': 56}, + 'tool0': {'actual': 0.0, 'offset': 0, 'target': 340} + } + } + } + return test_data - url = f"{self.api_url}/status" - try: - r = requests.get(url, headers=self.headers, timeout=5) - r.raise_for_status() - return r.json() - except: - return {"status": {}, "job": {}} + # url = f"{self.api_url}/status" + # try: + # r = requests.get(url, headers=self.headers, timeout=5) + # r.raise_for_status() + # return r.json() + # except: + # return {"status": {}, "job": {}} def pause_print(self): return self._post_action("pause_print", action="pause") @@ -83,7 +83,7 @@ class AIOPrrintSystemAPI: return self._post_action("auto_leveling") 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): return self.send_gcode("M84") diff --git a/utils/auto_fan_status.py b/utils/auto_fan_status.py index 3cf207b..84e4ce2 100644 --- a/utils/auto_fan_status.py +++ b/utils/auto_fan_status.py @@ -5,6 +5,7 @@ from PyQt6.QtCore import QTimer class AutoFanStatus: def __init__(self, update_interval_ms=1000): self.cpu_temp = 0.0 + self.cpu_load = 0.0 # 1 分钟 CPU 负载 self.fan_speed = 0 self.fan_state = "Unknown" self.fan_rpm = 0 @@ -37,4 +38,12 @@ class AutoFanStatus: self.fan_speed = 0 self.fan_state = "Unknown" self.fan_rpm = 0 - self.is_auto_fan_service_running = False \ No newline at end of file + 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 \ No newline at end of file diff --git a/utils/config_parse.py b/utils/config_parse.py index cf46b1d..70c5e05 100644 --- a/utils/config_parse.py +++ b/utils/config_parse.py @@ -42,8 +42,6 @@ class ConfigParse(QObject): self._last_mtime = mtime except OSError: return - - print("Config changed") old_config = self.config new_config = self._load_config() @@ -68,4 +66,5 @@ class ConfigParse(QObject): self.gcode_dir = self.config.get("gcode_dir", 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.home_positions = self.config.get("home_positions", {"x": 134, "y": 123, "z": 10}) self.move_max_speed = self.config.get("move_max_speed", None) \ No newline at end of file diff --git a/utils/gcode_viewer.py b/utils/gcode_viewer.py new file mode 100644 index 0000000..54aecb8 --- /dev/null +++ b/utils/gcode_viewer.py @@ -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() \ No newline at end of file diff --git a/utils/wifi_manager.py b/utils/wifi_manager.py index 8b3a7ae..9f9ab0f 100644 --- a/utils/wifi_manager.py +++ b/utils/wifi_manager.py @@ -3,8 +3,9 @@ import time import re class WifiManager: - def __init__(self, interface="wlan0"): + def __init__(self, interface="wlan0", backend="nmcli"): self.interface = interface + self.backend = backend # "nmcli" or "wpa_cli" def _run_wpa_cli(self, *args): """执行 wpa_cli 命令""" @@ -16,128 +17,209 @@ class WifiManager: print(f"执行 wpa_cli {args} 失败: {e.stderr}") 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): - """列出已保存的 wifi(wpa_cli list_networks 以 Tab 分隔)""" - output = self._run_wpa_cli("list_networks") - networks = [] - lines = output.splitlines() - if len(lines) > 1: - for line in lines[1:]: # skip header - parts = line.split('\t') - if len(parts) >= 4: + if self.backend == "wpa_cli": + output = self._run_wpa_cli("list_networks") + networks = [] + lines = output.splitlines() + if len(lines) > 1: + for line in lines[1:]: + parts = line.split('\t') + 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({ "network_id": parts[0], "ssid": parts[1], - "bssid": parts[2], - "flags": parts[3] + "bssid": "", + "flags": parts[-1] }) - return networks + return networks def scan_networks(self): - """扫描范围内的 wifi""" - self._run_wpa_cli("scan") - time.sleep(3) # 等待扫描完成 - output = self._run_wpa_cli("scan_results") - networks = [] - lines = output.splitlines() - if len(lines) > 1: - for line in lines[1:]: - # bssid / frequency / signal level / flags / ssid - parts = line.split('\t') - if len(parts) >= 5: - networks.append({ - "bssid": parts[0], - "frequency": parts[1], - "signal_level": parts[2], - "flags": parts[3], - "ssid": parts[4] - }) - return networks + if self.backend == "wpa_cli": + self._run_wpa_cli("scan") + time.sleep(3) + output = self._run_wpa_cli("scan_results") + networks = [] + lines = output.splitlines() + if len(lines) > 1: + for line in lines[1:]: + parts = line.split('\t') + if len(parts) >= 5: + networks.append({ + "bssid": parts[0], + "frequency": parts[1], + "signal_level": parts[2], + "flags": parts[3], + "ssid": parts[4] + }) + 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): - """连接普通 Wi-Fi (WPA2-PSK)""" - network_id = self._run_wpa_cli("add_network") - if not network_id.isdigit(): - 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}"') + if self.backend == "wpa_cli": + network_id = self._run_wpa_cli("add_network") + if not network_id.isdigit(): 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}"') + else: self._run_wpa_cli("set_network", network_id, "key_mgmt", "NONE") + 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: - self._run_wpa_cli("set_network", network_id, "key_mgmt", "NONE") - - self._run_wpa_cli("enable_network", network_id) - self._run_wpa_cli("select_network", network_id) - self._run_wpa_cli("save_config") - return True + args = ["device", "wifi", "connect", ssid, "ifname", self.interface] + if password: + args.extend(["password", password]) + res = self._run_nmcli(*args) + return "successfully" in res.lower() or "成功" in res def connect_eap(self, ssid, identity, password): - """连接企业级 Wi-Fi (WPA-EAP PEAP/MSCHAPv2)""" - network_id = self._run_wpa_cli("add_network") - if not network_id.isdigit(): + if self.backend == "wpa_cli": + network_id = self._run_wpa_cli("add_network") + 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 - 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): - """通过 network_id 连接已保存的网络""" - ret1 = self._run_wpa_cli("select_network", str(network_id)) - self._run_wpa_cli("save_config") - return bool(ret1) + if self.backend == "wpa_cli": + ret1 = self._run_wpa_cli("select_network", str(network_id)) + self._run_wpa_cli("save_config") + 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): - """删除某个已保存的网络""" - self._run_wpa_cli("remove_network", str(network_id)) - self._run_wpa_cli("save_config") + if self.backend == "wpa_cli": + self._run_wpa_cli("remove_network", str(network_id)) + self._run_wpa_cli("save_config") + else: + self._run_nmcli("connection", "delete", "uuid", str(network_id)) def get_current_status(self): - """获取当前网络状态""" - output = self._run_wpa_cli("status") - status = {} - for line in output.splitlines(): - if '=' in line: - key, val = line.split('=', 1) - status[key] = val - return status + if self.backend == "wpa_cli": + output = self._run_wpa_cli("status") + status = {} + for line in output.splitlines(): + if '=' in line: + key, val = line.split('=', 1) + status[key] = val + 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): - """ - 开启热点 (AP 模式) - 注意:要求网卡和 wpa_supplicant 均支持 AP 模式 (mode=2) - """ - network_id = self._run_wpa_cli("add_network") - 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, "mode", "2") - self._run_wpa_cli("set_network", network_id, "key_mgmt", "WPA-PSK") - 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("select_network", network_id) - self._run_wpa_cli("save_config") - return network_id + if self.backend == "wpa_cli": + network_id = self._run_wpa_cli("add_network") + 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, "mode", "2") + self._run_wpa_cli("set_network", network_id, "key_mgmt", "WPA-PSK") + 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("select_network", network_id) + self._run_wpa_cli("save_config") + return network_id + else: + 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() + for net in networks: + if net.get("ssid") == ssid: + return net.get("network_id") + return ssid def close_hotspot(self, network_id=None): - """关闭热点""" - if network_id is not None: - self._run_wpa_cli("remove_network", str(network_id)) - self._run_wpa_cli("reconfigure") - self._run_wpa_cli("save_config") + if self.backend == "wpa_cli": + if network_id is not None: + self._run_wpa_cli("remove_network", str(network_id)) + self._run_wpa_cli("reconfigure") + 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__": - # Example Usage - wifi = WifiManager("wlan0") + wifi = WifiManager("wlan0", backend="nmcli") print("Current status:", wifi.get_current_status()) print("Saved networks:", wifi.list_saved_networks())