← 返回教程库

手机远程控制你的设备

最后更新 2026-06-22
L3 · 联网与 IoT ⏱ 约 18 分钟 🟢 软件/低风险
你将学到
  • 理解为什么内网设备公网访问不到(NAT),以及绕过它的三条路线
  • 用公共 MQTT Broker 中转,让手机在任何网络下控制家里的 ESP32-S3
  • 用 esp-mqtt 在 MQTT_EVENT_DATA 回调里解析命令控 GPIO,再 publish 状态回执做双向闭环
  • 处理断线重连与状态同步,用 retain 消息让手机一连上就看到设备的真实状态

你在公司,突然想起出门时忘了关阳台的灯。要是能掏出手机点一下就好了。或者反过来——快到家了,想提前把客厅的灯打开。这件事听起来简单,但你第一次动手时多半会卡在一个地方:手机和家里的 ESP32-S3 明明都连着网,为什么就是连不上对方?

这一节把"手机在任何地方控制家里设备"这个完整问题讲透。前提是你已经会用 esp-mqtt 收发消息——如果还没有,先去看 MQTT 上云,那一节讲的发布/订阅、Broker、topic、事件回调是本节的地基。本节不重复这些概念,专心解决"远程"这两个字带来的真正麻烦,以及怎么把单向的"收命令控灯"升级成双向的"命令下去、状态回来"的闭环。


真正的难点:你的设备藏在 NAT 后面

先想清楚为什么远程控制不像本地控制那么简单。

在家里,你的手机和 ESP32-S3 连同一个路由器,它们在同一个局域网(比如 192.168.1.x)。手机直接访问 192.168.1.50 就能找到 ESP32-S3,这就是 ESP32 当网页服务器 那一节能跑通的原因。

可一旦你走出家门,手机切到 4G 或别家的 WiFi,事情就变了。192.168.1.50 这个地址是私有地址,全世界有无数个路由器下面都有 192.168.1.50,它在公网上没有任何意义。你的家庭宽带对外只有一个公网 IP(还经常是动态的、甚至是运营商大内网下的"假公网"),路由器用 NAT(网络地址转换)把内网的一堆设备共享这一个出口。

NAT 的特点是:内网设备可以主动连出去,但外面的人没法主动连进来。 就像公司的总机能往外打电话,外面却没法直接拨到某个工位的分机。你的 ESP32-S3 躲在 NAT 后面,公网上的手机根本"找不到"它。

所以远程控制的本质问题,就是:怎么让一台躲在内网的设备,和一台在外网的手机,搭上线。


三条出路,先给观点

绕过 NAT 有三条主流路线,我先把结论摆出来,你按自己的情况选:

路线 思路 适合谁 麻烦点
① 公共/云 Broker 中转 手机和设备都主动连同一个公网 MQTT Broker,借它转发 想最快跑通、不想碰服务器 依赖第三方服务的稳定性
② 内网穿透 用 frp、花生壳把内网服务"映射"到公网 已经自建了 Web/服务,想直接暴露 要配置、要么花钱要么自己搭服务器
③ 自建公网 Broker 自己租台云服务器跑 MQTT Broker 设备多、要自己掌控数据 要会运维,有月租成本

我的建议:新手从①开始,没有之一。 它的妙处在于——手机和 ESP32-S3 都是"主动连出去",完美避开了 NAT 不让外部连入的限制。两边都连上同一个公网 Broker,谁给谁发消息,Broker 负责转,NAT 根本不需要被穿透。

这正是 MQTT 相比 局域网网页控制 的关键差别:网页服务器是"别人主动连进设备",出了门 NAT 就把你挡死了;MQTT 是"设备主动连出去挂在云上",无论你人在哪、设备在哪,两边都连得上那个公网中间人。换句话说,远程控制不是去"穿透" NAT,而是绕开它——让双方都从内往外连,到云上汇合。

📌 说明

