title: 嵌入式操作系统 | 设备面板:用 Qt 控件把"设备"画出来(本节不接串口)
course: 嵌入式 Ubuntu 系统开发
lesson: 25
module: 模块七 综合项目
duration: 2 学时
tags:
- 项目
- PyQt5
- 控件
- 信号槽
- QPainter
description: 用 PyQt5 的控件和布局把"设备面板"搭出来:LED 圆点、风扇档位、温度显示、日志区,学会"信号 → 槽"(点按钮就调用你写的函数),学生只填 3 个槽函数约 15 行;本节完全不碰串口,下课前人人能做出"点按钮 → 灯变绿 + 日志一行";选做部分用 QPainter + QTimer 画一个会转的风扇。
嵌入式操作系统 | 设备面板:用 Qt 控件把"设备"画出来(本节不接串口)
本课程开源地址(Gitee) :https://gitee.com/fujianxinxi/qianrushixitongyingyongkaifa.git
课件、示例代码与验收脚本都在该仓库,可直接
git clone或下载 ZIP 使用。
模块七 · 2 学时 · 环境:VMware + Ubuntu 20.04(PyQt5 要在这个桌面的终端里跑)
一句话概述 :用 PyQt5 的控件和布局把"设备面板"搭出来(LED 圆点 / 风扇档位 / 温度 / 日志),学会"信号 → 槽 ":点按钮就调用你写的函数。学生只填 3 个槽函数、约 15 行;本节完全不上串口,下课前人人能做出"点按钮 → 灯变绿 + 日志一行"。
本节目标
- 能说出 Qt 窗口程序的三件套(
QApplication/ 窗口 / 控件 + 布局),知道它和 uloop 一样是"事件循环" - 会读骨架代码,会写槽函数 ,会把它和按钮的
clicked、下拉框的currentIndexChanged连起来 - 能做出效果:点【灯 开】→ 圆点变绿 + 日志一行;切档位 → 标签和日志同步变化
- (选做)会用
QPainter+QTimer画一个"会转的风扇"
一、讲解内容
1.1 知识讲解
先说清楚:GUI 程序和终端程序最根本的区别。 终端程序是"我写一步它走一步"(scanf、read 等输入);GUI 程序反过来------它自己转圈等你:鼠标点一下、键盘敲一下、定时器到点,都是"事件",事件来了就调用你注册的函数。
| C(uloop,课时 16--18) | Python(Qt,本次) |
|---|---|
uloop_init() |
QApplication(sys.argv) |
uloop_fd_add(&fd, ULOOP_READ)(有数据就回调) |
btn.clicked.connect(self.on_led_on)(点按钮就回调) |
uloop_timeout_set(&t, 1000) |
QTimer(到点就回调) |
uloop_run() |
app.exec_() |
回调函数 on_readable() |
槽函数 on_led_on() |
所以:槽函数里绝对不能写
while True、也不能sleep(5)------你一卡,整个界面就不刷新了("未响应")。要定时干活就用QTimer(课时 26 会用它在后台轮询串口)。
面板要用到的控件(就这 5 个,够用了):
| 控件 | 干什么 | 本节用在哪 |
|---|---|---|
QLabel |
显示文字(也能被样式表画成圆点/色块) | LED 圆点、"亮/灭"、档位、温度 |
QPushButton |
按钮 | 灯开 / 灯关 / 复位 |
QComboBox |
下拉框 | 风扇 0~3 档 |
QPlainTextEdit |
多行文本(只读) | 日志区 |
QGroupBox |
带标题的分组框 | "设备"、"日志"两个块 |
布局(Layout):控件不是"摆坐标",而是"放进布局里自动排":
| 布局 | 排法 | 本节用在哪 |
|---|---|---|
QVBoxLayout |
从上往下一行一行排 | 窗口根布局(上面设备区、下面日志区) |
QGridLayout |
按"第几行第几列"摆 | "设备"区(左边状态、右边按钮) |
addStretch() |
占位弹簧(把控件顶到一边) | 日志区撑满剩余空间 |
setColumnStretch(6, 1) |
第 6 列之后留白 | 让按钮不被拉得太宽 |
信号 → 槽(本节唯一要记的新语法):
python
self.btn_on.clicked.connect(self.on_led_on) # 点按钮 → 调用 on_led_on()
self.fan_box.currentIndexChanged.connect(self.on_fan_changed) # 下拉框变了 → 调用 on_fan_changed(level)
self.btn_reset.clicked.connect(self.on_reset)
clicked、currentIndexChanged 叫信号 (控件自己发的通知),你写的那几个函数叫槽 (收到通知后干什么)。connect 就是把两者接上 。
注意带参数的信号:下拉框给的是"第几项"(0~3),所以你的槽函数要收这个参数:
python
def on_fan_changed(self, level): # level 就是 0/1/2/3
...
"LED 圆点"是怎么画出来的------一个样式表小技巧。 QLabel 是个方块,但只要给它加圆角,就变成圆点:
python
"background-color: #00c853; border-radius: 12px; border: 1px solid #616161;" # 亮(绿)
"background-color: #9e9e9e; border-radius: 12px; border: 1px solid #616161;" # 灭(灰)
(控件尺寸是 24×24,半径写 12 正好是个圆。)
老师已经给好的 4 个"改界面"函数------你只管调用它们:
| 函数 | 作用 | 例子 |
|---|---|---|
self.set_led(True/False) |
圆点变绿/变灰,文字变"亮/灭" | self.set_led(True) |
self.set_fan(0~3) |
改档位文字 + 让下拉框同步 | self.set_fan(2) |
self.set_temperature(25.0) |
改温度文字 | self.set_temperature(26.5) |
self.log("...") |
往日志区写一行(同时打印到终端) | self.log("LED 已点亮") |
要你填的就 3 个槽函数 (骨架里用 TODO 标出来了):
| 槽函数 | 什么时候被调用 | 要写什么 |
|---|---|---|
on_led_on() |
点【灯 开】 | 点亮 + 写日志(2 行) |
on_led_off() |
点【灯 关】 | 熄灭 + 写日志(2 行) |
on_fan_changed(level) |
切风扇档位 | 改档位 + 写日志(2 行) |
骨架里还留了一个**已经写好的
on_reset()(复位)**当样板------照它的样子抄就行了。
(选做)风扇为什么会转? 完整版里有一个自己画的控件 FanWidget:QTimer 每 60 毫秒把角度加一点,然后调 self.update() 请求重画,Qt 就会回调 paintEvent(),里面用 QPainter 把 3 片叶子按当前角度画出来。这和"动画片一帧一帧画"是一个套路,档位越高每帧转得越多,看起来就转得快。
最后再提醒一次运行环境(课时 24 讲过,这里最容易卡住人):
| 情况 | 现象 | 怎么办 |
|---|---|---|
| 你在虚拟机桌面里跑 | 正常弹窗 | ✅ 就这么做 |
| 你在 VS Code 的 SSH 终端里跑 | could not connect to display |
换到虚拟机桌面;或 xvfb-run -a python3 device_panel.py --shot /tmp/p.png 存张图看效果 |
1.2 操作步骤(一步步来,每步跑一次看现象)
第 0 步:先确认三件小事(目录 / 库 / 能不能看见窗口)
0-1 代码目录在不在
shell
cd ~/lab/课时25-设备面板界面
ls # 应该看到 device_panel.py(骨架)和 device_panel_full.py(完整版)
现象:列出这两个文件。
如果提示"没有那个目录":把课程资料里的
代码/课时25-设备面板界面整个文件夹拷到~/lab/,例如
cp -r <课程资料路径>/课时25-设备面板界面 ~/lab/,然后再cd过去。
0-2 PyQt5 装了吗
shell
python3 -c "import PyQt5; print('PyQt5 OK')"
现象:PyQt5 OK。如果报 ModuleNotFoundError: No module named 'PyQt5',补一条命令(详细环境清单见课时 24 附录 F):
shell
sudo apt update
sudo apt install -y python3-pyqt5
0-3 你现在能看到窗口吗
shell
echo $DISPLAY
| 输出 | 说明 | 怎么办 |
|---|---|---|
:0 / :1 之类 |
你在虚拟机桌面里 | ✅ 直接往下做 |
| 空(什么都不输出) | 你在 VS Code 的 SSH 终端里 | ① 换到虚拟机桌面里做(推荐);② 或装虚拟显示器:sudo apt install -y xvfb,之后用 xvfb-run -a python3 device_panel.py --shot /tmp/p.png 把界面渲染成图片看 |
第 1 步:先把骨架跑起来------界面长什么样
shell
python3 device_panel.py
现象:弹出一个窗口(约 660×430),里面有:
text
┌─ 设备 ───────────────────────────────────────────────┐
│ LED 灯 ●(灰) 灭 [灯 开] [灯 关] │
│ 风扇 0 档(停) 风扇档位 [0 档(停) ▾] │
│ 温度 25.0 ℃ [ 复 位 ] │
├─ 日志 ───────────────────────────────────────────────┤
│ 面板已启动(本节还没接串口,先点按钮看界面反应) │
└──────────────────────────────────────────────────────┘
下图是骨架刚跑起来的样子(本机实测截图):

