用 FastAPI + MQTT 做一个 ESP32最小化跑通示例

用 FastAPI + MQTT 做一个 ESP32 实时监控的最小示例

一个最小化的 ESP32 实时监控示例,代码不多,但把 MQTT 接入 FastAPI 这件事里

几个容易踩的地方都记下来了。

起因

手头有一块 ESP32,想在浏览器里实时看温湿度,顺便能远程控制设备(开关风扇、点灯)。

现成的物联网平台要么太重、要么收费,就用熟悉的这几个组件自己拼一个最小的:

ESP32 → MQTT → FastAPI → WebSocket → 浏览器

每个环节都不复杂,串起来之后代码量也就几百行。

但真跑起来还是碰到了一些不看文档猜不到的地方,这篇文章就把这些记下来。

手边暂时没有板子,设备端用 MQTTX 模拟,功能表现跟真机一致。


一、先说结果长什么样

跑通之后的效果:

  • ESP32(或 MQTTX)往 devices/esp32-01/telemetry 发一包 JSON
  • 后端订阅到,校验,转成 WebSocket 消息推给所有前端
  • 浏览器页面上的温度、湿度读数实时跳动
  • 点"向设备发送指令"按钮,后端往 devices/esp32-01/command 发一条 MQTT 指令
  • 整条链路一个后端进程 + 一个 broker 就够了,单端口 8000 同时提供 API 和前端页面

技术栈:

位置 选型
后端 FastAPI 0.125 + gmqtt 0.8
Broker Mosquitto 2.x
前端 Vue 3 + Vite + TypeScript + rxjs
配置 python-dotenv

后端一共就 4 个 Python 文件,加起来不到 300 行。


二、架构:为什么是这 4 个文件

一开始我把所有东西都塞在 main.py 里,写到一半觉得乱,于是拆成了四层:

复制代码
fastapi-mqtt-play/
├── main.py       # 应用入口:MQTT 客户端、生命周期、Swagger 元信息
├── config.py     # 配置层:读取 .env,环境变量优先
├── model.py      # 数据契约:Pydantic 模型
├── routes.py     # 接口层:REST + WebSocket + 静态资源挂载
└── public/monitor_mqtt/   # 前端工程(Vue 3 + Vite + rxjs)
    └── dist/              # 构建产物,提交进仓库,后端直接托管

拆分的逻辑是这样的:

复制代码
        ┌─────────────────────────────────┐
        │         main.py (装配层)         │
        │  lifespan / on_message / app     │
        └───┬──────────┬──────────┬───────┘
            │          │          │
     ┌──────▼───┐ ┌────▼────┐ ┌───▼──────┐
     │ config.py│ │model.py │ │routes.py │
     │ 配置解析  │ │数据契约  │ │ 接口处理  │
     └──────────┘ └─────────┘ └──────────┘

config.py ------ 只做环境变量解析,不含任何业务逻辑

python 复制代码
"""应用配置:从项目根目录的 .env 读取,环境变量优先。"""

import os
from pathlib import Path

from dotenv import load_dotenv

BASE_DIR = Path(__file__).parent
ENV_FILE = BASE_DIR / ".env"

# override=False:已存在的系统环境变量优先于 .env 中的值
load_dotenv(ENV_FILE, override=False)


def _get_str(key: str, default: str) -> str:
    value = os.environ.get(key)
    return value if value not in (None, "") else default


def _get_int(key: str, default: int) -> int:
    raw = _get_str(key, str(default))
    try:
        return int(raw)
    except ValueError:
        return default


# ---------- MQTT 配置 ----------
MQTT_BROKER = _get_str("MQTT_BROKER", "localhost")
MQTT_PORT = _get_int("MQTT_PORT", 1883)
CLIENT_ID = _get_str("CLIENT_ID", "fastapi-backend")

这里有个细节值得说:override=False 意味着系统环境变量优先级高于 .env 。

好处是 CI / Docker 里可以临时覆盖,不用改文件:

bash 复制代码
docker run -e MQTT_BROKER=10.0.0.5 ...