也有人用 HTTP 轮询/长连接 做远程:设备每隔几秒主动去云服务器问一句"有没有我的命令"。能跑,但费电、有延迟(命令最坏要等一个轮询周期才下去),还得你自己写云端接口。MQTT 的长连接 + 主动推送天生比它省电、低延迟,这也是 IoT 普遍选 MQTT 而不是裸 HTTP 的原因(详见 MQTT 上云 里"为什么 IoT 偏爱它"那段)。

路线①里最省事的是国内的巴法云(bemfa)这类免费公共平台,注册即用,详见 接入巴法云。路线③如果你想自己掌控,看 自建 MQTT Broker。本节用路线①把完整方案走一遍,原理通了,换成自建也是一样的代码——只改 URI 和鉴权字段,这套 esp-mqtt 骨架原封不动。

💡 提示

别一上来就折腾内网穿透或买服务器。MQTT 中转用一个免费公共 Broker 就能验证整套逻辑,跑通了再考虑要不要换成自己的,省得在"环境没搭好"和"代码有 bug"之间分不清问题出在哪。


MQTT 中转方案:完整流程

整套方案的角色分三方:

  • ESP32-S3:连家里 WiFi → 连公网 Broker → 订阅"命令" topic → 收到命令控制继电器 → 把当前状态发布到"状态" topic。
  • 公网 MQTT Broker:谁都连不进内网,但谁都连得上它,它就是中间人。
  • 手机端:连同一个 Broker,往"命令" topic 发指令,同时订阅"状态" topic 看设备回报。

约定两个 topic(名字自己取,全网唯一一点,别和别人撞):

  • qifudev/dev01/cmd —— 手机发命令到这里,ESP32-S3 订阅它
  • qifudev/dev01/state —— ESP32-S3 发状态到这里,手机订阅它

命令和状态分两个 topic,是个值得养成的习惯:命令是"我想让它怎样",状态是"它现在实际怎样",两者不一定一致(比如命令发出去了但设备还没执行完)。分开后逻辑清爽,也方便后面做状态确认。这也呼应 MQTT 上云 里那条 topic 设计经验:别让设备订阅自己发的 topic,否则容易绕成回环。

手机端:先用现成 App 验证

不用一上来就写代码。手机装一个 MQTT 客户端 App,比如 IoT MQTT Panel(安卓)或 MQTTool(iOS),填 Broker 地址(broker.emqx.io)和端口(1883),就能可视化地建按钮和文本框:

  • 建一个开关按钮,绑到 qifudev/dev01/cmd,按下发 on,松开发 off(或做成两个按钮分别发 on/off)。
  • 建一个文本显示,订阅 qifudev/dev01/state,实时显示设备回报。

等逻辑跑通,你想做得好看,再去写一个 网页控制面板,用浏览器里的 MQTT over WebSocket 连同一个 Broker——那是后面的事,先用 App 把链路打通。

ESP32-S3 端:完整可跑代码

下面是设备端完整可跑main/main.c。它直接复用 MQTT 上云 那套 esp-mqtt 事件驱动骨架,只是把单向的"收命令控灯"升级成双向闭环:MQTT_EVENT_DATA 里解析命令控继电器,每控一次就 esp_mqtt_client_publish 回报一次状态。 WiFi 部分仍用上一篇 l3-wifiwifi_init_sta()(开头一行 extern 占位,真编译时把它的实现贴进来)。继电器接法见 继电器控制强电,这里假设接在 GPIO 5(ESP32-S3 上是合法的普通 GPIO)。

#include <string.h>
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "driver/gpio.h"
#include "esp_log.h"
#include "nvs_flash.h"
#include "mqtt_client.h"      // esp-mqtt:ESP-IDF 内置,无需额外依赖

#define RELAY_GPIO 5          // 继电器/LED,ESP32-S3 上的普通 GPIO

static const char *TAG = "remote";

