嵌入式操作系统 | 上位机界面:Flask 网页显示 + 控制闭环
本课程开源地址(Gitee) :https://gitee.com/fujianxinxi/qianrushixitongyingyongkaifa.git
课件、示例代码与验收脚本都在该仓库,可直接
git clone或下载 ZIP 使用。
模块七 · 2 学时 · 环境:Ubuntu(Flask + 前面几节跑通的设备面板/网关)+ socat 虚拟串口
一句话概述 :写网页上位机 ------Flask 起一个页面,后台线程收网关的 UDP 上报(灯/风扇/温度/在线),按钮走 TCP 给网关发命令。做完这件事,用鼠标就能控制那个"串口设备",而且 Windows 宿主机浏览器里也能操作。
本节目标
- 会写 Flask 应用(
@app.route("/")+ 页面渲染),知道"网页就是一个返回 HTML 的函数" - 会用后台线程收 UDP 上报更新共享状态,并说清"为什么共享数据要加锁"
- 会做零 JavaScript 的交互 :按钮用
<form>、自动刷新用<meta http-equiv="refresh">(学生看得懂、命令行也能验证) - 能跑通闭环:点网页按钮 → 网关 → 串口设备动作 → 网关上报 → 页面显示新状态
一、讲解内容
1.1 知识讲解
上位机在项目里的位置(前面的三条链路,本节把它们连成"人能用"的东西):
鼠标点按钮 TCP(长度前缀 + 帧) 串口 55 AA 帧
┌──────────┐ ─────────────────────────▶ ┌────────┐ ─────────────────▶ ┌──────────┐
│ 浏览器 │ │ 网关 │ │ 设备面板 │
│ 网页界面 │ ◀───────────────────────── │(课时29) │ ◀───────────────── │(课时26) │
└──────────┘ UDP 0x81 每 2 秒 └────────┘ 0x80 每秒 └──────────┘
▲ ▲ ▲
│ ┌─────┴──────┐ │
└──────────────┤ 本节:app.py │────────────────┘
页面每 2 秒刷新 │ ① 收 UDP │
│ ② 存状态 │
│ ③ 发 TCP 命令│
└────────────┘
为什么用 Flask(网页)而不是 PyQt5 窗口? 回顾课时 24 的那张表:
| PyQt5 窗口(课时 25/26) | Flask 网页(本节) | |
|---|---|---|
| 在哪看 | 必须虚拟机桌面 | 虚拟机浏览器 / Windows 宿主机 / VS Code 端口转发 |
| 部署演示 | 要开桌面 | 一条 python3 app.py,别人的手机/电脑也能看 |
| 需要学什么 | Qt 控件、信号槽 | HTML 表单 + 一点点 Flask(本课连 JS 都不用) |
三个知识点,本课就这三个:
① Flask 路由 = "哪个网址交给哪个函数"
python
@app.route("/") # 浏览器访问 http://IP:5000/ 时执行 index()
def index():
return render_page() # 返回一段 HTML 字符串(浏览器就显示成页面)
@app.route("/cmd") # 点按钮时访问 /cmd?name=led_on
def cmd():
...
return redirect("/") # 处理完回到首页(PRG 模式,见下)
② 后台线程 + 共享状态(必须加锁). 主线程在跑 Flask(处理网页请求),还要有另一个线程去收 UDP 上报:
python
state = {"led": 0, "fan": 0, "temp": 25.0, "online": 0, "updated": 0.0, "msg": "..."}
lock = threading.Lock() # 两个线程都碰 state,所以要有锁
def udp_listener(): # 后台线程:收网关上报
while True:
data, _ = s.recvfrom(2048)
...
with lock: # 改 state 前先拿锁
state["led"] = payload[0]
...
threading.Thread(target=udp_listener, daemon=True).start() # 启动时开线程
daemon=True :主程序退出时这个线程跟着退出(否则进程不肯结束)。
为什么要锁:一个线程在读(渲染页面)、另一个在写(收到上报),不加锁可能读到"改了一半"的数据。
③ 零 JavaScript 的交互(本节刻意这么设计)
| 需求 | 不用 JS 的办法 |
|---|---|
| 按钮发命令 | <form method="get" action="/cmd"><input type="hidden" name="name" value="led_on"><button>灯 开</button></form> |
| 页面自动刷新 | <meta http-equiv="refresh" content="2">(一行,每 2 秒刷一次) |
| 点完按钮不要重复提交 | PRG 模式 :/cmd 处理完 redirect("/"),浏览器地址栏变回 /,刷新不会重发命令 |
这就是"网页版上位机"的最小闭环 :命令走 TCP(可靠)、状态走 UDP 上报(轻量)。想学 JavaScript 的同学可以用本课顺手提供的 **/api/state(JSON 接口)**自己做定时 fetch 版(选做)。