_get_int 还做了容错------环境变量里写错成 MQTT_PORT=abc 时会回落到默认值,而不是直接抛异常让服务起不来。

model.py ------ 对外 JSON 契约的唯一来源

python 复制代码
from pydantic import BaseModel, Field


class SensorData(BaseModel):
    """ESP32 上报的传感器数据"""
    device_id: str = Field(..., examples=["esp32-01"], description="设备唯一标识")
    temperature: float = Field(..., examples=[25.6], description="温度(摄氏度)")
    humidity: float = Field(..., examples=[48.3], description="湿度(%)")
    timestamp: int = Field(..., examples=[1735689600], description="Unix 时间戳(秒)")


class CommandRequest(BaseModel):
    """下发给设备的控制指令"""
    action: str = Field(..., examples=["set_fan"], description="指令名称,如 set_fan / set_led / reboot")
    params: dict = Field(default_factory=dict, examples=[{"speed": 80}], description="指令参数")


class ControlResponse(BaseModel):
    status: str = Field(..., examples=["sent"], description="发送结果状态")
    device: str = Field(..., examples=["esp32-01"], description="目标设备 ID")
    command: CommandRequest = Field(..., description="实际下发的指令内容")


class HealthResponse(BaseModel):
    status: str = Field(..., examples=["running"], description="服务状态")
    mqtt_connected: bool = Field(..., examples=[True], description="MQTT 是否已连接")

这几个模型不只是"数据类",它们同时是 Swagger 文档的生成器 ------examples 和 description

直接渲染到 /docs 页面里。写 Field 描述的时候顺手就把 API 文档写完了。

另外注意 params: dict = Field(default_factory=dict, ...) 用的是 default_factory 而不是

= {}。后者在 Pydantic 里虽然也能跑,但 default_factory 是官方推荐写法,避免可变默认值

在多个实例间共享的隐患。

routes.py ------ 只依赖 model,不直接碰 MQTT 实现

python 复制代码
from pathlib import Path
from typing import Set

from fastapi import APIRouter, FastAPI, Request, WebSocket, WebSocketDisconnect
from fastapi.responses import FileResponse, HTMLResponse
from fastapi.staticfiles import StaticFiles

from model import CommandRequest, ControlResponse, HealthResponse

router = APIRouter()

# WebSocket 连接管理(供 main.py 的 MQTT 回调推送使用)
active_connections: Set[WebSocket] = set()

# 前端构建产物目录(public/monitor_mqtt/dist)
DIST_DIR = Path(__file__).parent / "public" / "monitor_mqtt" / "dist"

这层有个刻意为之的设计 :routes.py 里没有 import gmqtt。

需要发 MQTT 消息时,走 request.app.state.mqtt_client:

python 复制代码
@router.post(
    "/control/{device_id}",
    response_model=ControlResponse,
    status_code=200,
    tags=["设备控制"],
    summary="下发控制指令",
    description=(
        "向指定 ESP32 设备发布一条 MQTT 指令。"
        "\n\n指令会通过 `devices/{device_id}/command` 主题下发,QoS 为 1。"
    ),
    responses={
        422: {"description": "指令体格式不合法"},
        503: {"description": "MQTT 未连接"},
    },
)
async def control_device(device_id: str, command: CommandRequest, request: Request):
    """前端调用此接口控制设备"""
    topic = f"devices/{device_id}/command"
    payload = command.model_dump_json()
    print(f'topic={topic} payload={payload}')
    # gmqtt 的 publish 是同步方法(返回消息 ID),不是协程
    request.app.state.mqtt_client.publish(topic, payload, qos=1)
    return {"status": "sent", "device": device_id, "command": command}

好处很直接:将来要把 gmqtt 换成 EMQX SDK、AWS IoT SDK 或者 HiveMQ Client,
只需要改 main.py 里的装配代码,routes.py 一行都不用动。

"新增功能时先改哪个文件"也有固定套路:

改 model.py 定义契约 → 改 routes.py 加路由 → 需要新配置就改 config.py。


三、运行原理:数据是怎么流动的

先看整条链路:

复制代码
   ┌──────────────┐   MQTT publish     ┌───────────────┐   subscribe
   │  ESP32 设备   │ ─────────────────► │   Mosquitto    │ ◄────────────────
   │ (或 MQTTX)   │   devices/+/       │    Broker      │    devices/+/telemetry
   └──────────────┘   telemetry        └───────────────┘
                                                   │
                                            on_message 回调
                                                   │  解析 + 校验
                                                   ▼
                                          ┌────────────────┐
                                          │    main.py      │
                                          │  SensorData 校验 │
                                          └────────────────┘
                                                   │  json
                                                   ▼
      ┌──────────────────────────┐   WebSocket /ws   ┌──────────────┐
      │  前端 (Vue 3 + rxjs)      │ ◄──────────────── │  routes.py    │
      │  实时刷新温度/湿度读数      │   广播给所有连接    │ active_connections │
      └──────────────────────────┘                    └──────────────┘
                                                   ▲
      ┌──────────────────────────┐   MQTT publish     │
      │  前端「向设备发送指令」按钮  │ ─────────────────► │
      └──────────────────────────┘  POST /control/{id} │
              FastAPI ────────────────────────────────┘

3.1 生命周期:MQTT 连接挂在哪

main.py 里用 FastAPI 的 lifespan 上下文管理器管理 MQTT 连接:

python 复制代码
@asynccontextmanager
async def lifespan(app: FastAPI):
    # 启动时连接 MQTT
    client = MQTTClient(CLIENT_ID)
    client.on_message = on_message

    await client.connect(MQTT_BROKER, MQTT_PORT)
    # 订阅所有设备的上报主题(gmqtt 的 subscribe 是同步方法,返回消息 ID)
    client.subscribe("devices/+/telemetry")

    app.state.mqtt_client = client
    yield
    # 关闭时断开
    await client.disconnect()

用 lifespan 而不是老式的 @app.on_event("startup"),是因为上下文管理器能保证

连接一定会被释放 ------异常退出、Ctrl+C、热重载,都会走 yield 之后的清理逻辑。

事件装饰器在某些异常路径下会漏掉清理。

这里就是我踩的第一个坑。

3.2 消息回调:收到一包数据之后发生了什么

python 复制代码
def on_message(client, topic, payload, qos, properties):
    print(f"[DEBUG] 收到任何消息: topic={topic}, payload={payload}")
    """收到 ESP32 上报的数据"""
    try:
        data = json.loads(payload)
        sensor_data = SensorData(**data)
        print(f"[MQTT] {topic} -> {sensor_data}")

        # 这里可以做:存入数据库、触发告警规则

        # 推送给所有连接的 WebSocket 客户端
        message = json.dumps(sensor_data.model_dump())
        for ws in active_connections.copy():
            asyncio.create_task(ws.send_text(message))

    except Exception as e:
        print(f"[ERROR] 解析失败: {e}")

流程是三步:

  1. 解析 + 校验 ------ json.loads 反序列化,再用 Pydantic 校验。
    字段缺失、类型不对(比如温度传了个字符串),都会在这一步抛异常被 catch 住,
    不会污染到后续的消息处理。
  2. 序列化 ------ model_dump() 把模型转成普通 dict,json.dumps 变成字符串。
  3. 广播 ------ 遍历所有活跃连接,逐个 send_text。

这里有两个小细节,都是踩过的:

为什么要 .copy()?

active_connections 是一个 Set,广播过程中某个 WebSocket 很可能正好断开,

对应的 discard() 会修改集合,遍历时直接报 Set changed size during iteration。

复制一份快照再遍历就安全了。

为什么用 create_task 而不是 await?

on_message 是 gmqtt 触发的同步回调 ,里面根本没有 await 的位置。

而且即便能 await 也不该------一个慢客户端会把整个广播卡住。

用 create_task 把每个推送丢进事件循环,各推各的,互不阻塞。

代码里那句 # 这里可以做:存入数据库、触发告警规则 是我特意留的扩展点,

后面会说怎么填。

3.3 WebSocket 连接管理