#define BROKER_URI  "mqtt://broker.emqx.io:1883"  // 公网测试 Broker,明文 1883
#define TOPIC_CMD   "qifudev/dev01/cmd"           // 订阅:手机发来的命令
#define TOPIC_STATE "qifudev/dev01/state"         // 发布:回报真实状态

// 上一篇的 wifi_init_sta():连上 2.4G WiFi、拿到 IP 才返回。把它的实现贴进来。
extern void wifi_init_sta(void);

static bool s_relay_on = false;   // 设备当前状态

// 把当前状态发布出去,retain=1 让晚来的订阅者一连上就立刻读到
static void publish_state(esp_mqtt_client_handle_t client) {
    const char *msg = s_relay_on ? "on" : "off";
    // 参数:client, topic, data, len(0=自动strlen), QoS=1, retain=1
    esp_mqtt_client_publish(client, TOPIC_STATE, msg, 0, 1, 1);
    ESP_LOGI(TAG, "已回报状态: %s", msg);
}

static void set_relay(esp_mqtt_client_handle_t client, bool on) {
    s_relay_on = on;
    gpio_set_level(RELAY_GPIO, on ? 1 : 0);   // 低电平触发的模块要反过来
    publish_state(client);                    // 控完立刻回报,命令-状态闭环
}

// MQTT 事件回调:连上、收命令、断开,全在这里接住——和 l3-mqtt 一个范式
static void mqtt_event_handler(void *arg, esp_event_base_t base,
                               int32_t event_id, void *event_data) {
    esp_mqtt_event_handle_t event = event_data;
    esp_mqtt_client_handle_t client = event->client;

    switch ((esp_mqtt_event_id_t) event_id) {
    case MQTT_EVENT_CONNECTED:
        ESP_LOGI(TAG, "已连上 Broker");
        // 订阅放这里:断线重连会再次触发本事件,订阅自动重做——不会漏订阅
        esp_mqtt_client_subscribe(client, TOPIC_CMD, 1);   // 第三参数是 QoS
        publish_state(client);                             // 上线先报一次真实状态
        break;

    case MQTT_EVENT_DATA:   // 收到订阅的命令
        // event->data 不带结尾 '\0',必须按 data_len 来读
        ESP_LOGI(TAG, "收到命令 [%.*s]: %.*s",
                 event->topic_len, event->topic,
                 event->data_len, event->data);
        if (event->data_len == 2 && strncmp(event->data, "on", 2) == 0)
            set_relay(client, true);
        else if (event->data_len == 3 && strncmp(event->data, "off", 3) == 0)
            set_relay(client, false);
        else if (event->data_len == 1 && event->data[0] == '?')
            publish_state(client);   // 手机问一句"你现在啥状态",立刻回报
        break;

    case MQTT_EVENT_DISCONNECTED:
        ESP_LOGW(TAG, "和 Broker 断开了(esp-mqtt 会自动重连,重连后自动重订阅)");
        break;

    default:
        break;
    }
}

void app_main(void) {
    ESP_ERROR_CHECK(nvs_flash_init());   // WiFi 校准数据存 NVS,连网前必做
    wifi_init_sta();                     // 先连网拿到 IP,再连 Broker

    gpio_reset_pin(RELAY_GPIO);
    gpio_set_direction(RELAY_GPIO, GPIO_MODE_OUTPUT);
    gpio_set_level(RELAY_GPIO, 0);       // 上电默认关

    esp_mqtt_client_config_t cfg = {
        .broker.address.uri = BROKER_URI,
    };
    esp_mqtt_client_handle_t client = esp_mqtt_client_init(&cfg);
    esp_mqtt_client_register_event(client, ESP_EVENT_ANY_ID,
                                   mqtt_event_handler, NULL);
    esp_mqtt_client_start(client);       // 启动后台任务,开始连 Broker

    // 主循环啥都不用干——收命令、控灯、回状态全在事件回调里,
    // 收发/心跳/重连全是 esp-mqtt 后台任务在跑,不用你 client.loop()
    while (1) {
        vTaskDelay(pdMS_TO_TICKS(1000));
    }
}
📌 说明

