基本界面达成

This commit is contained in:
2026-05-11 00:21:16 +08:00
parent 649677f564
commit 65f221a5d8
13 changed files with 1818 additions and 347 deletions

17
config(example).json Normal file
View File

@@ -0,0 +1,17 @@
{
"api_url": "http://127.0.0.1:5001/api/v1",
"api_key": "############################################",
"gcode_dir": "/home/octo/.octoprint/uploads",
"move_axis_area": {
"x_min": -14,
"x_max": 222,
"y_min": -16,
"y_max": 228,
"z_min": -0.5,
"z_max": 250
},
"move_max_speed": {
"xy": 3000,
"z": 200
}
}

22
install.sh Normal file → Executable file
View File

@@ -2,4 +2,24 @@
# apt install -y python3-pyqt6.qtwebengine
# apt install -y python3-pyqt6.qtquick3d
# apt install -y python3-pyqt6.qtquick
# apt install -y python3-pyqt6.qtquick
echo "Installing Printer Screen Menu..."
echo "Cleaning up old installation..."
rm -rf /etc/systemd/system/printer-screen.service
rm -rf /opt/Printer_Screen_Menu
echo "Copying new files..."
mkdir -p /opt/Printer_Screen_Menu
cp -r ./. /opt/Printer_Screen_Menu/
echo "Setting up systemd service..."
chmod +x /opt/Printer_Screen_Menu/run.sh
systemctl disable printer-screen.service
cp printer-screen.service /etc/systemd/system/
systemctl daemon-reload
systemctl enable printer-screen.service
systemctl start printer-screen.service
echo "Installation complete. Printer Screen Menu should now be running."

108
main.py
View File

@@ -1,36 +1,96 @@
import sys
import os
import json
import time
from datetime import datetime
from PyQt6.QtWidgets import (QApplication, QWidget, QVBoxLayout, QHBoxLayout,
QPushButton, QLabel, QStackedWidget)
from PyQt6.QtCore import Qt, QSize
from PyQt6.QtCore import Qt, QSize, QTimer
from PyQt6.QtGui import QIcon, QFont
from pages.status_page import StatusPage
from pages.control_page import ControlPage
from pages.setting_page import SettingPage
from utils.aio_print_api import AIOPrrintSystemAPI
from utils.auto_fan_status import AutoFanStatus
from utils.config_parse import ConfigParse
from utils.wifi_manager import WifiManager
def load_config():
config_path = os.path.join(os.path.dirname(__file__), "config.json")
try:
with open(config_path, "r", encoding="utf-8") as f:
return json.load(f)
except Exception as e:
print(f"Failed to load config: {e}")
return {}
class MainWindow(QWidget):
def __init__(self):
super().__init__()
# 初始化 API 客户端
config = load_config()
self.config_parser = ConfigParse()
self.config_parser.config_changed.connect(self._on_config_changed)
self.api_client = AIOPrrintSystemAPI(
api_url=config.get("api_url", "http://127.0.0.1:5001/api/v1"),
api_key=config.get("api_key", "")
api_url=self.config_parser.api_url,
api_key=self.config_parser.api_key
)
self.auto_fan_status = AutoFanStatus()
self.wifi_manager = WifiManager()
self._last_network_check = 0.0
self._is_network_connected = False
self.init_ui()
# 定时刷新风扇状态显示
self._fan_timer = QTimer(self)
self._fan_timer.timeout.connect(self._update_top_bar)
self._fan_timer.start(1000)
def _check_network(self):
"""检查网络连接状态每30秒检测一次避免频繁调用"""
now = time.time()
if now - self._last_network_check < 30:
return self._is_network_connected
self._last_network_check = now
try:
status = self.wifi_manager.get_current_status()
self._is_network_connected = status.get("wpa_state") == "COMPLETED"
except Exception:
self._is_network_connected = False
return self._is_network_connected
def _on_config_changed(self, config_instance):
"""配置变化时更新 API 客户端等相关设置"""
self.api_client = AIOPrrintSystemAPI(
api_url=self.config_parser.api_url,
api_key=self.config_parser.api_key
)
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)
speed = f"{speed_pct:.1f}%" if s.is_auto_fan_service_running else "--.-%"
state = s.fan_state if s.is_auto_fan_service_running else "Auto Fan service is not running"
rpm = f"{s.fan_rpm:.1f} r/min" if s.is_auto_fan_service_running else "--.-- r/min"
# 根据运行状态改变颜色
if s.is_auto_fan_service_running:
if s.fan_state == "Stalled":
color = "#e86c60" # 风扇异常-红色
else:
color = "#a0d8a0" # 正常-浅绿色
else:
color = "#e8a060" # 异常-橙色
self._top_bar.setStyleSheet(
f"background-color: #2a2a2a; color: {color}; "
f"font-size: 18px; font-weight: 600; padding: 4px 16px;"
)
self._fan_label.setText(
f"🌡 {temp} {state} 𖣘 {speed} {rpm}"
)
# 更新时钟(有网络时显示)
if self._check_network():
now = datetime.now()
self._clock_label.setText(now.strftime("%H:%M:%S"))
self._clock_label.show()
else:
self._clock_label.hide()
def init_ui(self):
# 整体布局
main_layout = QVBoxLayout(self)
@@ -50,6 +110,27 @@ class MainWindow(QWidget):
self.stacked_widget.addWidget(self.page_control)
self.stacked_widget.addWidget(self.page_settings)
# 风扇状态横条
self._top_bar = QWidget()
self._top_bar.setFixedHeight(36)
self._top_bar.setStyleSheet(
"background-color: #2a2a2a; color: #a0d8a0; "
"font-size: 18px; font-weight: 600; padding: 4px 16px;"
)
top_layout = QHBoxLayout(self._top_bar)
top_layout.setContentsMargins(16, 0, 16, 0)
self._fan_label = QLabel("🌡 --°C -- 𖣘 --% -- r/min")
self._fan_label.setAlignment(Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter)
top_layout.addWidget(self._fan_label)
top_layout.addStretch()
# 时钟标签(有网络时显示)
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;")
self._clock_label.hide()
top_layout.addWidget(self._clock_label)
# 底部按钮区
bottom_layout = QHBoxLayout()
bottom_layout.setContentsMargins(10, 10, 10, 10)
@@ -70,7 +151,8 @@ class MainWindow(QWidget):
bottom_layout.addWidget(self.btn_control)
bottom_layout.addWidget(self.btn_settings)
# 将主显示区底部按钮加入整体布局
# 将主显示区、风扇横条、底部按钮加入整体布局
main_layout.addWidget(self._top_bar)
main_layout.addWidget(self.stacked_widget)
main_layout.addWidget(bottom_widget)

View File