python 复制代码
# ---------- WebSocket:前端实时接收数据 ----------
@router.websocket("/ws")
async def websocket_endpoint(websocket: WebSocket):
    await websocket.accept()
    active_connections.add(websocket)
    try:
        while True:
            # 保持连接,等待客户端发送心跳或指令
            await websocket.receive_text()
    except WebSocketDisconnect:
        active_connections.discard(websocket)
    except Exception:
        active_connections.discard(websocket)

服务端用一个全局 Set[WebSocket] 维护在线连接:

  • 连接建立 → accept() 之后加入集合
  • 客户端可以发任意文本作为心跳,服务端收到后忽略
  • 断开 → 从集合移除

那个 while True: await websocket.receive_text() 看着有点怪,但作用是

让协程不要立刻结束 。WebSocket 端点函数一旦返回,连接就关闭了;

必须一直阻塞在这儿,等客户端断开时 receive_text() 抛 WebSocketDisconnect,

再清理集合、正常退出。

except Exception 那条兜底分支也不能省------客户端异常断开(比如直接 kill 掉浏览器进程)

不一定抛 WebSocketDisconnect,不兜住的话集合里会残留死连接,广播时推给一个已经不存在的

socket,同样会报错。

3.4 前后端怎么对接

前端这边,Vite dev server 配了 WebSocket 代理:

ts 复制代码
// vite.config.ts
server: {
  proxy: {
    '/wsApi': {
      target: 'ws://127.0.0.1:8000',
      ws: true,                      // 关键:开启 WebSocket 转发
      changeOrigin: true,
      rewrite: path => path.replace(/^\/wsApi/, ''), // /wsApi/xxx -> /ws/xxx
    }
  },
}

开发时前端连 ws://localhost:5173/wsApi/ws,Vite 把它转发到后端的 /ws。

这里 ws: true 和 rewrite 两个选项缺一不可:

ws: true 开启 WebSocket 转发模式,rewrite 把前缀 /wsApi 去掉,

这样后端完全不需要知道前端开发时用了什么路径。

⚠️ 这个代理只在 pnpm dev 时生效 。生产构建产物是由后端托管的,

前端连 WebSocket 必须用绝对地址 ws://<host>/ws,写 /wsApi/ws 会 404。

实际连接代码用 rxjs 的 webSocket 封装,配了自动重连和多订阅者共享:

ts 复制代码
import { webSocket, type WebSocketSubject } from 'rxjs/webSocket'
import { retry, share } from 'rxjs/operators'
import { onMounted, onUnmounted, reactive, ref } from 'vue';

const connected = ref(false)

let socket: WebSocketSubject<unknown> | null = null
const message = reactive({
  device_id:"",
  humidity:"",
  temperature:"",
  timestamp:""
})
onMounted(() => {
  socket = webSocket({
    url: `/wsApi/ws`,
    deserializer: msg => JSON.parse(msg.data),
    serializer: msg => JSON.stringify(msg),
    openObserver: { next: () => connected.value = true },
    closeObserver: { next: () => connected.value = false }
  })

  socket.pipe(
    retry({ delay: 3000 }),
    share()
  ).subscribe({
    next: (msg:any) => {
      message.device_id = msg.device_id;
      message.humidity = msg.humidity
      message.temperature = msg.temperature
      message.timestamp = msg.timestamp;
    },
    error: err => console.error(err)
  })

  // 发送订阅(连接未建立时会先入队,onopen 后补发)
  socket.next({ type: 'subscribe', payload: { channel: 'trades' } })
})

onUnmounted(() => {
  socket?.complete()
  socket = null
})

两个操作符值得说一下:

  • retry({ delay: 3000 }) ------ 现场网络抖一下、或者后端热重载,
    前端不该就此白屏。有了它,断线 3 秒后自动重连,页面上的读数会自己恢复。
  • share() ------ 多个组件同时订阅这条消息流时,不加它会建立多条 WebSocket 连接,
    服务端的连接集合也会被撑大。加了之后,无论几个订阅者,底层只有一条物理连接。

openObserver / closeObserver 用来把连接状态暴露给 UI,