这段为了聚焦远程控制,把上一篇的 wifi_init_sta() 实现省成一行 extern 声明——真编译时要么把它的代码贴进同一个文件,要么拆成独立的 wifi.c。两篇的 WiFi 骨架完全一样。

在工程目录下一条命令编译、烧录、看日志:

idf.py build flash monitor

你应该看到什么

idf.py monitor 的日志里,先滚出 WiFi 那节那串(拿到 IP: 192.168.x.x),紧接着 esp-mqtt 打几条底层握手日志,然后是你回调里这两句:

I (3210) remote: 已连上 Broker
I (3215) remote: 已回报状态: off

现在把手机的 WiFi 关掉,只用 4G——这一步很关键,证明手机和家里设备根本不在同一个网。在 IoT MQTT Panel 里按下"开"按钮,串口立刻打出:

I (8120) remote: 收到命令 [qifudev/dev01/cmd]: on
I (8123) remote: 已回报状态: on

家里的继电器"咔"一声,灯亮了。App 上订阅 state 的那个文本框同步变成 on

你人在外面、走着 4G,家里的灯应声而亮——这一刻整条双向链路就通了:手机的命令经公网 Broker 转给 ESP32-S3,ESP32-S3 执行后又把状态经 Broker 转回手机,全程没有任何"穿透 NAT"的操作,因为两边都是主动连出去的。


命令到状态:把闭环讲透

这篇和上一篇 l3-mqtt 最大的不同,就是多了回报这一环。上一篇只是"收命令控灯",这一篇是"收命令 → 控灯 → 回报真实状态",多走了半圈。值得把这半圈讲清楚。

注意 set_relay() 这个函数:它先 gpio_set_level 真正动继电器,紧接着调 publish_state() 把当前状态发回 state topic。也就是说,设备不是闷头执行,而是执行完吼一嗓子"我现在是 on 了"。手机上看到的状态,永远来自设备亲口回报,而不是"我点了开按钮所以它应该开了"的一厢情愿。

为什么这半圈值钱?因为命令可能在路上丢、设备可能没执行成功。把命令(cmd)和反馈(state)严格分开,手机的状态灯只认 state 的回报——这样你看到的永远是设备的真实状态。这正是工业 IoT 里"指令"和"反馈"必须分离的思路,在家用项目里同样好用。

还有一处呼应 esp-mqtt 的范式优势:订阅写在 MQTT_EVENT_CONNECTED 里,断线重连后会再次触发这个事件、自动把订阅重做一遍。 这正好避开了 Arduino PubSubClient 那个经典坑——重连后忘了补订阅、从此收不到命令。在 esp-mqtt 里你照着这个范式写,"重连 + 重订阅 + 重新回报一次状态"是天然就对的,不用你在主循环里盯着。


掉线重连与状态同步

远程项目最容易翻车的不是"连不上",而是"连上了但状态对不上"。三个坑必须处理。

坑一:设备掉线后没自动回来。 家里 WiFi 抖一下、Broker 临时断一下,设备就失联了。在 esp-mqtt 里这件事你几乎不用操心esp_mqtt_client_start() 之后,断线重连全在后台任务里跑,断了会进 MQTT_EVENT_DISCONNECTED(打条日志即可),连回来再进 MQTT_EVENT_CONNECTED。关键是——重连后必须重新 subscribe,否则订阅丢了就再也收不到命令。因为我们把 esp_mqtt_client_subscribe 写在了 MQTT_EVENT_CONNECTED 里,每次重连都自动重订阅,这个坑天然被堵上了。这是 esp-mqtt 比 Arduino 省心的地方:你不用在 loop() 里手动判断"连没连上、要不要重订阅"。