1.2 操作步骤(一条命令一步,看现象)
第 0 步:环境检查(Flask 装了吗?前面几节的东西在吗?)
shell
python3 -c "import flask; print('Flask', flask.__version__)" # 应打印 Flask 1.1.1
which socat # 虚拟串口
python3 -c "import serial; print('pyserial OK')" # 设备面板要用
ls ~/lab/课时29-网关状态表与上报/gateway_full # 上一节的网关(编译好的)
缺 Flask 就补(完整清单见课时 24 附录 F):
shell
sudo apt update
sudo apt install -y python3-flask
第 1 步:先把"设备面板 + 网关"跑起来(三个终端,和上节课一样)
shell
# 终端 A:虚拟串口
socat -d -d pty,raw,echo=0,link=/tmp/ttyV0 pty,raw,echo=0,link=/tmp/ttyV1
# 终端 B:设备面板
cd ~/lab/课时26-设备面板接串口 && python3 device_panel_serial.py
# 终端 C:网关(每 2 秒上报到 127.0.0.1:9100)
cd ~/lab/课时29-网关状态表与上报 && ./gateway_full /tmp/ttyV0 9000 127.0.0.1 9100
第 2 步:启动网页上位机(终端 D)
shell
cd ~/lab/课时30-上位机界面
python3 app.py
现象(实测,四条启动信息):
text
网页上位机启动:http://0.0.0.0:5000(宿主机用 http://<虚拟机IP>:5000)
命令发往网关:TCP 127.0.0.1:9000;状态来自网关:UDP 9100
正在监听网关上报:UDP 9100
* Running on http://0.0.0.0:5000/ (Press CTRL+C to quit)
注意:UDP 9100 只允许一个程序监听 ------如果你上一节课的
state_listener.py还开着,这里会报Address already in use。
第 3 步:用浏览器打开(三种方式,任选)

