ARTICLE · 1012519
不用装App!浏览器直连MQTT,遥控自主移动机器人
还在用串口助手调试机器人?不如试试这个纯前端网页控制台。
它专为 STM32 + ESP-01M MQTT打造,保存为 car.html,用浏览器打开就能连上MQTT Broker,用按钮或键盘遥控机器人前进、后退、左转、右转、原地旋转 180°,还能实时看到编码器数据。深色科技风,电脑、手机都能用,无需安装任何 App。
下面带你快速认识这个“网页遥控器”。
<!DOCTYPE html><htmllang="zh-CN"><head><metacharset="UTF-8"><metaname="viewport"content="width=device-width,initial-scale=1,maximum-scale=1,user-scalable=no,viewport-fit=cover"><metaname="theme-color"content="#0d1117"><title>STM32 MQTT 自主移动机器人控制台</title><style>:root{--bg:#0d1117; --card:#171d27; --line:#262d3a;--txt:#e6edf3; --dim:#8b98a8;--acc:#3b82f6; --ok:#22c55e; --err:#ef4444; --warn:#f59e0b;}*{box-sizing:border-box;-webkit-tap-highlight-color:transparent;}body{margin:0; min-height:100vh; padding:16px 12px 40px;background:radial-gradient(1200px 600px at 50% -10%,#1b2740 0%,transparent 60%),var(--bg);color:var(--txt);font-family:-apple-system,BlinkMacSystemFont,"Segoe UI","PingFang SC","Microsoft YaHei",sans-serif;font-size:15px; -webkit-font-smoothing:antialiased;}.app{max-width:560px;margin:0 auto;display:flex;flex-direction:column;gap:14px;}/* ---------- 顶部 ---------- */header{display:flex;align-items:center;justify-content:space-between;gap:10px;padding:2px;}header h1{font-size:17px;margin:0;font-weight:600;letter-spacing:.3px;}.conn-badge{display:flex;align-items:center;gap:7px;padding:6px 12px;border-radius:999px;background:var(--card);border:1px solid var(--line);font-size:12.5px;color:var(--dim);white-space:nowrap;}.dot{width:9px;height:9px;border-radius:50%;background:#6b7280;transition:.3s;}.dot.on{background:var(--ok);animation:pulse 1.6s infinite;}@keyframes pulse{0%{box-shadow:0 0 0 0 rgba(34,197,94,.55)}70%{box-shadow:0 0 0 8px rgba(34,197,94,0)}100%{box-shadow:0 0 0 0 rgba(34,197,94,0)}}/* ---------- 卡片 ---------- */.card{background:linear-gradient(180deg,#171d27,#12171f);border:1px solid var(--line); border-radius:16px; padding:14px;box-shadow:0 8px 24px rgba(0,0,0,.35);}.card-title{display:flex;align-items:center;justify-content:space-between;font-size:12px;font-weight:700;color:var(--dim);letter-spacing:1px;margin-bottom:12px;}.card-title .mini{font-size:11px;font-weight:500;letter-spacing:0;background:#0b0f15;border:1px solid var(--line);color:var(--dim);padding:3px 9px;border-radius:8px;cursor:pointer;}/* ---------- 表单 ---------- */.grid2{display:grid;grid-template-columns:1fr 1fr;gap:10px;}label.field{display:flex;flex-direction:column;gap:5px;font-size:11.5px;color:var(--dim);}label.field input{width:100%;padding:9px 10px;border-radius:10px;background:#0b0f15;border:1px solid var(--line);color:var(--txt);font-size:14px;outline:none;transition:.15s;}label.field input:focus{border-color:var(--acc);box-shadow:0 0 0 3px rgba(59,130,246,.15);}.hint{font-size:11.5px;color:#6b7a8d;line-height:1.6;margin-top:10px;}/* ---------- 按钮 ---------- */.row{display:flex;gap:10px;margin-top:12px;}button{font-family:inherit;font-size:14px;font-weight:600;border-radius:11px;border:1px solid var(--line);background:#1b2230;color:var(--txt);padding:11px 16px;cursor:pointer;transition:.15s;touch-action:manipulation;user-select:none;}button:hover:not(:disabled){background:#232b3b;}button:active:not(:disabled){transform:scale(.97);}button:disabled{opacity:.35;cursor:not-allowed;}button.primary{background:linear-gradient(180deg,#3b82f6,#2563eb);border-color:#2563eb;flex:1;}button.primary:hover:not(:disabled){background:linear-gradient(180deg,#4b8ef7,#2f6fe0);}button.ghost{flex:1;}button.danger{background:linear-gradient(180deg,#ef4444,#dc2626);border-color:#dc2626;flex:1;}button.danger:hover:not(:disabled){background:linear-gradient(180deg,#f45a5a,#e02d2d);}/* ---------- 参数区 ---------- */.param-bar{display:flex;gap:10px;align-items:flex-end;flex-wrap:wrap;}.param-bar .grow{flex:1;min-width:110px;}.presets{display:flex;gap:6px;flex-wrap:wrap;margin-top:8px;}.presets button{padding:5px 12px;font-size:12px;font-weight:500;border-radius:8px;background:#0b0f15;color:var(--dim);}.presets button:hover{color:var(--txt);border-color:#3a4557;}.speed-box{display:flex;flex-direction:column;gap:5px;font-size:11.5px;color:var(--dim);min-width:150px;flex:1;}.speed-line{display:flex;align-items:center;gap:10px;}.speed-line b{color:var(--acc);font-size:16px;min-width:42px;text-align:right;}input[type=range]{-webkit-appearance:none;appearance:none;flex:1;height:6px;border-radius:3px;background:linear-gradient(90deg,var(--acc) 0%,var(--acc) 40%,#242c3a 40%);outline:none;}input[type=range]::-webkit-slider-thumb{-webkit-appearance:none;width:20px;height:20px;border-radius:50%;background:#fff;border:3px solid var(--acc);cursor:pointer;box-shadow:0 2px 6px rgba(0,0,0,.4);}input[type=range]::-moz-range-thumb{width:16px;height:16px;border-radius:50%;background:#fff;border:3px solid var(--acc);cursor:pointer;}/* ---------- 方向盘 ---------- */.dpad{display:grid;grid-template-columns:repeat(3,1fr);grid-template-rows:repeat(3,1fr);gap:10px;max-width:290px;margin:18px auto 4px;}.dir{aspect-ratio:1;border-radius:50%;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:1px;padding:0;border:1px solid #2b3444;background:radial-gradient(circle at 30% 25%,#2a3344,#161c26);color:#9fb0c7;box-shadow:0 4px 12px rgba(0,0,0,.35), inset 0 1px 0 rgba(255,255,255,.05);}.dir .arrow{font-size:20px;line-height:1;}.dir .label{font-size:10.5px;font-weight:600;letter-spacing:.5px;opacity:.8;}.dir:hover:not(:disabled){color:#fff;border-color:#3d4a5f;}.dir:active:not(:disabled){background:radial-gradient(circle at 30% 25%,#3b82f6,#1d4ed8);color:#fff;transform:scale(.93);}.dir.stop{border-color:rgba(239,68,68,.55);background:radial-gradient(circle at 30% 25%,#2a2028,#1a1418);color:#f87171;}.dir.stop:active:not(:disabled){background:radial-gradient(circle at 30% 25%,#ef4444,#b91c1c);color:#fff;}.grid-1-2{grid-area:1/2/2/3;}.grid-2-1{grid-area:2/1/3/2;}.grid-2-2{grid-area:2/2/3/3;}.grid-2-3{grid-area:2/3/3/4;}.grid-3-2{grid-area:3/2/4/3;}/* ---------- 状态 ---------- */.status-grid{display:grid;grid-template-columns:repeat(4,1fr);gap:8px;text-align:center;}.status-grid > div{background:#0b0f15;border:1px solid var(--line);border-radius:11px;padding:10px 4px;}.status-grid .lbl{display:block;font-size:10.5px;color:var(--dim);margin-bottom:5px;}.status-grid b{font-size:16px;font-weight:700;color:#7dd3fc;font-variant-numeric:tabular-nums;}/* ---------- 日志 ---------- */#log{margin:0;max-height:200px;overflow:auto;padding:10px;background:#0b0f15;border:1px solid var(--line);border-radius:10px;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:11.5px;line-height:1.6;color:#93a3b8;word-break:break-all;}#log div{white-space:pre-wrap;}#log .ok{color:#4ade80;} #log .err{color:#f87171;}#log .warn{color:#fbbf24;} #log .pub{color:#60a5fa;}#log .recv{color:#c084fc;}</style></head><body><divclass="app"><header><h1>🚗 STM32 MQTT 自主移动机器人</h1><divclass="conn-badge"><spanclass="dot"id="dot"></span><spanid="connText">未连接</span></div></header><!-- ===== ① 连接配置 ===== --><sectionclass="card"><divclass="card-title"><span>① 连接 MQTT Broker</span></div><divclass="grid2"><labelclass="field">Broker 地址 / IP<inputid="host"value="192.168.137.138"placeholder="192.168.137.138"inputmode="url"></label><labelclass="field">WebSocket 端口<inputid="port"value="8083"placeholder="8083"inputmode="numeric"></label><labelclass="field">WS 路径<inputid="path"value="/mqtt"placeholder="/mqtt"></label><labelclass="field">Client ID<inputid="clientId"placeholder="自动生成"></label><labelclass="field">用户名(可选)<inputid="user"placeholder="留空表示无"></label><labelclass="field">密码(可选)<inputid="pass"type="password"placeholder="留空表示无"></label></div><divclass="row"><buttonid="btnConnect"class="primary">连接</button><buttonid="btnDisconnect"class="ghost"disabled>断开</button></div><divclass="hint">⚠️ 浏览器只能通过 <b>WebSocket</b> 连 MQTT,请确认 Broker 已开启 WS 监听:<br>· EMQX:端口 <b>8083</b>,路径 <b>/mqtt</b>(默认)<br>· Mosquitto:需配置 <code>listener 9001</code> + <code>protocol websockets</code>,路径填 <b>/</b><br>· 页面用 https 打开时必须用 wss,用 http / 本地文件打开则用 ws</div></section><!-- ===== ② 运动控制 ===== --><sectionclass="card"><divclass="card-title"><span>② 运动控制</span><spanclass="mini"id="topicEcho">/stm32/esp01m/receive</span></div><divclass="param-bar"><labelclass="field grow">距离 (cm) / 角度 (°)<inputid="param"type="number"value="30"min="1"max="999"inputmode="numeric"></label><divclass="speed-box"><span>速度</span><divclass="speed-line"><inputid="speed"type="range"min="30"max="100"step="5"value="40"><b><spanid="speedVal">40</span>%</b></div></div></div><divclass="presets"><buttondata-param="10">10 cm</button><buttondata-param="30">30 cm</button><buttondata-param="50">50 cm</button><buttondata-param="100">100 cm</button><buttondata-param="90">90°</button><buttondata-param="180">180°</button></div><!-- 方向盘 --><divclass="dpad"><buttonclass="dir grid-1-2"data-need-connid="btnFwd"><spanclass="arrow">▲</span><spanclass="label">前进</span></button><buttonclass="dir grid-2-1"data-need-connid="btnLeft"><spanclass="arrow">◀</span><spanclass="label">左转</span></button><buttonclass="dir stop grid-2-2"data-need-connid="btnStop"><spanclass="arrow">■</span><spanclass="label">停止</span></button><buttonclass="dir grid-2-3"data-need-connid="btnRight"><spanclass="arrow">▶</span><spanclass="label">右转</span></button><buttonclass="dir grid-3-2"data-need-connid="btnBwd"><spanclass="arrow">▼</span><spanclass="label">后退</span></button></div><divclass="row"><buttonclass="ghost"data-need-connid="btnRot180"style="flex:1">↻ 原地旋转 180°</button></div><divclass="hint">快捷键:<b>W / ↑</b> 前进 · <b>S / ↓</b> 后退 · <b>A / ←</b> 左转 · <b>D / →</b> 右转 · <b>空格</b> 停止<br>闭环运动执行期间 STM32 会阻塞,指令过快会被自动忽略。</div></section><!-- ===== ③ 状态 ===== --><sectionclass="card"><divclass="card-title"><span>③ 自主移动机器人状态</span><spanclass="mini"id="stTime">--:--:--</span></div><divclass="status-grid"><div><spanclass="lbl">左编码器</span><bid="stL">--</b></div><div><spanclass="lbl">右编码器</span><bid="stR">--</b></div><div><spanclass="lbl">忙</span><bid="stBusy">--</b></div><div><spanclass="lbl">消息数</span><bid="stCount">0</b></div></div></section><!-- ===== ④ 日志 ===== --><sectionclass="card"><divclass="card-title"><span>④ 通信日志</span><spanclass="mini"id="btnClear">清空</span></div><divid="log"></div></section></div><scriptsrc="https://unpkg.com/mqtt@5.10.1/dist/mqtt.min.js"></script><script>(function () {'use strict';var $ = function (id) { return document.getElementById(id); };/* ============ 与 STM32 固件一致的 Topic ============ */var TOPIC_CMD = '/stm32/esp01m/receive';var TOPIC_STATUS = '/stm32/esp01m/status';/* ============ 状态码(与固件 switch 对应) ============ */var CMD = { FWD: 1, BWD: 2, LEFT: 3, RIGHT: 4, ROT180: 5, STOP: 6 };var client = null;var isConnected = false;var lastSendTs = 0;var SEND_GAP = 700; // ms,防止指令发太快(STM32 闭环是阻塞的)var msgCount = 0;/* ---------------- 日志 ---------------- */function log(msg, cls) {var box = $('log');var d = document.createElement('div');if (cls) d.className = cls;var t = new Date().toLocaleTimeString('zh-CN', { hour12: false });d.textContent = '[' + t + '] ' + msg;box.appendChild(d);box.scrollTop = box.scrollHeight;while (box.childNodes.length > 250) box.removeChild(box.firstChild);}/* ---------------- 连接状态切换 ---------------- */function setConnected(on) {isConnected = !!on;$('dot').className = 'dot' + (on ? ' on' : '');$('connText').textContent = on ? '已连接' : '未连接';$('btnConnect').disabled = on;$('btnDisconnect').disabled = !on;document.querySelectorAll('[data-need-conn]').forEach(function (b) {b.disabled = !on;});}/* ---------------- 参数读取 ---------------- */function getParam() {var v = parseInt($('param').value, 10);if (!isFinite(v) || v < 1) v = 1;if (v > 999) v = 999;return v;}function getSpeed() {var v = parseInt($('speed').value, 10);if (!isFinite(v)) v = 40;return Math.min(100, Math.max(30, v)); // 固件最低 30%,低于会被强制抬到 30}/* ---------------- 发送 MQTT 指令 ---------------- */function sendCmd(status, param, speed) {if (!isConnected || !client) {log('⚠️ 未连接 Broker,无法发送', 'warn');return;}var now = Date.now();if (status !== CMD.STOP && (now - lastSendTs) < SEND_GAP) {log('⏳ 上一条指令还在执行,已忽略本次点击', 'warn');return;}lastSendTs = now;param = Math.round(Number(param) || 0);speed = Math.round(Number(speed) || 40);/* 严格按固件要求:{"status":N,"pose":[距离/角度, 速度, ...]} */var payload = JSON.stringify({status: status,pose: [param, speed, 0, 0, 0, 0],force: [0, 0, 0, 0, 0, 0]});client.publish(TOPIC_CMD, payload, { qos: 1, retain: false }, function (err) {if (err) log('❌ 发布失败: ' + err.message, 'err');else log('→ 发送 ' + payload, 'pub');});}/* 快捷封装 */function doMove(status) { sendCmd(status, getParam(), getSpeed()); }/* ---------------- 连接 ---------------- */function buildUrl() {var host = $('host').value.trim();var port = $('port').value.trim() || '8083';var path = $('path').value.trim() || '/';if (path.charAt(0) !== '/') path = '/' + path;var proto = (location.protocol === 'https:') ? 'wss' : 'ws';return proto + '://' + host + ':' + port + path;}function doConnect() {if (typeof mqtt === 'undefined') {log('❌ mqtt.js 加载失败,请检查网络 / CDN', 'err');return;}if (client) { try { client.end(true); } catch (e) {} client = null; }var url = buildUrl();var cid = $('clientId').value.trim() ||('web_car_' + Math.random().toString(16).slice(2, 10));/* 记住填写内容 */try {['host','port','path','clientId','user'].forEach(function (k) {localStorage.setItem('car_' + k, $(k).value);});} catch (e) {}log('正在连接 ' + url + ' ...');client = mqtt.connect(url, {clientId: cid,clean: true,connectTimeout: 8000,reconnectPeriod: 3000,keepalive: 30,username: $('user').value.trim() || undefined,password: $('pass').value || undefined});client.on('connect', function () {setConnected(true);log('✅ 已连接 Broker', 'ok');client.subscribe(TOPIC_STATUS, { qos: 1 }, function (err) {if (err) log('❌ 订阅失败: ' + err.message, 'err');else log('✅ 已订阅 ' + TOPIC_STATUS, 'ok');});});client.on('reconnect', function () { log('🔄 正在重连...', 'warn'); });client.on('close', function () {if (isConnected) log('🔌 连接已断开', 'warn');setConnected(false);});client.on('error', function (e) {setConnected(false);log('❌ 连接错误: ' + (e && e.message ? e.message : e), 'err');});/* 接收 STM32 上报的状态 */client.on('message', function (topic, payload) {var text = payload.toString();if (topic === TOPIC_STATUS) {log('← 状态 ' + text, 'recv');msgCount++;$('stCount').textContent = msgCount;$('stTime').textContent = new Date().toLocaleTimeString('zh-CN', { hour12: false });try {var o = JSON.parse(text);if (typeof o.l !== 'undefined') $('stL').textContent = o.l;if (typeof o.r !== 'undefined') $('stR').textContent = o.r;if (typeof o.busy !== 'undefined') $('stBusy').textContent = o.busy ? '是' : '否';} catch (e) {log('状态 JSON 解析失败', 'err');}} else {log('← [' + topic + '] ' + text, 'recv');}});}function doDisconnect() {if (client) {try { client.end(true); } catch (e) {}client = null;}setConnected(false);log('已手动断开连接', 'warn');}/* ---------------- 事件绑定 ---------------- */$('btnConnect').addEventListener('click', doConnect);$('btnDisconnect').addEventListener('click', doDisconnect);$('btnFwd').addEventListener('click', function () { doMove(CMD.FWD); });$('btnBwd').addEventListener('click', function () { doMove(CMD.BWD); });$('btnLeft').addEventListener('click', function () { doMove(CMD.LEFT); });$('btnRight').addEventListener('click', function () { doMove(CMD.RIGHT); });$('btnStop').addEventListener('click', function () { sendCmd(CMD.STOP, 0, 0); });$('btnRot180').addEventListener('click',function () { sendCmd(CMD.ROT180, 180, getSpeed()); });/* 参数预设 */document.querySelectorAll('.presets button').forEach(function (b) {b.addEventListener('click', function () {$('param').value = b.getAttribute('data-param');});});/* 速度滑块 */$('speed').addEventListener('input', function () {var v = this.value;$('speedVal').textContent = v;var pct = ((v - 30) / 70) * 100;this.style.background ='linear-gradient(90deg,#3b82f6 0%,#3b82f6 ' + pct + '%,#242c3a ' + pct + '%)';});$('speed').dispatchEvent(new Event('input'));/* 清空日志 */$('btnClear').addEventListener('click', function () { $('log').innerHTML = ''; });/* 键盘控制 */document.addEventListener('keydown', function (e) {if (e.target && (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA')) return;var k = e.key.toLowerCase();var map = {'w': CMD.FWD, 'arrowup': CMD.FWD,'s': CMD.BWD, 'arrowdown': CMD.BWD,'a': CMD.LEFT, 'arrowleft': CMD.LEFT,'d': CMD.RIGHT,'arrowright': CMD.RIGHT};if (map[k] !== undefined) {e.preventDefault();doMove(map[k]);} else if (k === ' ' || k === 'escape') {e.preventDefault();sendCmd(CMD.STOP, 0, 0);}});/* ---------------- 恢复上次填写的配置 ---------------- */try {['host','port','path','clientId','user'].forEach(function (k) {var v = localStorage.getItem('car_' + k);if (v !== null && v !== '') $(k).value = v;});} catch (e) {}setConnected(false);log('页面就绪,请点击「连接」连上 Broker', 'ok');log('Topic 下发: ' + TOPIC_CMD);log('Topic 上报: ' + TOPIC_STATUS);})();</script></body></html>
三步上手,电脑手机都能控
第一步:保存文件把代码复制下来,命名为 car.html。
第二步:电脑打开双击 car.html,浏览器会直接运行。填写 Broker 地址、端口、路径,点击“连接”。连接成功后,就可以用按钮控制机器人了。
第三步:手机打开确保手机和电脑在同一局域网,把 car.html 放到手机能访问的位置,或者用手机浏览器打开。触摸方向盘,点哪走哪,比想象中更顺手。

① 连接 MQTT Broker
填写 Broker 地址、WebSocket 端口、WS 路径,点击“连接”即可。支持用户名、密码、Client ID 自定义,连接成功后右上角会亮起绿色状态点。
小提示:浏览器只能通过 WebSocket 连 MQTT。EMQX 默认端口 8083,路径 /mqtt;Mosquitto 需配置 listener 9001 + protocol websockets,路径填 /。如果页面用 https 打开,必须用 wss;用 http 或本地文件打开则用 ws。
② 运动控制
中央是一个圆形方向盘布局:
▲ 前进:按设定距离直行
▼ 后退:按设定距离倒车
◀ 左转:按设定角度左转
▶ 右转:按设定角度右转
■ 停止:立即停车
↻ 原地旋转 180°:一键掉头
每个按钮都支持触摸和鼠标点击,手机上操作也很顺手。
距离/角度可以直接输入,速度用滑块调节,范围 30%–100%,固件最低支持 30%。还提供快捷预设:10cm、30cm、50cm、100cm、90°、180°,点一下就能填入。
③ 机器人状态
实时显示左编码器、右编码器脉冲数、忙标志和消息计数。机器人上报的状态会以 JSON 解析,页面自动刷新,调试时心里有数。
④ 通信日志
所有收发的 MQTT 消息都会记录在这里,不同颜色区分发送、接收、成功、错误、警告。调试超方便,消息太多还能一键清空。

🎮 图形化方向盘,点哪走哪
页面中央是一个圆形方向盘布局:
▲ 前进:按设定距离直行
▼ 后退:按设定距离倒车
◀ 左转:按设定角度左转
▶ 右转:按设定角度右转
■ 停止:立即停车
↻ 原地旋转 180°:一键掉头
每个按钮都支持触摸和鼠标点击,手机上操作也很顺手。
📏 距离、角度、速度都能调
距离/角度:输入框直接填数字,单位是厘米或度。
速度:滑块调节,范围 30%–100%(固件最低支持 30%)。
快捷预设:一键填入 10cm、30cm、50cm、100cm、90°、180°。
⌨️ 键盘也能控制
电脑上打开网页后,直接按键盘:
W/↑:前进S/↓:后退A/←:左转D/→:右转空格/Esc:停止
不用鼠标,操作更快。