@@ -1,26 +1,535 @@
from PyQt6.QtWidgets import QWidget, QVBoxLayout, QHBoxLayout, QPushButton, QLabel
from PyQt6.QtCore import Qt
from PyQt6.QtWidgets import (
QWidget, QVBoxLayout, QHBoxLayout, QPushButton, QLabel,
QFrame, QGridLayout, QSizePolicy, QLineEdit,
)
from PyQt6.QtCore import Qt, QTimer, QPointF, QRectF
from PyQt6.QtGui import QFont, QPainter, QColor, QBrush, QPen, QDoubleValidator
from utils.config_parse import ConfigParse
MOVE_STEP = 10 # 每次点击移动 mm (保留备用)
MOVE_DETECT_MS = 100
class JoystickWidget(QWidget):
"""圆形 XY 摇杆 — 拖动小圆控制方向,松开回中,越远越快"""
def __init__(self, parent=None, max_speed=3000):
super().__init__(parent)
self.setFixedSize(320, 320)
self.setMouseTracking(True)
self._max_speed = max_speed # 最大进给速度 mm/min
self._radius_outer = 140 # 外圈半径
self._radius_inner = 30 # 内圈小圆半径
self._deadzone = 10 # 死区像素
# 当前小圆偏移(相对中心,像素)
self._dx = 0.0
self._dy = 0.0
self._pressed = False
# 定时发送 GCode
self._move_timer = QTimer(self)
self._move_timer.setInterval(120) # ~8次/秒
self._move_timer.timeout.connect(self._emit_move)
# 回调(上层设置)
self.on_move = None # callable(dx_norm, dy_norm, feedrate)
def _emit_move(self):
"""定时器触发:计算速度方向并回调"""
dist = (self._dx ** 2 + self._dy ** 2) ** 0.5
if dist < self._deadzone:
return
ratio = min(dist / (self._radius_outer - self._radius_inner), 1.0)
# 速度映射30% ~ 100% * max_speed
speed = int((0.3 + ratio * 0.7) * self._max_speed)
if dist > 0:
nx = self._dx / dist
ny = self._dy / dist
if self.on_move:
self.on_move(nx, ny, speed)
def paintEvent(self, event):
painter = QPainter(self)
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
cx, cy = self.width() / 2, self.height() / 2
# 外圈背景
painter.setBrush(QBrush(QColor("#2a2a2a")))
painter.setPen(QPen(QColor("#666666"), 3))
painter.drawEllipse(QPointF(cx, cy), self._radius_outer, self._radius_outer)
# 十字准线(浅)
painter.setPen(QPen(QColor("#555555"), 1))
painter.drawLine(int(cx - self._radius_outer), int(cy),
int(cx + self._radius_outer), int(cy))
painter.drawLine(int(cx), int(cy - self._radius_outer),
int(cx), int(cy + self._radius_outer))
# 小圆(可拖动)
knob_x = cx + self._dx
knob_y = cy + self._dy
painter.setBrush(QBrush(QColor("#4a9fc8" if self._pressed else "#6fb8dd")))
painter.setPen(QPen(QColor("#2f6f91"), 2))
painter.drawEllipse(QPointF(knob_x, knob_y), self._radius_inner, self._radius_inner)
# 中心小点标记
painter.setBrush(QBrush(QColor("#888888")))
painter.setPen(Qt.PenStyle.NoPen)
painter.drawEllipse(QPointF(cx, cy), 4, 4)
def mousePressEvent(self, event):
if event.button() == Qt.MouseButton.LeftButton:
self._pressed = True
self._update_knob(event.position())
self._move_timer.start()
def mouseMoveEvent(self, event):
if self._pressed:
self._update_knob(event.position())
def mouseReleaseEvent(self, event):
if event.button() == Qt.MouseButton.LeftButton:
self._pressed = False
self._move_timer.stop()
self._dx = 0.0
self._dy = 0.0
self.update()
def _update_knob(self, pos):
cx, cy = self.width() / 2, self.height() / 2
dx = pos.x() - cx
dy = pos.y() - cy
max_r = self._radius_outer - self._radius_inner
dist = (dx ** 2 + dy ** 2) ** 0.5
if dist > max_r:
dx = dx / dist * max_r
dy = dy / dist * max_r
self._dx = dx
self._dy = dy
self.update()
class ZFaderWidget(QWidget):
"""Z 轴拨杆 — 上下拖动控制 Z 速度,松开回中,越远越快"""
def __init__(self, parent=None, max_speed=200):
super().__init__(parent)
self.setFixedSize(120, 320)
self.setMouseTracking(True)
self._max_speed = max_speed
self._track_h = 260 # 滑道高度
self._knob_h = 40 # 滑块高度
self._knob_w = 80 # 滑块宽度
self._deadzone = 8
self._dy = 0.0 # 相对中心的偏移
self._pressed = False
self._move_timer = QTimer(self)
self._move_timer.setInterval(MOVE_DETECT_MS)
self._move_timer.timeout.connect(self._emit_move)
self.on_move = None # callable(direction, feedrate) direction: +1 or -1
def _emit_move(self):
dist = abs(self._dy)
if dist < self._deadzone:
return
max_dist = (self._track_h - self._knob_h) / 2
ratio = min(dist / max_dist, 1.0)
# speed = int((0.3 + ratio * 0.7) * self._max_speed / 10)
speed = ratio * self._max_speed / (1000/MOVE_DETECT_MS)
direction = 1 if self._dy < 0 else -1 # dy<0 → 向上
if self.on_move:
self.on_move(direction, speed)
def paintEvent(self, event):
painter = QPainter(self)
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
cx = self.width() / 2
# 滑道
rect = QRectF(cx - 12, (self.height() - self._track_h) / 2, 24, self._track_h)
painter.setBrush(QBrush(QColor("#2a2a2a")))
painter.setPen(QPen(QColor("#666666"), 2))
painter.drawRoundedRect(rect, 12, 12)
# 中心刻度线
mid_y = self.height() / 2
painter.setPen(QPen(QColor("#777777"), 2))
painter.drawLine(int(cx - 20), int(mid_y), int(cx + 20), int(mid_y))
# 滑块
knob_center_y = mid_y + self._dy
knob_y = knob_center_y - self._knob_h / 2
knob_rect = QRectF(cx - self._knob_w / 2, knob_y, self._knob_w, self._knob_h)
color = "#4a9fc8" if self._pressed else "#6fb8dd"
painter.setBrush(QBrush(QColor(color)))
painter.setPen(QPen(QColor("#2f6f91"), 2))
painter.drawRoundedRect(knob_rect, 10, 10)
# Z 标签
painter.setPen(QPen(QColor("#a0a0a0"), 1))
font = QFont("sans-serif", 14, QFont.Weight.Bold)
painter.setFont(font)
painter.drawText(knob_rect, Qt.AlignmentFlag.AlignCenter, "Z")
def mousePressEvent(self, event):
if event.button() == Qt.MouseButton.LeftButton:
self._pressed = True
self._update_knob(event.position().y())
self._move_timer.start()
def mouseMoveEvent(self, event):
if self._pressed:
self._update_knob(event.position().y())
def mouseReleaseEvent(self, event):
if event.button() == Qt.MouseButton.LeftButton:
self._pressed = False
self._move_timer.stop()
self._dy = 0.0
self.update()
def _update_knob(self, py):
mid_y = self.height() / 2
max_d = (self._track_h - self._knob_h) / 2
dy = py - mid_y
dy = max(-max_d, min(max_d, dy))
self._dy = dy
self.update()
class ControlPage(QWidget):
def __init__(self, api_client, parent=None):
super().__init__(parent)
self.api_client = api_client
self.config_parser = ConfigParse()
self.config_parser.config_changed.connect(self._on_config_changed)
self._load_limits()
self._load_speeds()
# 当前坐标(本地跟踪)
self.pos_x = 0.0
self.pos_y = 0.0
self.pos_z = 0.0
self._is_printing = False
self._homed = False # 是否已轴回零
self._motor_on = True # 电机是否已使能默认True关电机后置False
self.init_ui()
self._sync_inputs()
self._apply_state()
self._start_status_poll()
def _load_speeds(self):
ms = self.config_parser.move_max_speed or {}
self.move_speed_xy = ms.get("xy", 3000)
self.move_speed_z = ms.get("z", 200)
def _start_status_poll(self):
self._status_timer = QTimer(self)
self._status_timer.timeout.connect(self._poll_status)
self._status_timer.start(2000)
def _poll_status(self):
"""定时轮询打印机状态,更新按钮启用状态和坐标"""
try:
data = self.api_client.get_status()
flags = data.get("status", {}).get("state", {}).get("flags", {})
printing = flags.get("printing", False)
paused = flags.get("paused", False)
self._is_printing = printing or paused
except Exception:
self._is_printing = False
self._update_buttons()
def _load_limits(self):
ma = self.config_parser.move_axis_area or {}
self.x_min = ma.get("x_min", 0)
self.x_max = ma.get("x_max", 300)
self.y_min = ma.get("y_min", 0)
self.y_max = ma.get("y_max", 300)
self.z_min = ma.get("z_min", 0)
self.z_max = ma.get("z_max", 300)
def _on_config_changed(self, config_instance):
self._load_limits()
self._load_speeds()
print(f"z-max-s:{self.move_speed_z}")
# ── 状态管理 ──────────────────────────────────────────
def _is_control_enabled(self):
"""摇杆/拨杆/坐标输入是否可用:已回零 + 电机已使能 + 非打印中"""
return self._homed and self._motor_on and not self._is_printing
def _apply_state(self):
"""根据回零/电机状态同步所有控件"""
enabled = self._is_control_enabled()
self.joystick.setEnabled(enabled)
self.z_fader.setEnabled(enabled)
for inp in (self.input_x, self.input_y, self.input_z):
inp.setEnabled(enabled)
if not enabled:
inp.setText("---")
if enabled:
self._sync_inputs()
def _sync_inputs(self):
"""将 pos 值同步到输入框"""
if self._is_control_enabled():
self.input_x.setText(f"{self.pos_x:.3f}")
self.input_y.setText(f"{self.pos_y:.3f}")
self.input_z.setText(f"{self.pos_z:.3f}")
def _on_coord_changed(self):
"""输入框手动输入触发运动"""
if not self._is_control_enabled():
return
try:
tx = float(self.input_x.text())
ty = float(self.input_y.text())
tz = float(self.input_z.text())
except ValueError:
return
tx = max(self.x_min, min(self.x_max, tx))
ty = max(self.y_min, min(self.y_max, ty))
tz = max(self.z_min, min(self.z_max, tz))
gcode = f"G1 X{tx:.3f} Y{ty:.3f} Z{tz:.3f} F3000"
self.api_client.send_gcode(gcode)
self.pos_x, self.pos_y, self.pos_z = tx, ty, tz
self._sync_inputs()
def _update_buttons(self):
printing = self._is_printing
self.btn_pause.setEnabled(printing)
self.btn_stop.setEnabled(printing)
idle = not printing
self.btn_home.setEnabled(idle)
self.btn_level.setEnabled(idle)
self.btn_motor_off.setEnabled(idle)
self._apply_state()
# ── GCode 发送 ──────────────────────────────────────────
def _send_move(self, x=None, y=None, z=None, feedrate=None):
if not self._is_control_enabled():
return
target_x = self.pos_x if x is None else x
target_y = self.pos_y if y is None else y
target_z = self.pos_z if z is None else z
target_x = max(self.x_min, min(self.x_max, target_x))
target_y = max(self.y_min, min(self.y_max, target_y))
target_z = max(self.z_min, min(self.z_max, target_z))
f = feedrate or 3000
gcode = f"G1 X{target_x:.3f} Y{target_y:.3f} Z{target_z:.3f} F{f}"
self.api_client.send_gcode(gcode)
self.pos_x, self.pos_y, self.pos_z = target_x, target_y, target_z
self._sync_inputs()
# ── 摇杆/拨杆回调 ──
def _on_joystick_move(self, nx, ny, speed):
if not self._is_control_enabled():
return
step = 5.0
dx = nx * step
dy = ny * step
tx = max(self.x_min, min(self.x_max, self.pos_x + dx))
ty = max(self.y_min, min(self.y_max, self.pos_y + dy))
gcode = f"G1 X{tx:.1f} Y{ty:.1f} F{speed}"
self.api_client.send_gcode(gcode)
self.pos_x, self.pos_y = tx, ty
self._sync_inputs()
def _on_fader_move(self, direction, speed):
if not self._is_control_enabled():
return
print(f"d:{direction} s:{speed}")
step = speed * MOVE_DETECT_MS / 1000
tz = self.pos_z + direction * step
tz = max(self.z_min, min(self.z_max, tz))
gcode = f"G1 Z{tz:.3f} F{speed}"
self.api_client.send_gcode(gcode)
self.pos_z = tz
self._sync_inputs()
# ── 指令按钮 ──
def _cmd_pause(self):
self.api_client.pause_print()
def _cmd_stop(self):
self.api_client.stop_print()
def _cmd_home(self):
self.api_client.home_axes(["x", "y", "z"])
self.pos_x = self.pos_y = self.pos_z = 0.0
self._homed = True
self._motor_on = True
self._sync_inputs()
self._apply_state()
def _cmd_level(self):
self.api_client.auto_leveling()
def _cmd_motor_off(self):
self.api_client.off_motors()
self._motor_on = False
self._apply_state()
# ── UI 构建 ──────────────────────────────────────────────
def init_ui(self):
layout = QVBoxLayout()
label = QLabel("控制页面")
label.setAlignment(Qt.AlignmentFlag.AlignCenter)
layout.addWidget(label)
# 添加一些控制按钮
button_layout = QHBoxLayout()
start_button = QPushButton("开始打印")
stop_button = QPushButton("停止打印")
button_layout.addWidget(start_button)
button_layout.addWidget(stop_button)
layout.addLayout(button_layout)
self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed)
main_layout = QHBoxLayout(self)
main_layout.setContentsMargins(14, 14, 14, 14)
main_layout.setSpacing(14)
self.setLayout(layout)
# =========================== 左栏 ===========================
left_panel = QFrame()
left_panel.setStyleSheet("background-color: #3f3f3f; border-radius: 10px;")
left_panel.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed)
left_layout = QVBoxLayout(left_panel)
left_layout.setContentsMargins(16, 14, 16, 14)
left_layout.setSpacing(8)
left_title = QLabel("控制")
left_title.setAlignment(Qt.AlignmentFlag.AlignCenter)
left_title.setStyleSheet("color: #efefef; font-size: 22px; font-weight: 700;")
left_layout.addWidget(left_title)
btn_style = """
QPushButton {
min-height: 50px;
font-size: 18px;
font-weight: 600;
color: #f8f8f8;
background-color: #555555;
border: 2px solid #888888;
border-radius: 8px;
padding: 4px 12px;
}
QPushButton:hover {
background-color: #636363;
border-color: #aaaaaa;
}
QPushButton:pressed {
background-color: #3d3d3d;
border-color: #5a9fcf;
}
QPushButton:disabled {
color: #606060;
background-color: #3a3a3a;
border-color: #505050;
}
"""
grid_btns = QGridLayout()
grid_btns.setSpacing(8)
self.btn_pause = QPushButton("⏸ 暂停打印")
self.btn_pause.setStyleSheet(btn_style)
self.btn_pause.clicked.connect(self._cmd_pause)
grid_btns.addWidget(self.btn_pause, 0, 0)
self.btn_stop = QPushButton("⏹ 停止打印")
self.btn_stop.setStyleSheet(btn_style)
self.btn_stop.clicked.connect(self._cmd_stop)
grid_btns.addWidget(self.btn_stop, 0, 1)
self.btn_home = QPushButton("⌂ 轴回零")
self.btn_home.setStyleSheet(btn_style)
self.btn_home.clicked.connect(self._cmd_home)
grid_btns.addWidget(self.btn_home, 1, 0)
self.btn_level = QPushButton("◉ 自动调平")
self.btn_level.setStyleSheet(btn_style)
self.btn_level.clicked.connect(self._cmd_level)
grid_btns.addWidget(self.btn_level, 1, 1)
self.btn_motor_off = QPushButton("⏻ 关电机")
self.btn_motor_off.setStyleSheet(btn_style)
self.btn_motor_off.clicked.connect(self._cmd_motor_off)
grid_btns.addWidget(self.btn_motor_off, 2, 0, 1, 2)
left_layout.addLayout(grid_btns)
main_layout.addWidget(left_panel, 2)
# =========================== 右栏 ===========================
right_panel = QFrame()
right_panel.setStyleSheet("background-color: #3f3f3f; border-radius: 10px;")
right_panel.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed)
right_layout = QVBoxLayout(right_panel)
right_layout.setContentsMargins(16, 14, 16, 14)
right_layout.setSpacing(10)
# ── 坐标输入 ──
coord_row = QHBoxLayout()
coord_row.setSpacing(12)
coord_row.setAlignment(Qt.AlignmentFlag.AlignCenter)
input_style = """
QLineEdit {
min-width: 80px;
max-width: 120px;
min-height: 44px;
font-size: 24px;
font-weight: 700;
color: #e0e0e0;
background-color: #2a2a2a;
border: 2px solid #646464;
border-radius: 8px;
padding: 4px 10px;
text-align: center;
}
QLineEdit:focus {
border-color: #5a9fcf;
}
QLineEdit:disabled {
color: #606060;
background-color: #222222;
border-color: #444444;
}
"""
for axis, val in [("X", 0.0), ("Y", 0.0), ("Z", 0.0)]:
lbl = QLabel(f"{axis}:")
lbl.setStyleSheet("color: #d0d0d0; font-size: 24px; font-weight: 700;")
coord_row.addWidget(lbl)
inp = QLineEdit(f"{val:.1f}")
inp.setStyleSheet(input_style)
inp.setAlignment(Qt.AlignmentFlag.AlignCenter)
inp.setValidator(QDoubleValidator(-9999, 9999, 1))
inp.returnPressed.connect(self._on_coord_changed)
setattr(self, f"input_{axis.lower()}", inp)
coord_row.addWidget(inp)
right_layout.addLayout(coord_row)
# ── 操作器 ──
manip_row = QHBoxLayout()
manip_row.setSpacing(14)
manip_row.setAlignment(Qt.AlignmentFlag.AlignCenter)
self.joystick = JoystickWidget(max_speed=self.move_speed_xy)
self.joystick.on_move = self._on_joystick_move
manip_row.addWidget(self.joystick)
self.z_fader = ZFaderWidget(max_speed=self.move_speed_z)
self.z_fader.on_move = self._on_fader_move
manip_row.addWidget(self.z_fader)
right_layout.addLayout(manip_row)
main_layout.addWidget(right_panel, 3)
self._update_buttons()