终端里也会同时打印这一行(log() 里两处都写了,方便排查)。
第 2 步:点一下【灯 开】------看"灯不亮"的 TODO 现象
点按钮,现象是:灯不亮、档位不变,只有日志区多了一行:
text
按了【灯 开】------ TODO:把这一行换成上面提示的两句
这一行是骨架里故意留的:它说明**"信号 → 槽"已经接通了**(否则函数根本不会被调用),
只是"活还没干"------接下来就是你来干。

第 3 步:填 on_led_on() 和 on_led_off()(各 2 行)
打开骨架,找到这两个函数,把它们改成:
python
def on_led_on(self):
"""点【灯 开】时被调用"""
self.set_led(True)
self.log("LED 已点亮")
def on_led_off(self):
"""点【灯 关】时被调用"""
self.set_led(False)
self.log("LED 已熄灭")
保存后重新运行 python3 device_panel.py,再点按钮。
现象:LED 圆点变绿、右边文字变**"亮"**,日志区多一行(终端也打印):
text
面板已启动(本节还没接串口,先点按钮看界面反应)
LED 已点亮

如果没变化:先看第 4 步的排查表(八成是没保存或者函数名拼错了)。
第 4 步:填 on_fan_changed(level)(2 行)
python
def on_fan_changed(self, level):
"""切风扇档位时被调用;level 就是下拉框的序号 0~3"""
self.set_fan(level)
self.log("风扇 → %d 档" % level)
保存、重新运行,把下拉框切到"2 档"。
现象:左边"风扇"那行变成 2 档 ,日志区多一行 风扇 → 2 档。
第 5 步:对照已经写好的 on_reset()(复位)理解
python
def on_reset(self):
self.set_led(False)
self.set_fan(0)
self.log("复位:灯灭、风扇停")
它一次改了两个状态------这就是"一个槽函数里可以同时更新多个控件" 。
到这一步,你已经会写槽函数了:改状态 + 写日志,就这两件事。
第 6 步(选做):让风扇转起来------跑完整版
shell
python3 device_panel_full.py
现象(和骨架相比多三样):右下角有一个蓝色三叶风扇 ;把档位切到 2 档,风扇转起来 (档位越高越快,0 档停);温度每秒钟自己小幅度漂移(这是本地假数据,课时 26 会换成串口里的真数据)。