| 在哪打开 | 地址 | 备注 |
|---|---|---|
| 虚拟机里的浏览器 | http://127.0.0.1:5000 |
最直接 |
| Windows 宿主机浏览器 | http://<虚拟机IP>:5000 |
虚拟机里 ip a 查 IP(本机实测是 192.168.31.129) |
| VS Code Remote-SSH | 端口面板 → 5000 → "在浏览器中打开" | 自动转发,最省事 |
页面长这样(左边一列是状态、下面一排按钮、最下面显示"最近一次操作"):
text
┌─ 设备监控(Flask 网页上位机)──────────────────────────────┐
│ LED 灯 ●(绿) 亮 ← 绿色圆点 = 亮(样式表画的) │
│ 风扇 2 档 │
│ 温度 24.5 ℃ ← 红色大字 │
│ 设备状态 在线 │
│ 最后更新 05:39:46(0 秒前) │
│ ────────────────────────────────────────────────────────│
│ [灯 开] [灯 关] [风扇 0 档] [风扇 1 档] [风扇 2 档] ... │
│ │
│ 最近一次操作:风扇 2 档 → 已发送 55 AA 02 01 02 50 51 │
└────────────────────────────────────────────────────────────┘
第 4 步:不开浏览器也能验证(SSH 里很方便)
shell
# ① 看状态(JSON,页面拿的就是它)
wget -qO- http://127.0.0.1:5000/api/state
现象(实测):
text
{"age":0.014,"fan":0,"led":0,"msg":"页面已启动,等待网关上报...","online":1,"temp":24.7,"updated":1790426382.2}
shell
# ② 用"网页接口"点灯(等价于点了页面上的【灯 开】)
wget -qO- 'http://127.0.0.1:5000/cmd?name=led_on' | head -3
现象(实测):接口返回 302(重定向回首页),网页程序日志出现
text
已发命令 55 AA 01 01 01 E0 50
127.0.0.1 - - [26/Sep/2026 05:39:42] "GET /cmd?name=led_on HTTP/1.1" 302 -
再发一条风扇命令,然后看状态就变了(实测):
shell
wget -qO- 'http://127.0.0.1:5000/cmd?name=fan_2' > /dev/null
sleep 4
wget -qO- http://127.0.0.1:5000/api/state
text
{"age":0.111,"fan":2,"led":1,"online":1,"temp":24.5,
"msg":"风扇 2 档 → 已发送 55 AA 02 01 02 50 51(网关回了 00 0A 55 AA 80 04 01 02 00 F5 8E 60)"}
第 5 步:看页面渲染出来的内容(本节"闭环"的证据)
shell
wget -qO- http://127.0.0.1:5000/ | grep -E 'LED 灯|温度|设备状态|最近一次操作'
现象(实测):
text
<tr><td>LED 灯</td><td><span class="dot on"></span>亮</td></tr>
<tr><td>温度</td><td class="temp">24.5 ℃</td></tr>
<tr><td>设备状态</td><td class="">在线</td></tr>
<b>最近一次操作:</b><span class="msg">风扇 2 档 → 已发送 55 AA 02 01 02 50 51(网关回了 ...)</span>
同时:设备面板上的灯真的亮了、风扇开始转(就是上面那张截图)。
第 6 步:看"设备掉线"时页面什么表现
shell
pkill -f 'device_panel_seria[l]' # 拔掉设备
sleep 6
wget -qO- http://127.0.0.1:5000/api/state # 看 online
现象:"online":0,页面上"设备状态"会显示红色的"离线(设备没在报状态) ",温度保留最后已知值。
第 7 步:收工。 依次 Ctrl+C:网页 → 网关 → 设备面板 → socat。
1.3 常见错误与排查
| 现象 | 原因 | 解决 |
|---|---|---|
启动就报 Address already in use(UDP 9100) |
上一节的 state_listener.py 或另一个 app.py 还开着 |
`ss -lunp |
启动就报 Address already in use(TCP 5000) |
上次的 app.py 没退干净 | `ss -ltnp |
| 页面一直"还没收到上报" | ① 网关没跑 ② 网关的上报目标不是本机 9100 ③ 端口不一致 | 看网关日志有没有"网关上报状态(0x81)";确认网关第 3/4 个参数是 127.0.0.1 9100 |
| 网页能开但点按钮"连不上网关" | 网关没跑 / 端口不是 9000 | 看页面下方"最近一次操作"里的提示;确认网关 TCP 端口 |
宿主机打不开 http://IP:5000 |
① 程序只监听 127.0.0.1 ② IP 拿错 ③ VMware 网络模式 | 本课代码是 host="0.0.0.0";ip a 查 IP;网络用 NAT/桥接或加端口转发 |
| 按钮点了页面没变化 | <meta refresh> 是 2 秒一次,等一下;或 redirect("/") 被浏览器缓存 |
手动 Ctrl+R;或把 content="2" 改小试试 |
| 页面中文乱码 | 少了 <meta charset="utf-8"> |
保留 HTML 里那一行 |
| 掉线后温度不变 | 设计如此:离线保留最后已知值 | 界面上同时显示"离线",别把老值当新值 |
| 想改界面(颜色/加按钮) | --- | 直接改 PAGE 里的 HTML/CSS;加按钮就在 CMDS 里加一条 + 页面加一个 <form> |
二、学生练习
本节命令速查表:
| 命令 | 作用 | 示例 |
|---|---|---|
python3 app.py |
启动网页上位机(0.0.0.0:5000) | Ctrl+C 退出 |
wget -qO- http://127.0.0.1:5000/api/state |
看 JSON 状态 | 页面拿的就是它 |
wget -qO- 'http://127.0.0.1:5000/cmd?name=led_on' |
命令行"点按钮" | 也可 fan_2 / status / reset |
| `wget -qO- http://127.0.0.1:5000/ | head -20` | 看页面 HTML |
| `ss -ltnp | grep 5000/ss -lunp |
grep 9100` |
ip a |
查虚拟机 IP | 宿主机访问用 |
| 编号 | 任务 | 提示 | 预期结果 |
|---|---|---|---|
| L30.1 | 四个终端跑起来 | 第 1、2 步 | 页面能打开,显示"在线 + 温度" |
| L30.2 | 在宿主机浏览器里打开并点按钮 | 第 3 步 | 点【灯 开】→ 设备面板灯亮 |
| L30.3 | 命令行验证 | 第 4、5 步 | /api/state 的 JSON 里 led:1、fan:2 |
| L30.4 | 掉线时页面表现 | 第 6 步 | online:0,页面显示红色"离线" |
| L30.5 | 加一个按钮:风扇 3 档 | CMDS 加一条 + 页面加一个 <form> |
点它,风扇转更快 |
| L30.6 | 把刷新周期改成 1 秒 | 改 content="2" → content="1" |
状态更新更快(也更费流量) |
| L30.7 | 选做:用 JS 自动刷新 | 用 /api/state + fetch + setInterval |
页面不闪、实时更新 |
| L30.8 | 选做:给"离线"加醒目样式 | 渲染时判断 online 输出不同 class |
一掉线界面很显眼 |
三、作业
- 必做:交 4 张截图(宿主机浏览器 里的页面 / 点按钮后设备面板的灯亮 /
/api/state的 JSON / 掉线时页面显示"离线"),并回答:① 网页和 PyQt5 窗口各适合什么场景?本课程为什么上位机用网页?② 为什么要用后台线程收 UDP?为什么改state要加锁?③ 什么是 PRG 模式?不加redirect("/")会有什么问题?④ 控制走 TCP、状态走 UDP 上报,这样分工的好处是什么? - 选做:① 完成 L30.6、L30.7;② 把页面改成"两栏布局"(左边状态、右边按钮),或给温度加一个"超过 26.5℃ 变红加粗"的样式
- 提交:截图 + 文字回答,命名
课时30-作业截图.png
四、本节小结与下节预告
本节把前四节串成了一个能演示的闭环 :网页上位机(Flask)→ TCP 命令 → 网关 → 串口 → 设备面板动作 → 状态经 0x80 上报 → 网关状态表 → 每 2 秒 0x81 UDP 上报 → 页面刷新显示。你还验证了"设备掉线时界面显示离线",并且在 Windows 宿主机浏览器里也能操作。
下节课(课时 31,选做)做产品化 :把网关里的"串口号 / 波特率 / TCP 端口 / 上报目标"从命令行参数改成从 /etc/config/device 读 (uci,课时 19),再用 ubus 暴露一个 device.status(课时 20),这样"改配置不用重新编译、状态也能被别的程序查询"。
附录:怎么运行 + 完整代码
文件在
代码/课时30-上位机界面/app.py(约 255 行,含页面 HTML)。运行前需要:课时 26 的设备面板 + 课时 29 的网关。
附录 A|环境准备 + 怎么运行(四个终端)
shell
# ===== ⓿ 环境准备(缺哪个装哪个;完整清单见课时 24 附录 F)=====
sudo apt update
sudo apt install -y python3-flask python3-serial socat
sudo apt install -y python3-pyqt5 xvfb # 设备面板要用
# ===== 终端 A:虚拟串口 =====
socat -d -d pty,raw,echo=0,link=/tmp/ttyV0 pty,raw,echo=0,link=/tmp/ttyV1
# ===== 终端 B:设备面板(课时 26)=====
cd ~/lab/课时26-设备面板接串口 && python3 device_panel_serial.py
# ===== 终端 C:网关(课时 29,每 2 秒上报)=====
cd ~/lab/课时29-网关状态表与上报 && ./gateway_full /tmp/ttyV0 9000 127.0.0.1 9100
# ===== 终端 D:网页上位机(本节)=====
cd ~/lab/课时30-上位机界面 && python3 app.py
ip a | grep inet # 查虚拟机 IP,宿主机浏览器开 http://IP:5000
# ===== 命令行验证(不开浏览器)=====
wget -qO- http://127.0.0.1:5000/api/state
wget -qO- 'http://127.0.0.1:5000/cmd?name=led_on' > /dev/null
wget -qO- 'http://127.0.0.1:5000/cmd?name=fan_2' > /dev/null
sleep 4 && wget -qO- http://127.0.0.1:5000/api/state
附录 B|app.py 关键片段
B-1 协议部分(和前面几节一字不差)
python
def crc16_modbus(data: bytes) -> int:
crc = 0xFFFF
for b in data:
crc ^= b
for _ in range(8):
crc = (crc >> 1) ^ 0xA001 if crc & 1 else crc >> 1
return crc
def build_frame(type_: int, payload: bytes = b"") -> bytes:
body = bytes([type_, len(payload)]) + payload
crc = crc16_modbus(body)
return b"\x55\xaa" + body + bytes([crc & 0xFF, crc >> 8])
# 按钮名 → 要发的帧
CMDS = {
"led_on": ("灯 开", build_frame(0x01, b"\x01")),
"led_off": ("灯 关", build_frame(0x01, b"\x00")),
"fan_0": ("风扇 0 档", build_frame(0x02, b"\x00")),
"fan_1": ("风扇 1 档", build_frame(0x02, b"\x01")),
"fan_2": ("风扇 2 档", build_frame(0x02, b"\x02")),
"fan_3": ("风扇 3 档", build_frame(0x02, b"\x03")),
"status": ("查状态", build_frame(0x03)),
"reset": ("复位", build_frame(0x04)),
}
B-2 后台线程收 UDP 上报(0x81)
python
def udp_listener():
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
s.bind(("0.0.0.0", STATE_PORT))
print("正在监听网关上报:UDP %d" % STATE_PORT)
while True:
data, _ = s.recvfrom(2048)
if len(data) < 6 or data[0] != 0x55 or data[1] != 0xAA:
continue
type_, length = data[2], data[3]
payload = data[4:4 + length]
got = data[4 + length] | (data[5 + length] << 8)
if crc16_modbus(data[2:4 + length]) != got:
print("收到一帧上报,但 CRC 不对,丢弃")
continue
if type_ == 0x81 and length == 5:
with lock: # 改共享状态要加锁
state["led"] = payload[0]
state["fan"] = payload[1]
state["temp"] = ((payload[2] << 8) | payload[3]) / 10.0
state["online"] = payload[4]
state["updated"] = time.time()
B-3 三个路由(页面 / 控制 / JSON 接口)
python
@app.route("/")
def index():
return render_page()
@app.route("/cmd")
def cmd():
name = request.args.get("name", "")
if name not in CMDS:
with lock:
state["msg"] = "不认识的命令:%s" % name
return redirect("/")
label, frame = CMDS[name]
result = send_command(frame)
with lock:
state["msg"] = "%s → %s" % (label, result)
return redirect("/") # PRG 模式:重定向回首页,刷新不会重发命令
@app.route("/api/state")
def api_state():
"""给"想用 JS 自动刷新"的同学用(选做)"""
with lock:
data = dict(state)
data["age"] = (time.time() - data["updated"]) if data["updated"] else None
return jsonify(data)
B-4 控制命令怎么发(长度前缀 + 帧,复用课时 28 的约定)
python
def send_command(frame: bytes) -> str:
pkt = bytes([len(frame) >> 8, len(frame) & 0xFF]) + frame # 长度前缀 + 帧
try:
s = socket.create_connection((GATEWAY_HOST, GATEWAY_PORT), timeout=2.0)
s.sendall(pkt)
s.settimeout(0.5)
try:
reply = s.recv(64)
except socket.timeout:
reply = b""
s.close()
except OSError as exc:
print("连网关失败:%s" % exc)
return "连不上网关(%s:%d):先确认网关跑起来了" % (GATEWAY_HOST, GATEWAY_PORT)
print("已发命令 %s" % frame_text(frame))
if reply:
return "已发送 %s(网关回了 %s)" % (frame_text(frame), frame_text(reply))
return "已发送 %s" % frame_text(frame)
B-5 页面"自动刷新"就一行(不用 JS)
html
<meta http-equiv="refresh" content="2"> <!-- 每 2 秒自动刷新一次,不用 JS -->
想换成 JS 版(页面不闪):在页面里加一句
setInterval(() => fetch('/api/state').then(r => r.json()).then(s => { /* 更新 DOM */ }), 1000);这就是 L30.7 那道选做题。