坑二:设备重启后手机看到的是假状态。 假设灯本来开着,ESP32-S3 断电重启了一下。手机这时打开 App,订阅 state topic——如果设备发的是普通消息(retain=0),App 只能收到"订阅之后"才发出的消息,订阅那一刻之前的状态它一概不知,屏幕上要么空白要么显示个错误的默认值。

解决办法就是代码里 publish_state()esp_mqtt_client_publish 的最后一个参数 retain 设成 1。Broker 会把每个 topic 的最后一条 retain 消息存下来,任何新订阅者一连上就立刻收到它。这样手机无论什么时候打开 App,都能马上看到设备最近一次回报的真实状态,不用傻等设备下次主动发。

坑三:命令在断线期间丢了。 你出门路上信号差,手机发的 on 命令可能根本没到设备。把订阅和发布的 QoS 提到 1(代码里 subscribepublish 的 QoS 参数都填了 1),意思是"保证至少送达一次"——Broker 会确认、必要时重发,比默认的 QoS 0"发了就不管"可靠得多。远程开关这种"丢一条就出问题"的场景,QoS 1 是合理起点(QoS 各等级的区别见 MQTT 上云 里那段)。

💡 提示

retain 消息只保留"最后一条",专门用来表达"当前状态"这种语义,别拿它发流水日志。另外配合代码里的 ? 命令——手机一打开就发个 ?,设备收到立刻回报一次,和 retain 双保险,确保状态永远是真的。

🚧 避坑

安全这条线别忽略。 公共 Broker 上任何人都能订阅你的 topic、也能往你的 cmd topic 发命令——别人猜到你的 topic 名就能远程开你家的灯。练手无所谓,但只要这设备能控制有意义的东西(门锁、电器),就必须:① 端口从明文 mqtt://...:1883 换成 mqtts://...:8883(MQTT over TLS),给消息加密;② 用带账号鉴权的 Broker(巴法云/自建),别人没密钥连不进来。鉴权、TLS、命令校验这些怎么落地,见 设备安全

掉线重连(自动)+ retain 状态 + QoS 1 命令,这三件事做到位,你的远程开关才算"能用",而不是"演示时能用"。


故障排查表

现象 可能原因 怎么查
卡在 WiFi,压根没到"已连上 Broker" 没网,或 wifi_init_sta() 没拿到 IP 先把 l3-wifi 跑通,确认日志里有"拿到 IP"
有 IP 但连不上 Broker,回调进不了 CONNECTED URI 写错,或运营商/校园网封了 1883 端口 核对 mqtt://broker.emqx.io:1883;换手机热点试
连上后反复掉线、自动又连上 client_id 撞了(多块板共用同 ID) esp-mqtt 默认按芯片信息生成唯一 ID;别在 cfg.credentials.client_id 写死同一个
手机发了命令设备没反应 topic 写错,或没在 CONNECTED 里订阅 核对两端 topic 一字不差;确认 subscribeMQTT_EVENT_CONNECTED 分支
收到的命令是乱码/尾巴有脏字符 data 当成带结尾的 C 字符串读了 event->data_len 读,用 %.*sstrncmp,别 strcmp
手机打开看到状态是空的/错的 没用 retain,或没发 ? 查询 publish 最后一个参数设 1(retain),App 启动发一次 ?
偶尔丢命令、信号差时尤其明显 QoS 0"发了就不管" 订阅和发布的 QoS 都提到 1
在家好好的,出门就不行 连的是局域网 Broker / 设备 IP 必须连公网 Broker(不是 192.168 地址),设备主动连出

变体玩法

变体一:做一个网页控制面板。 现成 App 够用但不够你自己的。用浏览器里的 MQTT over WebSocket(mqtt.js),写个网页连同一个 Broker,做几个好看的按钮和状态灯。这样你发个链接给家人,谁都能用,不用装 App。怎么搭网页见 ESP32 当网页服务器