这样页面上能显示一个"已连接 / 未连接"的小圆点,比让用户对着停住不动的数字猜要好。

⚠️ 这里还有两个待收尾的地方,都是我写的时候留下的:

  1. url: '/wsApi/ws' 写的是开发代理路径。 生产构建产物是由后端托管的,

    8000 端口上并没有 /wsApi 这个前缀,直接连会 404。

    生产环境需要按当前域名动态拼绝对地址,例如:

    ts 复制代码
    const proto = location.protocol === 'https:' ? 'wss' : 'ws'
    const url = `${proto}://${location.host}/ws`

    更好的做法是用 import.meta.env.DEV 区分开发和生产。

  2. socket.next({ type: 'subscribe', payload: { channel: 'trades' } }) 是无效消息。

    trades 这个频道名是从别处抄来的,后端根本没实现频道订阅------

    它的 receive_text() 收到什么都会忽略(只当心跳用)。

    这行可以删掉,或者把它改成真正的心跳逻辑。

3.5 单端口部署

前端构建产物直接交给 FastAPI 托管:

python 复制代码
@router.get("/monitor", response_class=HTMLResponse, include_in_schema=False)
async def monitor():
    """监控页面(前端构建产物)"""
    return FileResponse(DIST_DIR / "index.html")


@router.get("/favicon.ico", include_in_schema=False)
async def favicon():
    return FileResponse(DIST_DIR / "favicon.ico")


# 构建产物中的静态资源(index.html 以绝对路径 /assets/... 引用)
def mount_static(app: FastAPI) -> None:
    """把前端构建产物挂到 app 上(include_router 不会搬运 Mount,需要单独调用)"""
    app.mount("/assets", StaticFiles(directory=DIST_DIR / "assets"), name="assets")

于是 8000 一个端口同时提供:

地址 内容
/monitor 实时监控页面
/docs Swagger UI
/redoc ReDoc
/health 健康检查

不用 nginx,不用起第二个服务,部署起来就是 uvicorn main:app 一条命令。


四、注意点:我踩过的坑

这一节记录的都是实际卡住过的点。

坑 1:await client.subscribe(...) 直接把服务干崩

第一次写完跑起来,uvicorn 直接退出:

复制代码
TypeError: object int can't be used in 'await' expression

原因在 gmqtt 的 API 设计上:

方法 类型 写法
connect() / disconnect() 协程 await client.connect(...)
subscribe() / publish() 同步方法,返回消息 ID(int) client.subscribe(...)

await 一个 int 当然是类型错误。正确写法:

python 复制代码
client.subscribe("devices/+/telemetry")        # ✅ 不要 await
client.publish(topic, payload, qos=1)           # ✅ 不要 await

这个坑很隐蔽,因为直觉上"网络操作都是异步的"。但在读文档之前,谁也想不到

subscribe 居然是同步的。

坑 2:Could not import module "main"

复制代码
INFO: Will watch for changes in these directories: ['D:\cursor-workspace\fastapi-mqtt-play\public\monitor_mqtt']
ERROR: Error loading ASGI app. Could not import module "main".

看到日志里的目录路径就明白了------我是在 public/monitor_mqtt/ 目录里敲的启动命令,

而 --reload 监听的是当前工作目录 。main.py 在上一层,自然 import 不到。

结论:uvicorn main:app 永远从项目根目录执行。

bash 复制代码
uvicorn main:app --reload --host 0.0.0.0 --port 8000

坑 3:router.mount() 静默失效

我最初把静态挂载写在 router 上:

python 复制代码
router = APIRouter()
router.mount("/assets", StaticFiles(directory=DIST_DIR / "assets"), name="assets")

不报错,但也没效果------前端 JS 全 404。

查了 FastAPI 源码才发现:include_router() 只会搬运 APIRoute 和
APIWebSocketRoute 两种路由对象,Mount 对象会被静默丢弃
。

不抛异常、不打日志,就那么没了。

所以只能把挂载抽成独立函数,在 main.py 里对 app 直接调用:

python 复制代码
app.include_router(router)
mount_static(app)

坑 4:静态资源 404 的另一层原因

