30 嵌入式操作系统 | 上位机界面:Flask 网页显示 + 控制闭环

嵌入式操作系统 | 上位机界面:Flask 网页显示 + 控制闭环

本课程开源地址(Gitee) :https://gitee.com/fujianxinxi/qianrushixitongyingyongkaifa.git

课件、示例代码与验收脚本都在该仓库,可直接 git clone 或下载 ZIP 使用。
模块七 · 2 学时 · 环境:Ubuntu(Flask + 前面几节跑通的设备面板/网关)+ socat 虚拟串口

一句话概述 :写网页上位机 ------Flask 起一个页面,后台线程收网关的 UDP 上报(灯/风扇/温度/在线),按钮走 TCP 给网关发命令。做完这件事,用鼠标就能控制那个"串口设备",而且 Windows 宿主机浏览器里也能操作。

本节目标

  1. 会写 Flask 应用(@app.route("/") + 页面渲染),知道"网页就是一个返回 HTML 的函数"
  2. 会用后台线程收 UDP 上报更新共享状态,并说清"为什么共享数据要加锁"
  3. 会做零 JavaScript 的交互 :按钮用 <form>、自动刷新用 <meta http-equiv="refresh">(学生看得懂、命令行也能验证)
  4. 能跑通闭环:点网页按钮 → 网关 → 串口设备动作 → 网关上报 → 页面显示新状态

一、讲解内容

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 那道选做题。

相关推荐
ysu_03141 小时前
Docker-Desktop-WSL2实战指南-Windows从安装配置到项目容器化
windows·python·docker·容器·composer·dockerfile·wsl2
郝学胜-神的一滴1 小时前
AI 编程智能体 02:AI智能体到底是什么
开发语言·人工智能·python·程序人生·pycharm
念何架构之路1 小时前
go-grpc服务端调用
开发语言·后端·golang
Guarding and trust2 小时前
python系列之多线程编程
python
我的div丢了肿么办2 小时前
go语言中如何安装第3方的包
后端·go
小蒜学长2 小时前
基于Spring Boot+Vue的“禾源”农产品销售系统设计与实现(代码+数据库+LW)
java·spring boot·后端·农产品销售系统·产品溯源
用户EasyAdminBlazor2 小时前
EasyAdminBlazor 审批事务:为什么审批失败必须全部回滚?
后端
梅雅达编程笔记2 小时前
03_网页表格数据抓取
爬虫·python·beautifulsoup·pandas·数据采集
迅猛龙办公室2 小时前
Python实现求两个数字之和
java·开发语言·python