文章目录
系列第十三篇。前面十二章,板子已经能自己测、自己判、自己动,还能被手机蓝牙和 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 秒上报,原路反向走回浏览器 ───────────────────┘
逐跳说清楚,后面排错才知道该在哪一段下手:
| 跳 | 链路 | 协议/端口 | 出问题时的现象 |
|---|---|---|---|
| ① | 浏览器 → broker | wss / 8084 | 右上角一直"未连接",F12 有 WebSocket 报错 |
| ② | broker 内部转发 | TCP / 1883 | 一般不会坏,公网服务 |
| ③ | broker ↔ ESP-12F | TCP / 1883 | ESP 端日志 +MQTTCONNECTED 不出现 |
| ④ | ESP-12F ↔ STM32 | USART2 / 115200 | ESP 连上了,但板子日志没有 [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 | 网页 → 设备 | H5 | ESP-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 脚本 |
| 8083 | WebSocket(ws://) | 浏览器(http 页面) |
| 8084 | WebSocket 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.io | broker.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] || '智能家居';
}
两个小细节,都是"排错时会怀疑人生"的类型:
id必须正好是page-+data-page的值。 写成page-dashborad(拼错)不会报错,只会表现为"点了导航,内容区空白"。因为classList.add加在null上才会抛异常 —— 这里getElementById返回null,null.classList会抛TypeError,所以其实会报错,但报错在 Console 里一闪而过,很多人根本没往上看。- 导航高亮靠
[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 | 谁写它 | 写什么 |
|---|---|---|
datetime | updateDateTime() | 当前时间字符串 |
conn-dot | updateConnStatus() | class = 'conn-dot on' / 'conn-dot off' |
conn-text | updateConnStatus() | MQTT 已连接 / MQTT 未连接 |
page-title | switchPage() | 页面标题 |
stat-temp | message 回调 | d.t.toFixed(1) |
stat-humidity | message 回调 | d.h |
gauge-temp | message 回调 | d.t + '°' |
gauge-humid | message 回调 | d.h + '%' |
temp-gauge | updateTempGauge() | SVG stroke-dashoffset |
humid-gauge | updateHumidGauge() | SVG stroke-dashoffset |
air-badge | updateAir() | 文字 + class |
air-aqi / air-eco2 / air-tvoc | updateAir() | 三个数值 |
air-bar-fill | updateAir() | style.width |
air-tip | updateAir() | 一行提示 |
qb-fan / qb-heater / qb-curtain | updateQuickButton() | class + .qb-status 文字 |
devices-grid | renderDevices() | 整块 innerHTML |
activity-list | renderActivities() | 整块 innerHTML |
device-modal | openDeviceDetail() / closeModal() | class(show) |
modal-device-name / modal-device-body | openDeviceDetail() | 标题 / 内容 |
一个非常实用的小技巧:在 Console 里输入
document.getElementById('stat-temp')。如果返回null,说明id写错了、或者元素被删了、或者脚本执行时元素还没解析出来(后面 9.5 会讲)。
2.4 样式只讲三点
style.css 有两百多行,本章不逐条讲,但有三点和"能不能跑"直接相关:
- 主题变量:
--bg-primary、--text-secondary、--success-color这些是 CSS 变量,toggleTheme()往body上加dark类,靠变量整体换色。所以你新增板块时必须用变量,写死#fff在深色主题下就是一块刺眼的白斑。 .conn-dot.on/.conn-dot.off:绿色/灰色小球。updateConnStatus()是整体替换className,不是classList.add—— 所以原来的conn-dot前缀必须一起写上,写成dot.className = 'on'球就没了。.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.com | mqtt.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 把第一个踢掉 |
clean | true | 不保留会话,每次都是全新的 | false + 随机 ID 会残留无用会话,反而更乱 |
connectTimeout | 5000 | 5 秒内没握上手就放弃本次 | 太长 → 教室网络差时白等;太短 → 好网络也连不上 |
reconnectPeriod | 3000 | 断线后 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 四个回调,各管一件事
| 回调 | 什么时候触发 | 本工程里做什么 |
|---|---|---|
connect | WebSocket 握手 + 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~50 | pub_temp(DHT11) | stat-temp、gauge-temp、temp-gauge |
h | 湿度 % | 0~100 | pub_humi(DHT11) | stat-humidity、gauge-humid、humid-gauge |
a | AQI 等级 | 1~5 | pub_aqi(ENS160) | air-aqi、air-badge、air-bar-fill |
c | eCO2 ppm | 400~5000 | pub_eco2(ENS160) | air-eco2、air-badge |
v | TVOC ppb | 0~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 未连接,命令未发送');
}
}
三个要点:
client &&这个判空不能省。init()里才创建 client,如果有人在你没注意的时候提前触发了sendCmd,client还是null,null.publish直接抛异常。mqttConnected判断的是"有没有连上",不是"设备在不在"。这就是 4.4 说的那个区别:命令发出去了,但没人收。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_ON | FAN_PWM = 800(约 80% 占空比) |
| 风扇关 | FAN_OFF | FAN_PWM = 0 |
| 加热开 | HER_ON | HER_PWM = 800 |
| 加热关 | HER_OFF | HER_PWM = 0 |
| 窗帘开 | CURTAIN_ON | TIM5 CH1 CCR = 500(约 2.5% → 一端) |
| 窗帘关 | CURTAIN_OFF | TIM5 CH1 CCR = 2000(约 10% → 另一端) |
| 全部关闭 | ALL_OFF | 上面三路全关 + 窗帘落下 |
6.7 为什么网页上看不到自己发的命令
因为 H5 只订阅了 2604/topic-HH(上报),没有订阅 2604/cmd-HH(命令)。
这带来两个后果:
- 活动栏里"风扇 已开启"是网页自己记的,不是设备回的确认。设备有没有真的开,网页不知道。
- 如果你在网页上点了风扇,同时用 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 的值域是没有上下界的:
| t | t/50 | 不加夹取 | 加了夹取 |
|---|---|---|---|
| 55(异常高温) | 1.1 | offset = -31.4,圆环"多转"一段,视觉错乱 | offset = 0,满环 |
| -5(传感器异常) | -0.1 | offset = 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">🌬</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">🌿</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">🌡</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 >= 3 | aqi > 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 | 计算 | 实际宽度 | 含义 |
|---|---|---|---|
| 1 | 20% | 20% | 优 |
| 2 | 40% | 40% | 良 |
| 3 | 60% | 60% | 一般 |
| 4 | 80% | 80% | 差 |
| 5 | 100% | 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 全部销毁再重建。带来三个必须知道的结果:
- 原来的事件监听全没了。 所以设备卡里的开关用的是内联
onclick字符串(onclick="toggleDevice('fan')"),而不是addEventListener。内联写法在 DOM 重建后依然有效,因为它是写在 HTML 属性里的。 - 每次重建会闪一下(如果 CSS 有动画)。本工程的卡片没有入场动画,所以看不出来。
- 输入框里的内容会丢。 设备卡里没有输入框,所以无所谓;但如果你以后加了个"设备备注"输入框,重渲染一次就清空了 —— 那时就得改成"只更新有变化的节点"。
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.html | file:///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: … | 命令已发布到 broker | sendCmd 没被触发,或没连上 |
如果出现:
[MQTT] 非 JSON 数据,忽略: xxx
说明收到的东西不是 JSON。先看 xxx 到底是什么 —— 有可能是:
- 设备把调试日志也发到了
TOPIC_PUB(固件 bug); - 主题被别的同学占用了(他也用
2604/topic-HH,但发的是别的内容); - 你自己用 MQTTX 往这个主题发过测试字符串。
10.4 手机访问
-
手机和电脑连同一个 WiFi(很多教室的 WiFi 开了"客户端隔离",会直接失败,改用手机热点)。
-
查电脑 IP:
ipconfig找"无线局域网适配器 WLAN"下的
IPv4 地址,例如192.168.1.23。 -
手机浏览器打开:
http://192.168.1.23:8000 -
第一次访问 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
然后:
- 订阅
2604/topic-HH,看有没有数据进来 → 有,说明设备在发,问题在网页; - 向
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 defined | script 顺序 / 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 defined | app.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 返回 null | Console 里验证元素存在 |
Cannot set properties of null | 同上的直接报错形式 | 同上 |
Unexpected token / 解析失败 | 没 payload.toString() | 先转字符串 |
| 解析偶发失败 | 尾部有 \r\n | trim() |
| 温度 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 那几行,而是:
- 搞清楚浏览器不能走 1883,必须
wss+8084+/mqtt; - 搞清楚**「已连接」只代表浏览器到 broker**,不代表板子在线;
- 搞清楚按钮变蓝只是本地状态,设备有没有动要看串口;
- 接受网页看不到自己的命令这个设计,不去"修"它。
这四条想清楚了,剩下的都是查表。
到这里,所有功能模块都单独跑通过了。但"每个模块单独能跑"和"整机装在一起能跑"是两件事 —— 上电顺序、拨码开关、模式切换、断线重连、验收话术,这些是最后一章的内容。下一章我们把整机从冷启动到完整演示走一遍,并且列一份"答辩时会被问什么"的清单。
附录 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">🌬</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">🌿</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">🌡</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(不接板子)
- 起服务,打开页面;
- F12 → Console 看有没有
[MQTT] 已连接 wss://broker.emqx.io:8084/mqtt; - 没有 → 查 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 步:接上真板子,走完整链路
- 拨码开关打到 WiFi 侧;
- 板子进远程模式,再按一次 KEY4 走 MQTT 初始化;
- 串口看到
+MQTTCONNECTED、[PUB] OK; - 网页 2 秒内出现温湿度 + 空气质量;
- 点"风扇",串口出现
[CMD] FAN_ON,风扇转。
四步都过,本章功能即闭环。 哪一步先挂,就停在哪一步修,别跳。
本文详解如何使用mqtt.js在浏览器中实现对STM32智能家居设备的实时控制。内容涵盖H5与MQTT Broker的安全连接(wss协议)、主题订阅/发布机制、消息解析(Buffer→JSON)、本地乐观更新、设备状态同步及常见调试陷阱。重点强调浏览器无法直连MQTT 1883端口,必须使用WebSocket封装;H5仅订阅上报主题,不监听命令主题;页面‘已连接’不等于设备在线,需结合串口日志交叉验证。
1995

被折叠的 条评论
为什么被折叠?