就算挂载方式改对了,还有个坑:Vite 构建出来的 index.html 里,资源引用是绝对路径:

html 复制代码
<script src="/assets/index-XXXX.js"></script>

所以不能 把整个 dist 目录挂到 /monitor 下面------那样 HTML 里请求 /assets/xxx.js

会落到 /monitor/assets/xxx.js,404。

正确做法就是代码里那样:/monitor 只返回 index.html,/assets 单独挂载。

坑 5:日志"没收到",其实是 stdout 缓冲

排查"为什么 on_message 没触发"的时候,把输出重定向到文件:

bash 复制代码
uvicorn main:app > log.txt 2>&1

日志文件里空空如也。但 MQTTX 明明发了消息,broker 也显示有连接。

加上 PYTHONUNBUFFERED=1 之后,日志立刻出来了:

复制代码
[MQTT] devices/esp32-01/telemetry -> device_id='esp32-01' temperature=25.6 ...

消息从头到尾都收到了 ,只是 Python 在 stdout 是文件时用了块缓冲(4KB/8KB 才刷一次),

看起来像"没收到"。

这是个很反直觉的坑:日志系统骗了你。

以后遇到"明明有事件但没日志",第一件事是怀疑缓冲,不是怀疑业务代码。

坑 6:两个 Mosquitto 实例,消息永远不通

这个坑排查花了最久。

现象:后端订阅了 devices/+/telemetry,MQTTX 也连上了同一个地址,

但后端就是收不到消息。

最后发现本机同时跑着两个 Mosquitto:

PID 启动方式 监听地址
35912 mosquitto.exe run(Windows 服务) 127.0.0.1 / [::1]
11236 mosquitto.exe -v -c mosquitto.conf(手动) 0.0.0.0 / [::]

两个实例是完全独立的 broker,各有各的订阅表和消息空间。

MQTTX 连到了 A 实例,后端连到了 B 实例,自然对不上话。

怎么确认的?查 $SYS/broker/uptime:

bash 复制代码
mosquitto_sub -h 127.0.0.1 -t '$SYS/broker/uptime' -C 1

两个地址返回的 uptime 不一样(7992 秒 vs 7643 秒),说明确实是两个进程。

再交叉验证:往 A 实例 publish,B 实例 mosquitto_sub 收不到,反之亦然。

教训:

  • 本机调试 MQTT 前先 netstat -ano | findstr :1883 看清楚有几个进程在监听
  • ESP32 在局域网里必须连本机的 LAN IP (比如 192.168.207.31),
    绝不能用 localhost------那是 ESP32 自己,不是你的电脑
  • 后端和设备必须连同一个 broker 实例,地址要一致

坑 7:WebSocket 起不来

改了前端代理之后 /ws 一直连不上。查 uvicorn 启动日志发现:

复制代码
No supported WebSocket library detected

uvicorn 本身不内置 WebSocket 实现,需要 websockets 或 wsproto 其中之一。

而 pip install uvicorn 是裸装,standard extra 里的 websockets 不会带上。

所以依赖里必须写全:

复制代码
uvicorn[standard]==0.54.0
websockets==17.4

uvicorn[standard] 会连带装上 httptools、watchfiles、PyYAML 这一组扩展。

坑 8:fastapi-mqtt 其实没被用上

依赖清单里躺着 fastapi-mqtt==3.0.0,但翻遍代码,MQTT 客户端用的是 gmqtt:

python 复制代码
from gmqtt import Client as MQTTClient

fastapi-mqtt 是 FastAPI 官方维护的 MQTT 扩展,提供了把 broker 直接接入应用生命周期的

现成封装。本项目为了完全掌控连接时机和订阅行为才直接用 gmqtt。

两个都能用,按需选:

  • 想快速上手、订阅逻辑简单 → fastapi-mqtt
  • 需要精细控制重连、订阅时机、多主题 → gmqtt(本项目的选择)

五、跑起来

装依赖

bash 复制代码
pip install -r requirements.txt
复制代码
fastapi==0.125.0
fastapi-mqtt==3.0.0
gmqtt==0.8.0
python-dotenv==1.2.4
uvicorn[standard]==0.54.0
websockets==17.4