变体二:多设备一套代码。 把 topic 里的 dev01 换成 dev02dev03,一套代码烧多块板子,手机上建多组按钮,一个 App 管全家。再加个温湿度,把 DHT11 的读数也 publish 到一个 .../sensor topic 上,手机就能远程看家里的温度了——这正是 l3-mqtt 里说的"加设备只是多订阅/多发一个 topic"。

变体三:换成巴法云/自建 Broker。 公共 Broker 没鉴权、不稳,正式给自己用就该换。这套 esp-mqtt 骨架不动,只把 BROKER_URI 换成平台地址、在 cfg.credentials 里填上账号鉴权(或 cfg.broker.verification 配 TLS 证书)即可。改法见 接入巴法云自建 MQTT Broker


动手挑战

在你已有的硬件上,做一个真正的手机远程开关 + 实时状态回显

  1. 用上面的 main.c 烧录 ESP32-S3,接一个继电器(或先用一颗 LED 接 GPIO 5 代替继电器验证逻辑)。
  2. 手机装 IoT MQTT Panel,建"开""关"两个按钮发到 qifudev/dev01/cmd,建一个文本订阅 qifudev/dev01/state
  3. 关掉手机 WiFi,只用 4G,远程开关一次,确认灯响应、状态文本同步。
  4. 把 ESP32-S3 断电重启,重启后不碰手机,观察 App 上的状态文本——它应该靠 retain 消息自动显示出设备的真实状态。
  5. 进阶:拔掉家里路由器 10 秒再插上,看设备能不能靠 esp-mqtt 的后台重连自动恢复响应(不用你写一行重连代码)。

第 4 步是这次挑战的核心,做到了说明你真的理解了 retain 和状态同步,而不只是"能远程点个灯"。卡住了?把你的 main.cidf.py monitor 的完整日志和想要的效果一起发给 AI,让它帮你改回调——记得提醒它用 ESP-IDF 5.x 的 esp-mqtt,别给你回 Arduino 的 PubSubClient

本篇代码为参考实现,需结合你所用的最新 ESP-IDF 文档自校,尤其 esp_mqtt_client_config_t 字段(5.x 的 .broker.address.uri)、esp_mqtt_client_publish/subscribe 的参数随版本可能微调,以官方 esp-mqtt 文档为准。


小结与下一步

远程控制的关键不在代码量,而在想通一件事:内网设备和外网手机都连不到对方,但它们都能连到同一个公网 Broker,于是 Broker 成了它们的"接头点"。 想透这条,NAT 这个拦路虎就被绕过去了——不是穿透它,而是双方都从内往外连、到云上汇合。

剩下的就是把链路做扎实,而 esp-mqtt 替你包办了大半:断线重连后台自动、订阅写在 MQTT_EVENT_CONNECTED 里自动重做、命令控完立刻 publish 回状态做闭环、状态用 retain、关键命令上 QoS 1。 这套骨架和 l3-mqtt 一脉相承,区别只是多了"回报"这半圈。

现在你的手机能在任何地方控制家里的设备了。但用 App 终究隔了一层,下一步我们把控制端搬进浏览器,做一个真正属于你自己的实时网页面板——靠 WebSocket 实时双向通信,让网页和设备保持一条随时互推消息的长连接,按钮一点状态秒变,不用刷新。安全这块别落下,把明文换 TLS、加上鉴权和命令校验,看 设备安全。如果你对 esp-mqtt 还有点不踏实,回 MQTT 上云 再过一遍事件回调和 topic 设计,本节的所有东西都建立在它之上。

📄 来源 / 自校链接

本文为公开资料整理,非亲测。关键参数与代码请结合实物与下列官方来源验证。

内容有错、看不懂、或想看下一期?告诉我们 →

本文为公开资料的学习整理,非亲测。涉接线/花钱/合规的步骤请结合实物与官方最新资料验证,风险自负。见免责声明