View File

@@ -14,10 +14,16 @@ from PyQt6.QtWidgets import (
QFrame,
QScrollArea,
QAbstractItemView,
QApplication,
QCheckBox,
)
from PyQt6.QtCore import Qt, QEvent, QObject, QThread, QTimer, pyqtSignal
from PyQt6.QtGui import QMouseEvent
from PyQt6.QtCore import Qt, QEvent, QObject, QThread, QTimer, QSize, pyqtSignal
from PyQt6.QtGui import QMouseEvent, QPixmap, QImage, QFont
import codecs
import io
import os
import subprocess
import qrcode
from utils.wifi_manager import WifiManager
from utils.floating_keyboard import FloatingKeyboard
@@ -139,42 +145,85 @@ class SettingPage(QWidget):
self._keyboard = FloatingKeyboard()
self._keyboard_attached = False
# 监听全局焦点变化,可靠处理键盘消失
self._focus_changed_connected = False
self.init_ui()
self._start_status_timer()
def _on_app_focus_changed(self, old_widget, new_widget):
"""全局焦点变化时检查是否需要关闭键盘"""
if not self._keyboard_attached:
return
# 如果新焦点在输入控件上,什么都不做
if isinstance(new_widget, (QLineEdit, QComboBox, QPushButton)):
return
# 如果新焦点是键盘自身的子控件,也不关闭
if new_widget and self._keyboard.isAncestorOf(new_widget):
return
# 如果新焦点是键盘本身,不关闭
if new_widget is self._keyboard:
return
# 如果是热点开关按钮的 ON/OFF 文本区域,不关闭
if isinstance(new_widget, QWidget) and new_widget.parent() is self.hotspot_toggle:
return
# 其他情况 → 关闭键盘
self._dismiss_keyboard()
def _connect_focus_signal(self):
"""连接全局焦点信号"""
if not self._focus_changed_connected:
QApplication.instance().focusChanged.connect(self._on_app_focus_changed)
self._focus_changed_connected = True
def _disconnect_focus_signal(self):
"""断开全局焦点信号"""
if self._focus_changed_connected:
QApplication.instance().focusChanged.disconnect(self._on_app_focus_changed)
self._focus_changed_connected = False
def _attach_keyboard(self, widget):
"""将悬浮键盘绑定到指定输入框并显示"""
self._keyboard.attach(widget)
self._keyboard.show_below(widget)
self._keyboard_attached = True
self._connect_focus_signal()
def _dismiss_keyboard(self):
"""关闭悬浮键盘"""
self._keyboard.hide()
self._keyboard.detach()
self._keyboard_attached = False
self._disconnect_focus_signal()
def _on_input_focus_in(self, widget):
"""输入框获得焦点时的处理"""
self._attach_keyboard(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.ssid_input, self.identity_input, self.password_input):
self._attach_keyboard(obj)
if obj in (self.ssid_input, self.identity_input, self.password_input,
self.hotspot_ssid, self.hotspot_password):
self._on_input_focus_in(obj)
# 如果不是输入框获得焦点且键盘正显示,则关闭键盘
elif self._keyboard_attached and not isinstance(obj, (QLineEdit, QComboBox, QPushButton)):
self._dismiss_keyboard()
elif event.type() == QEvent.Type.FocusOut:
# 延迟检查:如果焦点真的离开了所有输入框,就关闭键盘
if obj in (self.ssid_input, self.identity_input, self.password_input):
if obj in (self.ssid_input, self.identity_input, self.password_input,
self.hotspot_ssid, self.hotspot_password):
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.ssid_input, self.identity_input, self.password_input, self.auth_combo):
if w not in (self.ssid_input, self.identity_input, self.password_input,
self.auth_combo, self.hotspot_ssid, self.hotspot_password):
self._dismiss_keyboard()
@staticmethod
@@ -262,25 +311,23 @@ class SettingPage(QWidget):
"""
)
self.item_list.addItem("WiFi设置")
self.item_list.addItem("热点设置")
self.item_list.addItem("电源设置")
self.item_list.addItem("其它设置(待定)")
self.item_list.currentRowChanged.connect(self.display_setting)
left_layout.addWidget(self.item_list)
layout.addWidget(left_panel, 1)
# 右侧设置参数区域(放入可滚动区域,便于触屏上下滑动)
# 右侧设置参数区域
self.settings_stack = QStackedWidget()
self.settings_stack.setStyleSheet("background-color: #444444; border-radius: 10px;")
# 每个 init 方法返回一个包裹了 DragScrollArea 的页面
self.init_wifi_settings()
self.init_hotspot_settings()
self.init_power_settings()
self.init_todo_settings()
right_scroll = DragScrollArea()
right_scroll.setWidgetResizable(True)
right_scroll.setFrameShape(QFrame.Shape.NoFrame)
# 将堆叠面板放入 scroll area
right_scroll.setWidget(self.settings_stack)
right_scroll.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded)
right_scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
layout.addWidget(right_scroll, 3)
layout.addWidget(self.settings_stack, 3)
self.item_list.setCurrentRow(0)
@@ -527,11 +574,316 @@ class SettingPage(QWidget):
wifi_layout.addWidget(connect_button)
wifi_layout.addStretch()
self.settings_stack.addWidget(wifi_widget)
self.settings_stack.addWidget(self._wrap_scroll(wifi_widget))
self.update_auth_fields()
self.refresh_saved_wifi()
self.refresh_current_status()
def init_hotspot_settings(self):
hotspot_widget = QWidget()
hotspot_layout = QVBoxLayout(hotspot_widget)
hotspot_layout.setContentsMargins(20, 20, 20, 20)
hotspot_layout.setSpacing(16)
title = QLabel("热点设置")
title.setStyleSheet("color: #f2f2f2; font-size: 26px; font-weight: 700;")
hotspot_layout.addWidget(title)
# ── 左右分栏 ──
split_row = QHBoxLayout()
split_row.setSpacing(20)
# ====== 左栏:信息设置 ======
left_col = QVBoxLayout()
left_col.setSpacing(12)
desc = QLabel("将设备设置为WiFi热点其他设备可扫描连接")
desc.setStyleSheet("color: #aaaaaa; font-size: 16px;")
desc.setWordWrap(True)
left_col.addWidget(desc)
# SSID 输入
ssid_label = QLabel("热点名称 (SSID)")
ssid_label.setStyleSheet("color: #d0d0d0; font-size: 18px; font-weight: 500;")
left_col.addWidget(ssid_label)
self.hotspot_ssid = QLineEdit("PrinterScreen-AP")
self.hotspot_ssid.setPlaceholderText("输入热点名称")
self.hotspot_ssid.setStyleSheet(
"min-height: 44px; font-size: 20px; color: #f2f2f2; "
"background-color: #3a3a3a; border: 2px solid #707070; "
"border-radius: 8px; padding: 4px 12px;"
)
self.hotspot_ssid.installEventFilter(self)
left_col.addWidget(self.hotspot_ssid)
# 密码输入
pw_label = QLabel("热点密码")
pw_label.setStyleSheet("color: #d0d0d0; font-size: 18px; font-weight: 500;")
left_col.addWidget(pw_label)
self.hotspot_password = QLineEdit("12345678")
self.hotspot_password.setPlaceholderText("至少8位")
self.hotspot_password.setStyleSheet(
"min-height: 44px; font-size: 20px; color: #f2f2f2; "
"background-color: #3a3a3a; border: 2px solid #707070; "
"border-radius: 8px; padding: 4px 12px;"
)
self.hotspot_password.installEventFilter(self)
left_col.addWidget(self.hotspot_password)
# 热点开关行
toggle_row = QHBoxLayout()
toggle_row.setSpacing(16)
toggle_label = QLabel("热点开关")
toggle_label.setStyleSheet("color: #d0d0d0; font-size: 20px; font-weight: 600;")
toggle_row.addWidget(toggle_label)
self.hotspot_toggle = QPushButton()
self.hotspot_toggle.setCheckable(True)
self.hotspot_toggle.setFixedSize(80, 40)
self.hotspot_toggle.setCursor(Qt.CursorShape.PointingHandCursor)
self._apply_toggle_style(False)
self.hotspot_toggle.toggled.connect(self._on_hotspot_toggled)
toggle_row.addWidget(self.hotspot_toggle)
toggle_row.addStretch()
left_col.addLayout(toggle_row)
# 热点状态标签
self.hotspot_status = QLabel("热点状态:关闭")
self.hotspot_status.setStyleSheet("color: #cccccc; font-size: 17px;")
left_col.addWidget(self.hotspot_status)
left_col.addStretch()
split_row.addLayout(left_col, 1)
# ====== 右栏:二维码 ======
right_col = QVBoxLayout()
right_col.setSpacing(8)
right_col.setAlignment(Qt.AlignmentFlag.AlignCenter)
qr_title = QLabel("扫码连接")
qr_title.setStyleSheet("color: #dcdcdc; font-size: 20px; font-weight: 600;")
qr_title.setAlignment(Qt.AlignmentFlag.AlignCenter)
right_col.addWidget(qr_title)
self.qr_label = QLabel()
self.qr_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
self.qr_label.setFixedSize(240, 240)
self.qr_label.setStyleSheet(
"background-color: #ffffff; border: 2px solid #646464; border-radius: 8px;"
)
right_col.addWidget(self.qr_label, alignment=Qt.AlignmentFlag.AlignCenter)
self.qr_hint = QLabel("开启热点后自动生成二维码")
self.qr_hint.setAlignment(Qt.AlignmentFlag.AlignCenter)
self.qr_hint.setStyleSheet("color: #909090; font-size: 15px;")
right_col.addWidget(self.qr_hint)
right_col.addStretch()
split_row.addLayout(right_col, 1)
hotspot_layout.addLayout(split_row)
self.settings_stack.addWidget(self._wrap_scroll(hotspot_widget))
def _apply_toggle_style(self, checked):
if checked:
self.hotspot_toggle.setStyleSheet("""
QPushButton {
background-color: #4CAF50;
border: 2px solid #66BB6A;
border-radius: 20px;
font-size: 16px;
font-weight: bold;
color: white;
}
QPushButton:checked {
background-color: #4CAF50;
border: 2px solid #66BB6A;
}
""")
self.hotspot_toggle.setText("ON")
else:
self.hotspot_toggle.setStyleSheet("""
QPushButton {
background-color: #555555;
border: 2px solid #707070;
border-radius: 20px;
font-size: 16px;
font-weight: bold;
color: #aaaaaa;
}
QPushButton:checked {
background-color: #4CAF50;
border: 2px solid #66BB6A;
}
""")
self.hotspot_toggle.setText("OFF")
def _on_hotspot_toggled(self, checked):
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)
return
if len(password) < 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
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)
else:
try:
self.wifi_manager.close_hotspot()
except Exception:
pass
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("开启热点后自动生成二维码")
def _generate_qr_code(self, ssid, password):
"""生成 WiFi 二维码并显示"""
wifi_str = f"WIFI:S:{ssid};T:WPA;P:{password};;"
try:
qr = qrcode.QRCode(box_size=6, border=2)
qr.add_data(wifi_str)
qr.make(fit=True)
img = qr.make_image(fill_color="black", back_color="white")
# 转换为 QPixmap
buffer = io.BytesIO()
img.save(buffer, format="PNG")
buffer.seek(0)
pixmap = QPixmap()
pixmap.loadFromData(buffer.getvalue())
scaled = pixmap.scaled(220, 220, Qt.AspectRatioMode.KeepAspectRatio,
Qt.TransformationMode.SmoothTransformation)
self.qr_label.setPixmap(scaled)
self.qr_hint.setText(f"扫码连接 WiFi: {ssid}")
except Exception as e:
self.qr_hint.setText(f"二维码生成失败: {str(e)}")
def init_power_settings(self):
power_widget = QWidget()
power_layout = QVBoxLayout(power_widget)
power_layout.setContentsMargins(20, 20, 20, 20)
power_layout.setSpacing(16)
title = QLabel("电源设置")
title.setStyleSheet("color: #f2f2f2; font-size: 26px; font-weight: 700;")
power_layout.addWidget(title)
desc = QLabel("重启或关闭设备")
desc.setStyleSheet("color: #aaaaaa; font-size: 16px;")
power_layout.addWidget(desc)
power_layout.addStretch()
# 重启按钮
reboot_btn = QPushButton("↻ 重启系统")
reboot_btn.setStyleSheet("""
QPushButton {
min-height: 70px;
font-size: 26px;
font-weight: 700;
color: #ffffff;
background-color: #2f6f91;
border: 2px solid #4a9fc8;
border-radius: 14px;
padding: 10px 24px;
}
QPushButton:hover {
background-color: #3a85b3;
border-color: #6fb8dd;
}
QPushButton:pressed {
background-color: #1e4d66;
border-color: #2f6f91;
}
""")
reboot_btn.clicked.connect(self._confirm_reboot)
power_layout.addWidget(reboot_btn)
power_layout.addSpacing(20)
# 关机按钮
shutdown_btn = QPushButton("⏻ 关机")
shutdown_btn.setStyleSheet("""
QPushButton {
min-height: 70px;
font-size: 26px;
font-weight: 700;
color: #ffffff;
background-color: #c0392b;
border: 2px solid #e74c3c;
border-radius: 14px;
padding: 10px 24px;
}
QPushButton:hover {
background-color: #e74c3c;
border-color: #ff6b6b;
}
QPushButton:pressed {
background-color: #922b21;
border-color: #c0392b;
}
""")
shutdown_btn.clicked.connect(self._confirm_shutdown)
power_layout.addWidget(shutdown_btn)
power_layout.addStretch()
self.settings_stack.addWidget(self._wrap_scroll(power_widget))
def _confirm_reboot(self):
reply = QMessageBox.question(
self, "确认重启",
"确定要重启系统吗?\n所有未保存的数据将丢失。",
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
QMessageBox.StandardButton.No
)
if reply == QMessageBox.StandardButton.Yes:
self._styled_message(
QMessageBox.Icon.Information, self, "重启",
"系统正在重启..."
)
QTimer.singleShot(500, lambda: os.system("sudo reboot"))
def _confirm_shutdown(self):
reply = QMessageBox.question(
self, "确认关机",
"确定要关闭系统吗?\n关闭后需要手动重新开机。",
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
QMessageBox.StandardButton.No
)
if reply == QMessageBox.StandardButton.Yes:
self._styled_message(
QMessageBox.Icon.Information, self, "关机",
"系统正在关机..."
)
QTimer.singleShot(500, lambda: os.system("sudo poweroff"))
def init_todo_settings(self):
todo_widget = QWidget()
todo_widget.setStyleSheet(
@@ -570,7 +922,7 @@ class SettingPage(QWidget):
todo_layout.addStretch()
self.settings_stack.addWidget(todo_widget)
self.settings_stack.addWidget(self._wrap_scroll(todo_widget))
def refresh_saved_wifi(self):
try:
@@ -734,7 +1086,6 @@ class SettingPage(QWidget):
def _detect_auth_mode(network):
"""根据 wpa_supplicant 返回的标准 flags 判断认证方式"""
flags = network.get("flags", "").strip()
# 无加密标记 → 开放网络
if not flags or flags in ("", "[ESS]", "[NONE]", "NONE"):
return "open"
@@ -892,4 +1243,15 @@ class SettingPage(QWidget):
def display_setting(self, index):
if index < 0:
return
self.settings_stack.setCurrentIndex(index)
self.settings_stack.setCurrentIndex(index)
@staticmethod
def _wrap_scroll(widget):
"""将页面 widget 放入独立的 DragScrollArea 中"""
scroll = DragScrollArea()
scroll.setWidgetResizable(True)
scroll.setFrameShape(QFrame.Shape.NoFrame)
scroll.setWidget(widget)
scroll.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded)
scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
return scroll

View File

@@ -2,9 +2,12 @@ import os
import json
import re
from PyQt6.QtWidgets import (QWidget, QHBoxLayout, QVBoxLayout,
QPushButton, QLabel, QFrame, QGraphicsView, QGraphicsScene, QGraphicsPathItem)
from PyQt6.QtCore import Qt, QTimer, QThread, pyqtSignal, QUrl, QObject, pyqtProperty, QRectF
from PyQt6.QtGui import QColor, QPen, QPainter, QPainterPath
QPushButton, QLabel, QFrame, QGraphicsView,
QGraphicsScene, QGraphicsPathItem, QProgressBar)
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
def get_gcode_dir():
config_path = os.path.join(os.path.dirname(os.path.dirname(__file__)), "config.json")
@@ -17,167 +20,390 @@ def get_gcode_dir():
GCODE_DIR = get_gcode_dir()
class GCode2DPreviewWidget(QGraphicsView):
def __init__(self, parent=None):
# ── 状态主题色 ──────────────────────────────────────────
STATUS_COLORS = {
"Operational": "#4CAF50",
"Printing": "#2196F3",
"Paused": "#FF9800",
"Error": "#F44336",
"Offline": "#9E9E9E",
"Cancelling": "#FF5722",
"Finishing": "#8BC34A",
}
STATUS_LABELS = {
"Operational": "就绪",
"Printing": "打印中",
"Paused": "已暂停",
"Error": "错误",
"Offline": "离线",
"Cancelling": "取消中",
"Finishing": "整理中",
}
class CardFrame(QFrame):
"""统一的信息卡片样式"""
def __init__(self, title="", 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)
# 翻转Y轴让(0,0)位于左下角,适配物理坐标系
self.scale(1, -1)
self.setStyleSheet("""
CardFrame {
background-color: #3a3a3a;
border: 1px solid #555555;
border-radius: 8px;
}
""")
layout = QVBoxLayout(self)
layout.setContentsMargins(12, 10, 12, 10)
layout.setSpacing(4)
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()
# 判断是一组连续点的坐标(单线条)[[x1,y1], [x2,y2]]
# 还是包含多根独立线条 [[[x1,y1], [x2,y2]], [[x3,y3], [x4,y4]]]
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))
# 保持 Cosmetic以确保视图缩放时线条粗细在屏幕上看起来一致
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)
if title:
title_lbl = QLabel(title)
title_lbl.setStyleSheet("color: #aaaaaa; font-size: 13px; font-weight: 500; border: none;")
layout.addWidget(title_lbl)
self.content_layout = layout
def add_row(self, label, value_widget):
row = QHBoxLayout()
row.setSpacing(8)
lbl = QLabel(label)
lbl.setStyleSheet("color: #cccccc; font-size: 15px; border: none;")
lbl.setFixedWidth(70)
row.addWidget(lbl)
row.addWidget(value_widget, 1)
self.content_layout.addLayout(row)
class TempGauge(QWidget):
"""温度计指示器"""
def __init__(self, label="Tool", parent=None):
super().__init__(parent)
self.setFixedSize(100, 80)
self._label = label
self._actual = 0.0
self._target = 0.0
def set_value(self, actual, target):
self._actual = actual
self._target = target
self.update()
def paintEvent(self, event):
p = QPainter(self)
p.setRenderHint(QPainter.RenderHint.Antialiasing)
w, h = self.width(), self.height()
# 背景条
bar_x, bar_w = 16, 20
bar_y, bar_h = 10, 56
p.setPen(QPen(QColor("#555555"), 1))
p.setBrush(QBrush(QColor("#2a2a2a")))
p.drawRoundedRect(bar_x, bar_y, bar_w, bar_h, 4, 4)
# 填充柱(按温度比例,最高 300°C
ratio = min(max(self._actual / 300, 0), 1)
fill_h = int((bar_h - 4) * ratio)
if fill_h > 0:
grad = QLinearGradient(0, bar_y + bar_h, 0, bar_y)
grad.setColorAt(0, QColor("#f57c00"))
grad.setColorAt(1, QColor("#e53935") if self._actual > 200 else QColor("#ffb74d"))
p.setBrush(QBrush(grad))
p.setPen(Qt.PenStyle.NoPen)
p.drawRoundedRect(bar_x + 2, bar_y + bar_h - 2 - fill_h, bar_w - 4, fill_h, 3, 3)
# 目标值标记线
if self._target > 0:
tgt_y = bar_y + bar_h - int((bar_h - 4) * min(self._target / 300, 1))
p.setPen(QPen(QColor("#ffffff"), 2))
p.drawLine(bar_x - 2, tgt_y, bar_x + bar_w + 2, tgt_y)
# 文字
font = QFont("sans-serif", 11, QFont.Weight.Bold)
p.setFont(font)
p.setPen(QPen(QColor("#e0e0e0")))
p.drawText(44, 16, w - 44, 24, Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter,
f"{self._actual:.0f}°")
if self._target > 0:
font2 = QFont("sans-serif", 10)
p.setFont(font2)
p.setPen(QPen(QColor("#888888")))
p.drawText(44, 34, w - 44, 20, Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter,
f"{self._target:.0f}°")
font3 = QFont("sans-serif", 10, QFont.Weight.Bold)
p.setFont(font3)
p.setPen(QPen(QColor("#aaaaaa")))
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):
def __init__(self, api_client, parent=None):
super().__init__(parent)
self.api_client = api_client
self.file_name = "None"
self.progress = 0
self.progress = 0.0
self.display_name = "None"
self.state = "Unknown"
self.print_time = 0
self.print_time_left = 0
self.tool_temp_actual = 0.0
self.tool_temp_target = 0.0
self.bed_temp_actual = 0.0
self.bed_temp_target = 0.0
self.config_parser = ConfigParse()
self.config_parser.config_changed.connect(self._on_config_changed)
self.gcode_dir = self.config_parser.gcode_dir
self.init_ui()
self.timer = QTimer(self)
self.timer.timeout.connect(self.update_status)
self.timer.start(1000)
self.update_status()
def _on_config_changed(self, config_instance):
self.gcode_dir = self.config_parser.gcode_dir
def fresh_status_valve(self):
data = self.api_client.get_status()
# print("Status Data:", data)
if data:
status = data.get("status", {})
job = data.get("job", {})
self.file_name = job.get("job", {}).get("file", {}).get("name", "None")
self.progress = job.get("progress", {}).get("completion", 0)
self.progress = job.get("progress", {}).get("completion", 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)
self.print_time_left = job.get("progress", {}).get("printTimeLeft", 0)
self.print_time = job.get("progress", {}).get("printTime", 0) or 0
self.print_time_left = job.get("progress", {}).get("printTimeLeft", 0) or 0
temp = status.get("temperature", {})
self.tool_temp_actual = (temp.get("tool0", {}) or {}).get("actual", 0) or 0
self.tool_temp_target = (temp.get("tool0", {}) or {}).get("target", 0) or 0
self.bed_temp_actual = (temp.get("bed", {}) or {}).get("actual", 0) or 0
self.bed_temp_target = (temp.get("bed", {}) or {}).get("target", 0) or 0
# print(f"Updated Status: state={self.state}, file_name={self.file_name}, progress={self.progress:.1f}%, display_name={self.display_name}")
def format_time(self, seconds):
if seconds is None:
@staticmethod
def format_time(seconds):
if seconds is None or seconds == 0:
return "N/A"
m, s = divmod(seconds, 60)
m, s = divmod(int(seconds), 60)
h, m = divmod(m, 60)
if h > 0:
return f"{int(h)}h {int(m)}m {int(s)}s"
return f"{h}h {m:02d}m {s:02d}s"
elif m > 0:
return f"{int(m)}m {int(s)}s"
return f"{m}m {s:02d}s"
else:
return f"{int(s)}s"
return f"{s}s"
def _make_card(self, title=""):
card = CardFrame(title)
return card
def _truncate(self, text, max_len=22):
return text if len(text) <= max_len else text[:max_len - 2] + ""
def init_ui(self):
self.fresh_status_valve()
main_layout = QHBoxLayout(self)
self.left_frame = QFrame()
self.left_frame.setStyleSheet("background-color: #444444; border-radius: 10px; color: white;")
left_layout = QVBoxLayout(self.left_frame)
self.lbl_status = QLabel(f"Status: {self.state}")
left_layout.addWidget(self.lbl_status)
self.lbl_job = QLabel(f"File: {self.display_name}")
left_layout.addWidget(self.lbl_job)
self.lbl_progress = QLabel(f"Progress: {self.progress if self.progress else 0:.1f}%")
left_layout.addWidget(self.lbl_progress)
self.lbl_print_time = QLabel(f"Print Time: {self.format_time(self.print_time)}")
left_layout.addWidget(self.lbl_print_time)
self.lbl_print_time_left = QLabel(f"Print Time Left: {self.format_time(self.print_time_left)}")
left_layout.addWidget(self.lbl_print_time_left)
left_layout.addStretch()
#TODO: 3D Gcode View in right frame, use QML and QtQuick3D to render the Gcode vertices, pass the progress to QML to show the current layer
# Due to the complexity of parsing Gcode and rendering it in 3D, this part will be implemented in a separate thread to avoid blocking the UI, and the vertices will be passed to QML for rendering. The progress will also be passed to QML to show the current layer being printed.
# Load QtQuick3D View
# self.right_frame = QFrame()
# right_layout = QVBoxLayout(self.right_frame)
# self.view = QQuickView()
# self.view.setResizeMode(QQuickView.ResizeMode.SizeRootObjectToView)
# qml_file = os.path.join(os.path.dirname(__file__), "gcode_view2.qml")
# self.view.setSource(QUrl.fromLocalFile(qml_file))
# gcode_data = self.load_gcode_vertices(os.path.join(GCODE_DIR, self.file_name))
# self.view.rootContext().setContextProperty("gcodeData", gcode_data)
# container = QWidget.createWindowContainer(self.view, self)
# right_layout.addWidget(container)
main_layout.setContentsMargins(8, 8, 8, 8)
main_layout.setSpacing(8)
self.right_frame = QFrame()
self.right_frame.setStyleSheet("background-color: #444444; border-radius: 10px;")
right_layout = QVBoxLayout(self.right_frame)
self.gcode_view = GCode2DPreviewWidget()
right_layout.addWidget(self.gcode_view)
main_layout.addWidget(self.left_frame, 1)
main_layout.addWidget(self.right_frame, 2)
# ── 左侧信息面板 ──────────────────────────────────
left_frame = QFrame()
left_frame.setStyleSheet("background-color: #333333; border-radius: 10px;")
left_layout = QVBoxLayout(left_frame)
left_layout.setContentsMargins(10, 10, 10, 10)
left_layout.setSpacing(6)
# — 状态徽章 —
self._status_badge = QLabel()
self._status_badge.setFixedHeight(40)
self._status_badge.setAlignment(Qt.AlignmentFlag.AlignCenter)
font_badge = QFont("sans-serif", 18, QFont.Weight.Bold)
self._status_badge.setFont(font_badge)
left_layout.addWidget(self._status_badge)
# — 文件信息卡片 —
self._file_card = CardFrame("当前文件")
self._file_name_lbl = QLabel("--")
self._file_name_lbl.setStyleSheet("color: #ffffff; font-size: 16px; font-weight: 600; border: none;")
self._file_name_lbl.setWordWrap(True)
self._file_card.content_layout.addWidget(self._file_name_lbl)
left_layout.addWidget(self._file_card)
# — 进度卡片 —
self._progress_card = CardFrame("打印进度")
# 进度条
self._progress_bar = QProgressBar()
self._progress_bar.setTextVisible(True)
self._progress_bar.setFixedHeight(28)
self._progress_bar.setStyleSheet("""
QProgressBar {
background-color: #2a2a2a;
border: 1px solid #555;
border-radius: 6px;
text-align: center;
color: white;
font-size: 14px;
font-weight: bold;
}
QProgressBar::chunk {
background-color: #4CAF50;
border-radius: 5px;
}
""")
self._progress_card.content_layout.addWidget(self._progress_bar)
# 时间行
time_row = QHBoxLayout()
time_row.setSpacing(10)
self._time_elapsed_lbl = QLabel("已用: --")
self._time_elapsed_lbl.setStyleSheet("color: #bbbbbb; font-size: 14px; border: none;")
self._time_left_lbl = QLabel("剩余: --")
self._time_left_lbl.setStyleSheet("color: #bbbbbb; font-size: 14px; border: none;")
time_row.addWidget(self._time_elapsed_lbl)
time_row.addStretch()
time_row.addWidget(self._time_left_lbl)
self._progress_card.content_layout.addLayout(time_row)
left_layout.addWidget(self._progress_card)
# — 温度卡片 —
self._temp_card = CardFrame("温度")
temp_row = QHBoxLayout()
temp_row.setSpacing(8)
self._tool_gauge = TempGauge("喷头")
self._bed_gauge = TempGauge("热床")
temp_row.addWidget(self._tool_gauge)
temp_row.addWidget(self._bed_gauge)
temp_row.addStretch()
self._temp_card.content_layout.addLayout(temp_row)
left_layout.addWidget(self._temp_card)
left_layout.addStretch()
# ── 右侧预留区域 ─────────────────────────────
right_frame = QFrame()
right_frame.setStyleSheet("background-color: #3a3a3a; border-radius: 10px;")
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)
main_layout.addWidget(left_frame, 2)
main_layout.addWidget(right_frame, 3)
def update_status(self):
self.fresh_status_valve()
self.lbl_status.setText(f"Status: {self.state}")
self.lbl_job.setText(f"File: {self.display_name}")
self.lbl_progress.setText(f"Progress: {self.progress if self.progress else 0:.1f}%")
# Pass progress to QML
# root_obj = self.view.rootObject()
# if root_obj:
# root_obj.setProperty("progress", prog if prog else 0)
# 状态徽章
status_key = self.state.split()[0] if self.state else "Offline"
color = STATUS_COLORS.get(status_key, "#9E9E9E")
label = STATUS_LABELS.get(status_key, self.state)
self._status_badge.setText(f"{label}")
self._status_badge.setStyleSheet(
f"background-color: #2a2a2a; color: {color}; "
f"border: 2px solid {color}; border-radius: 8px; "
f"font-size: 18px; font-weight: bold; padding: 4px;"
)
# 文件
self._file_name_lbl.setText(self._truncate(self.display_name, 28))
# 进度
prog = min(max(self.progress, 0), 100)
self._progress_bar.setValue(int(prog))
self._progress_bar.setFormat(f"{prog:.1f}%")
self._time_elapsed_lbl.setText(f"已用: {self.format_time(self.print_time)}")
self._time_left_lbl.setText(f"剩余: {self.format_time(self.print_time_left)}")
# 打印中时进度条变蓝
if self.state.startswith("Printing"):
self._progress_bar.setStyleSheet("""
QProgressBar {
background-color: #2a2a2a;
border: 1px solid #555;
border-radius: 6px;
text-align: center;
color: white;
font-size: 14px;
font-weight: bold;
}
QProgressBar::chunk {
background-color: #2196F3;
border-radius: 5px;
}
""")
else:
self._progress_bar.setStyleSheet("""
QProgressBar {
background-color: #2a2a2a;
border: 1px solid #555;
border-radius: 6px;
text-align: center;
color: white;
font-size: 14px;
font-weight: bold;
}
QProgressBar::chunk {
background-color: #4CAF50;
border-radius: 5px;
}
""")
# 温度
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)

24
printer-screen.service Normal file
View File

@@ -0,0 +1,24 @@
[Unit]
Description=Printer Screen Touch UI
After=systemd-udev-settle.service local-fs.target
Conflicts=getty@tty1.service
[Service]
Type=simple
User=root
Group=root
WorkingDirectory=/opt/Printer_Screen_Menu
Environment=QT_QPA_PLATFORM=eglfs
Environment=QT_QPA_EGLFS_INTEGRATION=eglfs_kms
ExecStart=/opt/Printer_Screen_Menu/run.sh
Restart=always
RestartSec=1
StandardInput=tty
TTYPath=/dev/tty1
[Install]
WantedBy=multi-user.target

9
run.sh
View File

@@ -1,8 +1,9 @@
#!/bin/bash
# echo "Running Printer Screen Menu..."
# 激活虚拟环境
source "$(dirname "$0")/.venv/bin/activate"
# echo "Virtual environment activated."
# 告诉Qt使用 eglfs 插件(无桌面环境直接利用 KMS/DRM/EGL 渲染)
export QT_QPA_PLATFORM=eglfs
# export QT_QUICK_BACKEND=software
@@ -14,7 +15,11 @@ export QT_QPA_PLATFORM=eglfs
# 若启动报错,可以打开以下变量排查 eglfs 相关日志
# export QT_DEBUG_PLUGINS=1
# echo "QT_QPA_PLATFORM: $QT_QPA_PLATFORM"
# echo "directory: $(dirname "$0")"
# echo "Starting Printer Screen Menu..."
# 启动界面
python "$(dirname "$0")/main.py"
python "$(dirname "$0")/main.py" > /dev/null 2>&1
# python "$(dirname "$0")/main.py"
# .venv/bin/python main.py

View File

@@ -15,63 +15,60 @@ class AIOPrrintSystemAPI:
except:
return None
def print(self, *args, **kwargs):
pass
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': '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
# 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")
@@ -79,4 +76,16 @@ class AIOPrrintSystemAPI:
def stop_print(self):
return self._post_action("cancel_print")
def home_axes(self, axes=["x", "y", "z"]):
return self._post_action("home_axes", axes=axes)
def auto_leveling(self):
return self._post_action("auto_leveling")
def send_gcode(self, gcode):
return self._post_action("send_gcode", gcode=gcode)
def off_motors(self):
return self.send_gcode("M84")

40
utils/auto_fan_status.py Normal file
View File

@@ -0,0 +1,40 @@
import json
from PyQt6.QtCore import QTimer
class AutoFanStatus:
def __init__(self, update_interval_ms=1000):
self.cpu_temp = 0.0
self.fan_speed = 0
self.fan_state = "Unknown"
self.fan_rpm = 0
self.is_auto_fan_service_running = False
self._last_temp = 0.0
self._same_counter = 0
self._update_interval_ms = update_interval_ms
self._timer = QTimer()
self._timer.setSingleShot(False)
self._timer.timeout.connect(self.update_status)
self._timer.start(self._update_interval_ms)
def update_status(self):
try:
with open("/dev/shm/fan_status.json", "r") as f:
data = json.load(f)
self.cpu_temp = data.get("temp", 0.0)
self.fan_speed = data.get("pwm", 0)
self.fan_state = "Stalled" if data.get("is_stalled", False) else "Running"
self.fan_rpm = data.get("rpm", 0)
if self.cpu_temp == self._last_temp:
self._same_counter += 1
else:
self._same_counter = 0
self._last_temp = self.cpu_temp
self.is_auto_fan_service_running = self._same_counter < 5
except (FileNotFoundError, json.JSONDecodeError, OSError):
self.cpu_temp = 0.0
self.fan_speed = 0
self.fan_state = "Unknown"
self.fan_rpm = 0
self.is_auto_fan_service_running = False

71
utils/config_parse.py Normal file
View File

@@ -0,0 +1,71 @@
import json
import os
from PyQt6.QtCore import QObject, pyqtSignal, QFileSystemWatcher
class ConfigParse(QObject):
"""配置文件解析与自动重载"""
config_changed = pyqtSignal(object) # 发送自身实例
def __init__(self, config_file="config.json"):
super().__init__()
self.config_file = config_file
self.api_url = None
self.api_key = None
self.gcode_dir = None
self.move_axis_area = None
self.move_max_speed = None
self.config = self._load_config()
self._parse_config()
# 使用 QFileSystemWatcher 监视文件变化
self._watcher = QFileSystemWatcher(self)
self._watch_path = os.path.abspath(self.config_file)
self._last_mtime = 0
self._start_watching()
def _start_watching(self):
"""启动文件监视"""
if not os.path.isfile(self._watch_path):
return
self._last_mtime = os.path.getmtime(self._watch_path)
if self._watch_path not in self._watcher.files():
self._watcher.addPath(self._watch_path)
self._watcher.fileChanged.connect(self._on_file_changed)
def _on_file_changed(self, path):
"""文件变化时重新加载(防止保存中多次触发,用 mtime 去抖动)"""
try:
mtime = os.path.getmtime(path)
if mtime <= self._last_mtime:
return
self._last_mtime = mtime
except OSError:
return
print("Config changed")
old_config = self.config
new_config = self._load_config()
if new_config == old_config:
return
self.config = new_config
self._parse_config()
self.config_changed.emit(self)
def _load_config(self):
try:
with open(self.config_file, "r") as f:
return json.load(f)
except Exception as e:
print(f"Error loading config: {e}")
return {}
def _parse_config(self):
self.api_url = self.config.get("api_url", None)
self.api_key = self.config.get("api_key", None)
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.move_max_speed = self.config.get("move_max_speed", None)

View File

@@ -8,12 +8,17 @@
from PyQt6.QtWidgets import (
QWidget, QVBoxLayout, QHBoxLayout, QPushButton,
QApplication, QLineEdit, QTextEdit,
QApplication, QLineEdit, QTextEdit, QGridLayout
)
from PyQt6.QtCore import Qt, QEvent, QPoint, pyqtSignal
from PyQt6.QtCore import Qt, QEvent, QPoint, QTimer, pyqtSignal
from PyQt6.QtGui import QMouseEvent, QTextCursor
# ─── 长按重复配置 ─────────────────────────────────────────
LONG_PRESS_DELAY = 400 # 按住多少毫秒后开始重复
LONG_PRESS_REPEAT = 80 # 开始重复后每多少毫秒触发一次
# ─── 键盘布局定义 ──────────────────────────────────────────────
KEY_ROWS = {
@@ -31,14 +36,18 @@ KEY_ROWS = {
NUMBER_ROW = ["1", "2", "3", "4", "5", "6", "7", "8", "9", "0"]
# 符号键:按 Shift 或符号切换时数字行互换
# 符号键:按 ?!(. 切换时替换数字行
SYMBOL_ROW = ["!", "@", "#", "$", "%", "^", "&", "*", "(", ")"]
# 显示用Qt 中 & 需要写 && 才能显示一个 &
SYMBOL_ROW_DISPLAY = ["!", "@", "#", "$", "%", "^", "&&", "*", "(", ")"]
# 右侧额外符号(放在字母区行末或单独行)
# 额外符号:切换到符号键盘后显示在字母区
EXTRA_SYMBOLS = [
["-", "=", "[", "]", "\\", ";", "'", ",", ".", "/"],
["_", "+", "{", "}", "|", ":", "\"", "<", ">", "?"],
]
# 常见后缀(放在符号键盘多余的字母位置上)
COMMON_SUFFIXES = [".com", ".cn", ".org", ".net", ".edu", ".co"]
KEY_STYLE = """
QPushButton {
@@ -64,7 +73,7 @@ QPushButton:pressed {
CTRL_KEY_STYLE = """
QPushButton {
min-width: 72px;
min-width: 52px;
min-height: 52px;
font-size: 18px;
font-weight: 600;
@@ -84,29 +93,9 @@ QPushButton:pressed {
}
"""
SPACE_STYLE = """
QPushButton {
min-width: 280px;
min-height: 52px;
font-size: 18px;
color: transparent;
background-color: #4a4a4a;
border: 2px solid #646464;
border-radius: 8px;
}
QPushButton:hover {
background-color: #5a5a5a;
border-color: #888888;
}
QPushButton:pressed {
background-color: #2f6f91;
border-color: #5a9fcf;
}
"""
ACTIVE_CTRL_STYLE = """
QPushButton {
min-width: 72px;
min-width: 52px;
min-height: 52px;
font-size: 18px;
font-weight: 600;
@@ -136,6 +125,9 @@ class FloatingKeyboard(QWidget):
| Qt.WindowType.Tool
)
self.setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground)
# 防止键盘窗口/按钮抢走输入框焦点
self.setAttribute(Qt.WidgetAttribute.WA_ShowWithoutActivating)
self.setFocusPolicy(Qt.FocusPolicy.NoFocus)
self.setStyleSheet("background: transparent;")
self._drag_pos = None
@@ -154,9 +146,9 @@ class FloatingKeyboard(QWidget):
outer.setStyleSheet(
"background-color: #333333; border: 2px solid #555555; border-radius: 12px;"
)
layout = QVBoxLayout(outer)
layout.setContentsMargins(8, 8, 8, 8)
layout.setSpacing(4)
outer_layout = QVBoxLayout(outer)
outer_layout.setContentsMargins(8, 8, 8, 8)
outer_layout.setSpacing(4)
# ── 顶部拖拽条 ──
title_bar = QWidget()
@@ -168,8 +160,8 @@ class FloatingKeyboard(QWidget):
title_layout.setContentsMargins(8, 0, 8, 0)
drag_label = QPushButton("≡ 键盘")
drag_label.setStyleSheet(
"""
drag_label.setFocusPolicy(Qt.FocusPolicy.NoFocus)
drag_label.setStyleSheet("""
QPushButton {
background: transparent;
color: #aaaaaa;
@@ -177,15 +169,14 @@ class FloatingKeyboard(QWidget):
border: none;
text-align: left;
}
"""
)
""")
title_layout.addWidget(drag_label)
title_layout.addStretch()
close_btn = QPushButton("")
close_btn.setFixedSize(28, 28)
close_btn.setStyleSheet(
"""
close_btn.setFocusPolicy(Qt.FocusPolicy.NoFocus)
close_btn.setStyleSheet("""
QPushButton {
background: transparent;
color: #aaaaaa;
@@ -197,107 +188,175 @@ class FloatingKeyboard(QWidget):
background-color: #555555;
color: #ffffff;
}
"""
)
""")
close_btn.clicked.connect(self.hide)
title_layout.addWidget(close_btn)
layout.addWidget(title_bar)
outer_layout.addWidget(title_bar)
# ── 数字/符号行 ──
self.num_layout = QHBoxLayout()
self.num_layout.setSpacing(4)
# ── 内容区:左侧 4 行按键 + 右侧功能键 ──
content = QHBoxLayout()
content.setSpacing(6)
# ====== 左侧QGridLayout 4行×10列等宽列 ======
grid = QGridLayout()
grid.setSpacing(4)
for col in range(10):
grid.setColumnStretch(col, 1)
# Row 0: 数字行
self.num_buttons = []
for ch in NUMBER_ROW:
btn = self._make_key(ch)
for col, ch in enumerate(NUMBER_ROW):
btn = self._make_key(ch, ch)
self.num_buttons.append(btn)
self.num_layout.addWidget(btn)
layout.addLayout(self.num_layout)
grid.addWidget(btn, 0, col)
# ── 字母行 ──
self._letter_buttons = [] # flat list for shift toggle
for row_keys in KEY_ROWS["normal"]:
row_layout = QHBoxLayout()
row_layout.setSpacing(4)
row_layout.addStretch()
for ch in row_keys:
btn = self._make_key(ch)
self._letter_buttons.append(btn)
row_layout.addWidget(btn)
row_layout.addStretch()
layout.addLayout(row_layout)
# Row 1: 字母行 q ~ p
self._letter_buttons = []
for col, ch in enumerate(KEY_ROWS["normal"][0]):
btn = self._make_key(ch, ch)
self._letter_buttons.append(btn)
grid.addWidget(btn, 1, col)
# ── 额外符号行(- = [ ] 等) ──
self.extra_layout = QHBoxLayout()
self.extra_layout.setSpacing(4)
self.extra_layout.addStretch()
self.extra_buttons = []
for ch in EXTRA_SYMBOLS[0]:
btn = self._make_key(ch)
self.extra_buttons.append(btn)
self.extra_layout.addWidget(btn)
self.extra_layout.addStretch()
layout.addLayout(self.extra_layout)
# Row 2: 字母行 a ~ l (9个第10列空位留给符号模式)
for col, ch in enumerate(KEY_ROWS["normal"][1]):
btn = self._make_key(ch, ch)
self._letter_buttons.append(btn)
grid.addWidget(btn, 2, col)
# ── 功能键行 ──
ctrl_layout = QHBoxLayout()
ctrl_layout.setSpacing(4)
# Row 3: [?!(.] + 字母 z ~ m + [⇧] + [Caps]
self.sym_btn = self._make_ctrl_btn("?!(.", self._toggle_symbol)
grid.addWidget(self.sym_btn, 3, 0)
self.shift_btn = QPushButton("")
self.shift_btn.setStyleSheet(CTRL_KEY_STYLE)
self.shift_btn.clicked.connect(self._toggle_shift)
ctrl_layout.addWidget(self.shift_btn)
for i, ch in enumerate(KEY_ROWS["normal"][2]):
btn = self._make_key(ch, ch)
self._letter_buttons.append(btn)
grid.addWidget(btn, 3, i + 1)
self.caps_btn = QPushButton("A/a")
self.caps_btn.setStyleSheet(CTRL_KEY_STYLE)
self.caps_btn.clicked.connect(self._toggle_caps)
ctrl_layout.addWidget(self.caps_btn)
self.shift_btn = self._make_ctrl_btn("", self._toggle_shift)
grid.addWidget(self.shift_btn, 3, 8)
self.sym_btn = QPushButton("?123")
self.sym_btn.setStyleSheet(CTRL_KEY_STYLE)
self.sym_btn.clicked.connect(self._toggle_symbol)
ctrl_layout.addWidget(self.sym_btn)
self.caps_btn = self._make_ctrl_btn("Caps", self._toggle_caps)
grid.addWidget(self.caps_btn, 3, 9)
backspace_btn = QPushButton("")
backspace_btn.setStyleSheet(CTRL_KEY_STYLE)
# 各行等高分占布局
for row in range(4):
grid.setRowStretch(row, 1)
content.addLayout(grid)
# ====== 右侧4个竖向功能键拉伸填满高度 ======
right_col = QVBoxLayout()
right_col.setSpacing(4)
RIGHT_BTN_STYLE = """
QPushButton {
min-width: 80px;
min-height: 52px;
font-size: 18px;
font-weight: 600;
color: #f2f2f2;
background-color: #555555;
border: 2px solid #707070;
border-radius: 8px;
padding: 4px 8px;
}
QPushButton:hover {
background-color: #666666;
border-color: #909090;
}
QPushButton:pressed {
background-color: #2f6f91;
border-color: #5a9fcf;
}
"""
backspace_btn = QPushButton("⌫ 退格")
backspace_btn.setStyleSheet(RIGHT_BTN_STYLE)
backspace_btn.setFocusPolicy(Qt.FocusPolicy.NoFocus)
backspace_btn.clicked.connect(lambda: self._send_key("\b"))
ctrl_layout.addWidget(backspace_btn)
self._enable_long_press(backspace_btn, lambda: self._send_key("\b"))
right_col.addWidget(backspace_btn, stretch=1)
ctrl_layout.addStretch()
del_btn = QPushButton("Del")
del_btn.setStyleSheet(RIGHT_BTN_STYLE)
del_btn.setFocusPolicy(Qt.FocusPolicy.NoFocus)
del_btn.clicked.connect(lambda: self._send_key("\x7f"))
self._enable_long_press(del_btn, lambda: self._send_key("\x7f"))
right_col.addWidget(del_btn, stretch=1)
enter_btn = QPushButton("↵ 回车")
enter_btn.setStyleSheet(CTRL_KEY_STYLE)
enter_btn.setStyleSheet(RIGHT_BTN_STYLE)
enter_btn.setFocusPolicy(Qt.FocusPolicy.NoFocus)
enter_btn.clicked.connect(lambda: self._send_key("\n"))
ctrl_layout.addWidget(enter_btn)
self._enable_long_press(enter_btn, lambda: self._send_key("\n"))
right_col.addWidget(enter_btn, stretch=1)
layout.addLayout(ctrl_layout)
# ── 空格行 ──
space_layout = QHBoxLayout()
space_layout.setSpacing(4)
space_layout.addStretch()
self.space_btn = QPushButton(" ") # full-width space as placeholder
self.space_btn.setStyleSheet(SPACE_STYLE)
self.space_btn = QPushButton(" 空格")
self.space_btn.setStyleSheet(RIGHT_BTN_STYLE)
self.space_btn.setFocusPolicy(Qt.FocusPolicy.NoFocus)
self.space_btn.clicked.connect(lambda: self._send_key(" "))
space_layout.addWidget(self.space_btn)
self._enable_long_press(self.space_btn, lambda: self._send_key(" "))
right_col.addWidget(self.space_btn, stretch=1)
space_layout.addStretch()
layout.addLayout(space_layout)
content.addLayout(right_col)
outer_layout.addLayout(content)
outer_layout = QVBoxLayout(self)
outer_layout.setContentsMargins(0, 0, 0, 0)
outer_layout.addWidget(outer)
outer_container = QVBoxLayout(self)
outer_container.setContentsMargins(0, 0, 0, 0)
outer_container.addWidget(outer)
self.setFixedWidth(680)
self.setFixedWidth(840)
# ── 按键工厂 ──
def _make_key(self, text):
def _make_key(self, text, send_text=None):
"""创建按键send_text 为实际发送字符(如 & 按钮显示 && 但发送 &"""
btn = QPushButton(text)
btn.setStyleSheet(KEY_STYLE)
btn.clicked.connect(lambda checked, t=text: self._send_key(t))
btn.setFocusPolicy(Qt.FocusPolicy.NoFocus) # 不抢输入框焦点
btn._real_text = send_text if send_text is not None else text
btn.clicked.connect(lambda: self._send_key(btn._real_text))
# 普通按键自动加长按
self._enable_long_press(btn, lambda: self._send_key(btn._real_text))
return btn
@staticmethod
def _make_ctrl_btn(text, callback):
"""创建控制类按钮(?!(./⇧/Caps等"""
btn = QPushButton(text)
btn.setStyleSheet(CTRL_KEY_STYLE)
btn.setFocusPolicy(Qt.FocusPolicy.NoFocus) # 不抢输入框焦点
btn.clicked.connect(callback)
return btn
def _enable_long_press(self, btn, callback):
"""为按键添加长按连续触发功能(按住 LONG_PRESS_DELAY ms 后每 LONG_PRESS_REPEAT ms 重复)
callback 为点击/重复时执行的 callable无参数
"""
timer = QTimer(btn)
timer.setSingleShot(True)
btn._repeat_timer = timer
def on_pressed():
timer.setSingleShot(True)
timer.setInterval(LONG_PRESS_DELAY)
timer.start()
def on_released():
timer.stop()
def on_timer():
# 首次超时:进入快速重复模式
if timer.isSingleShot():
timer.setSingleShot(False)
timer.setInterval(LONG_PRESS_REPEAT)
timer.start()
callback()
btn.pressed.connect(on_pressed)
btn.released.connect(on_released)
timer.timeout.connect(on_timer)
# ── 按键发送 ──
def _send_key(self, text):
@@ -305,6 +364,9 @@ class FloatingKeyboard(QWidget):
if text == "\b":
self._backspace()
return
if text == "\x7f":
self._delete_forward()
return
if self._symbol_mode:
self._toggle_symbol()
@@ -323,6 +385,10 @@ class FloatingKeyboard(QWidget):
tc.insertText(text)
widget.setTextCursor(tc)
# 上档键:激活一次后自动取消
if self._shift_on:
self._toggle_shift()
def _backspace(self):
widget = self._get_target()
if widget is None:
@@ -344,6 +410,25 @@ class FloatingKeyboard(QWidget):
tc.movePosition(QTextCursor.MoveOperation.Right, QTextCursor.MoveMode.KeepAnchor)
tc.removeSelectedText()
def _delete_forward(self):
"""向前删除(删除光标后的字符)"""
widget = self._get_target()
if widget is None:
return
if isinstance(widget, QLineEdit):
cursor = widget.cursorPosition()
current = widget.text()
if cursor < len(current):
new_text = current[:cursor] + current[cursor + 1:]
widget.setText(new_text)
widget.setCursorPosition(cursor)
elif isinstance(widget, QTextEdit):
tc = widget.textCursor()
if not tc.hasSelection():
tc.movePosition(QTextCursor.MoveOperation.Right, QTextCursor.MoveMode.KeepAnchor)
tc.removeSelectedText()
def _get_target(self):
"""获取当前有效的目标输入控件"""
if self._target_widget and self._target_widget.hasFocus():
@@ -368,26 +453,47 @@ class FloatingKeyboard(QWidget):
def _toggle_symbol(self):
self._symbol_mode = not self._symbol_mode
if self._symbol_mode:
for btn, ch in zip(self.num_buttons, SYMBOL_ROW):
btn.setText(ch)
for btn, ch in zip(self.extra_buttons, EXTRA_SYMBOLS[1]):
btn.setText(ch)
# 进入符号键盘
# Row 0: 数字行 → SYMBOL_ROW
for btn, ch, disp in zip(self.num_buttons, SYMBOL_ROW, SYMBOL_ROW_DISPLAY):
btn.setText(disp)
btn._real_text = ch
# Row 1: 字母行 q-p (indices 0-9) → EXTRA_SYMBOLS[0]
for i, ch in enumerate(EXTRA_SYMBOLS[0]):
self._letter_buttons[i].setText(ch)
self._letter_buttons[i]._real_text = ch
# Row 2: 字母行 a-l (indices 10-18) → EXTRA_SYMBOLS[1][0:9]
for i, ch in enumerate(EXTRA_SYMBOLS[1][:9]):
self._letter_buttons[10 + i].setText(ch)
self._letter_buttons[10 + i]._real_text = ch
# Row 3: z-m (indices 19-25) → EXTRA_SYMBOLS[1][9] + 后缀
self._letter_buttons[19].setText(EXTRA_SYMBOLS[1][9])
self._letter_buttons[19]._real_text = EXTRA_SYMBOLS[1][9]
for i, suffix in enumerate(COMMON_SUFFIXES):
self._letter_buttons[20 + i].setText(suffix)
self._letter_buttons[20 + i]._real_text = suffix
self.sym_btn.setStyleSheet(ACTIVE_CTRL_STYLE)
self.sym_btn.setText("ABC")
else:
# 退出符号键盘 → 恢复数字和字母
for btn, ch in zip(self.num_buttons, NUMBER_ROW):
btn.setText(ch)
for btn, ch in zip(self.extra_buttons, EXTRA_SYMBOLS[0]):
btn.setText(ch)
btn._real_text = ch
# 字母区根据当前 shift/caps 状态恢复
self._apply_shift_caps()
self.sym_btn.setStyleSheet(CTRL_KEY_STYLE)
self.sym_btn.setText("?123")
self.sym_btn.setText("?!(.")
def _apply_shift_caps(self):
if self._symbol_mode:
return # 符号键盘下不改变字母区显示
use_shift = self._shift_on != self._caps_on # XOR
rows = KEY_ROWS["shift"] if use_shift else KEY_ROWS["normal"]
flat = [ch for row in rows for ch in row]
for btn, ch in zip(self._letter_buttons, flat):
btn.setText(ch)
btn._real_text = ch
# ── 窗口拖拽 ──

View File

@@ -17,13 +17,13 @@ class WifiManager:
return ""
def list_saved_networks(self):
"""列出已保存的 wifi"""
"""列出已保存的 wifiwpa_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()
parts = line.split('\t')
if len(parts) >= 4:
networks.append({
"network_id": parts[0],