装 Mosquitto

下载地址:https://mosquitto.org/download/,Windows 下装完改 mosquitto.conf:

复制代码
listener 1883
allow_anonymous true

启动:

bash 复制代码
mosquitto.exe -v -c mosquitto.conf

allow_anonymous true 是为了免密码快速联调,上生产必须换成账号密码 + TLS。

配环境变量

bash 复制代码
cp .env.example .env
ini 复制代码
MQTT_BROKER=127.0.0.1
MQTT_PORT=1883
CLIENT_ID=fastapi-backend

启动后端

bash 复制代码
uvicorn main:app --reload --host 0.0.0.0 --port 8000

模拟设备

用 MQTTX,连 127.0.0.1:1883,向

devices/esp32-01/telemetry 发:

json 复制代码
{"device_id": "esp32-01", "temperature": 25.6, "humidity": 48.3, "timestamp": 1735689600}

验证 WebSocket

bash 复制代码
pnpm install -g wscat
wscat -c ws://192.168.207.31:8000/ws

连上会显示 connected!,然后每次 MQTTX 发消息,wscat 里就实时打印一次:

json 复制代码
{"device_id":"esp32-01","temperature":25.6,"humidity":48.3,"timestamp":1735689600}

构建前端

bash 复制代码
cd public/monitor_mqtt
pnpm install
pnpm build

构建产物输出到 dist/,这个目录是提交进仓库的 ,因为后端 /monitor 路由在运行时

直接提供这份产物。

访问 http://127.0.0.1:8000/monitor 就能看到监控页面了。

命令速查

bash 复制代码
# 命令行订阅 / 发布
mosquitto_sub -h 127.0.0.1 -t "devices/+/telemetry" -v
mosquitto_pub -h 127.0.0.1 -t "devices/esp32-01/telemetry" \
  -m '{"device_id":"esp32-01","temperature":25.6,"humidity":48.3,"timestamp":1735689600}'

六、后续能怎么扩展

现在这个版本只够"看个温度湿度",但骨架是齐的,往下走的方向不少。

6.1 数据持久化

在 on_message 里把数据写库(SQLAlchemy + SQLite/PostgreSQL,时序数据可以上 TimescaleDB),

就能支持历史曲线查询了。

不过别直接 在回调里同步写库------一条慢 SQL 会把整个 MQTT 回调卡住,

连带影响广播。更好的做法是解耦:

复制代码
on_message ──┬──→ 广播(内存,直接推)
             └──→ asyncio.Queue → worker 协程 → 写库

广播走内存永远快,写库慢也不影响实时性。

6.2 告警规则引擎

在 on_message 里对温湿度做阈值判断,超限时:

  • 发布告警到 alerts/{device_id} 主题
  • 通过 WebSocket 推一条 {"type": "alert", "level": "high", ...} 给前端
  • 接入通知渠道(邮件 / 企业微信 / 钉钉机器人)

阈值配置建议也放 .env,或者搞一张独立的规则表。

6.3 设备管理

现在后端是"无状态的",来了消息就转发。加一张 devices 表就能做很多事:

  • 记录 device_id、型号、固件版本、在线状态、上次心跳时间
  • 用 MQTT 遗嘱消息(Last Will) 或心跳超时判定设备掉线
  • 设备上下线事件也通过 WebSocket 推给前端
  • 提供批量下发接口 POST /devices/batch/command

6.4 安全加固

现在这套是内网裸奔状态,真要上生产至少得补三处:

层 现状 生产要求
MQTT allow_anonymous true 账号密码 + TLS 双向认证,ESP32 端烧独立证书
HTTP allow_origins=["*"] JWT 登录,收紧到具体域名
WebSocket 无鉴权 握手时校验 token

6.5 多实例与广播优化

现在 active_connections 是进程内的全局变量。这意味着:

bash 复制代码
uvicorn main:app --workers 4

会起 4 个进程,各自有各的连接集合------MQTT 消息只会广播到其中一个进程的客户端 ,

