STM32 从 0 到 1 做智能家居(十三):H5 网页控制端 —— 用 mqtt.js 把浏览器接到板子上

本文详解如何使用mqtt.js在浏览器中实现对STM32智能家居设备的实时控制。内容涵盖H5与MQTT Broker的安全连接(wss协议)、主题订阅/发布机制、消息解析(Buffer→JSON)、本地乐观更新、设备状态同步及常见调试陷阱。重点强调浏览器无法直连MQTT 1883端口,必须使用WebSocket封装;H5仅订阅上报主题,不监听命令主题;页面‘已连接’不等于设备在线,需结合串口日志交叉验证。

文章目录

系列第十三篇。前面十二章,板子已经能自己测、自己判、自己动,还能被手机蓝牙和 MQTT 命令驱动。这一章补上最后一块拼图:一个能在浏览器里打开的网页,实时看到温湿度与空气质量,点一下就能开关设备。

这一章的坑和前几章完全不是一个类型。硬件章的坑是"线接错了",固件章的坑是"时序差了几十微秒",而前端章的坑是**“看起来一切都对,但就是不工作”**:

  • F12 一打开,红字 Uncaught ReferenceError: mqtt is not defined;
  • 页面右上角明明写着 MQTT 已连接,温度却一直是 --;
  • 开了两个标签页,状态一会儿绿一会儿灰,像在抽搐;
  • 改完 JS 刷新,页面纹丝不动 —— 以为自己改错文件了,其实是缓存。

这四个现象,我在同一晚上全遇到了一遍。它们背后没有一个是"代码逻辑写错",全都是环境、顺序、缓存、协议的问题。所以这一章我会把"代码怎么写"和"环境怎么才算对"分开讲,后者才是真正难排的地方。

一、先看清这条链路:H5 为什么能控制一块单片机

1.1 四个文件,各管一件事

H5 的全部内容都在这个目录里:

d:\...\8.HAL_SmartHome\3.H5界面\4班智能家居\
├─ index.html            页面结构(谁在哪个位置)
├─ style.css             页面样式(长什么样)
├─ app.js                页面逻辑(连 MQTT、收数据、发命令)
└─ bluetooth_proxy.py    ★ 旧方案遗留,MQTT 方案下完全用不到,可以不管

bluetooth_proxy.py 是早期"网页通过本地 Python 脚本转发蓝牙串口"的方案。那一版必须在本机跑一个 Python 进程,换个手机就打不开。改成 MQTT 之后,浏览器直接连公网 broker,这个脚本就废了 —— 看到它别以为还要运行,运行了反而会占串口。

三个文件的分工,用一句话说清:

文件负责典型内容
index.html有什么id="stat-temp" 这个格子、onclick="quickToggle('fan')" 这个按钮
style.css长什么样卡片圆角、深色主题变量、圆环颜色
app.js怎么动mqtt.connect、JSON.parse、textContent = ...

新手最容易搞混的是:index.html 里只写"空壳",数值全是 --。温度不是 HTML 写死的,是 app.js 收到 MQTT 消息后,用 document.getElementById('stat-temp').textContent = ... 填进去的。所以"页面显示 --" = “JS 没拿到数据”,而不是"HTML 写错了"。

1.2 一次点击,数据走了五跳

很多人以为"网页控制单片机"是网页直接跟板子通信。不是。中间隔着五跳:

   ①            ②                ③              ④            ⑤
浏览器  ──wss──▶  broker.emqx.io ──TCP 1883──▶ ESP-12F ──USART2──▶ STM32 ──▶ 风扇/加热/舵机
 (H5)            (公网服务器)                   (WiFi 模块)        (主控)
   │                                                                          │
   └──────────────── ⑥ 设备每 2 秒上报,原路反向走回浏览器 ───────────────────┘

逐跳说清楚,后面排错才知道该在哪一段下手:

跳链路协议/端口出问题时的现象
①浏览器 → brokerwss / 8084右上角一直"未连接",F12 有 WebSocket 报错
②broker 内部转发TCP / 1883一般不会坏,公网服务
③broker ↔ ESP-12FTCP / 1883ESP 端日志 +MQTTCONNECTED 不出现
④ESP-12F ↔ STM32USART2 / 115200ESP 连上了,但板子日志没有 [ESP] 行
⑤STM32 → 执行器PWM串口打印 [CMD] FAN_ON 了,风扇不转
⑥设备上报 → 浏览器同 ①③④ 反向页面连上了但数值一直是 --

排错第一原则:先确定断在哪一跳,再动手。 前十二章每一章都在训练这件事,这一章同样适用。最常用的分界点是三个地方:浏览器 F12 的 Console、串口助手里的 [ESP] 日志、板子上的实际动作。三者一对,断点立刻现形。

1.3 两条主题,各走一个方向

MQTT 是"按主题收发"的。本工程只用了两个主题,而且严格单向:

主题方向谁发布谁订阅内容
2604/topic-HH设备 → 网页ESP-12F(固件里 TOPIC_PUB)H5{"t":27,"h":44,"a":2,"c":600,"v":120}
2604/cmd-HH网页 → 设备H5ESP-12F(固件里 TOPIC_SUB)纯字符串 FAN_ON / FAN_OFF / …

这两行对应 app.js 开头的四个常量:

const BROKER_URL = 'wss://broker.emqx.io:8084/mqtt';   // 浏览器只能用 wss(8084),不能直连 1883
const TOPIC_DATA = '2604/topic-HH';                    // 订阅:设备上报的温湿度
const TOPIC_CMD  = '2604/cmd-HH';                      // 发布:控制命令
const CLIENT_ID  = 'h5_' + Math.random().toString(16).slice(2, 10);

注意 TOPIC_DATA 是"我订阅"、TOPIC_CMD 是"我发布"。H5 只订阅上报主题,不订阅命令主题 —— 所以你在网页上看不到自己发出的命令,这是设计如此,不是丢了。

1.4 为什么浏览器不能用 1883

这是本章第一个必须记住的硬约束:

端口协议谁能用
1883裸 TCP单片机、ESP、MQTTX、Python 脚本
8083WebSocket(ws://)浏览器(http 页面)
8084WebSocket over TLS(wss://)浏览器(推荐,http/https 页面都能用)

浏览器里只有 WebSocket API,没有"裸 TCP socket"。所以 mqtt.connect('mqtt://broker.emqx.io:1883') 在 Node.js 里可以,在浏览器里必然失败。

而 wss 又比 ws 稳:如果页面哪天部署到 https 域名下,ws:// 会被浏览器当作混合内容直接拦截,wss:// 不受影响。所以干脆一步到位用 wss://broker.emqx.io:8084/mqtt。

末尾那个 /mqtt 是 EMQX 的 WebSocket 路径,不能省,也不能写成 /ws。写成 / 会返回 404,表现为"WebSocket 握手失败"。

1.5 H5 与固件必须对齐的四件事

这四条任意一条不一致,页面都会"显示已连接但没数据"或"点了没反应"。建议做成一张对照表贴在显示器上:

项固件里的值(mqtt.c)网页里的值(app.js)不一致的后果
broker 地址broker.emqx.iobroker.emqx.io各连各的,永远碰不上
上报主题TOPIC_PUB = "2604/topic-HH"TOPIC_DATA = '2604/topic-HH'网页收不到数据
命令主题TOPIC_SUB = "2604/cmd-HH"TOPIC_CMD = '2604/cmd-HH'网页发了设备收不到
命令字面量FAN_ON / HER_ON / CURTAIN_ON / ALL_OFF同名设备打印 unknown

第四行是最阴的一条:主题对了、连接也对了,但命令字符串少一个下划线,设备就是不执行,而且网页这边一点报错都没有(发布成功 = 送达 broker,broker 不管内容对错)。

二、页面骨架:index.html 到底长什么样

2.1 DOM 树(照着看,别照着背)

<body>
└─ .app
   ├─ aside.sidebar                      左侧导航
   │   ├─ .logo                         "智能家居 / MQTT 控制端"
   │   ├─ nav.nav
   │   │   ├─ a.nav-item[data-page=dashboard]  onclick="switchPage('dashboard')"
   │   │   ├─ a.nav-item[data-page=devices]    onclick="switchPage('devices')"
   │   │   └─ a.nav-item[data-page=energy]     onclick="switchPage('energy')"
   │   └─ .sidebar-footer               "STM32 主机"
   └─ main.main
       ├─ header.header
       │   ├─ h1#page-title              随页面切换改文字
       │   └─ .header-right
       │       ├─ span#datetime          每秒刷新
       │       ├─ .conn-status
       │       │   ├─ span#conn-dot      class: conn-dot on / off
       │       │   └─ span#conn-text     "MQTT 已连接 / 未连接"
       │       └─ button[onclick=toggleTheme()]
       └─ .content#content
           ├─ .page.active#page-dashboard
           │   ├─ .stats-row            4 张统计卡
           │   ├─ .grid-2
           │   │   ├─ 快捷控制卡        #qb-fan / #qb-heater / #qb-curtain
           │   │   └─ 环境监控卡        2 个 SVG 圆环
           │   ├─ 空气质量卡            #air-badge #air-aqi #air-eco2 #air-tvoc #air-bar-fill
           │   └─ 最近活动卡            #activity-list
           ├─ .page#page-devices
           │   ├─ .filter-bar           一个"全部设备/在线"下拉
           │   └─ .grid-3#devices-grid  JS 生成的设备卡
           └─ .page#page-energy         4 张静态能耗卡 + 说明

2.2 三个页面不是"三个 HTML 文件"

所有页面都写在同一个 index.html 里,靠 class 切换来控制显示:

<div class="page active" id="page-dashboard"> ... </div>
<div class="page"        id="page-devices">   ... </div>
<div class="page"        id="page-energy">    ... </div>

style.css 里大概是这样(只看逻辑,不用抄):

.page        { display: none; }
.page.active { display: block; }

switchPage() 干的就是三件事:

function switchPage(page) {
    currentPage = page;
    document.querySelectorAll('.page').forEach(p => p.classList.remove('active'));       // 1. 全藏
    document.querySelectorAll('.nav-item').forEach(n => n.classList.remove('active'));   // 2. 导航取消高亮
    document.getElementById('page-' + page).classList.add('active');                    // 3. 显示目标
    document.querySelector(`[data-page="${page}"]`).classList.add('active');            // 4. 导航高亮
    const titles = { dashboard: '仪表盘', devices: '设备控制', energy: '能耗统计' };
    document.getElementById('page-title').textContent = titles[page] || '智能家居';
}

两个小细节,都是"排错时会怀疑人生"的类型:

  1. id 必须正好是 page- + data-page 的值。 写成 page-dashborad(拼错)不会报错,只会表现为"点了导航,内容区空白"。因为 classList.add 加在 null 上才会抛异常 —— 这里 getElementById 返回 null,null.classList 会抛 TypeError,所以其实会报错,但报错在 Console 里一闪而过,很多人根本没往上看。
  2. 导航高亮靠 [data-page="${page}"] 选择器。 如果某个 nav-item 忘了写 data-page,它的高亮就永远取消不掉,表现为"两个菜单同时亮"。

2.3 那些 id 是一个都不能少的"接口"

app.js 里到处是 document.getElementById('xxx')。这些 id 就是 HTML 和 JS 之间的接口。少一个,getElementById 返回 null,再点 .textContent 就是:

Uncaught TypeError: Cannot set properties of null (setting 'textContent')

完整接口清单(改页面时对着核):

id谁写它写什么
datetimeupdateDateTime()当前时间字符串
conn-dotupdateConnStatus()class = 'conn-dot on' / 'conn-dot off'
conn-textupdateConnStatus()MQTT 已连接 / MQTT 未连接
page-titleswitchPage()页面标题
stat-tempmessage 回调d.t.toFixed(1)
stat-humiditymessage 回调d.h
gauge-tempmessage 回调d.t + '°'
gauge-humidmessage 回调d.h + '%'
temp-gaugeupdateTempGauge()SVG stroke-dashoffset
humid-gaugeupdateHumidGauge()SVG stroke-dashoffset
air-badgeupdateAir()文字 + class
air-aqi / air-eco2 / air-tvocupdateAir()三个数值
air-bar-fillupdateAir()style.width
air-tipupdateAir()一行提示
qb-fan / qb-heater / qb-curtainupdateQuickButton()class + .qb-status 文字
devices-gridrenderDevices()整块 innerHTML
activity-listrenderActivities()整块 innerHTML
device-modalopenDeviceDetail() / closeModal()class(show)
modal-device-name / modal-device-bodyopenDeviceDetail()标题 / 内容

一个非常实用的小技巧:在 Console 里输入 document.getElementById('stat-temp')。如果返回 null,说明 id 写错了、或者元素被删了、或者脚本执行时元素还没解析出来(后面 9.5 会讲)。

2.4 样式只讲三点

style.css 有两百多行,本章不逐条讲,但有三点和"能不能跑"直接相关:

  1. 主题变量:--bg-primary、--text-secondary、--success-color 这些是 CSS 变量,toggleTheme() 往 body 上加 dark 类,靠变量整体换色。所以你新增板块时必须用变量,写死 #fff 在深色主题下就是一块刺眼的白斑。
  2. .conn-dot.on / .conn-dot.off:绿色/灰色小球。updateConnStatus() 是整体替换 className,不是 classList.add —— 所以原来的 conn-dot 前缀必须一起写上,写成 dot.className = 'on' 球就没了。
  3. .air-badge.good/.mid/.bad:三档底色。updateAir() 同样用 badge.className = 'air-badge bad' 整体替换,同理。

三、加载 mqtt.js:顺序错一个字都不行

3.1 两行 script,顺序是硬约束

index.html 的最后两行(在 </body> 之前):

<!-- mqtt.js:浏览器通过 wss 连 broker 的客户端库(必须先于 app.js 加载) -->
<script src="https://unpkg.com/mqtt@5.10.1/dist/mqtt.min.js"></script>
<script src="app.js"></script>

浏览器是按出现顺序执行 <script> 的(没有 async / defer 时)。app.js 里第一句就用到 mqtt:

client = mqtt.connect(BROKER_URL, { ... });

如果 app.js 先执行,那一刻全局还没有 mqtt 这个名字,于是:

Uncaught ReferenceError: mqtt is not defined

而且这是整份 app.js 直接停止执行,不是只报个警告。表现就是:页面样式在、导航能点(因为是 HTML 内联 onclick)、但时间不动、温度永远是 --、点按钮 Console 再来一句 quickToggle is not defined。

3.2 为什么是 unpkg + 5.10.1

选择原因
unpkg.commqtt.js 官方 npm 包镜像,路径与包内目录一致,不用翻文档找
mqtt@5.10.1锁定小版本,避免哪天 @5 升到 5.11 后 API 有细微变化
dist/mqtt.min.js浏览器可直接用的 UMD 包,加载后挂全局 mqtt

不要改成 import mqtt from 'mqtt'。那是打包器(webpack/vite)的写法,本工程是纯静态页面、没有构建步骤,直接 <script> 引 UMD 包才是对的。

3.3 mqtt is not defined 的三种成因

这个报错不止一种原因,别看到就以为顺序错了:

成因怎么确认怎么改
app.js 写在 mqtt.min.js 前面看 index.html 末尾两行顺序把 CDN 那行挪到前面
CDN 被墙/超时,mqtt.min.js 根本没加载F12 → Network,看 mqtt.min.js 是不是红色 failed / pending换 CDN 或改本地文件(见 3.4)
CDN 返回了内容但被拦(公司网络/拦截页)Network 里状态 200 但 Response 是 HTML同上,改本地文件

第三种最坑:状态码 200,你以为是好的,其实拿回来了一整页"上网认证"的 HTML,浏览器执行后什么都没挂上。只信 Network 面板,别信"应该能打开"。

3.4 离线化:把 mqtt.min.js 放到本地

如果 CDN 在教学楼里打不开,最稳的做法是下载一份放到同目录:

3.H5界面\4班智能家居\
├─ index.html
├─ app.js
├─ style.css
└─ mqtt.min.js        ← 新下载的

然后把 index.html 里那行改成相对路径:

<script src="mqtt.min.js"></script>
<script src="app.js"></script>

这样整页不再依赖外网(除了 broker 本身),换台电脑拷整个文件夹就能用。这也是答辩/验收时最推荐的形态,别在现场赌教室 WiFi。

四、连 broker:wss、随机 ClientID、四个回调

4.1 完整连接代码

app.js 里的 connectMQTT():

function connectMQTT() {
    client = mqtt.connect(BROKER_URL, {
        clientId: CLIENT_ID,
        clean: true,              // 每次都是新会话,避免旧的订阅残留
        connectTimeout: 5000,
        reconnectPeriod: 3000     // 断线后 3 秒自动重连
    });

    client.on('connect', () => {
        mqttConnected = true;
        updateConnStatus();
        client.subscribe(TOPIC_DATA, { qos: 1 });   // 只订阅设备上报主题
        addActivity('🔗', 'MQTT 已连接');
        console.log('[MQTT] 已连接', BROKER_URL);
    });
    ...
}

4.2 四个参数逐个解释

参数值作用不这么写会怎样
clientId'h5_' + 8 位随机十六进制broker 用它区分客户端写死 → 开第二个标签页时,broker 把第一个踢掉
cleantrue不保留会话,每次都是全新的false + 随机 ID 会残留无用会话,反而更乱
connectTimeout50005 秒内没握上手就放弃本次太长 → 教室网络差时白等;太短 → 好网络也连不上
reconnectPeriod3000断线后 3 秒自动重连0 = 永不重连,必须手动刷新页面

CLIENT_ID 的生成方式值得单独说:

const CLIENT_ID = 'h5_' + Math.random().toString(16).slice(2, 10);
// 例:h5_9f3a1c72
  • Math.random() → 0.6173...
  • .toString(16) → "0.9e0c3a5b7d1f..."
  • .slice(2, 10) → 去掉开头的 "0.",取 8 个十六进制字符
  • 前面加 h5_ → 一眼能看出这是网页端,方便在 broker 后台看连接列表

为什么要随机? MQTT 协议规定:同一个 ClientID 后连的会顶掉先连的。如果你把 CLIENT_ID 写死成 'h5_client',然后开两个标签页做对比测试,就会看到:

标签页 A:已连接 → 断开 → 已连接 → 断开 …(3 秒循环)
标签页 B:断开 → 已连接 → 断开 → 已连接 …(3 秒循环)

两个页面互相踢,永远不同时在线。这不是网络问题,是 ID 撞了。这也是"页面状态一会儿绿一会儿灰像抽搐"的头号原因。

4.3 四个回调,各管一件事

回调什么时候触发本工程里做什么
connectWebSocket 握手 + MQTT CONNACK 成功置 mqttConnected = true、改状态灯、订阅上报主题、记一条活动
message订阅的主题上有新消息解析 JSON、更新温度/湿度/空气质量
close连接断开(网络断、broker 踢、页面切后台超时)置 false、改状态灯、记一条"3 秒后重连"
error连接出错(认证失败、地址错、协议错)只打印到 Console,不改状态

注意 error 里没做任何界面处理。 这是有意的:mqtt.js 出错后紧接着也会触发 close,界面刷新交给 close 就够了。但这也意味着 —— 只看页面是看不出错的,错误只出现在 Console。所以调试期 F12 必须常开。

还有一条容易漏的:订阅写在 connect 回调里,不是写在 mqtt.connect() 之后。 如果写在后面,第一次连接还没建立,subscribe 会排队等连接;虽然 mqtt.js 会自动补发,但一旦中途重连,行为就不确定了。写在 connect 里,每次重连都会重新订阅一遍 —— 这才是 clean: true 下正确的姿势。

4.4 「MQTT 已连接」≠「STM32 在线」

这是本章最容易被误判的一点。client.on('connect') 只说明:

浏览器 ⇄ broker.emqx.io   这一跳通了

它完全不代表:

  • ESP-12F 连上了 broker;
  • STM32 在跑;
  • 板子进入了远程模式(MODE_REMOTE);
  • 拨码开关打在 WiFi 侧。

所以「右上角绿灯 + 温度 --」是一个非常典型的中间态:网页这端全对,设备那端根本没上线。

怎么快速区分?看三个地方:

观察点说明
串口助手里有没有 [PUB] OK 或 2604/topic-HH 相关日志有 → 设备在发,问题在网页;没有 → 设备没发
板子是否处于远程模式([MODE] Remote)不在远程模式,Mqtt_Poll 不会跑
ESP 是否连着(+MQTTCONNECTED)没连上,永远不会有上报

反过来也一样:设备在正常上报,但网页收不到 —— 那就是主题写错、或 H5 根本没订阅成功(Console 里没有 [MQTT] 已连接)。

五、收数据:Buffer → trim → JSON.parse

5.1 完整的 message 回调

client.on('message', (topic, payload) => {
    // 收到 STM32 上报的 JSON:{"t":27,"h":44}
    const msg = payload.toString().trim();
    console.log('[MQTT←STM32]', topic, msg);
    try {
        const d = JSON.parse(msg);
        if (typeof d.t === 'number') {
            lastTemp = d.t;
            document.getElementById('stat-temp').textContent = d.t.toFixed(1);
            document.getElementById('gauge-temp').textContent = d.t + '°';
            updateTempGauge(d.t);
        }
        if (typeof d.h === 'number') {
            lastHumi = d.h;
            document.getElementById('stat-humidity').textContent = d.h;
            document.getElementById('gauge-humid').textContent = d.h + '%';
            updateHumidGauge(d.h);
        }
        // 空气质量:a=AQI等级(1~5) c=eCO2(ppm) v=TVOC(ppb)
        if (typeof d.a === 'number' || typeof d.c === 'number' || typeof d.v === 'number') {
            updateAir(d.a, d.c, d.v);
        }
    } catch (e) {
        console.log('[MQTT] 非 JSON 数据,忽略:', msg);
    }
});

这九行里有四个"必须",逐个说。

5.2 必须 payload.toString()

payload 不是字符串,是 Buffer(浏览器里是 Uint8Array)。直接 JSON.parse(payload) 会抛:

SyntaxError: Unexpected token 'o' ...  (或者类似,取决于实现)

因为 JSON.parse 期望字符串,收到对象会先做 toString(),得到 "[object Uint8Array]",当然不合法。所以先 toString()。

5.3 必须 trim()

固件那边组 JSON 的时候,结尾很容易带上 \r\n(尤其是用 printf 拼的)。如果实际内容是:

{"t":27,"h":44}\r\n

JSON.parse 会抛 SyntaxError: Unexpected non-whitespace character after JSON at position 15。

trim() 把首尾空白全去掉,包括 \r、\n、空格、制表符。这是成本最低、收益最高的一行防御。

顺带说一句:trim() 只去掉首尾空白,中间的不管。如果设备发了 {"t":27}\r\n{"h":44} 这种一次两条,trim() 也救不了,JSON.parse 照样失败。真遇到这种,要么让固件一条一条发(本工程就是),要么按换行 split 后逐条解析。

5.4 必须 typeof d.t === 'number'

为什么不能写 if (d.t)?看三个反例:

真实值if (d.t)typeof d.t === 'number'
0(温度 0℃)假,不更新真,更新为 0
"27"(字符串)真假
undefined(字段缺失)假假

第一条是致命的:冬天板子放在阳台,温度真的是 0℃,页面却一直停在旧值,你还会以为是传感器坏了。用 typeof 判断类型,才是"字段存在且是数字"的准确语义。

5.5 必须 try/catch

JSON.parse 是会抛异常的。不包 try/catch,一条脏数据就能让回调抛错。虽然回调抛错不会让后面的消息永久收不到(mqtt.js 的事件分发会继续),但 Console 会红一片,而且那条消息之后的代码(updateAir 等)全都跳过。

更要命的是:如果你在 message 回调里做了状态维护(比如计数、累加),抛错会让状态停在中间态。包一层 try/catch,脏数据就只是"忽略 + 打印",其余照常。

本工程的做法很克制:

} catch (e) {
    console.log('[MQTT] 非 JSON 数据,忽略:', msg);
}

把原始内容打出来,方便判断到底是"设备发错了"还是"解析逻辑错了"。

5.6 五个字段从哪来

网页解析的五个字段,和固件 mqtt.c 里拼 JSON 的顺序一一对应:

字段含义范围固件里谁填网页里谁用
t温度 ℃0~50pub_temp(DHT11)stat-temp、gauge-temp、temp-gauge
h湿度 %0~100pub_humi(DHT11)stat-humidity、gauge-humid、humid-gauge
aAQI 等级1~5pub_aqi(ENS160)air-aqi、air-badge、air-bar-fill
ceCO2 ppm400~5000pub_eco2(ENS160)air-eco2、air-badge
vTVOC ppb0~1000+pub_tvoc(ENS160)air-tvoc、air-badge

固件实际发出的字符串形如:

{"t":26,"h":45,"a":2,"c":612,"v":137}

注意固件用 %d 拼,所以温度是整数,没有小数。而网页统计卡用的是 d.t.toFixed(1),所以会显示 26.0 —— 看着像有小数精度,其实没有。别被这个 26.0 骗了去怀疑 DHT11 分辨率。

5.7 数据两秒才来一次,首屏必然是 --

固件里的发布周期是 #define MQTT_PUB_PERIOD 2000(毫秒)。也就是说:

  • 页面上电后 0~2 秒:--(正常);
  • 2 秒后:第一组数据;
  • 之后每 2 秒一次。

如果超过 5 秒还是 --,才需要开始查。 秒级就断定"坏了"是新手最常见的误判。

另外,lastTemp / lastHumi 这两个变量在 app.js 里被赋值了,但没有被用到(预留给后续扩展)。这不是 bug,但看代码时容易以为"漏写了什么"。

六、发命令:sendCmd 与"本地乐观更新"

6.1 三行的发送函数

function sendCmd(cmd) {
    if (client && mqttConnected) {
        client.publish(TOPIC_CMD, cmd, { qos: 1 });
        console.log('[MQTT→STM32] cmd:', cmd);
    } else {
        addActivity('⚠️', 'MQTT 未连接,命令未发送');
    }
}

三个要点:

  1. client && 这个判空不能省。 init() 里才创建 client,如果有人在你没注意的时候提前触发了 sendCmd,client 还是 null,null.publish 直接抛异常。
  2. mqttConnected 判断的是"有没有连上",不是"设备在不在"。这就是 4.4 说的那个区别:命令发出去了,但没人收。
  3. qos: 1 = 至少送达一次。公网 broker 上偶有丢包,qos: 0(最多一次)可能在弱网下丢命令;qos: 1 稍慢一点但更可靠。注意 qos: 1 的语义是"至少一次",理论上可能重复,所以设备端的命令执行必须是幂等的(FAN_ON 执行两次和一次效果一样,本工程满足)。

6.2 devices 数组就是一张"命令字典"

const devices = [
    { id: 'fan',     name: '风扇',     location: '主控',  icon: '🌀', state: false, cmd_on: 'FAN_ON',     cmd_off: 'FAN_OFF' },
    { id: 'heater',  name: '加热器',   location: '主控',  icon: '🔥', state: false, cmd_on: 'HER_ON',     cmd_off: 'HER_OFF' },
    { id: 'curtain', name: '窗帘',     location: '主控',  icon: '🪟', state: false, cmd_on: 'CURTAIN_ON', cmd_off: 'CURTAIN_OFF' },
];

这个数组同时承担三个角色:

角色谁在用
命令字典sendCmd(d.state ? d.cmd_on : d.cmd_off)
界面数据源renderDevices() 用它生成设备卡
状态存储d.state 记录当前开/关

所以新增一个设备,只改这一处(再加对应的 HTML qb-xxx 或直接靠 renderDevices 自动生成),不需要动任何逻辑。

6.3 点一下按钮,实际发生了什么

以点"风扇"为例,quickToggle('fan') 的执行顺序:

1. devices.find(x => x.id === 'fan')        → 拿到对象
2. d.state = !d.state                       → false 变 true(本地状态先翻)
3. sendCmd('FAN_ON')                        → 发布到 2604/cmd-HH
4. updateQuickButton('fan')                 → 按钮变蓝、文字变"开启"
5. addActivity('🌀', '风扇 已开启')          → 活动栏插一条
6. if (currentPage === 'devices') renderDevices();   → 如果在设备页,重渲染卡片

注意第 2 步在发送之前。这就是"本地乐观更新"。

6.4 本地乐观更新:按钮变了 ≠ 设备动了

这是一个必须讲透的认知差:

按钮变蓝  = 网页本地状态翻了 + 发了一条命令出去
风扇转起来 = STM32 收到命令 + 执行 + PWM 输出

两者之间隔着五跳。任何一跳断了,按钮都照样变蓝,因为网页根本不知道后面失败了。

所以本工程特意在发送失败时留了一条活动记录:

addActivity('⚠️', 'MQTT 未连接,命令未发送');

但它只覆盖"浏览器没连上 broker"这一种情况。设备没上线、主题写错、命令字符串拼错,网页都不会有任何提示 —— 按钮依然会变蓝。

这也是我建议的验收方法:点按钮之前,先看串口助手。串口里出现 [CMD] FAN_ON,说明五跳全通;没有,说明命令压根没到板子。以串口为准,不要以网页为准。

6.5 全部关闭:ALL_OFF 一个顶三个

function allOff() {
    sendCmd('ALL_OFF');
    devices.forEach(d => d.state = false);
    renderDevices();
    devices.forEach(d => updateQuickButton(d.id));
    addActivity('🛑', '已发送全部关闭命令');
}

固件端 ALL_OFF 不是"依次执行三条命令",而是一次性全关:

  • 风扇 CCR = 0;
  • 加热 CCR = 0;
  • 窗帘舵机转到 2000µs(落下)。

所以网页这边只需要发一条。如果你发现"点了全部关闭,但风扇还在转",先查固件 ALL_OFF 分支是不是漏了某一路,而不是怀疑网页。

另外 renderDevices() 在这里被无条件调用了一次 —— 即使当前在仪表盘页。这不会出错,因为三个页面都在同一个 DOM 里,只是被 display:none 藏起来了,getElementById('devices-grid') 依然拿得到。

6.6 命令字面量:全工程只有一套

这是整个项目最需要"一致性"的地方。同一套命令字符串出现在三个地方:

位置用途写法示例
main.c 的 Remote_Cmd_Exec蓝牙/MQTT 命令解析strstr(cmd, "FAN_ON")
app.js 的 devices[].cmd_on/cmd_off网页发包'FAN_ON'
串口助手/蓝牙调试助手手工输入裸测FAN_ON

注意:窗帘是 CURTAIN_ON / CURTAIN_OFF,不是 CURTAIN_OPEN / CURTAIN_CLOSE。 这是我在写文档时反复搞混过的一处。名字取得不直观,但代码里就是这么写的,改任何一边都要三边一起改。

命令总表:

网页按钮发送字符串固件动作
风扇开FAN_ONFAN_PWM = 800(约 80% 占空比)
风扇关FAN_OFFFAN_PWM = 0
加热开HER_ONHER_PWM = 800
加热关HER_OFFHER_PWM = 0
窗帘开CURTAIN_ONTIM5 CH1 CCR = 500(约 2.5% → 一端)
窗帘关CURTAIN_OFFTIM5 CH1 CCR = 2000(约 10% → 另一端)
全部关闭ALL_OFF上面三路全关 + 窗帘落下

6.7 为什么网页上看不到自己发的命令

因为 H5 只订阅了 2604/topic-HH(上报),没有订阅 2604/cmd-HH(命令)。

这带来两个后果:

  1. 活动栏里"风扇 已开启"是网页自己记的,不是设备回的确认。设备有没有真的开,网页不知道。
  2. 如果你在网页上点了风扇,同时用 MQTTX 往命令主题也发了一条 FAN_ON,串口里会看到两次执行,但网页上只显示一次 —— 因为网页没在听命令主题。

如果你想让网页显示"设备已确认",有两条路:

  • 让固件在每次执行命令后往 TOPIC_PUB 补发一条状态(比如 {"fan":1}),网页解析后更新;
  • 或者让网页也订阅 TOPIC_CMD,收到就高亮一下。

本工程都没做。这是功能取舍,不是缺陷,但验收时要清楚:网页显示的是"我发了什么",不是"设备做了什么"。

七、仪表盘的两个圆环:314 是怎么来的

7.1 先算周长

index.html 里两个 SVG 圆环的写法是:

<svg viewBox="0 0 120 120" class="gauge-svg">
    <circle cx="60" cy="60" r="50" fill="none" stroke="#e0e0e0" stroke-width="10"/>
    <circle cx="60" cy="60" r="50" fill="none" stroke="#1976d2" stroke-width="10"
        stroke-dasharray="314" stroke-dashoffset="314" stroke-linecap="round"
        transform="rotate(-90 60 60)" id="temp-gauge"/>
</svg>

关键参数:

半径 r = 50
周长 C = 2 × π × r = 2 × 3.14159 × 50 = 314.159...

HTML 里写的是 314(取整)。没有用 314.16,是刻意的简化,代价是圆环闭合时留了约 0.16 像素的小缝 —— 肉眼几乎看不出来。

7.2 stroke-dasharray 和 stroke-dashoffset 到底在画什么

SVG 的 stroke-dasharray 是"虚线的实线段长度"。当它等于整个周长时:

dasharray = 314   →  整圈只有一段实线,长度正好等于一圈
dashoffset = 314  →  把这段实线"往后退 314",退满一圈 → 完全看不见
dashoffset = 0    →  不退 → 整圈都显示
dashoffset = 157  →  退半圈 → 只显示一半

所以:

dashoffset = 314 × (1 - 百分比)
  • 百分比 0% → offset 314 → 空环
  • 百分比 50% → offset 157 → 半环
  • 百分比 100% → offset 0 → 满环

updateTempGauge() 就是这一行:

function updateTempGauge(t) {
    const g = document.getElementById('temp-gauge');
    if (!g) return;
    // 温度范围 0~50°C 映射到 314 周长
    const pct = Math.max(0, Math.min(1, t / 50));
    g.setAttribute('stroke-dashoffset', 314 * (1 - pct));
}

7.3 为什么必须 Math.max(0, Math.min(1, ...))

t / 50 的值域是没有上下界的:

tt/50不加夹取加了夹取
55(异常高温)1.1offset = -31.4,圆环"多转"一段,视觉错乱offset = 0,满环
-5(传感器异常)-0.1offset = 345.4,圆环反向画offset = 314,空环

Math.min(1, ...) 砍掉上界,Math.max(0, ...) 砍掉下界。这一层防御不是可选的 —— ENS160/DHT11 偶尔会吐出一个离谱值,没有夹取就会看到圆环"抖一下"。

湿度同理:

function updateHumidGauge(h) {
    const g = document.getElementById('humid-gauge');
    if (!g) return;
    const pct = Math.max(0, Math.min(1, h / 100));
    g.setAttribute('stroke-dashoffset', 314 * (1 - pct));
}

7.4 rotate(-90 60 60):让弧从 12 点开始

SVG 的圆默认从 3 点钟方向开始画(角度 0)。不加旋转的话,进度弧会从右侧开始,看着很别扭。

transform="rotate(-90 60 60)"

意思是"以 (60,60) 为圆心,逆时针旋转 90°",于是起点从 3 点转到 12 点。三个参数缺一不可:只写 rotate(-90) 是绕 SVG 左上角转,整个圆会跑出画布。

7.5 一个真实的显示不一致

document.getElementById('stat-temp').textContent = d.t.toFixed(1);   // "26.0"
document.getElementById('gauge-temp').textContent = d.t + '°';       // "26°"

同一份数据,统计卡显示一位小数,圆环里显示整数。不是 bug,是两处写法不同。如果你要统一,改其中一处即可 —— 但别改着改着把 toFixed(1) 加到圆环上,26.0° 在那个小圆圈里会挤。

八、空气质量板块:三阈值,和一处"故意不一致"

8.1 HTML 结构

<div class="card">
    <div class="card-header">
        <h3>实时空气质量 (ENS160)</h3>
        <span class="air-badge" id="air-badge">等待数据</span>
    </div>
    <div class="card-body">
        <div class="air-monitor">
            <div class="air-item">
                <span class="air-icon" style="background:#e3f2fd;color:#1976d2">&#127788;</span>
                <span class="air-value" id="air-aqi">--</span>
                <span class="air-label">AQI 等级</span>
            </div>
            <div class="air-item">
                <span class="air-icon" style="background:#e8f5e9;color:#388e3c">&#127807;</span>
                <span class="air-value" id="air-eco2">--</span>
                <span class="air-label">eCO2 (ppm)</span>
            </div>
            <div class="air-item">
                <span class="air-icon" style="background:#fff3e0;color:#f57c00">&#127777;</span>
                <span class="air-value" id="air-tvoc">--</span>
                <span class="air-label">TVOC (ppb)</span>
            </div>
        </div>
        <div class="air-bar">
            <div class="air-bar-track">
                <div class="air-bar-fill" id="air-bar-fill"></div>
            </div>
            <span class="air-tip" id="air-tip">等待设备上报空气质量数据…</span>
        </div>
    </div>
</div>

位置很关键:放在 .grid-2 之后、"最近活动"之前。放到 .grid-2 里面会挤成两列布局,宽度不对。

8.2 判定逻辑

function updateAir(aqi, eco2, tvoc) {
    if (typeof aqi === 'number')  document.getElementById('air-aqi').textContent  = aqi;
    if (typeof eco2 === 'number') document.getElementById('air-eco2').textContent = eco2;
    if (typeof tvoc === 'number') document.getElementById('air-tvoc').textContent = tvoc;

    const isBad = (typeof aqi === 'number'  && aqi > 3) ||
                  (typeof eco2 === 'number' && eco2 > 1000) ||
                  (typeof tvoc === 'number' && tvoc > 650);
    const isMid = !isBad && ((typeof aqi === 'number' && aqi === 3) ||
                             (typeof eco2 === 'number' && eco2 > 800));
    ...
}

注意 isBad 里每个子条件都带了 typeof 判断。这不是啰嗦:updateAir(d.a, d.c, d.v) 可能收到 undefined(某个字段缺失),而 undefined > 1000 是 false,不会出错,但 undefined === 3 也是 false —— 不判断也能跑。那为什么还要写?因为如果字段是字符串 "3","3" > 3 是 false(字符串会转数字比较,其实这里会得到 true/false 的边缘行为),加上 typeof 就把语义钉死了。

8.3 固件与网页的阈值差异(重点)

这是原文 13.6 里提到的一处,必须讲清楚,否则你会以为自己发现了 bug:

判据固件(main.c)网页(app.js)
AQI 很差g_AQI >= 3aqi > 3
eCO2 很差> 1000 ppm> 1000 ppm
TVOC 很差> 650 ppb> 650 ppb
AQI = 3 时判为差 → 自动通风显示"空气一般"(黄色)

也就是说:

AQI = 3 时:板子会开风扇通风,网页却写着"空气一般"

看起来像矛盾,其实是展示层故意宽松一档:

  • 固件用 >= 是"宁可多通风",安全侧;
  • 网页用 > 是"别老报警",用户体验侧。

这在产品上是合理的(空调/LED 也是这样),但在答辩时非常容易被问:"为什么网页说空气一般,风扇却转了?"提前想好这句解释。

如果你希望两边完全一致,把网页的 aqi > 3 改成 aqi >= 3,aqi === 3 那段 isMid 自然就进不去了。改一处即可,不要两边都改。

8.4 三色条:从 20% 到 100%

const bar = document.getElementById('air-bar-fill');
if (typeof aqi === 'number') bar.style.width = Math.max(20, Math.min(100, aqi / 5 * 100)) + '%';

AQI 是 1~5,映射到 20%~100%:

AQI计算实际宽度含义
120%20%优
240%40%良
360%60%一般
480%80%差
5100%100%很差

为什么下限定在 20% 而不是 0%?因为 0% 宽度会让整条彩色渐变条消失,看起来像"没加载出来"。留 20% 至少能看见一截,视觉上是"空着的进度条",不是"坏掉的区块"。

air-bar-fill 的背景是 CSS 里的横向渐变:

.air-bar-fill {
    height: 100%; width: 20%;
    transition: width .4s ease;
    background: linear-gradient(90deg, #66bb6a, #ffa726, #ef5350);
}

这个渐变是固定在容器上的,不是"根据数值变色"。所以 AQI=5 时宽度 100%,你能看到完整的绿→橙→红;AQI=1 时宽度 20%,只看得到绿色那一小段。颜色是被"裁"出来的,不是算出来的 —— 这是很常见也很好用的一种写法,别去写 JS 动态改 background。

transition: width .4s ease 让每次更新有 0.4 秒的过渡,看起来不像"跳变"。如果嫌慢,改 .4s 即可;0s 就是瞬间跳。

九、页面切换、设备卡、详情弹窗、活动栏

9.1 设备卡是"每次重渲染",不是"原地改"

function renderDevices() {
    const grid = document.getElementById('devices-grid');
    grid.innerHTML = devices.map(d => `
        <div class="device-card">
            <div class="device-header">
                <div class="device-icon" style="background:${d.state ? '#e3f2fd' : 'var(--bg-primary)'}">${d.icon}</div>
                <div class="device-status"></div>
            </div>
            <div class="device-name">${d.name}</div>
            <div class="device-location">${d.location}</div>
            <div class="device-controls">
                <span style="font-size:13px;color:${d.state ? 'var(--success-color)' : 'var(--text-secondary)'}">
                    ${d.state ? '已开启' : '已关闭'}
                </span>
                <div class="switch ${d.state ? 'on' : ''}" onclick="toggleDevice('${d.id}')"></div>
            </div>
            <button class="btn btn-primary" style="width:100%;margin-top:12px;padding:8px;font-size:13px"
                onclick="openDeviceDetail('${d.id}')">详情</button>
        </div>
    `).join('');
}

innerHTML = ... 的效果是:整块 DOM 全部销毁再重建。带来三个必须知道的结果:

  1. 原来的事件监听全没了。 所以设备卡里的开关用的是内联 onclick 字符串(onclick="toggleDevice('fan')"),而不是 addEventListener。内联写法在 DOM 重建后依然有效,因为它是写在 HTML 属性里的。
  2. 每次重建会闪一下(如果 CSS 有动画)。本工程的卡片没有入场动画,所以看不出来。
  3. 输入框里的内容会丢。 设备卡里没有输入框,所以无所谓;但如果你以后加了个"设备备注"输入框,重渲染一次就清空了 —— 那时就得改成"只更新有变化的节点"。

filter-bar 里那个下拉:

<select onchange="renderDevices()">
    <option value="all">全部设备</option>
    <option value="online">在线</option>
</select>

接口在,功能没做。 onchange 只是重新渲染,并没有读 select.value 去过滤。所以选"在线"和选"全部设备"效果完全一样。这是刻意留的占位(真实项目里在线状态要靠设备心跳),看到别以为是 bug,也别拿它去验收。

9.2 详情弹窗:一个"自引用"的怪结构

function openDeviceDetail(id) {
    const d = devices.find(x => x.id === id);
    if (!d) return;
    document.getElementById('modal-device-name').textContent = d.name;
    document.getElementById('modal-device-body').innerHTML = `
        ...
        <div class="switch ${d.state ? 'on' : ''}" onclick="toggleDevice('${id}');openDeviceDetail('${id}')"></div>
        ...
    `;
    document.getElementById('device-modal').classList.add('show');
}

弹窗里那个开关的 onclick 是:

toggleDevice('fan'); openDeviceDetail('fan')
  • toggleDevice 翻转状态 + 发命令 + 重渲染列表;
  • 紧接着 openDeviceDetail 再把自己重画一遍,于是弹窗里的开关也跟着变。

如果没有第二次调用,弹窗会停在旧状态(因为弹窗内容是上一次生成的静态 HTML),而列表已经变了 —— 表现为"列表里开了,弹窗里还显示关着"。

9.3 遮罩点击关闭:为什么这句写在文件最外层

app.js 的最后几行:

// 关闭弹窗:点遮罩关闭
document.getElementById('device-modal').addEventListener('click', function(e) {
    if (e.target === this) closeModal();
});

window.addEventListener('load', init);

注意第一句是顶层语句,不是放在 init() 里。它在 app.js 被解析执行的那一刻就跑。

这要求 device-modal 这个元素此时已经存在。 而 index.html 把两个 <script> 放在 </body> 之前,所以执行到这句时 DOM 已经解析完了,能拿到元素。

如果你为了"规范"把 <script> 挪到 <head> 里,这句会立刻抛:

Uncaught TypeError: Cannot read properties of null (reading 'addEventListener')

而且抛在这里,后面的 window.addEventListener('load', init) 也不会执行 —— 整个页面永远不初始化。这是"移动一次 script 标签,全站瘫痪"的典型例子。

e.target === this 判断的是"点的是遮罩本身,而不是里面的白框"。冒泡上来的点击如果来自 .modal 内部,e.target 是内部元素,就不关。

9.4 活动栏最多留 8 条

function addActivity(icon, text) {
    const now = new Date();
    const time = now.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' });
    activities.unshift({ icon, text, time });
    if (activities.length > 8) activities.pop();     // 超过 8 条砍掉最旧的
    renderActivities();
}

unshift 插到最前面(最新在上),pop 砍掉最后面(最旧)。这两个方法别用反:用 push + shift 也能实现,但顺序会反过来(最新的在最下面)。

toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' }) 输出 "14:32",不含秒。同一分钟内多条活动的时间戳会完全一样,看着像"重复记录"。这是正常的。

初始两条是写死的:

activities = [
    { icon: '💡', text: '系统启动', time: '08:30' },
    { icon: '📡', text: '等待 MQTT 连接', time: '08:30' },
];

时间 08:30 是假的,纯粹占位。别拿它当"启动时间"去排查。

9.5 init() 只做四件事

function init() {
    updateDateTime();
    setInterval(updateDateTime, 1000);
    renderDevices();
    generateActivities();
    connectMQTT();      // 启动时自动连 broker,并订阅设备上报主题
}

挂载方式:

window.addEventListener('load', init);

用 load 而不是 DOMContentLoaded,是因为 load 会等图片、CSS、外部脚本都加载完。对本工程来说,load 能保证 mqtt.min.js 一定已执行完(CDN 慢的时候 DOMContentLoaded 可能先到)。这也是 3.1 那个顺序问题的第二道保险。

代价是:如果 CDN 卡住,load 会一直不触发,页面就"永远不动"。这时 F12 → Network 会看到 mqtt.min.js 一直 pending。“页面完全没反应” + “Network 里有 pending” = CDN 卡了,这是很实用的一个判断组合。

十、本地跑起来与调试

10.1 三种打开方式,推荐第二种

方式地址能用吗说明
双击 index.htmlfile:///d:/...不推荐部分浏览器限制 WebSocket / 跨域加载,CDN 也可能被拦
本地 HTTP 服务http://localhost:8000推荐和线上环境一致,最稳
手机/平板访问http://电脑IP:8000可以需同一 WiFi + 放行防火墙

file:// 为什么不行?因为 file:// 下页面的"源"是一个本地路径,浏览器对它的 WebSocket 和跨域请求有额外限制,不同浏览器表现还不一样。你在这台电脑上能开,换台就打不开 —— 这种"时好时坏"最难查,所以干脆别用。

10.2 起服务

cd "d:\IOT_Experiment\ZLRC_Experiment\STM32_SCH_project\project\8.HAL_SmartHome\3.H5界面\4班智能家居"
python -m http.server 8000

看到这行就成功了:

Serving HTTP on 0.0.0.0 port 8000 (http://0.0.0.0:8000/) ...

注意 0.0.0.0 —— 这表示"监听所有网卡",手机才能通过电脑 IP 访问。如果只显示 127.0.0.1,那手机就连不上(http.server 默认是 0.0.0.0,一般不用管)。

然后浏览器开 http://localhost:8000。必须带 index.html 吗? 不用,http.server 会自动返回目录下的 index.html。

10.3 F12 该看到的四条日志

正常流程下,Console 会按顺序出现:

[MQTT] 已连接 wss://broker.emqx.io:8084/mqtt
[MQTT←STM32] 2604/topic-HH {"t":26,"h":45,"a":2,"c":612,"v":137}
[MQTT←STM32] 2604/topic-HH {"t":26,"h":45,"a":2,"c":610,"v":135}
[MQTT→STM32] cmd: FAN_ON

对应四件事:

日志说明缺了说明什么
[MQTT] 已连接WebSocket + CONNACK 成功① 跳没通
[MQTT←STM32] …收到上报(每 2 秒一条)③④ 跳或主题不对
同一条持续出现数据在刷新—
[MQTT→STM32] cmd: …命令已发布到 brokersendCmd 没被触发,或没连上

如果出现:

[MQTT] 非 JSON 数据,忽略: xxx

说明收到的东西不是 JSON。先看 xxx 到底是什么 —— 有可能是:

  • 设备把调试日志也发到了 TOPIC_PUB(固件 bug);
  • 主题被别的同学占用了(他也用 2604/topic-HH,但发的是别的内容);
  • 你自己用 MQTTX 往这个主题发过测试字符串。

10.4 手机访问

  1. 手机和电脑连同一个 WiFi(很多教室的 WiFi 开了"客户端隔离",会直接失败,改用手机热点)。

  2. 查电脑 IP:

    ipconfig
    

    找"无线局域网适配器 WLAN"下的 IPv4 地址,例如 192.168.1.23。

  3. 手机浏览器打开:

    http://192.168.1.23:8000
    
  4. 第一次访问 Windows 会弹防火墙提示,必须勾"专用网络"允许,否则手机上一直转圈。

手机能打开页面 ≠ 能控制设备。 手机上的浏览器一样要连 broker,一样受学校网络限制(有些校园网只放行 80/443,8084 被挡)。这时 F12 看不到,只能靠页面右上角的连接状态判断。

10.5 改完一定要硬刷新

浏览器缓存 HTML/CSS/JS 非常顽固。普通刷新(F5)可能仍然用缓存里的 app.js。

Ctrl + F5(Mac 是 Cmd + Shift + R)强制重新下载。

判断有没有生效的最快方法:在 app.js 里加一句 console.log('[VERSION] v2'),刷完看 Console 里有没有 v2。没有,就是还吃的缓存。

开发期更彻底的做法:F12 → Network → 勾 Disable cache(保持 F12 开着才生效)。

10.6 用独立客户端验证 broker 这一段

这是最快的分段法。 打开 MQTTX Web 或 EMQX 的在线客户端,连:

地址:broker.emqx.io
端口:8083(ws)或 8084(wss)
路径:/mqtt

然后:

  1. 订阅 2604/topic-HH,看有没有数据进来 → 有,说明设备在发,问题在网页;
  2. 向 2604/cmd-HH 发 FAN_ON,看风扇转不转 → 转,说明"网页 → broker → 设备"整条链路是通的,问题在网页的发布代码。

这一步能把"网页问题"和"设备问题"彻底切开,比在两边瞎猜快得多。

十一、排查表(按症状查)

11.1 连不上 broker

现象最可能的原因处理
Console 报 WebSocket connection failed用了 ws:// 或 1883改 wss://broker.emqx.io:8084/mqtt
同上,且 URL 看着没问题路径少了 /mqtt补上,不能是 /
学校网络下必失败,手机热点正常校园网封了 8084用手机热点,或换 broker
mqtt is not definedscript 顺序 / CDN 失败见 3.3、3.4
页面一直"未连接"但无报错CDN 卡在 pending,load 不触发Network 面板确认,改本地 mqtt.min.js

11.2 连上了,但没有数据

现象最可能的原因处理
有 [MQTT] 已连接,无 [MQTT←STM32]主题不一致逐字符比对 TOPIC_PUB 与 TOPIC_DATA
同上固件不在远程模式按 KEY4 进远程,串口看 [MODE] Remote
同上ESP 没连上或 mqtt_ready = 0串口看有没有 +MQTTCONNECTED
同上拨码开关在蓝牙侧拨到 WiFi 侧,重新进远程
有 [MQTT←STM32] 但界面还是 --id 写错 / 元素被删Console 里 getElementById(...) 看是不是 null
有 [MQTT←STM32] 但报 非 JSON 数据设备发的不是 JSON看打印出来的原始内容

11.3 有数据,但点按钮设备不动

现象最可能的原因处理
Console 有 [MQTT→STM32] cmd: FAN_ON,串口无反应命令主题不一致比对 TOPIC_SUB 与 TOPIC_CMD
同上命令字面量拼错串口会打 [CMD] unknown,对照 6.6 的表
同上,且串口连 [ESP] 都没有ESP 没订阅成功重进远程模式,重新走初始化
点击后只弹"MQTT 未连接"浏览器掉线了等 3 秒自动重连,或刷新页面
点击后按钮变蓝但串口有 [CMD]执行器/PWM 问题回第 8 章查 PWM 与供电

11.4 页面状态自己抽搐

现象原因处理
绿灰交替,约 3 秒一次ClientID 撞了(开了两个标签页/两台电脑)别写死 ClientID,本工程已用随机
刷新后立刻断上一次会话没清干净clean: true
切到后台一段时间后断开浏览器休眠/网络挂起切回来会自动重连,属正常

11.5 页面"不动"

现象原因处理
时间不刷新、导航能点app.js 抛错中断了F12 看第一条红字
改了代码没变化缓存Ctrl + F5,或 Network 勾 Disable cache
双击 HTML 打开才出问题file:// 限制起 python -m http.server
手机打开一片空白防火墙 / 不同网段 / 客户端隔离换手机热点试

十二、本章易错点总结

现象真正原因处理
mqtt is not definedapp.js 在 mqtt.min.js 之前执行调换两个 <script> 顺序
同上但顺序没问题CDN 没加载成功(被墙/拦截页)F12→Network 确认,改本地 mqtt.min.js
页面样式在、逻辑全无某句 JS 抛错,后续全部停止先看 Console 第一条红字,别从中间猜
连不上,URL 是 mqtt://...:1883浏览器没有裸 TCP改 wss://...:8084/mqtt
连不上,端口 8083页面若是 https,ws:// 被拦统一用 wss + 8084
连不上,路径是 /EMQX WebSocket 路径是 /mqtt补上路径
开两个标签页互相踢ClientID 相同用 Math.random() 生成
一会儿连一会儿断同上,或 reconnectPeriod 太短导致风暴随机 ID + 3 秒重连
显示"已连接"却无数据主题名与固件不一致(大小写/-/_)逐字符比对
同上固件不在远程模式 / ESP 未连串口看 [MODE]、+MQTTCONNECTED
同上拨码开关在蓝牙侧拨到 WiFi 侧
数据来了但界面不更新id 拼错 → getElementById 返回 nullConsole 里验证元素存在
Cannot set properties of null同上的直接报错形式同上
Unexpected token / 解析失败没 payload.toString()先转字符串
解析偶发失败尾部有 \r\ntrim()
温度 0℃ 时不更新用 if (d.t) 把 0 判成假改 typeof d.t === 'number'
一条脏数据后界面卡住JSON.parse 没包 try/catch包上,脏数据只忽略
按钮变蓝但设备不动本地乐观更新,不代表设备执行以串口 [CMD] 为准
网页看不到自己发的命令H5 只订阅上报主题设计如此,别去"修"
点了"全部关闭"风扇还转固件 ALL_OFF 漏了一路查 main.c 的 ALL_OFF 分支
圆环在极端值下乱画没做 Math.min/Math.max 夹取夹到 0~1
圆环从右边开始转少了 rotate(-90 60 60)补上 transform(三参数都要)
网页说"空气一般",风扇却转了固件 AQI >= 3、网页 AQI > 3知道差异即可,或统一阈值
选"在线"筛选没效果onchange 只重渲染,没读 value占位功能,别当验收项
移动 <script> 到 head 后全站瘫痪顶层 getElementById(...).addEventListener 拿到 null脚本留在 </body> 前,或包进 load
改完代码页面没变浏览器缓存Ctrl + F5
双击 HTML 打开时好时坏file:// 限制起本地 HTTP 服务
手机打不开页面不同 WiFi / 防火墙 / 客户端隔离同网 + 放行专用网络
手机能开页面但控制不了校园网封 8084手机热点,或换网络
答辩现场连不上依赖外网 CDN + 公网 broker提前离线化 mqtt.min.js + 准备热点

十三、小结与下一篇预告

到这一章,整条链路终于闭环了:

按键/传感器 → STM32 判断 → PWM 执行
                ↑
      蓝牙(HC-06) ┘  ← 第 11 章
      MQTT(ESP-12F) ← 第 12 章
                ↑
          H5 网页 ← 本章

回头看这一章,真正花时间的不是写 mqtt.connect 那几行,而是:

  1. 搞清楚浏览器不能走 1883,必须 wss + 8084 + /mqtt;
  2. 搞清楚**「已连接」只代表浏览器到 broker**,不代表板子在线;
  3. 搞清楚按钮变蓝只是本地状态,设备有没有动要看串口;
  4. 接受网页看不到自己的命令这个设计,不去"修"它。

这四条想清楚了,剩下的都是查表。

到这里,所有功能模块都单独跑通过了。但"每个模块单独能跑"和"整机装在一起能跑"是两件事 —— 上电顺序、拨码开关、模式切换、断线重连、验收话术,这些是最后一章的内容。下一章我们把整机从冷启动到完整演示走一遍,并且列一份"答辩时会被问什么"的清单。


附录 A

A.1 app.js 完整关键段

/* ============================================================
 * 智能家居 H5 控制端 - 配合 STM32 + ESP-12F + MQTT
 * ============================================================ */

// ---------- MQTT 配置 ----------
const BROKER_URL = 'wss://broker.emqx.io:8084/mqtt';   // 浏览器只能用 wss(8084),不能直连 1883
const TOPIC_DATA = '2604/topic-HH';                    // 订阅:设备上报的温湿度
const TOPIC_CMD  = '2604/cmd-HH';                      // 发布:控制命令
const CLIENT_ID  = 'h5_' + Math.random().toString(16).slice(2, 10);

let client = null;
let mqttConnected = false;

const devices = [
    { id: 'fan',     name: '风扇',     location: '主控',  icon: '🌀', state: false, cmd_on: 'FAN_ON',     cmd_off: 'FAN_OFF' },
    { id: 'heater',  name: '加热器',   location: '主控',  icon: '🔥', state: false, cmd_on: 'HER_ON',     cmd_off: 'HER_OFF' },
    { id: 'curtain', name: '窗帘',     location: '主控',  icon: '🪟', state: false, cmd_on: 'CURTAIN_ON', cmd_off: 'CURTAIN_OFF' },
];

let currentPage = 'dashboard';
let activities = [];
let lastTemp = null;
let lastHumi = null;

function connectMQTT() {
    client = mqtt.connect(BROKER_URL, {
        clientId: CLIENT_ID,
        clean: true,
        connectTimeout: 5000,
        reconnectPeriod: 3000
    });

    client.on('connect', () => {
        mqttConnected = true;
        updateConnStatus();
        client.subscribe(TOPIC_DATA, { qos: 1 });
        addActivity('🔗', 'MQTT 已连接');
        console.log('[MQTT] 已连接', BROKER_URL);
    });

    client.on('message', (topic, payload) => {
        const msg = payload.toString().trim();
        console.log('[MQTT←STM32]', topic, msg);
        try {
            const d = JSON.parse(msg);
            if (typeof d.t === 'number') {
                lastTemp = d.t;
                document.getElementById('stat-temp').textContent = d.t.toFixed(1);
                document.getElementById('gauge-temp').textContent = d.t + '°';
                updateTempGauge(d.t);
            }
            if (typeof d.h === 'number') {
                lastHumi = d.h;
                document.getElementById('stat-humidity').textContent = d.h;
                document.getElementById('gauge-humid').textContent = d.h + '%';
                updateHumidGauge(d.h);
            }
            if (typeof d.a === 'number' || typeof d.c === 'number' || typeof d.v === 'number') {
                updateAir(d.a, d.c, d.v);
            }
        } catch (e) {
            console.log('[MQTT] 非 JSON 数据,忽略:', msg);
        }
    });

    client.on('close', () => {
        mqttConnected = false;
        updateConnStatus();
        addActivity('⚠️', 'MQTT 断开,3 秒后重连');
        console.log('[MQTT] 断开,3 秒后重连');
    });

    client.on('error', (e) => {
        console.log('[MQTT] 错误', e);
    });
}

function sendCmd(cmd) {
    if (client && mqttConnected) {
        client.publish(TOPIC_CMD, cmd, { qos: 1 });
        console.log('[MQTT→STM32] cmd:', cmd);
    } else {
        addActivity('⚠️', 'MQTT 未连接,命令未发送');
    }
}

function updateConnStatus() {
    const dot  = document.getElementById('conn-dot');
    const text = document.getElementById('conn-text');
    if (!dot) return;
    if (mqttConnected) {
        dot.className  = 'conn-dot on';
        text.textContent = 'MQTT 已连接';
    } else {
        dot.className  = 'conn-dot off';
        text.textContent = 'MQTT 未连接';
    }
}

function updateTempGauge(t) {
    const g = document.getElementById('temp-gauge');
    if (!g) return;
    const pct = Math.max(0, Math.min(1, t / 50));
    g.setAttribute('stroke-dashoffset', 314 * (1 - pct));
}

function updateHumidGauge(h) {
    const g = document.getElementById('humid-gauge');
    if (!g) return;
    const pct = Math.max(0, Math.min(1, h / 100));
    g.setAttribute('stroke-dashoffset', 314 * (1 - pct));
}

function updateAir(aqi, eco2, tvoc) {
    if (typeof aqi === 'number')  document.getElementById('air-aqi').textContent  = aqi;
    if (typeof eco2 === 'number') document.getElementById('air-eco2').textContent = eco2;
    if (typeof tvoc === 'number') document.getElementById('air-tvoc').textContent = tvoc;

    const isBad = (typeof aqi === 'number'  && aqi > 3) ||
                  (typeof eco2 === 'number' && eco2 > 1000) ||
                  (typeof tvoc === 'number' && tvoc > 650);
    const isMid = !isBad && ((typeof aqi === 'number' && aqi === 3) ||
                             (typeof eco2 === 'number' && eco2 > 800));

    const badge = document.getElementById('air-badge');
    if (isBad) {
        badge.textContent = '空气差 · 建议通风';
        badge.className   = 'air-badge bad';
    } else if (isMid) {
        badge.textContent = '空气一般';
        badge.className   = 'air-badge mid';
    } else {
        badge.textContent = '空气优';
        badge.className   = 'air-badge good';
    }

    const bar = document.getElementById('air-bar-fill');
    if (typeof aqi === 'number') bar.style.width = Math.max(20, Math.min(100, aqi / 5 * 100)) + '%';

    document.getElementById('air-tip').textContent = isBad
        ? 'AQI / eCO2 / TVOC 任一超限,建议开窗通风'
        : '空气质量正常,无需通风';
}

function quickToggle(id) {
    const d = devices.find(x => x.id === id);
    if (!d) return;
    d.state = !d.state;
    sendCmd(d.state ? d.cmd_on : d.cmd_off);
    updateQuickButton(id);
    addActivity(d.icon, `${d.name} 已${d.state ? '开启' : '关闭'}`);
    if (currentPage === 'devices') renderDevices();
}

function updateQuickButton(id) {
    const d = devices.find(x => x.id === id);
    const btn = document.getElementById('qb-' + id);
    if (!btn || !d) return;
    if (d.state) {
        btn.classList.add('active');
        btn.querySelector('.qb-status').textContent = '开启';
        btn.querySelector('.qb-status').className = 'qb-status on';
    } else {
        btn.classList.remove('active');
        btn.querySelector('.qb-status').textContent = '关闭';
        btn.querySelector('.qb-status').className = 'qb-status off';
    }
}

function toggleDevice(id) {
    const d = devices.find(x => x.id === id);
    if (!d) return;
    d.state = !d.state;
    sendCmd(d.state ? d.cmd_on : d.cmd_off);
    renderDevices();
    updateQuickButton(id);
    addActivity(d.icon, `${d.name} 已${d.state ? '开启' : '关闭'}`);
}

function allOff() {
    sendCmd('ALL_OFF');
    devices.forEach(d => d.state = false);
    renderDevices();
    devices.forEach(d => updateQuickButton(d.id));
    addActivity('🛑', '已发送全部关闭命令');
}

function init() {
    updateDateTime();
    setInterval(updateDateTime, 1000);
    renderDevices();
    generateActivities();
    connectMQTT();
}

function switchPage(page) {
    currentPage = page;
    document.querySelectorAll('.page').forEach(p => p.classList.remove('active'));
    document.querySelectorAll('.nav-item').forEach(n => n.classList.remove('active'));
    document.getElementById('page-' + page).classList.add('active');
    document.querySelector(`[data-page="${page}"]`).classList.add('active');
    const titles = { dashboard: '仪表盘', devices: '设备控制', energy: '能耗统计' };
    document.getElementById('page-title').textContent = titles[page] || '智能家居';
}

function toggleTheme() {
    document.body.classList.toggle('dark');
}

window.addEventListener('load', init);

A.2 index.html 关键片段

尾部脚本(顺序不能反):

<!-- mqtt.js:浏览器通过 wss 连 broker 的客户端库(必须先于 app.js 加载) -->
<script src="https://unpkg.com/mqtt@5.10.1/dist/mqtt.min.js"></script>
<script src="app.js"></script>
</body>

温湿度双圆环:

<div class="env-monitor">
    <div class="env-gauge">
        <svg viewBox="0 0 120 120" class="gauge-svg">
            <circle cx="60" cy="60" r="50" fill="none" stroke="#e0e0e0" stroke-width="10"/>
            <circle cx="60" cy="60" r="50" fill="none" stroke="#1976d2" stroke-width="10"
                stroke-dasharray="314" stroke-dashoffset="314" stroke-linecap="round"
                transform="rotate(-90 60 60)" id="temp-gauge"/>
        </svg>
        <div class="gauge-value">
            <span id="gauge-temp">--°</span>
            <span class="gauge-label">温度</span>
        </div>
    </div>
    <div class="env-gauge">
        <svg viewBox="0 0 120 120" class="gauge-svg">
            <circle cx="60" cy="60" r="50" fill="none" stroke="#e0e0e0" stroke-width="10"/>
            <circle cx="60" cy="60" r="50" fill="none" stroke="#f57c00" stroke-width="10"
                stroke-dasharray="314" stroke-dashoffset="314" stroke-linecap="round"
                transform="rotate(-90 60 60)" id="humid-gauge"/>
        </svg>
        <div class="gauge-value">
            <span id="gauge-humid">--%</span>
            <span class="gauge-label">湿度</span>
        </div>
    </div>
</div>

空气质量卡(放在 .grid-2 之后、最近活动之前):

<div class="card">
    <div class="card-header">
        <h3>实时空气质量 (ENS160)</h3>
        <span class="air-badge" id="air-badge">等待数据</span>
    </div>
    <div class="card-body">
        <div class="air-monitor">
            <div class="air-item">
                <span class="air-icon" style="background:#e3f2fd;color:#1976d2">&#127788;</span>
                <span class="air-value" id="air-aqi">--</span>
                <span class="air-label">AQI 等级</span>
            </div>
            <div class="air-item">
                <span class="air-icon" style="background:#e8f5e9;color:#388e3c">&#127807;</span>
                <span class="air-value" id="air-eco2">--</span>
                <span class="air-label">eCO2 (ppm)</span>
            </div>
            <div class="air-item">
                <span class="air-icon" style="background:#fff3e0;color:#f57c00">&#127777;</span>
                <span class="air-value" id="air-tvoc">--</span>
                <span class="air-label">TVOC (ppb)</span>
            </div>
        </div>
        <div class="air-bar">
            <div class="air-bar-track">
                <div class="air-bar-fill" id="air-bar-fill"></div>
            </div>
            <span class="air-tip" id="air-tip">等待设备上报空气质量数据…</span>
        </div>
    </div>
</div>

空气质量样式(用变量,别写死颜色):

.air-monitor { display: flex; gap: 16px; }
.air-item    { flex: 1; display: flex; flex-direction: column; align-items: center; gap: 6px; }
.air-icon    { width: 40px; height: 40px; border-radius: 10px;
               display: flex; align-items: center; justify-content: center; font-size: 18px; }
.air-value   { font-size: 24px; font-weight: 600; }
.air-label   { font-size: 12px; color: var(--text-secondary); }
.air-badge   { padding: 3px 10px; border-radius: 12px; font-size: 12px; }
.air-badge.good { background: #e8f5e9; color: #2e7d32; }
.air-badge.mid  { background: #fff8e1; color: #f57f17; }
.air-badge.bad  { background: #ffebee; color: #c62828; }
.air-bar-track  { height: 8px; background: var(--bg-primary); border-radius: 4px; overflow: hidden; }
.air-bar-fill   { height: 100%; width: 20%; transition: width .4s ease;
                  background: linear-gradient(90deg, #66bb6a, #ffa726, #ef5350); }
.air-tip        { font-size: 12px; color: var(--text-secondary); margin-top: 8px; display: block; }

A.3 起本地服务

cd "d:\IOT_Experiment\ZLRC_Experiment\STM32_SCH_project\project\8.HAL_SmartHome\3.H5界面\4班智能家居"
python -m http.server 8000

浏览器访问 http://localhost:8000;手机访问 http://电脑IPv4:8000。

A.4 最小验证:四步走完

第 1 步:只验网页能不能连 broker(不接板子)

  1. 起服务,打开页面;
  2. F12 → Console 看有没有 [MQTT] 已连接 wss://broker.emqx.io:8084/mqtt;
  3. 没有 → 查 3.3 / 4.2 / 11.1;有 → 继续。

第 2 步:只验网页能不能收到数据(用 MQTTX 假造)

在 MQTTX 里连 broker.emqx.io:8084(wss),向 2604/topic-HH 发布:

{"t":28,"h":55,"a":3,"c":820,"v":300}

页面应立刻:

  • 统计卡:28.0 / 55
  • 圆环:约 56% / 55%
  • 空气质量:AQI 3、eCO2 820、TVOC 300,徽章"空气一般"(黄)
  • 三色条:60%

这一步全对,说明"浏览器 + 主题 + 解析 + 渲染"整段没问题。 后面再出问题,只可能是设备侧。

第 3 步:验网页能不能发命令(还是用 MQTTX 收)

在 MQTTX 里订阅 2604/cmd-HH,然后在网页点"风扇":

  • MQTTX 应该收到 FAN_ON;
  • 再点一次,收到 FAN_OFF;
  • 点"全部关闭",收到 ALL_OFF。

收到了,说明发布这一跳也对。 如果 MQTTX 没收到但 Console 有 [MQTT→STM32] cmd:,那是 TOPIC_CMD 写错了。

第 4 步:接上真板子,走完整链路

  1. 拨码开关打到 WiFi 侧;
  2. 板子进远程模式,再按一次 KEY4 走 MQTT 初始化;
  3. 串口看到 +MQTTCONNECTED、[PUB] OK;
  4. 网页 2 秒内出现温湿度 + 空气质量;
  5. 点"风扇",串口出现 [CMD] FAN_ON,风扇转。

四步都过,本章功能即闭环。 哪一步先挂,就停在哪一步修,别跳。

评论
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符
 
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

个

红包个数最小为10个

元

红包金额最低5元

当前余额3.43元 前往充值 >
需支付:10.00元
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付元
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值