点几下按钮后,日志区会是这样(本机实测抓的):
text
面板已启动(温度是本地模拟的,课时 26 换成串口真数据)
LED 已点亮
风扇 → 2 档

第 7 步:没有桌面时的自检(存图看效果)
shell
xvfb-run -a python3 device_panel_full.py --shot /tmp/panel.png
现象(实测):
text
窗口截图已保存:/tmp/panel.png(成功)
打开这张 png,看到的就是面板的样子(灯是绿的、风扇在 2 档、温度 26.5 ℃)------说明代码没问题,只是这台机器没有屏幕。
第 8 步:收工。 关窗口即可(也可以按 Ctrl+C)。
下节课就把这个面板接上 /tmp/ttyV1 串口,让命令行一帧就能点亮这个灯。
1.3 常见错误与排查
| 现象 | 原因 | 解决 |
|---|---|---|
| 窗口出来了,但里面空荡荡/少控件 | 控件没放进布局(忘了 addWidget) |
检查 _build_body():每个控件都要 grid.addWidget(...) |
| 点按钮一点反应都没有(日志也不动) | ① 忘了 connect ② 函数名拼错 ③ 忘了 app.exec_() |
对照 self.btn_on.clicked.connect(self.on_led_on) 的三处名字是否一致 |
报 TypeError: on_fan_changed() takes 1 positional argument but 2 were given |
槽函数没收参数 | 写成 def on_fan_changed(self, level): |
| 灯亮了,但下拉框没跟着变 | 只改了文字、没同步控件 | 用老师的 set_fan()(它里面对 fan_box 调了 setCurrentIndex) |
| 风扇不转 | 档位是 0 |
0 档就是停;切 1~3 档再看 |
| 界面"未响应"/白屏 | 槽函数里写了 while True 或 time.sleep() |
别在槽函数里死循环/长睡眠,用 QTimer |
| 中文变成方块 | 虚拟机缺中文字体 | sudo apt install -y fonts-wqy-zenhei |
| 改了代码没变化 | 没保存,或者跑的是另一个文件(骨架/完整版搞混) | 看清楚命令行里跑的是哪个文件名;Ctrl+S 保存 |
xvfb-run: command not found |
没装 xvfb | sudo apt install -y xvfb |
二、学生练习
本节要点速查:
| 要用的东西 | 写法 | 说明 |
|---|---|---|
| 创建事件循环 | app = QApplication(sys.argv) / app.exec_() |
对应 uloop 的 init/run |
| 窗口 | class XXX(QMainWindow) + setCentralWidget(...) |
控件的容器 |
| 摆控件 | QVBoxLayout / QGridLayout + addWidget |
不要手写坐标 |
| 接信号 | btn.clicked.connect(self.on_led_on) |
点按钮就调用槽函数 |
| 带参数的信号 | fan_box.currentIndexChanged.connect(self.on_fan_changed) |
槽要收 level |
| 改界面 | self.set_led(True) / self.set_fan(2) / self.set_temperature(26.5) |
老师给好的 |
| 写日志 | self.log("...") |
界面 + 终端都会打印 |
| 定时 | QTimer + timeout.connect(槽) |
别用 while/sleep |
| 编号 | 任务 | 提示 | 预期结果 |
|---|---|---|---|
| L25.1 | 跑骨架,看清四个区域 | 第 1 步 | 窗口 + 日志一行(截图) |
| L25.2 | 点【灯 开】看"灯不亮" | 第 2 步 | 日志出现 TODO 文案,灯不亮(截图) |
| L25.3 | 填 on_led_on / on_led_off |
第 3 步 | 点按钮:圆点变绿、文字"亮"、日志"LED 已点亮" |
| L25.4 | 填 on_fan_changed |
第 4 步 | 切 2 档:标签"2 档" + 日志"风扇 → 2 档" |
| L25.5 | 给面板加一个"设备在线"小灯 | 复制 LED 那三行的写法(QLabel + 样式表圆点) |
面板多一个绿色小圆点,写着"在线" |
| L25.6 | 让日志只保留最近 10 行 | 在 log() 里判断行数,超过就删第一行(提示:self.log_view.document().setMaximumBlockCount(10) 一行就行) |
一直点按钮,日志永远只有 10 行 |
| L25.7 | 给"复位"按钮加一个确认框 | 提示:QMessageBox.question(...) |
点复位先弹窗问"确定要复位吗" |
| L25.8 | (选做)让风扇 3 档明显比 1 档快 | 改 FanWidget.on_tick 里 6 * self.level 的系数 |
3 档转得飞快 |
| L25.9 | (选做)把温度改成"报警"效果 | 超过 26.5 ℃ 把温度文字变红加粗(setStyleSheet) |
温度高时变红 |
三、作业
- 必做:交 3 张截图(骨架界面 / 点按钮出现 TODO / 填好后灯亮 + 日志),并回答:① "信号"和"槽"是什么关系?
connect干的是什么?② 为什么不能在按钮的槽函数里写while True或time.sleep(5)?③set_fan()里为什么要顺手把下拉框也改一下? - 选做:① 完成 L25.5、L25.6;② 给面板加一个亮度滑块 (
QSlider,0~100),拖动时日志打印"亮度 → 60%"(提示:slider.valueChanged.connect(...),和下拉框一个套路) - 提交:截图 + 文字回答,命名
课时25-作业截图.png
四、本节小结与下节预告
本节把"设备"画出来了:5 个控件 + 2 种布局 + 样式表圆点,掌握了 "信号 → 槽" 这条 GUI 的主线(点按钮 → 你写的函数 → 改界面 + 写日志),并且知道 GUI 里不能用死循环,要定时就用 QTimer 。选做部分还用一个自己画的 FanWidget(QPainter + QTimer)让风扇真的转了起来。
下节课(课时 26)是本项目的"高光时刻":给面板接上 /tmp/ttyV1 串口,用课时 22 的命令行手法发一帧 55 AA 01 01 01 E0 50,这个灯就真的亮了 ;温度也改成由串口上报。用到的正是本节的 QTimer(轮询串口,不用线程)。
附录:怎么运行 + 完整代码
两个文件都在
代码/课时25-设备面板界面/:device_panel.py是骨架 (上课填它),device_panel_full.py是完整版(卡住时对照/直接跑)。行尾请用 LF。
附录 A|前置环境 + 怎么运行
shell
# ===== 前置环境(第一次做时执行一遍;缺哪个装哪个,详见课时 24 附录 F)=====
sudo apt update
sudo apt install -y python3-pyqt5 # 窗口界面库(本课必须)
sudo apt install -y xvfb # 可选:没有桌面时用它渲染成图片
which python3 # 应该是 /usr/bin/python3
# 三查:库在不在、你是哪个 python、你的终端有没有"屏幕"
python3 -c "import PyQt5; print('PyQt5 OK')"
python3 -V
echo $DISPLAY # 桌面里是 :0 之类;SSH 里是空的
# ===== 怎么运行 =====
cd ~/lab/课时25-设备面板界面
ls # 应看到 device_panel.py 和 device_panel_full.py
# ① 骨架(上课填这个):必须在"看得见窗口"的地方跑
python3 device_panel.py
# ② 完整版(含会转的风扇、温度漂移)
python3 device_panel_full.py
# ③ 没有桌面时:渲染成图片再看(用图片查看器打开这两张 png)
xvfb-run -a python3 device_panel.py --shot /tmp/panel.png
xvfb-run -a python3 device_panel_full.py --shot /tmp/panel_full.png
附录 B|文件 device_panel.py(骨架,上课填它)
python
#!/usr/bin/env python3
"""课时 25 ------ 设备面板【骨架】:先把界面搭出来,这一节还不接串口
【在哪里看】PyQt5 是有窗口的程序,**必须在虚拟机桌面里运行**:
python3 device_panel.py # 弹出一个窗口
纯 SSH 的 VS Code 终端里会报 "could not connect to display",这时要么去虚拟机桌面,
要么先用虚拟显示器自检(能存图,看不到窗口):
xvfb-run -a python3 device_panel.py --shot /tmp/panel.png
【本节只做一件事】把界面搭出来、点了有反应。
带 TODO 的地方一共 3 个函数、约 15 行,是要你填的。
下一节课(课时 26)再把 /tmp/ttyV1 串口接上,让它能被命令行控制。
"""
import sys
from PyQt5.QtCore import Qt
from PyQt5.QtWidgets import (QApplication, QComboBox, QGridLayout, QGroupBox,
QLabel, QMainWindow, QPlainTextEdit, QPushButton,
QVBoxLayout, QWidget)
class DevicePanel(QMainWindow):
"""设备模拟器面板:左边看状态、右边点按钮、下面看日志"""
def __init__(self):
super().__init__()
self.setWindowTitle("设备模拟器(课时25:界面骨架)")
self.resize(660, 430)
# 设备当前状态(下一节课串口收到命令,改的就是这三个值)
self.led_on = False
self.fan_level = 0
self.temperature = 25.0
central = QWidget()
root = QVBoxLayout(central)
root.addWidget(self._build_body()) # 上面:设备状态 + 控制
root.addWidget(self._build_log(), stretch=1) # 下面:日志
self.setCentralWidget(central)
self.log("面板已启动(本节还没接串口,先点按钮看界面反应)")
# ================= 界面搭建(已写好,不用改)=================
def _build_body(self):
box = QGroupBox("设备")
grid = QGridLayout(box)
grid.setHorizontalSpacing(14)
grid.setVerticalSpacing(12)
# ------ 左边:状态 ------
self.led_dot = QLabel() # 一个小方块,用样式表变成圆点
self.led_dot.setFixedSize(24, 24)
self.led_text = QLabel()
grid.addWidget(QLabel("LED 灯"), 0, 0, alignment=Qt.AlignRight)
grid.addWidget(self.led_dot, 0, 1)
grid.addWidget(self.led_text, 0, 2)
self.fan_text = QLabel()
grid.addWidget(QLabel("风扇"), 1, 0, alignment=Qt.AlignRight)
grid.addWidget(self.fan_text, 1, 1, 1, 2)
self.temp_text = QLabel()
self.temp_text.setStyleSheet("font-size: 22px; color: #d84315;")
grid.addWidget(QLabel("温度"), 2, 0, alignment=Qt.AlignRight)
grid.addWidget(self.temp_text, 2, 1, 1, 2)
# ------ 右边:控制(按钮点一下就调用对应的"槽函数")------
self.btn_on = QPushButton("灯 开")
self.btn_off = QPushButton("灯 关")
self.btn_reset = QPushButton("复 位")
self.btn_on.clicked.connect(self.on_led_on) # 信号 → 槽
self.btn_off.clicked.connect(self.on_led_off)
self.btn_reset.clicked.connect(self.on_reset)
grid.addWidget(self.btn_on, 0, 3)
grid.addWidget(self.btn_off, 0, 4)
grid.addWidget(QLabel("风扇档位"), 1, 3)
self.fan_box = QComboBox()
self.fan_box.addItems(["0 档(停)", "1 档", "2 档", "3 档"])
self.fan_box.currentIndexChanged.connect(self.on_fan_changed)
grid.addWidget(self.fan_box, 1, 4)
grid.addWidget(self.btn_reset, 2, 3, 1, 2)
grid.setColumnStretch(5, 1) # 右边留白
return box
def _build_log(self):
box = QGroupBox("日志")
lay = QVBoxLayout(box)
self.log_view = QPlainTextEdit()
self.log_view.setReadOnly(True)
lay.addWidget(self.log_view)
return box
# ================= 改界面显示(已写好,填槽函数时直接调用)=================
def set_led(self, on):
"""把 LED 画成亮(绿)或灭(灰)"""
self.led_on = bool(on)
color = "#00c853" if self.led_on else "#9e9e9e"
self.led_dot.setStyleSheet(
"background-color: %s; border-radius: 12px; border: 1px solid #616161;" % color)
self.led_text.setText("亮" if self.led_on else "灭")
def set_fan(self, level):
"""改风扇档位:0~3"""
self.fan_level = int(level)
self.fan_text.setText("%d 档%s" % (self.fan_level,
"(停)" if self.fan_level == 0 else ""))
if self.fan_box.currentIndex() != self.fan_level:
self.fan_box.setCurrentIndex(self.fan_level) # 让下拉框也跟着变
def set_temperature(self, celsius):
self.temperature = float(celsius)
self.temp_text.setText("%.1f ℃" % self.temperature)
def log(self, text):
self.log_view.appendPlainText(text)
print(text) # 同时打到终端,方便排查
# ================= 要你填的 3 个槽函数(共约 15 行)=================
def on_led_on(self):
"""点【灯 开】时被调用"""
# TODO(第 4 步):把灯点亮,并写一行日志
# 提示:self.set_led(True) self.log("...")
self.log("按了【灯 开】------ TODO:把这一行换成上面提示的两句")
def on_led_off(self):
"""点【灯 关】时被调用"""
# TODO(第 4 步):把灯关掉 + 日志(照 on_reset 的样子写)
self.log("按了【灯 关】------ TODO")
def on_fan_changed(self, level):
"""切风扇档位时被调用;level 就是下拉框的序号 0~3"""
# TODO(第 5 步):改档位显示 + 日志
# 提示:self.set_fan(level) self.log("风扇 → %d 档" % level)
self.log("下拉框变成 %d ------ TODO" % level)
# ------ 下面这个"复位"已经写好,当样板:照它的样子写上面三个 ------
def on_reset(self):
self.set_led(False)
self.set_fan(0)
self.log("复位:灯灭、风扇停")
def main():
app = QApplication(sys.argv) # 事件循环(相当于 C 里的 uloop_init)
win = DevicePanel()
win.set_led(False) # 先画一次初始状态
win.set_fan(0)
win.set_temperature(25.0)
win.show()
if "--shot" in sys.argv: # 没有桌面时的自检:渲染并存图
path = sys.argv[sys.argv.index("--shot") + 1]
QApplication.processEvents()
ok = win.grab().save(path)
print("窗口截图已保存:%s(%s)" % (path, "成功" if ok else "失败"))
return 0
return app.exec_() # 进入事件循环(相当于 C 里的 uloop_run)
if __name__ == "__main__":
sys.exit(main())
附录 C|文件 device_panel_full.py(完整版:槽函数填好 + 会转的风扇)
python
#!/usr/bin/env python3
"""课时 25 ------ 设备面板【完整版】:留着的动画和"温度在变"都做好了
和 device_panel.py(骨架)的区别只有三处:
① 3 个 TODO 槽函数填好了
② 右下角多了一个用 QPainter 画出来、会转的风扇(选做第 7 步)
③ 温度用一个 QTimer 慢慢漂移,表示"传感器在测"(课时 26 会换成串口里的真数据)
运行(虚拟机桌面里):python3 device_panel_full.py
没桌面时自检: xvfb-run -a python3 device_panel_full.py --shot /tmp/panel.png
"""
import random
import sys
from PyQt5.QtCore import Qt, QTimer
from PyQt5.QtGui import QColor, QPainter
from PyQt5.QtWidgets import (QApplication, QComboBox, QGridLayout, QGroupBox,
QLabel, QMainWindow, QPlainTextEdit, QPushButton,
QVBoxLayout, QWidget)
class FanWidget(QWidget):
"""用 QPainter 画一个风扇:3 片叶子,档位越高转得越快"""
def __init__(self):
super().__init__()
self.setFixedSize(90, 90)
self.angle = 0
self.level = 0
self.timer = QTimer(self)
self.timer.timeout.connect(self.on_tick)
self.timer.start(60) # 每 60 毫秒动一下(≈16 帧/秒)
def set_level(self, level):
self.level = int(level)
def on_tick(self):
if self.level > 0: # 0 档就停着不动
self.angle = (self.angle + 6 * self.level) % 360
self.update() # 请求重画(会触发 paintEvent)
def paintEvent(self, event):
p = QPainter(self)
p.setRenderHint(QPainter.Antialiasing)
p.translate(self.width() / 2, self.height() / 2)
p.rotate(self.angle)
p.setPen(Qt.NoPen)
p.setBrush(QColor("#0288d1") if self.level > 0 else QColor("#bdbdbd"))
for _ in range(3): # 3 片叶子,每片转 120°
p.rotate(120)
p.drawEllipse(-6, -34, 12, 34)
p.setBrush(QColor("#616161"))
p.drawEllipse(-6, -6, 12, 12) # 中心轴
class DevicePanel(QMainWindow):
"""设备模拟器面板:左边看状态、右边点按钮、下面看日志"""
def __init__(self):
super().__init__()
self.setWindowTitle("设备模拟器(课时25:完整版)")
self.resize(700, 450)
self.led_on = False
self.fan_level = 0
self.temperature = 25.0
central = QWidget()
root = QVBoxLayout(central)
root.addWidget(self._build_body())
root.addWidget(self._build_log(), stretch=1)
self.setCentralWidget(central)
# 温度漂移(假数据,只为"看起来像在测";课时 26 换成串口真值)
self.sim_timer = QTimer(self)
self.sim_timer.timeout.connect(self.on_sim_tick)
self.sim_timer.start(1000)
self.log("面板已启动(温度是本地模拟的,课时 26 换成串口真数据)")
# ================= 界面 =================
def _build_body(self):
box = QGroupBox("设备")
grid = QGridLayout(box)
grid.setHorizontalSpacing(14)
grid.setVerticalSpacing(12)
self.led_dot = QLabel()
self.led_dot.setFixedSize(24, 24)
self.led_text = QLabel()
grid.addWidget(QLabel("LED 灯"), 0, 0, alignment=Qt.AlignRight)
grid.addWidget(self.led_dot, 0, 1)
grid.addWidget(self.led_text, 0, 2)
self.fan_text = QLabel()
grid.addWidget(QLabel("风扇"), 1, 0, alignment=Qt.AlignRight)
grid.addWidget(self.fan_text, 1, 1, 1, 2)
self.temp_text = QLabel()
self.temp_text.setStyleSheet("font-size: 22px; color: #d84315;")
grid.addWidget(QLabel("温度"), 2, 0, alignment=Qt.AlignRight)
grid.addWidget(self.temp_text, 2, 1, 1, 2)
self.btn_on = QPushButton("灯 开")
self.btn_off = QPushButton("灯 关")
self.btn_reset = QPushButton("复 位")
self.btn_on.clicked.connect(self.on_led_on)
self.btn_off.clicked.connect(self.on_led_off)
self.btn_reset.clicked.connect(self.on_reset)
grid.addWidget(self.btn_on, 0, 3)
grid.addWidget(self.btn_off, 0, 4)
grid.addWidget(QLabel("风扇档位"), 1, 3)
self.fan_box = QComboBox()
self.fan_box.addItems(["0 档(停)", "1 档", "2 档", "3 档"])
self.fan_box.currentIndexChanged.connect(self.on_fan_changed)
grid.addWidget(self.fan_box, 1, 4)
grid.addWidget(self.btn_reset, 2, 3, 1, 2)
self.fan_widget = FanWidget()
grid.addWidget(self.fan_widget, 0, 5, 3, 1, alignment=Qt.AlignCenter)
grid.setColumnStretch(6, 1)
return box
def _build_log(self):
box = QGroupBox("日志")
lay = QVBoxLayout(box)
self.log_view = QPlainTextEdit()
self.log_view.setReadOnly(True)
lay.addWidget(self.log_view)
return box
# ================= 改界面显示 =================
def set_led(self, on):
self.led_on = bool(on)
color = "#00c853" if self.led_on else "#9e9e9e"
self.led_dot.setStyleSheet(
"background-color: %s; border-radius: 12px; border: 1px solid #616161;" % color)
self.led_text.setText("亮" if self.led_on else "灭")
def set_fan(self, level):
self.fan_level = int(level)
self.fan_text.setText("%d 档%s" % (self.fan_level,
"(停)" if self.fan_level == 0 else ""))
self.fan_widget.set_level(self.fan_level)
if self.fan_box.currentIndex() != self.fan_level:
self.fan_box.setCurrentIndex(self.fan_level)
def set_temperature(self, celsius):
self.temperature = float(celsius)
self.temp_text.setText("%.1f ℃" % self.temperature)
def log(self, text):
self.log_view.appendPlainText(text)
print(text)
# ================= 槽函数(对比骨架:这里都填好了)=================
def on_led_on(self):
self.set_led(True)
self.log("LED 已点亮")
def on_led_off(self):
self.set_led(False)
self.log("LED 已熄灭")
def on_fan_changed(self, level):
self.set_fan(level)
self.log("风扇 → %d 档" % level)
def on_reset(self):
self.set_led(False)
self.set_fan(0)
self.log("复位:灯灭、风扇停")
def on_sim_tick(self):
"""温度小幅漂移:25.0 ± 0.3(真项目里这行换成串口收到的数据)"""
self.set_temperature(round(self.temperature + random.uniform(-0.3, 0.3), 1))
def main():
app = QApplication(sys.argv)
win = DevicePanel()
win.set_led(False)
win.set_fan(0)
win.set_temperature(25.0)
win.show()
if "--shot" in sys.argv:
path = sys.argv[sys.argv.index("--shot") + 1]
QApplication.processEvents()
ok = win.grab().save(path)
print("窗口截图已保存:%s(%s)" % (path, "成功" if ok else "失败"))
return 0
return app.exec_()
if __name__ == "__main__":
sys.exit(main())
附录 D|要填的 3 个槽函数(标准答案,对照用)
python
def on_led_on(self):
"""点【灯 开】时被调用"""
self.set_led(True)
self.log("LED 已点亮")
def on_led_off(self):
"""点【灯 关】时被调用"""
self.set_led(False)
self.log("LED 已熄灭")
def on_fan_changed(self, level):
"""切风扇档位时被调用;level 就是下拉框的序号 0~3"""
self.set_fan(level)
self.log("风扇 → %d 档" % level)
对照要点:每个槽函数都是"改状态 + 写日志"两件事 ;
改状态一律用老师给好的
set_led() / set_fan() / set_temperature(),别自己去动控件的样式表和文本------那样下节课接上串口时,"界面"和"数据"才不会各改各的(串口来了数据也走同一批 set 函数)。