其余三个进程的前端收不到数据。

要水平扩展,得把广播总线挪出去:

复制代码
MQTT ──→ 进程 A ──publish──→ Redis Pub/Sub ──subscribe──→ 进程 B / 进程 C ──→ WS 客户端

另外还有两个优化点:

  • 二进制帧 ------ orjson 编码 + ws.send_bytes,省掉字符串序列化和 UTF-8 编码开销
  • 背压策略 ------ 慢客户端推不出去时,是丢弃、断开还是排队,得有个明确策略

6.6 前端增强

WebSocket 连接、重连、共享订阅这些已经做完了(见 3.4 节),剩下的是往上加:

  • 用 scan 操作符做数据流节流/缓冲,避免设备高频上报时页面卡顿
  • 接 ECharts 画历史曲线(配合 6.1 的持久化)
  • 指令下发表单做成通用 JSON 编辑器(后端 CommandRequest.params 本来就是自由结构)
  • 用 openObserver 的连接状态驱动一个"在线/离线"指示灯

6.7 可观测性

  • Prometheus 指标:MQTT 连接状态、消息接收速率、WS 在线连接数、消息处理延迟
  • 结构化日志 :用 structlog / loguru 替代现在的 print
  • 链路追踪:OpenTelemetry 埋点,看 MQTT 收 → 校验 → 广播 三段的耗时分布

6.8 协议层

  • MQTT 5.0 :共享订阅($share/group/topic,多实例天然负载均衡)、
    消息过期(避免离线设备收到过期指令)、响应主题
  • QoS 2 + 指令回执 :devices/{id}/command/ack,让前端知道设备到底执行没执行
  • 设备影子(Device Shadow):把设备状态存后端,读写同步更接近云平台的语义

6.9 接入真实 ESP32

固件侧要做的:

  • 连接本机 LAN IP 的 broker(不是 localhost)
  • 定时向 devices/{device_id}/telemetry 发布传感器 JSON
  • 订阅 devices/{device_id}/command,解析后执行动作(开关风扇 / 点灯 / 重启)

好消息是后端一行都不用改 ------已经用 devices/+/telemetry 通配订阅了,

新设备接进来自动就能收。


七、小结

这套示例跑通之后回过头看,代码上值得保留的是几个朴素的设计决策:

  1. 配置外置到 .env,且系统环境变量优先 ------ 部署时不用改代码
  2. 数据契约集中在 model.py ------ 顺带把 Swagger 文档写了
  3. 路由层不碰 MQTT 实现 ------ 换 broker 只改一个文件
  4. lifespan 管理连接生命周期 ------ 异常退出也能干净收尾

而花时间最多的,是几个"看起来是代码问题、其实是环境问题"的坑:

stdout 缓冲、双 Mosquitto 实例隔离、uvicorn 不自带 WebSocket 实现。

排查这类问题,先怀疑环境,再怀疑业务代码,能省很多时间。


参考

相关推荐
峯5807 小时前
一文学习FastAPI
学习·fastapi
真上帝的左手9 小时前
12. 消息队列-Kafka进阶
分布式·中间件·kafka
车载中间件通信1 天前
[TC8测试]TCP_FLAGS_PROCESSING_06
数据库·网络协议·tcp/ip·中间件·tc8
TheBestRucy2 天前
从零构建企业级“中文法律智能问答系统”:FastAPI+Milvus+混合检索+真流式RAG实战
fastapi·milvus
kingx32 天前
网络安全事件应急响应:X集团 WebServer 入侵溯源与处置
中间件·web·应急响应·取证·恶意样本
卡提西亚3 天前
FastAPI基础入门
python·fastapi
特立独行的猫a3 天前
ROS 2 主流DDS中间件入门:Cyclone DDS 与 Fast DDS 完全指南
中间件·ros2·dds·fast dds·cyclone dds
小易老师AI实战3 天前
用中间件把任务规划嵌进智能体循环
中间件·状态管理·智能体·工具调用·langgraph·任务规划
Paper-Tiger3 天前
手搓 FastAPI + Redis + 流式 RAG 实录
fastapi